mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
017606bd61 | ||
|
|
c8e4346193 | ||
|
|
d6e47d18a5 | ||
|
|
64c4ce9e9d | ||
|
|
69c5ccdb4b | ||
|
|
0e43e80555 | ||
|
|
d7ec12037e | ||
|
|
43a35a5e62 | ||
|
|
d78738e96d | ||
|
|
d8975c6fb2 | ||
|
|
979dccc0b3 | ||
|
|
fd1b433940 | ||
|
|
432cf8d323 | ||
|
|
4f3f72116e | ||
|
|
d6b47b5363 | ||
|
|
7acc67bef2 | ||
|
|
9bf5108f4c | ||
|
|
7683341270 | ||
|
|
758dc26641 | ||
|
|
3153158b12 | ||
|
|
89c23e4453 | ||
|
|
3a41b6f462 | ||
|
|
a6d326664e | ||
|
|
d6a553691c | ||
|
|
2783684d10 | ||
|
|
6adef4c680 | ||
|
|
3820635809 | ||
|
|
f99b150381 | ||
|
|
882852ac73 | ||
|
|
21c6d59b2f | ||
|
|
5d3abd14d8 | ||
|
|
400e7ed81d | ||
|
|
0a083fe8b0 | ||
|
|
3579a5c3d4 | ||
|
|
452c2df34c | ||
|
|
b0c272a207 | ||
|
|
a60f75731b | ||
|
|
f2b4632931 |
No files matched your search
@@ -497,6 +497,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuMentionNote': 'Mention Note',
|
||||
'ai.chat.mentionNoteSearch': 'Search notes…',
|
||||
'ai.chat.mentionNoteEmpty': 'No matching notes',
|
||||
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
|
||||
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
|
||||
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
|
||||
'ai.chat.untitledNote': 'Untitled note',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
|
||||
@@ -497,6 +497,13 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuMentionNote': 'Mencionar nota',
|
||||
'ai.chat.mentionNoteSearch': 'Buscar notas…',
|
||||
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
|
||||
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
|
||||
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
|
||||
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
|
||||
'ai.chat.untitledNote': 'Nota sin título',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
|
||||
@@ -449,6 +449,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuMentionNote': 'Упомянуть заметку',
|
||||
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
|
||||
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
|
||||
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
|
||||
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
|
||||
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
|
||||
'ai.chat.untitledNote': 'Заметка без названия',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
|
||||
@@ -497,6 +497,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuMentionNote': '提及笔记',
|
||||
'ai.chat.mentionNoteSearch': '搜索笔记…',
|
||||
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
|
||||
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
|
||||
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
|
||||
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
|
||||
'ai.chat.untitledNote': '未命名笔记',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
|
||||
@@ -496,6 +496,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
'ai.chat.menuImage': '圖片',
|
||||
'ai.chat.menuMentionHost': '提及主機',
|
||||
'ai.chat.menuMentionNote': '提及筆記',
|
||||
'ai.chat.mentionNoteSearch': '搜尋筆記…',
|
||||
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
|
||||
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
|
||||
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
|
||||
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
|
||||
'ai.chat.untitledNote': '未命名筆記',
|
||||
'ai.chat.menuUserSkills': '使用者 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷指令',
|
||||
'ai.chat.slashCommands': '快捷指令',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
|
||||
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
|
||||
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
import {
|
||||
formatVaultNoteReferences,
|
||||
isVaultNoteAttachment,
|
||||
} from "./vaultNoteAttachment";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments whose text is inlined into the prompt instead of being sent as
|
||||
* file parts: terminal selections and Vault note mentions.
|
||||
*/
|
||||
export function isInlineTextAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
|
||||
): boolean {
|
||||
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
|
||||
|
||||
const blocks = [...terminalBlocks, ...noteBlocks];
|
||||
if (blocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return blocks.join("").trimStart();
|
||||
return `${prompt}${blocks.join("")}`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
|
||||
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
|
||||
|
||||
test("mentions carry identity without copying even large note bodies", () => {
|
||||
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
|
||||
const attachment = createVaultNoteAttachment(note)!;
|
||||
assert.equal(attachment.vaultNoteId, note.id);
|
||||
assert.equal(attachment.base64Data, "");
|
||||
assert.equal(attachment.dataUrl, "");
|
||||
assert.ok(JSON.stringify(attachment).length < 400);
|
||||
assert.ok(isInlineTextAttachment(attachment));
|
||||
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
|
||||
assert.match(prompt, /vault_notes_get/);
|
||||
assert.match(prompt, /latest content/);
|
||||
assert.doesNotMatch(prompt, /secret body/);
|
||||
});
|
||||
|
||||
test("same titled notes keep distinct exact IDs including imported special characters", () => {
|
||||
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
|
||||
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
|
||||
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
|
||||
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
|
||||
assert.equal(metadata.noteId, id);
|
||||
assert.equal(metadata.title, "Same title");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
|
||||
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
|
||||
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
|
||||
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
|
||||
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
|
||||
});
|
||||
|
||||
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
|
||||
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
|
||||
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
|
||||
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
|
||||
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
|
||||
assert.match(prompt, /systemctl status nginx/);
|
||||
assert.match(prompt, /empty-note/);
|
||||
assert.match(prompt, /^compare/);
|
||||
});
|
||||
|
||||
test("reference limit reserves replay space and does not count ordinary files", () => {
|
||||
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
|
||||
assert.equal(vaultNoteReferencesFit(notes), false);
|
||||
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
|
||||
assert.ok(accepted.length > 0);
|
||||
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
|
||||
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { VaultNote } from "../../domain/models";
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
|
||||
/** A mention stores only identity; the agent reads current content through the Vault tool. */
|
||||
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
|
||||
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
|
||||
if (!note.id.trim() || note.id.length > 200) return null;
|
||||
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename: title,
|
||||
mediaType: "text/markdown",
|
||||
base64Data: "",
|
||||
dataUrl: "",
|
||||
vaultNoteId: note.id,
|
||||
vaultNoteTitle: title,
|
||||
};
|
||||
}
|
||||
|
||||
export function isVaultNoteAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
|
||||
): boolean {
|
||||
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
|
||||
}
|
||||
|
||||
/** JSON preserves special characters and whitespace in exact note IDs. */
|
||||
export function formatVaultNoteReferences(
|
||||
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
|
||||
): string {
|
||||
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
|
||||
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
|
||||
}
|
||||
|
||||
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
|
||||
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
|
||||
}
|
||||
@@ -88,6 +88,8 @@ interface AIChatPanelContentProps {
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context. */
|
||||
onMentionNote?: (note: VaultNote) => void;
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
||||
@@ -157,6 +159,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setGlobalPermissionMode,
|
||||
notes = [],
|
||||
hosts = [],
|
||||
onMentionNote,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSection,
|
||||
@@ -361,6 +364,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={mentionHosts}
|
||||
notes={notes}
|
||||
onMentionNote={onMentionNote}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
AIPanelView,
|
||||
AgentModelPreset,
|
||||
AISessionScope,
|
||||
UploadedFile,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { VaultNote } from '../domain/models';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import {
|
||||
filterAgentModelPresetsForCliVersion,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { toast } from './ui/toast';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
getReadyUserSkillOptions,
|
||||
@@ -42,8 +45,9 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
|
||||
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
isInlineTextAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
@@ -597,6 +601,42 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
}, [removeDraftFile, scopeKey, currentAgentId]);
|
||||
|
||||
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
|
||||
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
|
||||
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
|
||||
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
|
||||
toast.warning(t('ai.chat.mentionNoteUnavailable'));
|
||||
return false;
|
||||
}
|
||||
if (!vaultNoteReferencesFit(attachments)) {
|
||||
toast.warning(t('ai.chat.mentionNoteTooMany'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [canMentionNotes, t]);
|
||||
|
||||
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
|
||||
const mentionNote = useCallback((note: VaultNote) => {
|
||||
if (!canMentionNotes) return;
|
||||
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
|
||||
if (!upload) {
|
||||
toast.error(t('ai.chat.mentionNoteInvalid', {
|
||||
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const existing = currentDraftRef.current?.attachments ?? [];
|
||||
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateDraft(scopeKey, currentAgentId, (current) => ({
|
||||
...current,
|
||||
attachments: [
|
||||
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
|
||||
upload,
|
||||
],
|
||||
}));
|
||||
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) return undefined;
|
||||
flushDraftText();
|
||||
@@ -1141,6 +1181,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const draft = currentDraftRef.current;
|
||||
const currentPanelView = panelViewRef.current;
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
if (!validateNoteMentions(draft?.attachments ?? [])) return;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
@@ -1149,18 +1190,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
vaultNoteId: file.vaultNoteId,
|
||||
vaultNoteTitle: file.vaultNoteTitle,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
|
||||
// Note-only sends (empty text + a mentioned note) still need a usable
|
||||
// session title: fall back to the first mentioned note's title so these
|
||||
// conversations don't all show up as "Untitled" in history.
|
||||
const noteOnlyTitle = attachments.find(
|
||||
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
|
||||
)?.vaultNoteTitle ?? '';
|
||||
const titleText = trimmed || noteOnlyTitle.trim();
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
flushDraftText();
|
||||
@@ -1337,7 +1387,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
autoTitleSession(sessionId, titleText);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
@@ -1360,7 +1410,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
titleText: trimmed,
|
||||
titleText,
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
@@ -1371,6 +1421,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
@@ -1467,6 +1518,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const draft = currentDraftRef.current;
|
||||
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
|
||||
|
||||
if (!validateNoteMentions(draft.attachments)) return;
|
||||
const trimmed = draft.text.trim();
|
||||
const attachments = draft.attachments.map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
@@ -1474,15 +1526,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
vaultNoteId: file.vaultNoteId,
|
||||
vaultNoteTitle: file.vaultNoteTitle,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if (!trimmed && !hasTerminalSelectionAttachments) return;
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if (!trimmed && !hasInlineTextAttachments) return;
|
||||
|
||||
const userMessageId = generateId();
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
|
||||
setSteerWarnings(current => {
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
@@ -1511,7 +1565,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
} finally {
|
||||
setSteeringSessionId(current => current === sessionId ? null : current);
|
||||
}
|
||||
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
}, [
|
||||
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
|
||||
|
||||
const stopStreamingForSession = useCallback(async (sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
@@ -1707,6 +1762,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
onMentionNote={canMentionNotes ? mentionNote : undefined}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
|
||||
@@ -332,3 +332,21 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
|
||||
assert.match(source, /onCompact\?\.\(\)/);
|
||||
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
|
||||
});
|
||||
|
||||
test('renders a Mention Note picker entry with a searchable note list', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
|
||||
assert.match(source, /ai\.chat\.menuMentionNote/);
|
||||
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
|
||||
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
|
||||
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
|
||||
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
|
||||
});
|
||||
|
||||
test('renders vault note attachment chips with the note title', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /file\.vaultNoteId \? \(/);
|
||||
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
|
||||
});
|
||||
+166
-4
@@ -6,7 +6,7 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import { ArrowUp, AtSign, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import {
|
||||
resolveModelSelectionWithThinking,
|
||||
resolveThinkingSelection,
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import type { VaultNote } from '../../domain/models';
|
||||
import { createVaultNoteSearchIndex } from '../../domain/notes';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -216,6 +218,10 @@ interface ChatInputProps {
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Available Vault → Notes entries for the Mention Note picker */
|
||||
notes?: VaultNote[];
|
||||
/** Callback when the user picks a note to attach as context */
|
||||
onMentionNote?: (note: VaultNote) => void;
|
||||
/** User skills currently selected for the next send */
|
||||
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
|
||||
/** Available user skills for /skill-slug insertion */
|
||||
@@ -271,6 +277,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
notes = [],
|
||||
onMentionNote,
|
||||
selectedUserSkills = [],
|
||||
userSkills = [],
|
||||
quickMessages = [],
|
||||
@@ -285,7 +293,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
parked = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
|
||||
const composerDisabled = disabled || isSteering;
|
||||
const composerTextRef = useRef(value);
|
||||
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
|
||||
@@ -298,13 +306,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
|
||||
const composerDesiredHeightRef = useRef<number | null>(null);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
|
||||
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'noteMention' | 'slashCommand' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Search query for the Mention Note picker
|
||||
const [noteQuery, setNoteQuery] = useState('');
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
|
||||
|
||||
@@ -313,6 +323,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const showThinkingPicker = activeMenu === 'thinking';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showNoteMention = activeMenu === 'noteMention';
|
||||
const showSlashCommandPicker = activeMenu === 'slashCommand';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
@@ -322,6 +333,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setInputPanelPos(null);
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
setNoteQuery('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -582,7 +594,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [readComposerText, commitComposerText, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
|
||||
const handleSelectNoteMention = useCallback((note: VaultNote) => {
|
||||
onMentionNote?.(note);
|
||||
closeAllMenus();
|
||||
}, [onMentionNote, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'noteMention' | 'slashCommand') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
if (!pos) return;
|
||||
setMenuPos(null);
|
||||
@@ -746,6 +763,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
if (showSlashCommandPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashCommandPicker, slashCommandKey]);
|
||||
|
||||
// Mention Note picker: filtered notes, highlight reset, search-input focus
|
||||
const searchMentionNotes = useMemo(
|
||||
() => showNoteMention ? createVaultNoteSearchIndex(notes) : null,
|
||||
[notes, showNoteMention],
|
||||
);
|
||||
const noteMentionItems = useMemo(
|
||||
() => searchMentionNotes?.(noteQuery) ?? [],
|
||||
[searchMentionNotes, noteQuery],
|
||||
);
|
||||
const noteMentionKey = useMemo(
|
||||
() => JSON.stringify(noteMentionItems.map((note) => note.id)),
|
||||
[noteMentionItems],
|
||||
);
|
||||
const noteSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const noteListId = React.useId();
|
||||
useEffect(() => {
|
||||
if (showNoteMention) setActiveMenuIndex(0);
|
||||
}, [showNoteMention, noteMentionKey]);
|
||||
useEffect(() => {
|
||||
if (!showNoteMention || noteMentionItems.length === 0) return;
|
||||
atMentionListRef.current?.scrollToIndex(activeMenuIndex);
|
||||
}, [activeMenuIndex, noteMentionKey, noteMentionItems.length, showNoteMention]);
|
||||
useEffect(() => {
|
||||
if (!showNoteMention) return;
|
||||
noteSearchInputRef.current?.focus();
|
||||
}, [showNoteMention]);
|
||||
|
||||
const handleNoteMentionKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const note = noteMentionItems[Math.min(activeMenuIndex, noteMentionItems.length - 1)];
|
||||
if (note) handleSelectNoteMention(note);
|
||||
return;
|
||||
}
|
||||
if (noteMentionItems.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % noteMentionItems.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + noteMentionItems.length) % noteMentionItems.length);
|
||||
}
|
||||
}, [activeMenuIndex, closeAllMenus, handleSelectNoteMention, noteMentionItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
|
||||
slashPickerListRef.current?.focus();
|
||||
@@ -1041,6 +1110,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
>
|
||||
{file.terminalSelection ? (
|
||||
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
|
||||
) : file.vaultNoteId ? (
|
||||
<BookOpen size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : file.mediaType.startsWith('image/') ? (
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : (
|
||||
@@ -1053,6 +1124,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : file.vaultNoteId ? (
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate max-w-[210px] text-foreground/82">
|
||||
{file.vaultNoteTitle || file.filename}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
)}
|
||||
@@ -1209,6 +1286,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Mention Note popover */}
|
||||
{showNoteMention && inputPanelPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(240, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="p-1.5 border-b border-border/40">
|
||||
<input
|
||||
ref={noteSearchInputRef}
|
||||
role="combobox"
|
||||
aria-label={t('ai.chat.menuMentionNote')}
|
||||
aria-expanded={true}
|
||||
aria-controls={noteListId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={noteMentionItems[activeMenuIndex] ? `${noteListId}-${activeMenuIndex}` : undefined}
|
||||
type="text"
|
||||
value={noteQuery}
|
||||
onChange={(e) => setNoteQuery(e.target.value)}
|
||||
onKeyDown={handleNoteMentionKeyDown}
|
||||
placeholder={t('ai.chat.mentionNoteSearch')}
|
||||
className="w-full h-6 rounded-md bg-muted/40 px-2 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
<div id={noteListId} role="listbox" aria-label={t('ai.chat.menuMentionNote')}>
|
||||
{noteMentionItems.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">{t('ai.chat.mentionNoteEmpty')}</div>
|
||||
) : (
|
||||
<div className="max-h-[280px]" style={{ height: Math.min(280, 8 + noteMentionItems.reduce((total, note) => total + (note.group ? 52 : 36), 0)) }}>
|
||||
<VariableSizeVirtualList
|
||||
ref={atMentionListRef}
|
||||
items={noteMentionItems}
|
||||
getItemHeight={(note) => note.group ? 52 : 36}
|
||||
getItemKey={(note) => note.id}
|
||||
className="h-full"
|
||||
contentClassName="p-1"
|
||||
renderItem={(note, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${noteListId}-${idx}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => handleSelectNoteMention(note)}
|
||||
className={`h-full w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<BookOpen size={11} className="text-muted-foreground/50 shrink-0" />
|
||||
<span className="truncate">{note.title || t('ai.chat.untitledNote')}</span>
|
||||
</div>
|
||||
{note.group ? (
|
||||
<div className="pl-5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{note.group}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* / command popover */}
|
||||
{showSlashPickerUI && createPortal(
|
||||
<>
|
||||
@@ -1311,6 +1458,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={t('ai.chat.menuMentionNote')}
|
||||
disabled={!onMentionNote}
|
||||
title={!onMentionNote ? t('ai.chat.mentionNoteUnavailable') : undefined}
|
||||
onClick={() => openInputPanelMenu('noteMention')}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<BookOpen size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionNote')}</span>
|
||||
{notes.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1640,6 +1800,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
|
||||
&& prev.onAddFiles === next.onAddFiles
|
||||
&& prev.onRemoveFile === next.onRemoveFile
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.notes === next.notes
|
||||
&& prev.onMentionNote === next.onMentionNote
|
||||
&& prev.selectedUserSkills === next.selectedUserSkills
|
||||
&& prev.userSkills === next.userSkills
|
||||
&& prev.quickMessages === next.quickMessages
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
|
||||
@@ -701,6 +701,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
|
||||
</div>
|
||||
) : att.vaultNoteId ? (
|
||||
<div
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
|
||||
>
|
||||
<BookOpen size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">{att.vaultNoteTitle || att.filename || 'note'}</span>
|
||||
</div>
|
||||
) : att.mediaType.startsWith('image/') ? (
|
||||
<img
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
|
||||
|
||||
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
@@ -210,3 +211,68 @@ test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest prece
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "A".repeat(5_000),
|
||||
mediaType: "text/markdown",
|
||||
filename: "runbook.md",
|
||||
vaultNoteId: "note-123",
|
||||
vaultNoteTitle: "Runbook",
|
||||
previewText: "restart nginx",
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
|
||||
|
||||
assert.match(result, /check this note/);
|
||||
assert.match(result, /Vault note reference/);
|
||||
assert.match(result, /"noteId":"note-123"/);
|
||||
assert.match(result, /"title":"Runbook"/);
|
||||
assert.doesNotMatch(result, /AAAAAA/);
|
||||
});
|
||||
|
||||
test("external recovery retains note identity before truncating a long user request", () => {
|
||||
const history = buildExternalBridgeContextMessages([{
|
||||
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
|
||||
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
|
||||
}]);
|
||||
const replay = history.find((message) => message.role === "user")!;
|
||||
assert.match(replay.content, /"noteId":"note-123"/);
|
||||
assert.match(replay.content, /vault_notes_get/);
|
||||
assert.match(replay.content, /truncated/);
|
||||
assert.ok(replay.content.length <= 2000);
|
||||
});
|
||||
|
||||
test("external recovery keeps multiple note IDs and a short user constraint", () => {
|
||||
const attachments = Array.from({length: 10}, (_, index) => ({
|
||||
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
|
||||
}));
|
||||
const history = buildExternalBridgeContextMessages([{
|
||||
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
|
||||
}]);
|
||||
const replay = history.find((message) => message.role === "user")!;
|
||||
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
|
||||
assert.match(replay.content, /Only compare; do not edit/);
|
||||
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
|
||||
});
|
||||
|
||||
for (const terminalSelection of [false, true]) {
|
||||
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
|
||||
const noteId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const request = "Only summarize yesterday's deployments; do not edit.";
|
||||
const messages: ChatMessage[] = [{
|
||||
id: 'old-note', role: 'user', timestamp: 1, content: request,
|
||||
attachments: [
|
||||
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
|
||||
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
|
||||
],
|
||||
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
|
||||
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
|
||||
}))];
|
||||
const compact = buildExternalBridgeContextMessages(messages)[0].content;
|
||||
assert.ok(compact.includes(noteId));
|
||||
assert.ok(compact.includes(request));
|
||||
assert.ok(compact.includes('other.txt'));
|
||||
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
|
||||
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
|
||||
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
|
||||
|
||||
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
||||
@@ -50,15 +51,19 @@ export function buildHistoricalUserReplayContent(
|
||||
content: string,
|
||||
attachments: ChatMessageAttachment[] = [],
|
||||
): string {
|
||||
const placeholders = attachments.map((attachment, index) => (
|
||||
isTerminalSelectionAttachment(attachment)
|
||||
? formatTerminalSelectionPlaceholder(attachment, index)
|
||||
: formatAttachmentPlaceholder(attachment, index)
|
||||
));
|
||||
if (!attachments.length) return content;
|
||||
// Keep note identities ahead of prose so bounded external history retains them.
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
|
||||
.map((attachment, index) => (
|
||||
isTerminalSelectionAttachment(attachment)
|
||||
? formatTerminalSelectionPlaceholder(attachment, index)
|
||||
: formatAttachmentPlaceholder(attachment, index)
|
||||
));
|
||||
|
||||
if (!placeholders.length) return content;
|
||||
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
|
||||
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
||||
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
||||
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
|
||||
}
|
||||
|
||||
function getToolCommand(toolCall?: ToolCall): string | undefined {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
importMarkdownFilesToVaultNotes,
|
||||
importMarkdownPayloadsToVaultNotes,
|
||||
matchesVaultNoteSearch,
|
||||
createVaultNoteSearchIndex,
|
||||
normalizeNoteGroups,
|
||||
normalizeVaultNotes,
|
||||
remapExpandedNoteGroupPaths,
|
||||
@@ -617,3 +618,18 @@ test("formatMarkdownListSelection prefixes every non-empty selected line", () =>
|
||||
test("formatMarkdownQuoteSelection prefixes every selected line", () => {
|
||||
assert.equal(formatMarkdownQuoteSelection("one\n\ntwo"), "> one\n>\n> two");
|
||||
});
|
||||
|
||||
test("note mention index preserves search fields, entity identity and edited snapshots", () => {
|
||||
const notes = [
|
||||
{ id: "a", title: "Same", content: "Alpha body", group: "Production", tags: ["API"], createdAt: 1, updatedAt: 1 },
|
||||
{ id: "b", title: "Same", content: "Beta body", createdAt: 1, updatedAt: 1 },
|
||||
];
|
||||
const search = createVaultNoteSearchIndex(notes);
|
||||
for (const query of ["", " SAME ", "ALPHA", "beta", "production", "api", "missing", "same alpha"]) {
|
||||
assert.deepEqual(search(query), notes.filter((note) => matchesVaultNoteSearch(note, query)));
|
||||
}
|
||||
assert.equal(search("beta")[0], notes[1]);
|
||||
const edited = [{ ...notes[0], content: "Replacement" }, notes[1]];
|
||||
assert.deepEqual(createVaultNoteSearchIndex(edited)("alpha"), []);
|
||||
assert.equal(createVaultNoteSearchIndex(edited)("replacement")[0], edited[0]);
|
||||
});
|
||||
@@ -284,6 +284,21 @@ export const matchesVaultNoteSearch = (
|
||||
].some((value) => value.toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
/** Build once per note-list snapshot, so typing does not lowercase full bodies. */
|
||||
export const createVaultNoteSearchIndex = (notes: readonly VaultNote[]) => {
|
||||
const entries = notes.map((note) => ({
|
||||
note,
|
||||
fields: [note.title, note.content, note.group ?? "", ...(note.tags ?? [])]
|
||||
.map((value) => value.toLowerCase()),
|
||||
}));
|
||||
return (query: string): VaultNote[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return entries
|
||||
.filter((entry) => !needle || entry.fields.some((field) => field.includes(needle)))
|
||||
.map((entry) => entry.note);
|
||||
};
|
||||
};
|
||||
|
||||
const isSanitizedRenderedLink = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readVaultNote } from './vaultNoteRead';
|
||||
|
||||
const note = { id: 'note-1', title: 'Runbook', content: '', createdAt: 1, updatedAt: 2 };
|
||||
|
||||
function read(...args: Parameters<typeof readVaultNote>) {
|
||||
const result = readVaultNote(...args);
|
||||
assert.ok('note' in result, JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
test('bounded pages reconstruct long Unicode notes without gaps or broken characters', () => {
|
||||
const content = `${'a'.repeat(5999)}\u{1F408}中文\n`.repeat(20);
|
||||
let offset: number | null = 0;
|
||||
let collected = '';
|
||||
while (offset !== null) {
|
||||
const result = read({ ...note, content }, { offset, maxChars: 1_000_000, expectedUpdatedAt: 2 });
|
||||
assert.ok(result.note);
|
||||
assert.equal(result.offset, offset);
|
||||
assert.ok(result.note.content.length <= 6000);
|
||||
assert.doesNotMatch(result.note.content, /^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/);
|
||||
collected += result.note.content;
|
||||
offset = result.nextOffset;
|
||||
}
|
||||
assert.equal(collected, content);
|
||||
});
|
||||
|
||||
test('small/empty notes and EOF are complete, and oversized metadata stays out', () => {
|
||||
for (const content of ['', 'small note']) {
|
||||
const result = read({ ...note, content, title: 'x'.repeat(100_000), tags: ['x'.repeat(100_000)] }, {});
|
||||
assert.ok(result.note);
|
||||
assert.equal(result.note.content, content);
|
||||
assert.equal(result.nextOffset, null);
|
||||
assert.ok(JSON.stringify(result).length < 1000);
|
||||
}
|
||||
assert.equal(read({ ...note, content: 'abc' }, { offset: 3 }).note?.content, '');
|
||||
});
|
||||
|
||||
test('literal search locates later and overlapping hits without skipping nearby matches', () => {
|
||||
const source = { ...note, content: `${'x'.repeat(9000)}ababa\u{1F408}tail` };
|
||||
const first = read(source, { query: 'aba', maxChars: 4 });
|
||||
assert.equal(first.matchOffset, 9000);
|
||||
assert.equal(first.note?.content, 'abab');
|
||||
const second = read(source, { query: 'aba', offset: first.nextOffset, expectedUpdatedAt: 2 });
|
||||
assert.equal(second.matchOffset, 9002);
|
||||
const missing = read(source, { query: 'aba', offset: second.nextOffset });
|
||||
assert.equal(missing.matchOffset, null);
|
||||
assert.equal(missing.nextOffset, null);
|
||||
assert.equal(missing.note?.content, '');
|
||||
assert.equal(read(source, { query: 'ABA' }).matchOffset, null);
|
||||
assert.equal(read(source, { query: '.*' }).matchOffset, null);
|
||||
});
|
||||
|
||||
test('invalid ranges, queries and changed notes fail explicitly', () => {
|
||||
const source = { ...note, content: 'a\u{1F408}b' };
|
||||
for (const params of [
|
||||
{ offset: -1 }, { offset: 0.5 }, { offset: 9 }, { offset: 2 }, { offset: '1' },
|
||||
{ maxChars: 0 }, { maxChars: NaN }, { maxChars: Infinity },
|
||||
{ query: '' }, { query: 'a'.repeat(201) }, { query: 1 }, { expectedUpdatedAt: 1 },
|
||||
]) assert.equal(readVaultNote(source, params).ok, false, JSON.stringify(params));
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { VaultNote } from './models';
|
||||
|
||||
// Stay below the shared tool fitter's 8,000-character string limit.
|
||||
export const MAX_NOTE_READ_CHARS = 6000;
|
||||
|
||||
/** Bounded reads use JavaScript (UTF-16) offsets, including for literal search. */
|
||||
export function readVaultNote(note: VaultNote, params: Record<string, unknown>) {
|
||||
const offset = params.offset ?? 0;
|
||||
const maxChars = params.maxChars ?? MAX_NOTE_READ_CHARS;
|
||||
const query = params.query;
|
||||
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset > note.content.length) {
|
||||
return { ok: false, error: 'offset must be an integer within the note content.' };
|
||||
}
|
||||
if (typeof maxChars !== 'number' || !Number.isSafeInteger(maxChars) || maxChars < 2) {
|
||||
return { ok: false, error: 'maxChars must be an integer of at least 2 (capped at 6000).' };
|
||||
}
|
||||
if (query !== undefined && (typeof query !== 'string' || query.length === 0 || query.length > 200)) {
|
||||
return { ok: false, error: 'query must be a nonempty literal string of at most 200 characters.' };
|
||||
}
|
||||
if (params.expectedUpdatedAt !== undefined && params.expectedUpdatedAt !== note.updatedAt) {
|
||||
return { ok: false, error: 'The note changed. Restart reading from offset 0 without expectedUpdatedAt.' };
|
||||
}
|
||||
const splitsPair = (position: number) => position > 0
|
||||
&& /[\uD800-\uDBFF]/.test(note.content[position - 1] ?? '')
|
||||
&& /[\uDC00-\uDFFF]/.test(note.content[position] ?? '');
|
||||
if (splitsPair(offset)) return { ok: false, error: 'offset splits a Unicode character. Use the returned nextOffset.' };
|
||||
|
||||
const matchOffset = typeof query === 'string' ? note.content.indexOf(query, offset) : undefined;
|
||||
let start = matchOffset === undefined ? offset : matchOffset < 0 ? note.content.length : matchOffset;
|
||||
if (splitsPair(start)) start -= 1;
|
||||
let end = Math.min(note.content.length, start + Math.min(maxChars, MAX_NOTE_READ_CHARS));
|
||||
if (splitsPair(end)) end -= 1;
|
||||
let nextOffset: number | null = end < note.content.length ? end : null;
|
||||
if (matchOffset !== undefined) {
|
||||
// Advance past the match start, not the excerpt, so nearby/overlapping hits remain discoverable.
|
||||
let searchFrom = matchOffset + 1;
|
||||
if (splitsPair(searchFrom)) searchFrom += 1;
|
||||
nextOffset = matchOffset >= 0 && searchFrom < note.content.length ? searchFrom : null;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
// Large optional metadata must not defeat the body limit.
|
||||
note: { id: note.id, title: Array.from(note.title).slice(0, 120).join(''),
|
||||
content: note.content.slice(start, end), createdAt: note.createdAt, updatedAt: note.updatedAt },
|
||||
offset: start,
|
||||
endOffset: end,
|
||||
totalChars: note.content.length,
|
||||
nextOffset,
|
||||
hasMore: nextOffset !== null,
|
||||
...(matchOffset === undefined ? {} : { matchOffset: matchOffset < 0 ? null : matchOffset }),
|
||||
};
|
||||
}
|
||||
@@ -206,7 +206,7 @@ const VAULT_CAPABILITIES = [
|
||||
id: "vault.note.get",
|
||||
domain: "vault",
|
||||
status: CAPABILITY_STATUS.IMPLEMENTED,
|
||||
description: "Get a Vault → Notes entry by id (full markdown content).",
|
||||
description: "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
|
||||
policy: {
|
||||
write: false,
|
||||
sensitiveRead: false,
|
||||
|
||||
@@ -167,6 +167,10 @@ const TOOL_INPUT_FIELDS = Object.freeze({
|
||||
"vault.note.list": {},
|
||||
"vault.note.get": {
|
||||
noteId: { type: "string", description: "Vault note ID from vault_notes_list." },
|
||||
offset: { type: "number", optional: true, description: "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset." },
|
||||
maxChars: { type: "number", optional: true, description: "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000." },
|
||||
query: { type: "string", optional: true, description: "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search." },
|
||||
expectedUpdatedAt: { type: "number", optional: true, description: "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed." },
|
||||
},
|
||||
"vault.note.create": {
|
||||
title: { type: "string", description: "Note title shown in Vault → Notes." },
|
||||
|
||||
@@ -90,7 +90,13 @@ function createVaultService(ctx = {}) {
|
||||
getNote: async (params = {}) => {
|
||||
const bridgeErr = requireBridge();
|
||||
if (bridgeErr) return bridgeErr;
|
||||
return invokeVaultAgent("note.get", { noteId: params.noteId });
|
||||
return invokeVaultAgent("note.get", {
|
||||
noteId: params.noteId,
|
||||
offset: params.offset,
|
||||
maxChars: params.maxChars,
|
||||
query: params.query,
|
||||
expectedUpdatedAt: params.expectedUpdatedAt,
|
||||
});
|
||||
},
|
||||
createNote: async (params = {}) => {
|
||||
const bridgeErr = requireBridge();
|
||||
|
||||
@@ -59,7 +59,7 @@ ${permissionRules}
|
||||
|
||||
**Attached host files:** When the user asks to import attached host/server data, call \`list_attachments\` then \`read_attachment\`. If the attachment is a known export format, pass the exact text to \`vault_hosts_import\`. If the format is unknown or \`vault_hosts_import\` cannot detect it, do not search a terminal or remote filesystem; read the attached text, extract host fields yourself, and call \`vault_hosts_create\` with \`dryRun: true\` first. If a tool result is truncated or compressed and includes a \`tool_output_read\` handle, use \`tool_output_read\` to recover the needed original text before extracting fields.
|
||||
|
||||
**Vault → Notes (sidebar markdown docs):** When the user explicitly wants documentation saved to **Vault → Notes** (the notes sidebar / 保险箱笔记), use \`vault_notes_create\` or \`vault_notes_update\` — **not** \`host_notes_set\` (Host Details only) and **not** as a substitute for creating a host.
|
||||
**Vault → Notes (sidebar markdown docs):** When the user explicitly wants documentation saved to **Vault → Notes** (the notes sidebar / 保险箱笔记), use \`vault_notes_create\` or \`vault_notes_update\` — **not** \`host_notes_set\` (Host Details only) and **not** as a substitute for creating a host. When a message references a Vault note, use \`vault_notes_get\` with its exact \`noteId\` to read the latest content before summarizing or editing. Use that same ID for updates; do not substitute a title search. If the note no longer exists, tell the user.
|
||||
|
||||
**Snippets vs automation scripts:** Use \`snippets_*\` for shell command text (paste/execute with optional \`{{variables}}\`). Use \`scripts_*\` for multi-step terminal automation written in JavaScript with the \`nct.*\` API (\`await nct.screen.sendLine\`, \`waitForText\` / \`waitForRegex\`, dialogs, progress). Call \`scripts_reference\` before authoring or editing scripts. Run scripts with \`scripts_run\` (set \`wait: true\` to block until done); use \`scripts_runs_list\`, \`scripts_run_stop\`, \`scripts_run_pause\`, and \`scripts_run_resume\` for lifecycle control. Create/update/delete vault entries with \`snippets_create/update/delete\` (any kind) or \`scripts_create/update/delete\` (scripts only).
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChatMessage } from '../types';
|
||||
import type { ExternalBridgeHistoryMessage } from './types';
|
||||
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from '../../../components/ai/cattyHistoryReplay';
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from '../../../application/state/vaultNoteAttachment';
|
||||
|
||||
type ExternalAgentHistoryMessage = ExternalBridgeHistoryMessage;
|
||||
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
|
||||
@@ -173,6 +174,13 @@ function summarizeMessage(
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user") return null;
|
||||
const notes = message.attachments?.filter(isVaultNoteAttachment) ?? [];
|
||||
if (notes.length) {
|
||||
// Budget prose separately: the reference instruction must not consume the request's 280 characters.
|
||||
const nonNotes = message.attachments?.filter((attachment) => !isVaultNoteAttachment(attachment));
|
||||
const request = truncateText(normalizeWhitespace(buildHistoricalUserReplayContent(message.content || '', nonNotes)), MAX_DURABLE_USER_MESSAGE_CHARS);
|
||||
return `User request: ${request}\n${formatVaultNoteReferences(notes)}`;
|
||||
}
|
||||
const content = getUserHistoryContent(message);
|
||||
if (!content) return null;
|
||||
if (isTrivialUserMessage(content)) return null;
|
||||
@@ -442,4 +450,4 @@ export function buildExternalBridgeContextMessages(messages: ChatMessage[]): Ext
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
}
|
||||
@@ -894,12 +894,32 @@
|
||||
"toolName": "vault_notes_get",
|
||||
"rpcMethod": "vault/notes/get",
|
||||
"localExecution": false,
|
||||
"description": "Get a Vault → Notes entry by id (full markdown content).",
|
||||
"description": "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
|
||||
"inputShape": {
|
||||
"noteId": {
|
||||
"type": "string",
|
||||
"optional": false,
|
||||
"description": "Vault note ID from vault_notes_list."
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"optional": true,
|
||||
"description": "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset."
|
||||
},
|
||||
"maxChars": {
|
||||
"type": "number",
|
||||
"optional": true,
|
||||
"description": "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000."
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search."
|
||||
},
|
||||
"expectedUpdatedAt": {
|
||||
"type": "number",
|
||||
"optional": true,
|
||||
"description": "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -946,12 +946,32 @@
|
||||
"toolName": "vault_notes_get",
|
||||
"rpcMethod": "vault/notes/get",
|
||||
"localExecution": false,
|
||||
"description": "Get a Vault → Notes entry by id (full markdown content).",
|
||||
"description": "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
|
||||
"inputShape": {
|
||||
"noteId": {
|
||||
"type": "string",
|
||||
"optional": false,
|
||||
"description": "Vault note ID from vault_notes_list."
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"optional": true,
|
||||
"description": "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset."
|
||||
},
|
||||
"maxChars": {
|
||||
"type": "number",
|
||||
"optional": true,
|
||||
"description": "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000."
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search."
|
||||
},
|
||||
"expectedUpdatedAt": {
|
||||
"type": "number",
|
||||
"optional": true,
|
||||
"description": "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../../../components/ai/cattyHistoryReplay';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
isInlineTextAttachment,
|
||||
} from '../../../../application/state/terminalSelectionAttachment';
|
||||
import {
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
@@ -411,7 +411,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
|
||||
if (attachments?.length) {
|
||||
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter(
|
||||
(attachment) => !isTerminalSelectionAttachment(attachment),
|
||||
(attachment) => !isInlineTextAttachment(attachment),
|
||||
);
|
||||
if (!modelAttachments.length) {
|
||||
sdkMessages.push({ role: 'user', content: modelText });
|
||||
|
||||
@@ -105,6 +105,10 @@ export interface ChatMessageAttachment {
|
||||
filename?: string;
|
||||
filePath?: string; // original filesystem path, when available
|
||||
terminalSelection?: boolean;
|
||||
/** Set when the attachment was created from a Vault → Notes mention. */
|
||||
vaultNoteId?: string;
|
||||
/** Original note title, for display and prompt labels. */
|
||||
vaultNoteTitle?: string;
|
||||
previewText?: string;
|
||||
lineCount?: number;
|
||||
}
|
||||
@@ -117,6 +121,10 @@ export interface UploadedFile {
|
||||
mediaType: string;
|
||||
filePath?: string;
|
||||
terminalSelection?: boolean;
|
||||
/** Set when the attachment was created from a Vault → Notes mention. */
|
||||
vaultNoteId?: string;
|
||||
/** Original note title, for display and prompt labels. */
|
||||
vaultNoteTitle?: string;
|
||||
previewText?: string;
|
||||
lineCount?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fitLargeToolResultForModel } from './harness/toolResultFitting';
|
||||
import type { GroupConfig, Host, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, VaultNote } from '../../domain/models';
|
||||
import { handleVaultAgentOp, runSerializedVaultAgentRequest, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
|
||||
|
||||
@@ -137,6 +139,46 @@ function createDeps(
|
||||
}
|
||||
|
||||
describe('handleVaultAgentOp vault notes', () => {
|
||||
it('bounds both Catty and MCP reads through the real service and forwards continuation/search parameters', async () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createVaultService } = require('../../electron/capabilities/services/vaultService.cjs');
|
||||
const { registerMcpTools } = require('../../electron/capabilities/codegen/mcpToolRegistry.cjs');
|
||||
const content = Array.from({ length: 10_000 }, (_, i) => `line ${i}: example text\n`).join('');
|
||||
const deps = createDeps({ notes: [{ id: 'note-1', title: 'Long', content, createdAt: 1, updatedAt: 2 }] });
|
||||
const service = createVaultService({ invokeVaultAgent: (op: string, params: Record<string, unknown>) => handleVaultAgentOp(op, params, deps) });
|
||||
type Result = { note: { content: string }; nextOffset: number | null; matchOffset?: number };
|
||||
const handlers = new Map<string, (params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>>();
|
||||
registerMcpTools({ tool: (...args: unknown[]) => handlers.set(args[0] as string, args.at(-1) as NonNullable<ReturnType<typeof handlers.get>>) }, {
|
||||
rpcCall: (_method: string, params: Record<string, unknown>) => service.getNote(params),
|
||||
scopeParams: {}, guardWriteOperation: () => null, catalogDescription: (_name: string, description: string) => description,
|
||||
});
|
||||
const get = handlers.get('vault_notes_get')!;
|
||||
let offset: number | null = 0;
|
||||
let collected = '';
|
||||
while (offset !== null) {
|
||||
const params = { noteId: 'note-1', offset, maxChars: 9000, expectedUpdatedAt: 2 };
|
||||
const raw = await service.getNote(params);
|
||||
const catty = fitLargeToolResultForModel({ result: raw, capabilityId: 'vault.note.get' });
|
||||
const mcp = JSON.parse((await get(params)).content[0].text) as Result;
|
||||
assert.deepEqual(catty, mcp);
|
||||
assert.ok(mcp.note.content.length <= 6000);
|
||||
collected += mcp.note.content;
|
||||
offset = mcp.nextOffset;
|
||||
}
|
||||
assert.equal(collected, content);
|
||||
const found = JSON.parse((await get({ noteId: 'note-1', query: 'line 9999:', maxChars: 20 })).content[0].text) as Result;
|
||||
assert.equal(found.matchOffset, content.indexOf('line 9999:'));
|
||||
assert.equal(found.note.content.length, 20);
|
||||
assert.equal((await service.getNote({ noteId: 'note-1', expectedUpdatedAt: 1 })).ok, false);
|
||||
assert.equal((await service.getNote({ noteId: 'missing' })).ok, false);
|
||||
const importedId = 'x'.repeat(201);
|
||||
deps.updateNotes([{ id: importedId, title: 'Imported', content, createdAt: 1, updatedAt: 2 }]);
|
||||
const imported = await service.getNote({ noteId: importedId });
|
||||
assert.equal(imported.ok, true);
|
||||
assert.equal(imported.note.id, importedId);
|
||||
assert.equal(imported.note.content.length, 6000);
|
||||
});
|
||||
|
||||
it('note.create persists to updateNotes and returns the new note', async () => {
|
||||
const updated: VaultNote[][] = [];
|
||||
const deps = createDeps({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { applySnippetVariables, parseSnippetVariables } from '../../domain/snippetVariables';
|
||||
import { getNextVaultOrder } from '../../domain/vaultOrder';
|
||||
import { readVaultNote } from '../../domain/vaultNoteRead';
|
||||
import {
|
||||
runAutomationScript,
|
||||
stopScriptRun,
|
||||
@@ -931,7 +932,7 @@ export async function handleVaultAgentOp(
|
||||
const noteId = String(params.noteId || '');
|
||||
const note = deps.getNotes().find((entry) => entry.id === noteId);
|
||||
if (!note) return { ok: false, error: `Vault note "${noteId}" was not found.` };
|
||||
return { ok: true, note: serializeVaultNoteForAgent(note) };
|
||||
return readVaultNote(note, params);
|
||||
}
|
||||
case 'note.create': {
|
||||
const title = sanitizeNoteTitle(params.title);
|
||||
|
||||
Reference in new issue
Block a user