Compare commits

...
Author SHA1 Message Date
bincxz 017606bd61 Keep note picker selection and untitled labels consistent 2026-09-07 14:21:13 +08:00
bincxz c8e4346193 Keep imported long note identities readable 2026-09-07 14:08:20 +08:00
bincxz d6e47d18a5 Retain mixed attachment context in compact note replay 2026-09-07 13:57:00 +08:00
bincxz 64c4ce9e9d Preserve compact note requests and announce picker selection 2026-09-07 13:49:14 +08:00
bincxz 69c5ccdb4b Bound agent note reads and add resumable literal search 2026-09-07 13:39:20 +08:00
bincxz 0e43e80555 fix(ai): keep accepted note references within replay space 2026-09-07 13:01:16 +08:00
bincxz d7ec12037e refactor(ai): share one instruction across note references 2026-09-07 12:53:58 +08:00
bincxz 43a35a5e62 fix(ai): retain note references ahead of replay prose 2026-09-07 12:52:21 +08:00
bincxz d78738e96d fix(ai): gate note references on reader availability 2026-09-07 12:45:58 +08:00
bincxz d8975c6fb2 refactor(ai): keep note mentions as lightweight references 2026-09-07 12:34:17 +08:00
bincxz 979dccc0b3 fix(ai): protect continuation before pruning note payloads 2026-09-07 12:18:27 +08:00
bincxz fd1b433940 fix(ai): reject carriage returns in note references 2026-09-07 12:04:00 +08:00
bincxz 432cf8d323 fix(ai): retain draft state binding in panel host 2026-09-07 11:51:18 +08:00
bincxz 4f3f72116e fix(ai): reuse normalized note search fields while typing 2026-09-07 11:39:00 +08:00
bincxz d6b47b5363 fix(ai): apply note attachment budgets synchronously 2026-09-07 11:29:47 +08:00
netcatty-bot 7acc67bef2 fix: address Codex review on PR #3290 2026-09-06 17:34:07 +00:00
netcatty-bot 9bf5108f4c fix: address Codex review on PR #3290 2026-09-06 17:05:33 +00:00
netcatty-bot 7683341270 fix: address Codex review on PR #3290 2026-09-06 16:41:50 +00:00
netcatty-bot 758dc26641 fix: address Codex review on PR #3290 2026-09-06 15:57:11 +00:00
netcatty-bot 3153158b12 fix: address Codex review on PR #3290 2026-09-06 15:32:54 +00:00
netcatty-bot 89c23e4453 fix: address Codex review on PR #3290 2026-09-06 15:07:31 +00:00
netcatty-bot 3a41b6f462 fix: address Codex review on PR #3290 2026-09-06 14:34:32 +00:00
netcatty-bot a6d326664e fix: address Codex review on PR #3290 2026-09-06 14:08:05 +00:00
netcatty-bot d6a553691c fix: address Codex review on PR #3290 2026-09-06 13:34:52 +00:00
netcatty-bot 2783684d10 fix: address Codex review on PR #3290 2026-09-06 13:03:00 +00:00
netcatty-bot 6adef4c680 fix: address Codex review on PR #3290 2026-09-06 12:32:52 +00:00
netcatty-bot 3820635809 fix: address Codex review on PR #3290 2026-09-06 12:01:57 +00:00
netcatty-bot f99b150381 fix: address Codex review on PR #3290 2026-09-06 11:31:41 +00:00
netcatty-bot 882852ac73 fix: address Codex review on PR #3290 2026-09-06 11:05:55 +00:00
netcatty-bot 21c6d59b2f fix: address Codex review on PR #3290 2026-09-06 10:35:20 +00:00
netcatty-bot 5d3abd14d8 fix: address Codex review on PR #3290 2026-09-06 10:05:51 +00:00
netcatty-bot 400e7ed81d fix: address Codex review on PR #3290 2026-09-06 09:36:58 +00:00
netcatty-bot 0a083fe8b0 fix: address Codex review on PR #3290 2026-09-06 09:04:39 +00:00
netcatty-bot 3579a5c3d4 fix: address Codex review on PR #3290 2026-09-06 08:40:55 +00:00
netcatty-bot 452c2df34c fix: address Codex review on PR #3290 2026-09-06 08:00:29 +00:00
netcatty-bot b0c272a207 fix: address Codex review on PR #3290 2026-09-06 07:32:54 +00:00
netcatty-bot a60f75731b fix: address Codex review on PR #3290 2026-09-06 07:12:02 +00:00
netcatty-bot f2b4632931 fix(#3289): automated AI fix 2026-09-06 06:45:05 +00:00
31 changed files with 764 additions and 34 deletions

No files matched your search

+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': 'Команды /',
+7
View File
@@ -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': '快捷命令',
+7
View File
@@ -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);
});
+38
View File
@@ -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;
}
+5
View File
@@ -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}
+66 -10
View File
@@ -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}
+18
View File
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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}`}
+66
View File
@@ -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'));
});
}
+12 -7
View File
@@ -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 {
+16
View File
@@ -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]);
});
+15
View File
@@ -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);
+62
View File
@@ -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));
});
+52
View File
@@ -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 }),
};
}
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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 });
+8
View File
@@ -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({
+2 -1
View File
@@ -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);