From d7d86086f0f2a32ebccd23bf273a2ded935ce241 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 30 Aug 2026 23:33:40 +0800 Subject: [PATCH 01/25] fix: resume queued prompts after slash commands --- .changeset/calm-queues-compact.md | 5 +++++ source/app/App.tsx | 1 + source/hooks/useAppHandlers.spec.tsx | 12 ++++++++++++ source/hooks/useAppHandlers.tsx | 7 ++++++- 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-queues-compact.md diff --git a/.changeset/calm-queues-compact.md b/.changeset/calm-queues-compact.md new file mode 100644 index 000000000..02f93bf28 --- /dev/null +++ b/.changeset/calm-queues-compact.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Resume queued prompts after manual context compaction completes. Closes #1060. diff --git a/source/app/App.tsx b/source/app/App.tsx index 84ed4ed3f..5fd7e748d 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -509,6 +509,7 @@ export default function App({ setIsCancelling: appState.setIsCancelling, setDevelopmentMode: appState.setDevelopmentMode, setIsConversationComplete: appState.setIsConversationComplete, + onCommandComplete: drainQueuedUserMessage, setIsToolExecuting: appState.setIsToolExecuting, setActiveMode: appState.setActiveMode, setCheckpointLoadData: appState.setCheckpointLoadData, diff --git a/source/hooks/useAppHandlers.spec.tsx b/source/hooks/useAppHandlers.spec.tsx index a73a59891..5475132cc 100644 --- a/source/hooks/useAppHandlers.spec.tsx +++ b/source/hooks/useAppHandlers.spec.tsx @@ -35,6 +35,7 @@ interface ProbeOverrides { developmentMode?: DevelopmentMode; client?: LLMClient | null; messages?: Message[]; + onCommandComplete?: () => void; } let captured: AppHandlers | null = null; @@ -102,6 +103,7 @@ function makeProps(overrides: ProbeOverrides) { setIsCancelling, setDevelopmentMode, setIsConversationComplete, + onCommandComplete: overrides.onCommandComplete, setIsToolExecuting, setActiveMode, setCheckpointLoadData, @@ -190,6 +192,16 @@ test('returns the expected handler surface', t => { t.is(typeof handlers.handleMessageSubmit, 'function'); }); +test('forwards slash-command completion so queued work can resume', async t => { + const onCommandComplete = spy<[]>(); + const {handlers, spies} = setup({onCommandComplete}); + + await handlers.handleMessageSubmit('/compact'); + + t.deepEqual(spies.setIsConversationComplete.calls, [[false], [true]]); + t.is(onCommandComplete.calls.length, 1); +}); + test('handleCancel without an abort controller is a no-op', t => { const { handlers, spies } = setup({ abortController: null }); diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index eef7c9a46..2aef3f599 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -73,6 +73,8 @@ interface UseAppHandlersProps { // Callbacks onClearCounterIncrement?: () => void; + /** Called after a slash command finishes, including delayed completions. */ + onCommandComplete?: () => void; // State setters updateMessages: (newMessages: Message[]) => void; @@ -696,7 +698,10 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { onAddToChatQueue: props.addToChatQueue, setLiveComponent: props.setLiveComponent, setIsToolExecuting: props.setIsToolExecuting, - onCommandComplete: () => props.setIsConversationComplete(true), + onCommandComplete: () => { + props.setIsConversationComplete(true); + props.onCommandComplete?.(); + }, setMessages: props.updateMessages, messages: props.messages, provider: props.currentProvider, From 762139cfbb8721b8269ce916058545cd7b033b22 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Mon, 31 Aug 2026 05:00:30 +0800 Subject: [PATCH 02/25] fix: safely resume queued prompts after commands --- .changeset/calm-queues-compact.md | 2 +- source/app/App.tsx | 44 ----------------- source/app/sections/interactive-app.spec.tsx | 39 +++++++++++++-- source/app/sections/interactive-app.tsx | 52 ++++++++++++++++++++ source/app/utils/app-util.spec.ts | 16 ++++++ source/components/user-input.spec.tsx | 4 +- source/components/user-input.tsx | 8 +-- source/hooks/useAppHandlers.tsx | 1 + 8 files changed, 109 insertions(+), 57 deletions(-) diff --git a/.changeset/calm-queues-compact.md b/.changeset/calm-queues-compact.md index 02f93bf28..e0f8b5575 100644 --- a/.changeset/calm-queues-compact.md +++ b/.changeset/calm-queues-compact.md @@ -2,4 +2,4 @@ "@nanocollective/nanocoder": patch --- -Resume queued prompts after manual context compaction completes. Closes #1060. +Resume queued prompts after slash commands and manual context compaction complete. Closes #1060. diff --git a/source/app/App.tsx b/source/app/App.tsx index 5fd7e748d..7d062b399 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -44,7 +44,6 @@ import {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import {useVSCodeServer} from '@/hooks/useVSCodeServer'; import {getAllSubagentProgress} from '@/services/subagent-events'; import {generateKey} from '@/session/key-generator'; -import type {ImageAttachment} from '@/types/core'; import type {ThemePreset} from '@/types/ui'; import {createPinoLogger} from '@/utils/logging/pino-logger'; import {setGlobalMessageQueue} from '@/utils/message-queue'; @@ -84,14 +83,6 @@ export default function App({ // Use extracted hooks const appState = useAppState(initialDevelopmentMode); const userMessageQueue = useUserMessageQueue(); - const queuedUserSubmitRef = React.useRef< - | (( - message: string, - displayValue: string, - images?: ImageAttachment[], - ) => Promise) - | null - >(null); const {exit} = useApp(); const {isTrusted, handleConfirmTrust, isTrustLoading, isTrustedError} = useDirectoryTrust(); @@ -249,35 +240,6 @@ export default function App({ } }, []); - const drainQueuedUserMessage = React.useCallback(() => { - // Defer to a macrotask, not a microtask. `onConversationComplete` fires - // deep inside the finishing turn's await chain, so a microtask drain would - // start the next turn BEFORE that turn's `resetStreamingState()` finally - // runs — and the stale reset would then wipe the new turn's abortController - // and isGenerating, leaving the busy indicator (and Escape-to-cancel) dead. - // A timeout runs after those continuations, so the drained turn keeps its - // busy state. - setTimeout(() => { - void userMessageQueue.drainNextMessage(async message => { - const submitQueuedMessage = queuedUserSubmitRef.current; - if (!submitQueuedMessage || !appState.client || !appState.toolManager) { - return false; - } - - await submitQueuedMessage( - message.message, - message.displayValue, - message.images, - ); - return true; - }); - }, 0); - }, [ - appState.client, - appState.toolManager, - userMessageQueue.drainNextMessage, - ]); - // Setup chat handler const chatHandler = useChatHandler({ client: appState.client, @@ -299,7 +261,6 @@ export default function App({ appState.setCompactToolCounts(null); appState.compactToolCountsRef.current = {}; appState.setLiveTaskList(null); - drainQueuedUserMessage(); }, // A turn that started in plan mode finished uninterrupted — a plan was // produced. Flag it so the interactive UI can show the plan review bar. @@ -509,7 +470,6 @@ export default function App({ setIsCancelling: appState.setIsCancelling, setDevelopmentMode: appState.setDevelopmentMode, setIsConversationComplete: appState.setIsConversationComplete, - onCommandComplete: drainQueuedUserMessage, setIsToolExecuting: appState.setIsToolExecuting, setActiveMode: appState.setActiveMode, setCheckpointLoadData: appState.setCheckpointLoadData, @@ -571,10 +531,6 @@ export default function App({ activeEditor: vscodeServer.activeEditor, }); - React.useEffect(() => { - queuedUserSubmitRef.current = handleUserSubmit; - }, [handleUserSubmit]); - // Setup non-interactive mode const {nonInteractiveLoadingMessage} = useNonInteractiveMode({ nonInteractivePrompt, diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index d8e8cea95..4b96b5467 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -41,6 +41,13 @@ interface Overrides { setPendingPlanProceed?: (v: string | null) => void; handleMessageSubmit?: (message: string) => Promise; currentSessionId?: string | null; + toolManager?: unknown; + queuedMessages?: Array<{id: string; message: string; displayValue: string}>; + handleUserSubmit?: (message: string) => Promise; + drainNextMessage?: ( + dispatch: (message: {id: string; message: string; displayValue: string}) => + boolean | Promise, + ) => boolean | Promise; } function makeProps(o: Overrides = {}) { @@ -49,6 +56,7 @@ function makeProps(o: Overrides = {}) { const appState = { client: o.client ?? null, + toolManager: o.toolManager ?? null, messages: o.messages ?? [], currentModel: 'mock-model', currentProvider: 'mock', @@ -140,16 +148,16 @@ function makeProps(o: Overrides = {}) { pendingToolConfirmation: null, handleToolConfirmation: noop, handleQuestionAnswer: noop, - handleUserSubmit: noopAsync, + handleUserSubmit: o.handleUserSubmit ?? noopAsync, userMessageQueue: { - queuedMessages: [], + queuedMessages: o.queuedMessages ?? [], enqueueMessage: () => ({ id: 'queued-test', message: '', displayValue: '', }), removeMessage: noop, - drainNextMessage: () => false, + drainNextMessage: o.drainNextMessage ?? (() => false), }, handleIdeSelect: noop, } as never; @@ -160,6 +168,31 @@ test('renders without crashing in default state', t => { t.truthy(lastFrame()); }); +test('does not drain queued prompts while a turn is generating', async t => { + let submitted = false; + const {unmount} = renderWithTheme( + { + submitted = true; + }, + })} + />, + ); + + await new Promise(resolve => setTimeout(resolve, 25)); + t.false(submitted); + unmount(); +}); + test('renders the static-component marker through ChatHistory', t => { const {lastFrame} = renderWithTheme( , diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index b2c1b135e..a079d8aac 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -99,6 +99,7 @@ export function InteractiveApp({ React.useState(null); const [restoredDraft, setRestoredDraft] = React.useState(null); + const drainInProgressRef = React.useRef(false); const handleToggleCompactDisplay = () => { const expanding = appState.compactToolDisplay; @@ -180,6 +181,57 @@ export function InteractiveApp({ appState.isToolExecuting || appState.abortController !== null); + // Drain queued prompts only after the previous turn is fully idle and all + // modal modes have closed. Command handlers and conversation completion can + // both signal completion, so keeping the drain here makes it idempotent and + // prevents nested or duplicate turns. + React.useEffect(() => { + if ( + cancellable || + appState.activeMode !== null || + appState.isSettingsMode || + !appState.isConversationComplete || + userMessageQueue.queuedMessages.length === 0 || + drainInProgressRef.current + ) { + return; + } + + drainInProgressRef.current = true; + let started = false; + const timeout = setTimeout(() => { + started = true; + void userMessageQueue + .drainNextMessage(async message => { + if (!appState.client || !appState.toolManager) return false; + await handleUserSubmit( + message.message, + message.displayValue, + message.images, + ); + return true; + }) + .finally(() => { + drainInProgressRef.current = false; + }); + }, 0); + + return () => { + clearTimeout(timeout); + if (!started) drainInProgressRef.current = false; + }; + }, [ + appState.activeMode, + appState.client, + appState.isConversationComplete, + appState.isSettingsMode, + appState.toolManager, + cancellable, + handleUserSubmit, + userMessageQueue.drainNextMessage, + userMessageQueue.queuedMessages.length, + ]); + const recallableSubmittedDraft = cancellable && chatHandler.isGenerating && diff --git a/source/app/utils/app-util.spec.ts b/source/app/utils/app-util.spec.ts index 5b608a247..0eb06749c 100644 --- a/source/app/utils/app-util.spec.ts +++ b/source/app/utils/app-util.spec.ts @@ -330,6 +330,22 @@ test.serial('chat message - displayValue is optional (callers without a placehol t.is(received.displayValue, undefined); }); +test.serial('delayed slash-command completion is delivered after the handler returns', async t => { + let completed = false; + const options = createResumeTestOptions({ + onCommandComplete: () => { + completed = true; + }, + }); + options.onShowStatus = () => {}; + + await handleMessageSubmission('/status', options); + + t.false(completed); + await new Promise(resolve => setTimeout(resolve, 125)); + t.true(completed); +}); + test.serial('retry command - /retry without a prior user turn shows an error', async t => { let queued: React.ReactNode = null; let submitted = false; diff --git a/source/components/user-input.spec.tsx b/source/components/user-input.spec.tsx index 8e82becb4..bef7894b2 100644 --- a/source/components/user-input.spec.tsx +++ b/source/components/user-input.spec.tsx @@ -419,14 +419,13 @@ test('UserInput navigates queued messages while busy with empty input', async t unmount(); }); -test('UserInput loads selected queued message for editing', async t => { +test('UserInput loads selected queued message for editing while idle', async t => { let removedId = ''; const {stdin, lastFrame, unmount} = render( { t.notRegex(output, /Available commands:/); unmount(); }); - diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index 927293465..1c4445e75 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -607,7 +607,7 @@ export default function UserInput({ const handleQueueNavigation = useCallback( (direction: 'up' | 'down') => { - if (!isBusy || input.length > 0 || queuedMessages.length === 0) { + if (input.length > 0 || queuedMessages.length === 0) { return false; } @@ -630,12 +630,11 @@ export default function UserInput({ setSelectedQueuedIndex(selectedQueuedIndex + 1); return true; }, - [isBusy, input.length, queuedMessages.length, selectedQueuedIndex], + [input.length, queuedMessages.length, selectedQueuedIndex], ); const loadSelectedQueuedMessage = useCallback(() => { if ( - !isBusy || input.length > 0 || selectedQueuedIndex < 0 || selectedQueuedIndex >= queuedMessages.length @@ -656,7 +655,6 @@ export default function UserInput({ setTextInputKey(prev => prev + 1); return true; }, [ - isBusy, input.length, selectedQueuedIndex, queuedMessages, @@ -666,7 +664,6 @@ export default function UserInput({ const removeSelectedQueuedMessage = useCallback(() => { if ( - !isBusy || input.length > 0 || selectedQueuedIndex < 0 || selectedQueuedIndex >= queuedMessages.length @@ -680,7 +677,6 @@ export default function UserInput({ ); return true; }, [ - isBusy, input.length, selectedQueuedIndex, queuedMessages, diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index 2aef3f599..d9540e114 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -747,6 +747,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.developmentMode, props.lastApiUsage, props.apiCallHistory, + props.onCommandComplete, clearMessages, enterCheckpointLoadMode, handleShowStatus, From 7af12f2a0a00c32426af6418c43bea8dc6af2145 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 31 Aug 2026 02:22:35 +0000 Subject: [PATCH 03/25] Update status badges [skip ci] --- badges/coverage.svg | 2 +- badges/forks.svg | 2 +- badges/repo-size.svg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/badges/coverage.svg b/badges/coverage.svg index 204c17353..67a2edb70 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -COVERAGE: 91.71%COVERAGE91.71% \ No newline at end of file +COVERAGE: 92.02%COVERAGE92.02% \ No newline at end of file diff --git a/badges/forks.svg b/badges/forks.svg index 6891326a2..e1ce2b1ff 100644 --- a/badges/forks.svg +++ b/badges/forks.svg @@ -1 +1 @@ -FORKS293 \ No newline at end of file +FORKS294 \ No newline at end of file diff --git a/badges/repo-size.svg b/badges/repo-size.svg index ad857e85c..1c98ceb20 100644 --- a/badges/repo-size.svg +++ b/badges/repo-size.svg @@ -1 +1 @@ -REPO SIZE: 34.8 MIBREPO SIZE34.8 MIB \ No newline at end of file +REPO SIZE: 35.2 MIBREPO SIZE35.2 MIB \ No newline at end of file From ddf7453be4d440569b198372317f1f9c6f13d10e Mon Sep 17 00:00:00 2001 From: kishore280 <70363583+kishore280@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:43:04 +0530 Subject: [PATCH 04/25] feat: migrate file search to a ripgrep-backed implementation (#928) * feat: migrate file search to a ripgrep-backed implementation Replace the hand-rolled JS file-search walker with one backed by the real `rg` binary (via @vscode/ripgrep) for a large speed improvement, per #889. - find_files and search_file_contents now shell out to rg for file listing and content search; file-autocomplete gets the same speedup for free via the shared walkProjectEntries. - Glob matching (find_files' pattern arg) uses a hand-written DP-table matcher instead of a compiled regex - the previous regex-based approach allowed catastrophic backtracking on adversarial patterns. - No --follow: a symlink checked into a project can point anywhere on disk: search must stay scoped to the project directory. - .gitignore handled via rg's own native discovery (--no-ignore-parent, --no-require-git), including nested .gitignore files; the supplementary empty-directory walker mirrors this with its own prefixed-pattern nested-.gitignore support. - Result limits enforced by streaming rg's own --json output and killing the process once enough matches are seen, rather than trusting rg's --max-count (which can overshoot with --context) or buffering unbounded output before truncating in JS. - Abort signals are wired to a manual child.kill() rather than spawn's own `signal` option, avoiding a race where Node's built-in abort handling drops the caller's abort reason. - Empty/whitespace-only queries and non-positive maxResults are rejected before ever reaching rg. Fixes two pre-existing upstream bugs needed to get a clean local install/test run: AVA 8's extensions option must be an array, and TypeScript 7 removed baseUrl (paths now use explicit ./ prefixes). * fix: stop a failed ripgrep spawn from stalling the process for the full timeout spawn({timeout}) only clears its timer on 'exit'. A spawn-time OS failure (bad cwd, missing binary) only emits 'error', so the timer stayed armed and delayed process exit by up to 30s. Own the timeout manually instead, cleared on both 'error' and 'close'. * chore: add changeset for ripgrep file search migration * fix: drop system rg probe, add --no-config to close 3 blocking review issues * fix: stop excluding binary-extension files from find_files/autocomplete * fix: make file-search respect .nanocoderignore * chore: update changeset * fix: address ripgrep-backed file search review items Path validation, PCRE2 dialect detection, empty-dir walk skip, brace-expansion DoS cap, raw-scan bound, context headroom. * fix: dedupe ignore-rule construction and clarify walkProjectEntries API * fix: use ripgrep's --engine auto for PCRE2 detection instead of hand-rolled regex * fix: stop runRipgrep's stdout from overshooting maxLines/maxMatches within a chunk * test: shrink the stdout-overshoot regression test to 200 async-written files * fix: widen rgMaxCount headroom to contextLines, not a flat +1 * fix: bound globTokenCache by total token count, not entry count * fix: stream findMatchingPaths results instead of sorting then buffering * docs: replace the sorted-option comment's implied guarantee with the measured distribution * test: add resetRipgrepPathCache and proper test isolation for ripgrep-path.spec.ts * fix: match directory-only .gitignore patterns in walkEmptyDirectories --- .changeset/ripgrep-file-search.md | 7 + package.json | 2 + pnpm-lock.yaml | 120 ++++ source/repo-map/index.ts | 64 +- source/utils/file-autocomplete.ts | 13 +- source/utils/file-search.spec.ts | 996 +++++++++++++++++++++++++- source/utils/file-search.ts | 1077 +++++++++++++++++++++++------ source/utils/ripgrep-path.spec.ts | 33 + source/utils/ripgrep-path.ts | 15 + 9 files changed, 2094 insertions(+), 233 deletions(-) create mode 100644 .changeset/ripgrep-file-search.md create mode 100644 source/utils/ripgrep-path.spec.ts create mode 100644 source/utils/ripgrep-path.ts diff --git a/.changeset/ripgrep-file-search.md b/.changeset/ripgrep-file-search.md new file mode 100644 index 000000000..0f5400fa5 --- /dev/null +++ b/.changeset/ripgrep-file-search.md @@ -0,0 +1,7 @@ +--- +"@nanocollective/nanocoder": minor +--- + +File search (path matching and content search) is now backed by `ripgrep` instead of a hand-rolled JS walker. + +Search also respects `.nanocoderignore` and binary files again, matching `list_directory` and file autocomplete. diff --git a/package.json b/package.json index 5fe0eaaa3..3c694e33b 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@nanocollective/get-md": "^1.6.0", "@nanocollective/prompt-scrub": "^1.0.1", + "@vscode/ripgrep": "^1.18.0", "ai": "6.0.193", "chalk": "^6.0.0", "chokidar": "^5.0.0", @@ -95,6 +96,7 @@ "ink-spinner": "^5.0.0", "ink-tab": "^5.2.0", "llama-tokenizer-js": "^1.2.2", + "lru-cache": "^11.5.1", "pino": "^10.1.0", "pino-pretty": "^13.1.3", "pino-roll": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e9cc4bf2..423355136 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@nanocollective/prompt-scrub': specifier: ^1.0.1 version: 1.0.1 + '@vscode/ripgrep': + specifier: ^1.18.0 + version: 1.18.0 ai: specifier: 6.0.193 version: 6.0.193(zod@4.4.3) @@ -93,6 +96,9 @@ importers: llama-tokenizer-js: specifier: ^1.2.2 version: 1.2.2 + lru-cache: + specifier: ^11.5.1 + version: 11.5.1 pino: specifier: ^10.1.0 version: 10.3.1 @@ -1752,6 +1758,69 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vscode/ripgrep-darwin-arm64@1.18.0': + resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} + cpu: [arm64] + os: [darwin] + + '@vscode/ripgrep-darwin-x64@1.18.0': + resolution: {integrity: sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==} + cpu: [x64] + os: [darwin] + + '@vscode/ripgrep-linux-arm64@1.18.0': + resolution: {integrity: sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==} + cpu: [arm64] + os: [linux] + + '@vscode/ripgrep-linux-arm@1.18.0': + resolution: {integrity: sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==} + cpu: [arm] + os: [linux] + + '@vscode/ripgrep-linux-ia32@1.18.0': + resolution: {integrity: sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==} + cpu: [ia32] + os: [linux] + + '@vscode/ripgrep-linux-ppc64@1.18.0': + resolution: {integrity: sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==} + cpu: [ppc64] + os: [linux] + + '@vscode/ripgrep-linux-riscv64@1.18.0': + resolution: {integrity: sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==} + cpu: [riscv64] + os: [linux] + + '@vscode/ripgrep-linux-s390x@1.18.0': + resolution: {integrity: sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==} + cpu: [s390x] + os: [linux] + + '@vscode/ripgrep-linux-x64@1.18.0': + resolution: {integrity: sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==} + cpu: [x64] + os: [linux] + + '@vscode/ripgrep-win32-arm64@1.18.0': + resolution: {integrity: sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==} + cpu: [arm64] + os: [win32] + + '@vscode/ripgrep-win32-ia32@1.18.0': + resolution: {integrity: sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==} + cpu: [ia32] + os: [win32] + + '@vscode/ripgrep-win32-x64@1.18.0': + resolution: {integrity: sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==} + cpu: [x64] + os: [win32] + + '@vscode/ripgrep@1.18.0': + resolution: {integrity: sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==} + '@vscode/vsce-sign-alpine-arm64@2.0.6': resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} cpu: [arm64] @@ -5729,6 +5798,57 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vscode/ripgrep-darwin-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-darwin-x64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-arm@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-linux-ppc64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-riscv64@1.18.0': + optional: true + + '@vscode/ripgrep-linux-s390x@1.18.0': + optional: true + + '@vscode/ripgrep-linux-x64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-arm64@1.18.0': + optional: true + + '@vscode/ripgrep-win32-ia32@1.18.0': + optional: true + + '@vscode/ripgrep-win32-x64@1.18.0': + optional: true + + '@vscode/ripgrep@1.18.0': + optionalDependencies: + '@vscode/ripgrep-darwin-arm64': 1.18.0 + '@vscode/ripgrep-darwin-x64': 1.18.0 + '@vscode/ripgrep-linux-arm': 1.18.0 + '@vscode/ripgrep-linux-arm64': 1.18.0 + '@vscode/ripgrep-linux-ia32': 1.18.0 + '@vscode/ripgrep-linux-ppc64': 1.18.0 + '@vscode/ripgrep-linux-riscv64': 1.18.0 + '@vscode/ripgrep-linux-s390x': 1.18.0 + '@vscode/ripgrep-linux-x64': 1.18.0 + '@vscode/ripgrep-win32-arm64': 1.18.0 + '@vscode/ripgrep-win32-ia32': 1.18.0 + '@vscode/ripgrep-win32-x64': 1.18.0 + '@vscode/vsce-sign-alpine-arm64@2.0.6': optional: true diff --git a/source/repo-map/index.ts b/source/repo-map/index.ts index 429d46d54..081cd5e75 100644 --- a/source/repo-map/index.ts +++ b/source/repo-map/index.ts @@ -312,40 +312,42 @@ async function scanFiles( const files: ScannedFile[] = []; let truncated = false; - await walkProjectEntries(cwd, undefined, async entry => { - if (entry.isDirectory) { - return false; - } - const language = languageFor(entry.relativePath); - if (!language) { - return false; - } - // Checked before the push so a repo holding exactly `maxFiles` indexable - // files is not reported as truncated. - if (files.length >= maxFiles) { - truncated = true; - return true; - } + const walkResult = await walkProjectEntries( + cwd, + undefined, + async entry => { + const language = languageFor(entry.relativePath); + if (!language) { + return false; + } + // Checked before the push so exactly `maxFiles` files isn't reported as truncated. + if (files.length >= maxFiles) { + truncated = true; + return true; + } - let source: string; - try { - source = await readFile(entry.absolutePath, 'utf-8'); - } catch { - return false; - } - if (source.length > maxFileBytes) { - return false; - } + let source: string; + try { + source = await readFile(entry.absolutePath, 'utf-8'); + } catch { + return false; + } + if (source.length > maxFileBytes) { + return false; + } - const stripped = stripNoise(source, language); - files.push({ - path: entry.relativePath.replace(/\\/g, '/'), - definitions: extractDefinitions(stripped, language), - references: countReferences(stripped, language), - }); + const stripped = stripNoise(source, language); + files.push({ + path: entry.relativePath.replace(/\\/g, '/'), + definitions: extractDefinitions(stripped, language), + references: countReferences(stripped, language), + }); - return false; - }); + return false; + }, + {includeDirectories: false}, + ); + truncated = truncated || walkResult.truncated; return {files, truncated}; } diff --git a/source/utils/file-autocomplete.ts b/source/utils/file-autocomplete.ts index 7e165b49a..b668e8c3c 100644 --- a/source/utils/file-autocomplete.ts +++ b/source/utils/file-autocomplete.ts @@ -31,12 +31,15 @@ async function getAllFiles(cwd: string): Promise { try { const allFiles: string[] = []; - await walkProjectEntries(cwd, undefined, entry => { - if (!entry.isDirectory) { + await walkProjectEntries( + cwd, + undefined, + entry => { allFiles.push(entry.relativePath.replace(/\\/g, '/')); - } - return false; - }); + return false; + }, + {includeDirectories: false}, + ); fileListCache = { files: allFiles, diff --git a/source/utils/file-search.spec.ts b/source/utils/file-search.spec.ts index 3038d1efc..f7c478886 100644 --- a/source/utils/file-search.spec.ts +++ b/source/utils/file-search.spec.ts @@ -1,13 +1,18 @@ -import {mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {mkdirSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; import { findMatchingPaths, + GLOB_TOKEN_CACHE_MAX_TOKENS, + globTokenCache, + isFatalRipgrepError, matchesGlob, searchProjectContents, SearchTimeoutError, + walkProjectEntries, } from './file-search'; function createTempDir(name: string): string { @@ -29,6 +34,98 @@ test('matchesGlob normalizes Windows-style separators in path and pattern', t => t.true(matchesGlob('src\\components\\Button.tsx', 'src\\**\\*.tsx')); }); +test('matchesGlob handles glob edge cases found during the regex-to-DP rewrite', t => { + // Leading '**/' can vanish entirely (zero directories). + t.true(matchesGlob('index.ts', '**/*.ts')); + t.true(matchesGlob('a/b/c/index.ts', '**/*.ts')); + // Embedded '**/' can also vanish entirely. + t.true(matchesGlob('a/index.ts', 'a/**/index.ts')); + t.true(matchesGlob('a/b/c/index.ts', 'a/**/index.ts')); + // Trailing '/**' requires a literal '/' to be present. + t.false(matchesGlob('a', 'a/**')); + t.true(matchesGlob('a/', 'a/**')); + t.true(matchesGlob('a/b', 'a/**')); + // '**' with no adjacent '/' still has slash-crossing power. + t.true(matchesGlob('x/xx/xxx', '*x**x')); + t.false(matchesGlob('a', '*a**a')); // needs two 'a's, only has one + t.true(matchesGlob('a/a', '*a**a')); + // Single '*' never crosses '/', even adjacent to '**'. + t.false(matchesGlob('a', 'a*/**')); + t.true(matchesGlob('ab/', 'a*/**')); +}); + +test('matchesGlob stays fast on a pattern shape that hangs a naive regex engine', t => { + // This shape hung for 20+ seconds against the old regex-based implementation. + const pathologicalPattern = `${'*a'.repeat(25)}b`; + const start = Date.now(); + const result = matchesGlob('a'.repeat(2000), pathologicalPattern); + t.true(Date.now() - start < 100); + t.false(result); +}); + +test('matchesGlob rejects a pattern longer than the sanity length cap', t => { + const error = t.throws(() => matchesGlob('a.ts', 'a'.repeat(1001))); + t.true(error instanceof Error); + t.regex(error?.message ?? '', /too long/); +}); + +test('matchesGlob rejects a pattern with too many brace-expansion combinations', t => { + // 200 sequential {a,b} groups hung for 30+ seconds before this cap existed. + const pattern = '{a,b}'.repeat(200); + const start = Date.now(); + const error = t.throws(() => matchesGlob('a'.repeat(400), pattern)); + t.true(Date.now() - start < 100); + t.true(error instanceof Error); + t.regex(error?.message ?? '', /too many brace-expansion combinations/); +}); + +test.serial( + 'globTokenCache stays bounded by total token count across many worst-case-sized entries', + t => { + // 6 sequential {aaaa,bbbb} groups is exactly MAX_BRACE_EXPANSIONS (2^6 = 64), padded near MAX_GLOB_PATTERN_LENGTH so each branch tokenizes to ~900+ tokens - a worst-case-sized entry. + const filler = 'a'.repeat(900); + const groups = '{aaaa,bbbb}'.repeat(6); + + for (let i = 0; i < 30; i++) { + const pattern = `p${i}_${filler}${groups}`; + matchesGlob('irrelevant/path.ts', pattern); + } + + t.true(globTokenCache.calculatedSize <= GLOB_TOKEN_CACHE_MAX_TOKENS); + }, +); + +test.serial( + 'matchesGlob reuses the cached tokenization for a repeated pattern', + t => { + const pattern = 'src/**/*.repeated-pattern-test.ts'; + matchesGlob('src/foo/repeated-pattern-test.ts', pattern); + t.true(globTokenCache.has(pattern)); + + const sizeBefore = globTokenCache.size; + matchesGlob('src/bar/repeated-pattern-test.ts', pattern); + t.is(globTokenCache.size, sizeBefore); + }, +); + +test.serial( + 'globTokenCache silently drops an entry whose own size exceeds the cache budget, and matchesGlob still works', + t => { + // Not reachable through matchesGlob itself (the two caps keep real patterns well under budget) - exercise the cache directly instead. + const oversized = [ + Array.from({length: GLOB_TOKEN_CACHE_MAX_TOKENS + 1}, () => ({ + type: 'literal' as const, + char: 'a', + })), + ]; + + globTokenCache.set('oversized-entry-test-key', oversized); + t.false(globTokenCache.has('oversized-entry-test-key')); + + t.true(matchesGlob('a.ts', '*.ts')); + }, +); + test.serial('findMatchingPaths returns files and directories cross-platform', async t => { const testDir = createTempDir('test-file-search-find-temp'); @@ -54,6 +151,419 @@ test.serial('findMatchingPaths returns files and directories cross-platform', as } }); +test.serial('findMatchingPaths finds an empty, nested directory', async t => { + const testDir = createTempDir('test-file-search-empty-dir-temp'); + + try { + mkdirSync(join(testDir, 'src', 'emptydir', 'nested', 'deeper'), { + recursive: true, + }); + writeFileSync(join(testDir, 'src', 'placeholder.ts'), 'export {};'); + + const shallow = await findMatchingPaths('emptydir', testDir, 50); + t.true(shallow.files.includes('src/emptydir')); + + const deep = await findMatchingPaths('deeper', testDir, 50); + t.true(deep.files.includes('src/emptydir/nested/deeper')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } +}); + +test.serial( + 'walkProjectEntries with includeDirectories=false never reports a directory, even an empty one', + async t => { + const testDir = createTempDir('test-file-search-no-dirs-temp'); + + try { + mkdirSync(join(testDir, 'src', 'emptydir'), {recursive: true}); + writeFileSync(join(testDir, 'src', 'placeholder.ts'), 'export {};'); + + const entries: {relativePath: string; isDirectory: boolean}[] = []; + await walkProjectEntries( + testDir, + undefined, + entry => { + entries.push(entry); + return false; + }, + {includeDirectories: false}, + ); + + t.false(entries.some(e => e.isDirectory)); + t.true(entries.some(e => e.relativePath === 'src/placeholder.ts')); + t.false(entries.some(e => e.relativePath === 'src/emptydir')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries reports truncated when the raw file-scan cap is hit', + async t => { + const testDir = createTempDir('test-file-search-raw-cap-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + for (let i = 0; i < 30; i++) { + writeFileSync(join(testDir, `f${i}.txt`), 'x'); + } + + const result = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 5}, + ); + t.true(result.truncated); + + const untruncated = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 1000}, + ); + t.false(untruncated.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries does not let stdout overshoot the raw file-scan cap within a single oversized chunk', + async t => { + const testDir = createTempDir('test-file-search-chunk-overshoot-temp'); + try { + mkdirSync(testDir, {recursive: true}); + // 200 files is enough for one stdout chunk to hold far more lines than the cap; batched async writes keep setup fast. + await Promise.all( + Array.from({length: 200}, (_, i) => + writeFile(join(testDir, `f${i}.txt`), 'x'), + ), + ); + let fileCount = 0; + const result = await walkProjectEntries( + testDir, + undefined, + entry => { + if (!entry.isDirectory) fileCount++; + return false; + }, + {includeDirectories: false, maxRawFilesScanned: 10}, + ); + t.is(fileCount, 10); + t.true(result.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries reports truncated when the empty-directory walk cap is hit, even with zero files', + async t => { + const testDir = createTempDir('test-file-search-dir-cap-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // No files at all, so only the JS-side empty-directory recursion can trip a cap here. + for (let i = 0; i < 30; i++) { + mkdirSync(join(testDir, `d${i}`), {recursive: true}); + } + + const result = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 5}, + ); + t.true(result.truncated); + + const untruncated = await walkProjectEntries( + testDir, + undefined, + () => false, + {maxRawFilesScanned: 1000}, + ); + t.false(untruncated.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries skips the empty-directory walk once the raw file-scan cap already fired', + async t => { + const testDir = createTempDir('test-file-search-skip-dir-walk-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + for (let i = 0; i < 30; i++) { + writeFileSync(join(testDir, `f${i}.txt`), 'x'); + } + mkdirSync(join(testDir, 'empty-dir'), {recursive: true}); + + const seenDirectories: string[] = []; + const result = await walkProjectEntries( + testDir, + undefined, + entry => { + if (entry.isDirectory) { + seenDirectories.push(entry.relativePath); + } + return false; + }, + {maxRawFilesScanned: 5}, + ); + + t.true(result.truncated); + // The empty-dir walk should have been skipped once the raw scan cap made the result incomplete. + t.false(seenDirectories.includes('empty-dir')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths hides an empty directory ignored by a nested .gitignore', + async t => { + const testDir = createTempDir('test-file-search-nested-gitignore-empty-temp'); + + try { + mkdirSync(join(testDir, 'pkg'), {recursive: true}); + writeFileSync(join(testDir, 'pkg', '.gitignore'), 'should-be-hidden\n'); + mkdirSync(join(testDir, 'pkg', 'should-be-hidden'), {recursive: true}); + mkdirSync(join(testDir, 'pkg', 'sub', 'should-be-hidden'), { + recursive: true, + }); + mkdirSync(join(testDir, 'pkg', 'still-visible'), {recursive: true}); + writeFileSync(join(testDir, 'pkg', 'kept.ts'), 'export {};'); + + const hidden = await findMatchingPaths('should-be-hidden', testDir, 50); + t.deepEqual(hidden.files, []); + + const visible = await findMatchingPaths('still-visible', testDir, 50); + t.true(visible.files.includes('pkg/still-visible')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths hides an empty directory ignored by a trailing-slash .gitignore pattern', + async t => { + // Uses a name outside DEFAULT_IGNORE_DIRS (not "dist") so the hardcoded exclusion list can't mask a regression in the trailing-slash check. + const testDir = createTempDir( + 'test-file-search-trailing-slash-gitignore-temp', + ); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, '.gitignore'), 'ignoredslash/\n'); + mkdirSync(join(testDir, 'ignoredslash'), {recursive: true}); + mkdirSync(join(testDir, 'empty'), {recursive: true}); + + const hidden = await findMatchingPaths('ignoredslash', testDir, 50); + t.deepEqual(hidden.files, []); + + const visible = await findMatchingPaths('empty', testDir, 50); + t.true(visible.files.includes('empty')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths and searchProjectContents respect .nanocoderignore', + async t => { + const testDir = createTempDir('test-file-search-nanocoderignore-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, '.nanocoderignore'), 'secret.txt\n'); + writeFileSync(testDir + '/secret.txt', 'findme_secret'); + writeFileSync(join(testDir, 'visible.txt'), 'findme_visible'); + + const pathResult = await findMatchingPaths('*.txt', testDir, 50); + t.deepEqual(pathResult.files, ['visible.txt']); + + const contentResult = await searchProjectContents( + 'findme_', + testDir, + 50, + false, + ); + t.deepEqual( + contentResult.matches.map(m => m.file), + ['visible.txt'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths lets .nanocoderignore un-ignore a DEFAULT_IGNORE_DIRS entry', + async t => { + const testDir = createTempDir('test-file-search-nanocoderignore-unignore-temp'); + + try { + mkdirSync(join(testDir, 'dist'), {recursive: true}); + writeFileSync(join(testDir, 'dist', 'bundle.js'), 'kept'); + writeFileSync(join(testDir, '.nanocoderignore'), '!dist\n!dist/**\n'); + + const result = await findMatchingPaths('bundle.js', testDir, 50); + t.deepEqual(result.files, ['dist/bundle.js']); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths lets .nanocoderignore un-ignore an empty dir hidden by root .gitignore', + async t => { + const testDir = createTempDir( + 'test-file-search-nanocoderignore-empty-dir-temp', + ); + + try { + mkdirSync(join(testDir, 'build-cache'), {recursive: true}); + writeFileSync(join(testDir, '.gitignore'), 'build-cache\n'); + writeFileSync(join(testDir, '.nanocoderignore'), '!build-cache\n'); + + const result = await findMatchingPaths('build-cache', testDir, 50); + t.deepEqual(result.files, ['build-cache']); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +// Symlinks are deliberately never followed - one could point anywhere outside the project. + +test.serial( + 'findMatchingPaths does not descend into a symlinked directory', + async t => { + const testDir = createTempDir('test-file-search-symlink-dir-temp'); + + try { + mkdirSync(join(testDir, 'real-target'), {recursive: true}); + writeFileSync(join(testDir, 'real-target', 'inner.ts'), 'export {};'); + symlinkSync( + join(testDir, 'real-target'), + join(testDir, 'linked-dir'), + 'junction', + ); + + const result = await findMatchingPaths('linked-dir', testDir, 50); + t.deepEqual(result.files, []); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths tolerates a symlink near an ancestor without hanging', + async t => { + const testDir = createTempDir('test-file-search-symlink-cycle-temp'); + + try { + mkdirSync(join(testDir, 'a', 'b'), {recursive: true}); + writeFileSync(join(testDir, 'normal.ts'), 'export {};'); + symlinkSync(testDir, join(testDir, 'a', 'b', 'loop'), 'junction'); + + const result = await findMatchingPaths('normal.ts', testDir, 50); + t.true(result.files.includes('normal.ts')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents does not read content behind a symlinked file', + async t => { + const testDir = createTempDir('test-file-search-symlink-file-temp'); + const targetDir = createTempDir('test-file-search-symlink-target-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + mkdirSync(targetDir, {recursive: true}); + writeFileSync(join(targetDir, 'real.ts'), 'searchTarget here'); + symlinkSync(join(targetDir, 'real.ts'), join(testDir, 'linked.ts'), 'file'); + + const result = await searchProjectContents('searchTarget', testDir, 10, false); + t.deepEqual(result.matches, []); + } finally { + rmSync(testDir, {recursive: true, force: true}); + rmSync(targetDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'a symlink pointing outside the project directory cannot leak content or paths', + async t => { + const projectDir = createTempDir('test-file-search-sandbox-project-temp'); + const outsideDir = createTempDir('test-file-search-sandbox-outside-temp'); + + try { + mkdirSync(join(projectDir, 'src'), {recursive: true}); + mkdirSync(outsideDir, {recursive: true}); + writeFileSync( + join(outsideDir, 'secret.txt'), + 'SECRET_OUTSIDE_CONTENT findme_outside', + ); + symlinkSync(outsideDir, join(projectDir, 'src', 'escape'), 'junction'); + + const contentResult = await searchProjectContents( + 'SECRET_OUTSIDE_CONTENT', + projectDir, + 10, + false, + ); + t.deepEqual(contentResult.matches, []); + + const fileResult = await findMatchingPaths('secret.txt', projectDir, 50); + t.deepEqual(fileResult.files, []); + } finally { + rmSync(projectDir, {recursive: true, force: true}); + rmSync(outsideDir, {recursive: true, force: true}); + } + }, +); + +test.serial('findMatchingPaths finds binary-extension files', async t => { + const testDir = createTempDir('test-file-search-binary-ext-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'icon.svg'), ''); + writeFileSync(join(testDir, 'photo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + writeFileSync(join(testDir, 'module.wasm'), Buffer.from([0x00, 0x61, 0x73, 0x6d])); + + const svgResult = await findMatchingPaths('*.svg', testDir, 50); + t.deepEqual(svgResult.files, ['icon.svg']); + + const pngResult = await findMatchingPaths('*.png', testDir, 50); + t.deepEqual(pngResult.files, ['photo.png']); + + const wasmResult = await findMatchingPaths('*.wasm', testDir, 50); + t.deepEqual(wasmResult.files, ['module.wasm']); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } +}); + test.serial('findMatchingPaths enforces maxResults and truncation', async t => { const testDir = createTempDir('test-file-search-max-temp'); @@ -71,6 +581,150 @@ test.serial('findMatchingPaths enforces maxResults and truncation', async t => { } }); +test.serial( + 'findMatchingPaths and searchProjectContents return nothing for a non-positive maxResults', + async t => { + const testDir = createTempDir('test-file-search-nonpositive-max-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'file.ts'), 'searchTarget'); + + for (const maxResults of [0, -1, -5]) { + const findResult = await findMatchingPaths('*.ts', testDir, maxResults); + t.deepEqual( + findResult, + {files: [], truncated: false}, + `findMatchingPaths maxResults=${maxResults}`, + ); + + const searchResult = await searchProjectContents( + 'searchTarget', + testDir, + maxResults, + false, + ); + t.deepEqual( + searchResult, + {matches: [], truncated: false}, + `searchProjectContents maxResults=${maxResults}`, + ); + } + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial('searchProjectContents rejects an empty or whitespace-only query', async t => { + const testDir = createTempDir('test-file-search-empty-query-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.ts'), 'content'); + + await t.throwsAsync(() => searchProjectContents('', testDir, 10, false)); + await t.throwsAsync(() => searchProjectContents(' ', testDir, 10, false)); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } +}); + +test.serial( + 'searchProjectContents stops early instead of buffering every match before truncating', + async t => { + const testDir = createTempDir('test-file-search-maxcount-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // rg should be killed once enough matches have streamed in, not left running. + const lines = Array.from({length: 500}, (_, i) => `searchTarget line ${i}`); + writeFileSync(join(testDir, 'big.ts'), lines.join('\n')); + + const result = await searchProjectContents('searchTarget', testDir, 5, false); + t.is(result.matches.length, 5); + t.true(result.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents stays bounded when context lines themselves also match (rg --max-count overshoot case)', + async t => { + const testDir = createTempDir('test-file-search-dense-context-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // Every line matches, so rg's own --max-count overshoots with --context (ripgrep#2843). + const lines = Array.from({length: 5000}, (_, i) => `searchTarget line ${i}`); + writeFileSync(join(testDir, 'dense.ts'), lines.join('\n')); + + const start = Date.now(); + const result = await searchProjectContents( + 'searchTarget', + testDir, + 5, + false, + undefined, + undefined, + undefined, + 3, + ); + const elapsed = Date.now() - start; + + t.is(result.matches.length, 5); + t.true(result.truncated); + t.true(elapsed < 5000, `expected a fast bounded search, took ${elapsed}ms`); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents keeps the full context window on the last match even when every context line is itself a match', + async t => { + const testDir = createTempDir('test-file-search-dense-context-headroom-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + // Every line matches, so the Nth match's trailing context lines stream in as type:"match" entries too, not type:"context". + const lines = Array.from({length: 5000}, (_, i) => `searchTarget line ${i}`); + writeFileSync(join(testDir, 'dense.ts'), lines.join('\n')); + + const contextLines = 3; + const result = await searchProjectContents( + 'searchTarget', + testDir, + 5, + false, + undefined, + undefined, + undefined, + contextLines, + ); + + t.is(result.matches.length, 5); + const lastMatch = result.matches[result.matches.length - 1]; + t.truthy(lastMatch); + for ( + let line = lastMatch.line - contextLines; + line <= lastMatch.line + contextLines; + line++ + ) { + t.true( + lastMatch.content.includes(`${line}: `), + `expected line ${line} in last match's context, got:\n${lastMatch.content}`, + ); + } + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + test.serial('searchProjectContents respects include, path, wholeWord and context', async t => { const testDir = createTempDir('test-file-search-search-temp'); @@ -113,6 +767,43 @@ test.serial('searchProjectContents respects include, path, wholeWord and context } }); +test.serial( + 'searchProjectContents gives each nearby match its own context block even when windows overlap', + async t => { + const testDir = createTempDir('test-file-search-context-overlap-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + const lines = Array.from({length: 12}, (_, i) => `line${i}`); + lines[4] = 'TARGET one'; + lines[6] = 'TARGET two'; + writeFileSync(join(testDir, 'a.txt'), lines.join('\n')); + + const result = await searchProjectContents( + 'TARGET', + testDir, + 10, + false, + undefined, + undefined, + undefined, + 2, + ); + + t.is(result.matches.length, 2); + t.is(result.matches[0]?.line, 5); + t.is(result.matches[1]?.line, 7); + // Both blocks share lines 5-7, which rg streams only once - each still gets its own block. + t.true(result.matches[0]?.content.includes('5: TARGET one')); + t.true(result.matches[0]?.content.includes('7: TARGET two')); + t.true(result.matches[1]?.content.includes('5: TARGET one')); + t.true(result.matches[1]?.content.includes('7: TARGET two')); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + test.serial('searchProjectContents skips ignored and binary files', async t => { const testDir = createTempDir('test-file-search-ignore-temp'); @@ -141,13 +832,103 @@ test.serial('searchProjectContents skips ignored and binary files', async t => { } }); +test.serial( + 'searchProjectContents respects a .gitignore nested in a subdirectory', + async t => { + const testDir = createTempDir('test-file-search-nested-gitignore-temp'); + + try { + mkdirSync(join(testDir, 'pkg'), {recursive: true}); + writeFileSync(join(testDir, 'pkg', '.gitignore'), 'ignored.ts\n'); + writeFileSync(join(testDir, 'pkg', 'ignored.ts'), 'searchTarget'); + writeFileSync(join(testDir, 'pkg', 'kept.ts'), 'searchTarget'); + + const result = await searchProjectContents('searchTarget', testDir, 10, false); + + t.deepEqual( + result.matches.map(match => match.file), + ['pkg/kept.ts'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents keeps binary excludes even under a broad include pattern', + async t => { + const testDir = createTempDir('test-file-search-include-order-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'code.ts'), 'searchTarget'); + writeFileSync(join(testDir, 'image.png'), 'searchTarget'); + + const result = await searchProjectContents( + 'searchTarget', + testDir, + 10, + false, + '**/*', + ); + + t.deepEqual( + result.matches.map(match => match.file), + ['code.ts'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents skips a file with NUL bytes even without a matching extension', + async t => { + const testDir = createTempDir('test-file-search-nul-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync( + join(testDir, 'weird.log'), + Buffer.from('searchTarget\0garbage\0bytes'), + ); + writeFileSync(join(testDir, 'clean.log'), 'searchTarget in a clean file'); + + const result = await searchProjectContents('searchTarget', testDir, 10, false); + + t.deepEqual( + result.matches.map(match => match.file), + ['clean.log'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'findMatchingPaths rejects quickly on a spawn failure instead of waiting out the timeout', + async t => { + // A nonexistent cwd makes spawn fail (ENOENT); this must not wait out the 30s timeout. + const bogusDir = join( + createTempDir('nonexistent-cwd'), + 'definitely', + 'does-not-exist', + ); + const start = Date.now(); + await t.throwsAsync(() => findMatchingPaths('a.ts', bogusDir, 50)); + t.true(Date.now() - start < 5000); + }, +); + test.serial('searchProjectContents throws SearchTimeoutError when timeout elapses', async t => { const testDir = createTempDir('test-file-search-timeout-temp'); try { mkdirSync(testDir, {recursive: true}); - // Many files with a query that never matches — walker keeps going, - // giving the abort timer a chance to fire between async I/O yields. + // Many files, query never matches - gives the timeout a chance to fire mid-walk. for (let i = 0; i < 500; i++) { writeFileSync(join(testDir, `file${i}.ts`), 'line a\nline b\nline c\n'); } @@ -171,3 +952,212 @@ test.serial('searchProjectContents throws SearchTimeoutError when timeout elapse rmSync(testDir, {recursive: true, force: true}); } }); + +test.serial( + 'searchProjectContents rejects with the caller-supplied abort reason, not a generic AbortError', + async t => { + const testDir = createTempDir('test-file-search-abort-reason-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + for (let i = 0; i < 2000; i++) { + writeFileSync( + join(testDir, `file${i}.ts`), + 'line a\nline b\nline c\n'.repeat(20), + ); + } + + const controller = new AbortController(); + const customReason = new Error('custom-abort-reason'); + setTimeout(() => controller.abort(customReason), 5); + + const error = await t.throwsAsync(() => + searchProjectContents( + 'no-such-thing-anywhere', + testDir, + 100000, + false, + undefined, + undefined, + undefined, + undefined, + 30000, + controller.signal, + ), + ); + t.is(error, customReason); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents still throws on a genuinely invalid regex', + async t => { + const testDir = createTempDir('test-file-search-badregex-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'f.ts'), 'content'); + + // rg exits 2 for an invalid regex too; must still reject, not return no matches. + await t.throwsAsync(() => + searchProjectContents('[invalid(regex', testDir, 10, false), + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents supports lookahead and backreferences (rg auto-selects pcre2)', + async t => { + const testDir = createTempDir('test-file-search-pcre2-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'foobar\nfoofoo\n'); + + const lookahead = await searchProjectContents( + 'foo(?=bar)', + testDir, + 10, + false, + ); + t.deepEqual( + lookahead.matches.map(m => m.content), + ['foobar'], + ); + + const backreference = await searchProjectContents( + `(foo)${String.fromCharCode(92)}1`, + testDir, + 10, + false, + ); + t.deepEqual( + backreference.matches.map(m => m.content), + ['foofoo'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents supports Python-style and Perl-style named backreferences (rg auto-selects pcre2)', + async t => { + const testDir = createTempDir('test-file-search-named-backref-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'foofoo\nfoobar\n'); + + const pythonStyle = await searchProjectContents( + '(?Pfoo)(?P=n)', + testDir, + 10, + false, + ); + t.deepEqual( + pythonStyle.matches.map(m => m.content), + ['foofoo'], + ); + + const perlQuoteStyle = await searchProjectContents( + `(?foo)${String.fromCharCode(92)}k'n'`, + testDir, + 10, + false, + ); + t.deepEqual( + perlQuoteStyle.matches.map(m => m.content), + ['foofoo'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents does not misjudge a possessive quantifier as an ordinary greedy one', + async t => { + const testDir = createTempDir('test-file-search-possessive-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'aaa\n'); + + // a++a is possessive - a++ leaves nothing for the trailing 'a' to match. + const result = await searchProjectContents('a++a', testDir, 10, false); + t.deepEqual(result.matches, []); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test( + 'isFatalRipgrepError recognizes a build of ripgrep with no PCRE2 support (real on Linux ARM)', + t => { + t.true( + isFatalRipgrepError( + 'rg: PCRE2 is not available in this build of ripgrep', + ), + ); + }, +); + +test.serial( + 'searchProjectContents still matches an ordinary named capture group', + async t => { + const testDir = createTempDir('test-file-search-named-group-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'foobar\n'); + + // Looks like lookbehind syntax at a glance but isn't. + const result = await searchProjectContents( + '(?foo)bar', + testDir, + 10, + false, + ); + t.deepEqual( + result.matches.map(m => m.content), + ['foobar'], + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'searchProjectContents throws on a nonexistent searchPath instead of returning no matches', + async t => { + const testDir = createTempDir('test-file-search-bad-searchpath-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + + await t.throwsAsync(() => + searchProjectContents( + 'foo', + testDir, + 10, + false, + undefined, + join(testDir, 'does-not-exist'), + ), + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); diff --git a/source/utils/file-search.ts b/source/utils/file-search.ts index 150dba61f..bb411e601 100644 --- a/source/utils/file-search.ts +++ b/source/utils/file-search.ts @@ -1,12 +1,20 @@ +import {spawn} from 'node:child_process'; +import type {Dirent} from 'node:fs'; import {lstat, readdir, readFile} from 'node:fs/promises'; import path from 'node:path'; +import ignore from 'ignore'; +import {LRUCache} from 'lru-cache'; import {BINARY_FILE_EXTENSIONS} from '@/constants'; -import {loadGitignore} from '@/utils/gitignore-loader'; +import {DEFAULT_IGNORE_DIRS, loadGitignore} from '@/utils/gitignore-loader'; +import {getLogger} from '@/utils/logging'; +import {resolveRipgrepPath} from '@/utils/ripgrep-path'; const MAX_CONTEXT_CONTENT_LENGTH = 1500; const MAX_MATCH_CONTENT_LENGTH = 300; const DEFAULT_SEARCH_TIMEOUT_MS = 30_000; +const MAX_RAW_FILES_SCANNED = 50_000; +const MAX_GLOB_PATTERN_LENGTH = 1000; export class SearchTimeoutError extends Error { constructor(timeoutMs: number) { @@ -35,70 +43,169 @@ function normalizePathForMatch(filePath: string): string { return filePath.replace(/\\/g, '/'); } -function escapeRegexChar(char: string): string { - return /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char; -} +const MAX_BRACE_EXPANSIONS = 64; function expandBraces(pattern: string): string[] { - const match = pattern.match(/\{([^{}]+)\}/); - if (!match || match.index === undefined) { - return [pattern]; - } + let combinationCount = 0; - const before = pattern.slice(0, match.index); - const after = pattern.slice(match.index + match[0].length); + const expand = (current: string): string[] => { + const match = current.match(/\{([^{}]+)\}/); + if (!match || match.index === undefined) { + combinationCount++; + if (combinationCount > MAX_BRACE_EXPANSIONS) { + throw new Error( + `Glob pattern has too many brace-expansion combinations (max ${MAX_BRACE_EXPANSIONS}).`, + ); + } + return [current]; + } - return match[1] - .split(',') - .flatMap(part => expandBraces(`${before}${part.trim()}${after}`)); -} + const before = current.slice(0, match.index); + const after = current.slice(match.index + match[0].length); -function globToRegExpSource(pattern: string): string { - let source = ''; + return match[1] + .split(',') + .flatMap(part => expand(`${before}${part.trim()}${after}`)); + }; - for (let index = 0; index < pattern.length; index++) { - const current = pattern[index]; - const next = pattern[index + 1]; + return expand(pattern); +} - if (current === '*') { - if (next === '*') { - const afterNext = pattern[index + 2]; - if (afterNext === '/') { - source += '(?:.*/)?'; - index += 2; +type GlobToken = + | {type: 'literal'; char: string} + | {type: 'slash'} + | {type: 'qmark'} + | {type: 'star'} + | {type: 'globstar'} + | {type: 'globstarSlash'}; + +function tokenizeGlob(pattern: string): GlobToken[] { + const tokens: GlobToken[] = []; + let index = 0; + while (index < pattern.length) { + const char = pattern[index]; + if (char === '*') { + if (pattern[index + 1] === '*') { + if (pattern[index + 2] === '/') { + tokens.push({type: 'globstarSlash'}); + index += 3; } else { - source += '.*'; - index += 1; + tokens.push({type: 'globstar'}); + index += 2; } } else { - source += '[^/]*'; + tokens.push({type: 'star'}); + index += 1; } continue; } - - if (current === '?') { - source += '[^/]'; + if (char === '?') { + tokens.push({type: 'qmark'}); + index += 1; continue; } - - if (current === '/') { - source += '/'; + if (char === '/') { + tokens.push({type: 'slash'}); + index += 1; continue; } + tokens.push({type: 'literal', char}); + index += 1; + } + return tokens; +} + +// DP table, not a compiled regex - no backtracking, so no ReDoS. +function matchTokens(text: string, tokens: GlobToken[]): boolean { + const textLength = text.length; + const tokenCount = tokens.length; - source += escapeRegexChar(current); + let previousRow = new Array(tokenCount + 1).fill(false); + previousRow[0] = true; + for (let tokenIndex = 1; tokenIndex <= tokenCount; tokenIndex++) { + const token = tokens[tokenIndex - 1]; + previousRow[tokenIndex] = + (token.type === 'star' || + token.type === 'globstar' || + token.type === 'globstarSlash') && + previousRow[tokenIndex - 1]; } - return source; + // True once any row hits this column - globstarSlash can start from any earlier row. + const columnEverTrue = [...previousRow]; + + for (let textIndex = 1; textIndex <= textLength; textIndex++) { + const currentRow = new Array(tokenCount + 1).fill(false); + const textChar = text[textIndex - 1]; + + for (let tokenIndex = 1; tokenIndex <= tokenCount; tokenIndex++) { + const token = tokens[tokenIndex - 1]; + let matched: boolean; + switch (token.type) { + case 'literal': + matched = previousRow[tokenIndex - 1] && textChar === token.char; + break; + case 'slash': + matched = previousRow[tokenIndex - 1] && textChar === '/'; + break; + case 'qmark': + matched = previousRow[tokenIndex - 1] && textChar !== '/'; + break; + case 'star': + matched = + currentRow[tokenIndex - 1] || + (previousRow[tokenIndex] && textChar !== '/'); + break; + case 'globstar': + matched = currentRow[tokenIndex - 1] || previousRow[tokenIndex]; + break; + case 'globstarSlash': + matched = + currentRow[tokenIndex - 1] || + (textChar === '/' && columnEverTrue[tokenIndex - 1]); + break; + } + currentRow[tokenIndex] = matched; + } + + previousRow = currentRow; + for (let tokenIndex = 0; tokenIndex <= tokenCount; tokenIndex++) { + columnEverTrue[tokenIndex] = + columnEverTrue[tokenIndex] || currentRow[tokenIndex]; + } + } + + return previousRow[tokenCount]; } -function buildGlobRegexes(pattern: string): RegExp[] { +// Bounds total tokens, not entry count - one entry can hold up to MAX_BRACE_EXPANSIONS arrays. +/** @internal Exported for direct unit testing only. */ +export const GLOB_TOKEN_CACHE_MAX_TOKENS = 1_000_000; + +/** @internal Exported for direct unit testing only. */ +export const globTokenCache = new LRUCache({ + maxSize: GLOB_TOKEN_CACHE_MAX_TOKENS, + sizeCalculation: tokenized => + tokenized.reduce((sum, tokens) => sum + tokens.length, 0), +}); + +function tokenizeExpandedPattern(pattern: string): GlobToken[][] { + const cached = globTokenCache.get(pattern); + if (cached) { + return cached; + } + + if (pattern.length > MAX_GLOB_PATTERN_LENGTH) { + throw new Error( + `Glob pattern is too long (${pattern.length} chars, max ${MAX_GLOB_PATTERN_LENGTH}).`, + ); + } + const normalizedPattern = normalizePathForMatch(pattern); - return expandBraces(normalizedPattern).map( - expanded => - // nosemgrep: detect-non-literal-regexp - new RegExp(`^${globToRegExpSource(expanded)}$`), - ); + const tokenized = expandBraces(normalizedPattern).map(tokenizeGlob); + + globTokenCache.set(pattern, tokenized); + return tokenized; } export function matchesGlob( @@ -110,93 +217,320 @@ export function matchesGlob( const target = matchBasename ? path.posix.basename(normalizedPath) : normalizedPath; - return buildGlobRegexes(pattern).some(regex => regex.test(target)); + return tokenizeExpandedPattern(pattern).some(tokens => + matchTokens(target, tokens), + ); } -function isIgnoredByBinaryHeuristics( - filePath: string, - content: string, -): boolean { - const ext = path.extname(filePath).toLowerCase(); - if (BINARY_FILE_EXTENSIONS.has(ext)) { - return true; +function defaultIgnoreGlobs( + projectIgnore: ReturnType, +): string[] { + const globs: string[] = []; + for (const dir of DEFAULT_IGNORE_DIRS) { + if (projectIgnore.ignores(dir)) { + globs.push('-g', `!${dir}`); + } } + return globs; +} - return content.includes('\0'); +async function assertPathExists(candidatePath: string): Promise { + await lstat(candidatePath); } -function formatMatchContent(content: string, maxLength: number): string { - if (content.length <= maxLength) { - return content; +// Possessive quantifiers parse under rg's default engine with different (wrong) semantics - the one case --engine auto can't self-detect. +const POSSESSIVE_QUANTIFIER_PATTERN = /[*+?]\+|\}\+/; + +function binaryExcludeGlobs(): string[] { + const globs: string[] = []; + for (const ext of BINARY_FILE_EXTENSIONS) { + globs.push('-g', `!*${ext}`); } - return `${content.slice(0, maxLength)}…`; + return globs; } -function buildSearchRegex( - query: string, - caseSensitive: boolean, - wholeWord: boolean, -): RegExp { - const flags = caseSensitive ? 'g' : 'gi'; - const source = wholeWord ? `\\b(?:${query})\\b` : query; - // nosemgrep: detect-non-literal-regexp - return new RegExp(source, flags); +const FATAL_RIPGREP_ERROR_PATTERNS = [ + /regex parse error/, + /error parsing glob/, + /PCRE2: error compiling pattern/, + /PCRE2 is not available in this build of ripgrep/, + /grep config error: unknown encoding/, +]; + +/** @internal Exported for direct unit testing only. */ +export function isFatalRipgrepError(stderr: string): boolean { + const firstLine = stderr.split('\n')[0]?.trim() ?? ''; + if (firstLine.startsWith('the literal')) { + return true; + } + return FATAL_RIPGREP_ERROR_PATTERNS.some(pattern => pattern.test(stderr)); } -export async function walkProjectEntries( +interface RunRipgrepResult { + stdout: string; + hitMaxLines: boolean; +} + +async function runRipgrep( + args: string[], cwd: string, - startPath: string | undefined, + timeoutMs: number, + signal?: AbortSignal, + maxMatches?: number, + maxLines?: number, + onLine?: (line: string) => boolean, +): Promise { + const rgPath = await resolveRipgrepPath(); + + return new Promise((resolve, reject) => { + // No `signal`/`timeout` in spawn options - Node's own handling leaks state. Own both. + const child = spawn(rgPath, args, {cwd}); + let stdout = ''; + let stderr = ''; + let killedForLimit = false; + let hitMaxLines = false; + let timedOut = false; + let matchCount = 0; + let lineCount = 0; + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + let lineRemainder = ''; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + if (killedForLimit) { + return; + } + + if ( + maxMatches === undefined && + maxLines === undefined && + onLine === undefined + ) { + stdout += chunk; + return; + } + + // Chunks aren't line-aligned - build stdout here so it can't overshoot the cap. + lineRemainder += chunk; + let newlineIndex = lineRemainder.indexOf('\n'); + while (newlineIndex >= 0) { + const line = lineRemainder.slice(0, newlineIndex); + lineRemainder = lineRemainder.slice(newlineIndex + 1); + stdout += line + '\n'; + + if (onLine?.(line)) { + killedForLimit = true; + child.kill(); + return; + } + + if (line) { + if (maxLines !== undefined) { + lineCount++; + } else { + // rg's --max-count overshoots with --context, so count matches ourselves. + try { + if ((JSON.parse(line) as {type?: string}).type === 'match') { + matchCount++; + } + } catch { + // no-op + } + } + } + + if (maxLines !== undefined && lineCount >= maxLines) { + killedForLimit = true; + hitMaxLines = true; + child.kill(); + return; + } + if (maxMatches !== undefined && matchCount >= maxMatches) { + killedForLimit = true; + child.kill(); + return; + } + + newlineIndex = lineRemainder.indexOf('\n'); + } + }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + const onAbort = () => { + child.kill(); + }; + signal?.addEventListener('abort', onAbort); + + child.on('error', err => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(err); + }); + + child.on('close', (code, closeSignal) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + + if (signal?.aborted) { + reject(signal.reason ?? new Error('Search aborted')); + return; + } + if (killedForLimit) { + resolve({stdout, hitMaxLines}); + return; + } + if (timedOut) { + reject(new SearchTimeoutError(timeoutMs)); + return; + } + // code is null when rg was killed by a signal we didn't send (e.g. OOM killer). + if (closeSignal) { + reject(new Error(`ripgrep terminated by signal ${closeSignal}`)); + return; + } + // Exit 1 = no matches. Exit 2 can be a recoverable mid-scan warning; only fail if empty. + if ( + code !== null && + code > 1 && + stdout.length === 0 && + isFatalRipgrepError(stderr) + ) { + reject(new Error(`ripgrep exited with code ${code}: ${stderr.trim()}`)); + return; + } + resolve({stdout, hitMaxLines: false}); + }); + }); +} + +const MAX_WALK_DEPTH = 200; + +// Unanchored `foo` matches any depth (dirPrefix/**/foo); anchored patterns stay scoped (dirPrefix/foo). +function prefixGitignoreLine( + line: string, + dirPrefix: string, +): string | undefined { + const trimmed = line.trimEnd(); + if (!trimmed || trimmed.startsWith('#')) { + return undefined; + } + if (!dirPrefix) { + return trimmed; + } + + const negated = trimmed.startsWith('!'); + const pattern = negated ? trimmed.slice(1) : trimmed; + const isAnchoredOrNested = + pattern.startsWith('/') || pattern.replace(/\/$/, '').includes('/'); + const prefixed = isAnchoredOrNested + ? `${dirPrefix}/${pattern.replace(/^\//, '')}` + : `${dirPrefix}/**/${pattern}`; + return negated ? `!${prefixed}` : prefixed; +} + +async function walkEmptyDirectories( + cwd: string, + rootPath: string, + seenDirs: Set, onEntry: (entry: ProjectEntry) => boolean | Promise, + projectIgnore: ReturnType, signal?: AbortSignal, -): Promise { - const ig = loadGitignore(cwd); - const rootPath = startPath ?? cwd; - await lstat(rootPath); + maxDirsWalked: number = MAX_RAW_FILES_SCANNED, +): Promise<{truncated: boolean}> { + // Seeded from projectIgnore for correct rule order; nested .gitignore merges in with higher precedence, same as git. + const ig = ignore(); + ig.add(projectIgnore); + let loggedDepthCap = false; + let dirsWalked = 0; + let hitDirCap = false; - const checkAborted = () => { + const visit = async ( + absolutePath: string, + depth: number, + ): Promise => { if (signal?.aborted) { throw signal.reason ?? new Error('Walk aborted'); } - }; - const visit = async ( - absolutePath: string, - relativePath: string, - ): Promise => { - checkAborted(); - if (relativePath && ig.ignores(normalizePathForMatch(relativePath))) { + if (depth > MAX_WALK_DEPTH) { + if (!loggedDepthCap) { + loggedDepthCap = true; + getLogger().warn( + {cwd, maxDepth: MAX_WALK_DEPTH}, + 'walkEmptyDirectories: hit max depth, some directories were not walked', + ); + } return false; } - const stats = await lstat(absolutePath); - const isDirectory = stats.isDirectory(); - const isSymlink = stats.isSymbolicLink(); + // readdir itself is the expensive part; cap on that, not on discovered entries. + dirsWalked++; + if (dirsWalked > maxDirsWalked) { + hitDirCap = true; + return true; + } - if (relativePath) { - const shouldStop = await onEntry({ - absolutePath, - relativePath, - isDirectory, - }); - if (shouldStop) { - return true; + const dirPrefix = normalizePathForMatch(path.relative(cwd, absolutePath)); + // cwd's .gitignore is already in projectIgnore - re-reading it would duplicate and invert precedence. + if (dirPrefix !== '') { + const gitignoreContent = await readFile( + path.join(absolutePath, '.gitignore'), + 'utf-8', + ).catch(() => undefined); + if (gitignoreContent !== undefined) { + const patterns = gitignoreContent + .split('\n') + .map(line => prefixGitignoreLine(line, dirPrefix)) + .filter((line): line is string => line !== undefined); + if (patterns.length > 0) { + ig.add(patterns); + } } } - if (!isDirectory || isSymlink) { + let children: Dirent[]; + try { + children = await readdir(absolutePath, {withFileTypes: true}); + } catch { return false; } - let children = await readdir(absolutePath, {withFileTypes: true}); - children = children.sort((a, b) => a.name.localeCompare(b.name)); - for (const child of children) { + if (!child.isDirectory()) { + continue; + } + const childAbsolutePath = path.join(absolutePath, child.name); - const childRelativePath = relativePath - ? path.join(relativePath, child.name) - : child.name; + const childRelativePath = normalizePathForMatch( + path.relative(cwd, childAbsolutePath), + ); + + // child is a directory; ignore needs a trailing slash to match directory-only patterns like "dist/". + if (ig.ignores(`${childRelativePath}/`)) { + continue; + } - if (await visit(childAbsolutePath, childRelativePath)) { + if (!seenDirs.has(childRelativePath)) { + seenDirs.add(childRelativePath); + const stop = await onEntry({ + absolutePath: childAbsolutePath, + relativePath: childRelativePath, + isDirectory: true, + }); + if (stop) { + return true; + } + } + + if (await visit(childAbsolutePath, depth + 1)) { return true; } } @@ -204,8 +538,231 @@ export async function walkProjectEntries( return false; }; - const rootRelativePath = path.relative(cwd, rootPath); - await visit(rootPath, rootRelativePath === '' ? '' : rootRelativePath); + await visit(rootPath, 0); + return {truncated: hitDirCap}; +} + +export interface WalkProjectEntriesOptions { + includeDirectories?: boolean; + signal?: AbortSignal; + maxRawFilesScanned?: number; + // Unsorted streams results early but isn't guaranteed faster - rg's discovery order is non-deterministic. + sorted?: boolean; +} + +function emitEntrySync( + onEntry: (entry: ProjectEntry) => boolean | Promise, + entry: ProjectEntry, +): boolean { + const stop = onEntry(entry); + if (stop instanceof Promise) { + throw new Error( + 'walkProjectEntries: onEntry must be synchronous when sorted: false', + ); + } + return stop; +} + +async function walkUnsortedFileStream( + cwd: string, + rootPath: string, + args: string[], + onEntry: (entry: ProjectEntry) => boolean | Promise, + includeDirectories: boolean, + projectIgnore: ReturnType, + signal: AbortSignal | undefined, + maxRawFilesScanned: number, +): Promise<{truncated: boolean}> { + const seenDirs = new Set(); + let stoppedEarly = false; + + const onLine = (line: string): boolean => { + const file = normalizePathForMatch(line); + if (!file) { + return false; + } + + const relativeFile = normalizePathForMatch(path.relative(cwd, file)); + if (projectIgnore.ignores(relativeFile)) { + return false; + } + + if (includeDirectories) { + const parts = relativeFile.split('/'); + let dirRelative = ''; + for (let index = 0; index < parts.length - 1; index++) { + dirRelative = index === 0 ? parts[0] : `${dirRelative}/${parts[index]}`; + if (seenDirs.has(dirRelative)) { + continue; + } + seenDirs.add(dirRelative); + if ( + emitEntrySync(onEntry, { + absolutePath: path.join(cwd, dirRelative), + relativePath: dirRelative, + isDirectory: true, + }) + ) { + stoppedEarly = true; + return true; + } + } + } + + if ( + emitEntrySync(onEntry, { + absolutePath: path.join(cwd, relativeFile), + relativePath: relativeFile, + isDirectory: false, + }) + ) { + stoppedEarly = true; + return true; + } + + return false; + }; + + const {hitMaxLines} = await runRipgrep( + args, + cwd, + DEFAULT_SEARCH_TIMEOUT_MS, + signal, + undefined, + maxRawFilesScanned, + onLine, + ); + + if (stoppedEarly) { + return {truncated: hitMaxLines}; + } + + let hitDirCap = false; + if (includeDirectories && !hitMaxLines) { + ({truncated: hitDirCap} = await walkEmptyDirectories( + cwd, + rootPath, + seenDirs, + onEntry, + projectIgnore, + signal, + maxRawFilesScanned, + )); + } + + return {truncated: hitMaxLines || hitDirCap}; +} + +export async function walkProjectEntries( + cwd: string, + startPath: string | undefined, + onEntry: (entry: ProjectEntry) => boolean | Promise, + options: WalkProjectEntriesOptions = {}, +): Promise<{truncated: boolean}> { + const { + includeDirectories = true, + signal, + maxRawFilesScanned = MAX_RAW_FILES_SCANNED, + sorted = true, + } = options; + const rootPath = startPath ?? cwd; + await assertPathExists(rootPath); + const projectIgnore = loadGitignore(cwd); + const args = [ + '--files', + '--hidden', + // No --follow (symlinks could escape cwd); --no-require-git works without a repo. + '--no-ignore-parent', + '--no-require-git', + '--no-config', + ...(sorted ? ['--sort', 'path'] : []), + ...defaultIgnoreGlobs(projectIgnore), + '--', + rootPath, + ]; + + if (!sorted) { + return walkUnsortedFileStream( + cwd, + rootPath, + args, + onEntry, + includeDirectories, + projectIgnore, + signal, + maxRawFilesScanned, + ); + } + + const {stdout, hitMaxLines} = await runRipgrep( + args, + cwd, + DEFAULT_SEARCH_TIMEOUT_MS, + signal, + undefined, + maxRawFilesScanned, + ); + const files = stdout + .split(/\r?\n/) + .filter(Boolean) + .map(normalizePathForMatch); + + const seenDirs = new Set(); + for (const file of files) { + if (signal?.aborted) { + throw signal.reason ?? new Error('Walk aborted'); + } + + const relativeFile = normalizePathForMatch(path.relative(cwd, file)); + if (projectIgnore.ignores(relativeFile)) { + continue; + } + + if (includeDirectories) { + const parts = relativeFile.split('/'); + + let dirRelative = ''; + for (let index = 0; index < parts.length - 1; index++) { + dirRelative = index === 0 ? parts[0] : `${dirRelative}/${parts[index]}`; + if (seenDirs.has(dirRelative)) { + continue; + } + seenDirs.add(dirRelative); + const stop = await onEntry({ + absolutePath: path.join(cwd, dirRelative), + relativePath: dirRelative, + isDirectory: true, + }); + if (stop) { + return {truncated: hitMaxLines}; + } + } + } + + const stop = await onEntry({ + absolutePath: path.join(cwd, relativeFile), + relativePath: relativeFile, + isDirectory: false, + }); + if (stop) { + return {truncated: hitMaxLines}; + } + } + + let hitDirCap = false; + if (includeDirectories && !hitMaxLines) { + ({truncated: hitDirCap} = await walkEmptyDirectories( + cwd, + rootPath, + seenDirs, + onEntry, + projectIgnore, + signal, + maxRawFilesScanned, + )); + } + + return {truncated: hitMaxLines || hitDirCap}; } export async function findMatchingPaths( @@ -213,129 +770,261 @@ export async function findMatchingPaths( cwd: string, maxResults: number, ): Promise<{files: string[]; truncated: boolean}> { + if (maxResults <= 0) { + // The push-then-check loop below always lets one entry through first. + return {files: [], truncated: false}; + } + const hasSlash = normalizePathForMatch(pattern).includes('/'); const files: string[] = []; let truncated = false; - await walkProjectEntries(cwd, undefined, entry => { - if (matchesGlob(entry.relativePath, pattern, !hasSlash)) { - files.push(normalizePathForMatch(entry.relativePath)); - if (files.length >= maxResults) { - truncated = true; - return true; + const walkResult = await walkProjectEntries( + cwd, + undefined, + entry => { + if (matchesGlob(entry.relativePath, pattern, !hasSlash)) { + files.push(normalizePathForMatch(entry.relativePath)); + if (files.length >= maxResults) { + truncated = true; + return true; + } } - } - return false; - }); + return false; + }, + {sorted: false}, + ); + truncated = truncated || walkResult.truncated; return {files, truncated}; } -export async function searchProjectContents( - query: string, +function formatMatchContent(content: string, maxLength: number): string { + if (content.length <= maxLength) { + return content; + } + return `${content.slice(0, maxLength)}…`; +} + +interface RgJsonMatch { + type: string; + data: { + path?: {text?: string}; + line_number?: number; + lines?: {text?: string}; + }; +} + +function parseRgJsonLines(stdout: string): Array<{ + type: 'match' | 'context'; + file: string; + lineNumber: number; + text?: string; +}> { + const results: Array<{ + type: 'match' | 'context'; + file: string; + lineNumber: number; + text?: string; + }> = []; + for (const line of stdout.split('\n')) { + if (!line) { + continue; + } + let parsed: RgJsonMatch; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (parsed.type !== 'match' && parsed.type !== 'context') { + continue; + } + const file = parsed.data.path?.text; + const lineNumber = parsed.data.line_number; + if (file === undefined || lineNumber === undefined) { + continue; + } + results.push({ + type: parsed.type, + file: normalizePathForMatch(file), + lineNumber, + text: parsed.data.lines?.text, + }); + } + return results; +} + +type RgLine = ReturnType[number]; + +function toRelativeFile(cwd: string, file: string): string { + const absolutePath = path.isAbsolute(file) ? file : path.join(cwd, file); + return normalizePathForMatch(path.relative(cwd, absolutePath)); +} + +function buildMatchesWithoutContext( + rgLines: RgLine[], cwd: string, maxResults: number, - caseSensitive: boolean, - include?: string, - searchPath?: string, - wholeWord?: boolean, - contextLines?: number, - timeoutMs: number = DEFAULT_SEARCH_TIMEOUT_MS, -): Promise<{matches: SearchMatch[]; truncated: boolean}> { +): {matches: SearchMatch[]; truncated: boolean} { const matches: SearchMatch[] = []; let truncated = false; - const regex = buildSearchRegex(query, caseSensitive, wholeWord ?? false); - const hasContext = contextLines !== undefined && contextLines > 0; - const normalizedContextLines = Math.max(0, contextLines ?? 0); - const includeHasSlash = include - ? normalizePathForMatch(include).includes('/') - : false; - const controller = new AbortController(); - const timeoutError = new SearchTimeoutError(timeoutMs); - const timer = setTimeout(() => controller.abort(timeoutError), timeoutMs); + for (const {file, lineNumber, text} of rgLines) { + if (text === undefined) { + continue; + } - try { - await walkProjectEntries( - cwd, - searchPath, - async entry => { - if (entry.isDirectory) { - return false; - } + matches.push({ + file: toRelativeFile(cwd, file), + line: lineNumber, + content: formatMatchContent( + text.replace(/\r?\n$/, '').trim(), + MAX_MATCH_CONTENT_LENGTH, + ), + }); - if ( - include && - !matchesGlob(entry.relativePath, include, !includeHasSlash) - ) { - return false; - } + if (matches.length >= maxResults) { + truncated = true; + break; + } + } - let content: string; - try { - content = await readFile(entry.absolutePath, 'utf-8'); - } catch { - return false; - } + return {matches, truncated}; +} - if (isIgnoredByBinaryHeuristics(entry.relativePath, content)) { - return false; - } +function buildMatchesWithContext( + rgLines: RgLine[], + cwd: string, + maxResults: number, + contextLines: number, +): {matches: SearchMatch[]; truncated: boolean} { + const textByFileAndLine = new Map>(); + const matchLinesByFile = new Map(); - const lines = content.split(/\r?\n/); + for (const {type, file, lineNumber, text} of rgLines) { + if (text !== undefined) { + let byLine = textByFileAndLine.get(file); + if (!byLine) { + byLine = new Map(); + textByFileAndLine.set(file, byLine); + } + byLine.set(lineNumber, text.replace(/\r?\n$/, '')); + } - for (let index = 0; index < lines.length; index++) { - const currentLine = lines[index] ?? ''; + if (type === 'match') { + const existing = matchLinesByFile.get(file); + if (existing) { + existing.push(lineNumber); + } else { + matchLinesByFile.set(file, [lineNumber]); + } + } + } - regex.lastIndex = 0; - if (!regex.test(currentLine)) { - continue; - } + const matches: SearchMatch[] = []; + let truncated = false; - const lineNumber = index + 1; - let matchContent = currentLine.trim(); - - if (hasContext) { - const start = Math.max(0, index - normalizedContextLines); - const end = Math.min( - lines.length - 1, - index + normalizedContextLines, - ); - const contextContent = lines - .slice(start, end + 1) - .map((line, offset) => `${start + offset + 1}: ${line}`) - .join('\n'); - matchContent = formatMatchContent( - contextContent, - MAX_CONTEXT_CONTENT_LENGTH, - ); - } else { - matchContent = formatMatchContent( - matchContent, - MAX_MATCH_CONTENT_LENGTH, - ); - } + outer: for (const [file, matchLines] of matchLinesByFile) { + const byLine = textByFileAndLine.get(file); + const relativeFile = toRelativeFile(cwd, file); - matches.push({ - file: normalizePathForMatch(entry.relativePath), - line: lineNumber, - content: matchContent, - }); + for (const lineNumber of matchLines) { + if (byLine?.get(lineNumber) === undefined) { + continue; + } - if (matches.length >= maxResults) { - truncated = true; - return true; - } + const blockLines: string[] = []; + for ( + let line = lineNumber - contextLines; + line <= lineNumber + contextLines; + line++ + ) { + const lineText = byLine?.get(line); + if (lineText !== undefined) { + blockLines.push(`${line}: ${lineText}`); } + } - return false; - }, - controller.signal, - ); - } finally { - clearTimeout(timer); + matches.push({ + file: relativeFile, + line: lineNumber, + content: formatMatchContent( + blockLines.join('\n'), + MAX_CONTEXT_CONTENT_LENGTH, + ), + }); + + if (matches.length >= maxResults) { + truncated = true; + break outer; + } + } } return {matches, truncated}; } + +export async function searchProjectContents( + query: string, + cwd: string, + maxResults: number, + caseSensitive: boolean, + include?: string, + searchPath?: string, + wholeWord?: boolean, + contextLines?: number, + timeoutMs: number = DEFAULT_SEARCH_TIMEOUT_MS, + signal?: AbortSignal, +): Promise<{matches: SearchMatch[]; truncated: boolean}> { + if (maxResults <= 0) { + return {matches: [], truncated: false}; + } + if (!query.trim()) { + throw new Error('Search query cannot be empty'); + } + await assertPathExists(searchPath ?? cwd); + + const projectIgnore = loadGitignore(cwd); + + const args = [ + '--json', + '--hidden', + '--no-ignore-parent', + '--no-require-git', + '--no-config', + '--sort', + 'path', + caseSensitive ? '--case-sensitive' : '--ignore-case', + ]; + if (wholeWord) { + args.push('--word-regexp'); + } + args.push( + '--engine', + POSSESSIVE_QUANTIFIER_PATTERN.test(query) ? 'pcre2' : 'auto', + ); + // Must precede the exclude globs: rg's `-g` is last-wins, so an include after would re-include them. + if (include) { + args.push('-g', include); + } + args.push(...defaultIgnoreGlobs(projectIgnore), ...binaryExcludeGlobs()); + const normalizedContextLines = Math.max(0, contextLines ?? 0); + if (normalizedContextLines > 0) { + args.push('--context', String(normalizedContextLines)); + } + args.push('--regexp', query, '--', searchPath ?? cwd); + + // No --max-count (overshoots with --context) - headroom of contextLines covers each match's own trailing context. + const rgMaxCount = Math.max(0, maxResults) + normalizedContextLines; + + const {stdout} = await runRipgrep(args, cwd, timeoutMs, signal, rgMaxCount); + const rgLines = parseRgJsonLines(stdout).filter( + line => !projectIgnore.ignores(toRelativeFile(cwd, line.file)), + ); + + return normalizedContextLines > 0 + ? buildMatchesWithContext(rgLines, cwd, maxResults, normalizedContextLines) + : buildMatchesWithoutContext(rgLines, cwd, maxResults); +} diff --git a/source/utils/ripgrep-path.spec.ts b/source/utils/ripgrep-path.spec.ts new file mode 100644 index 000000000..9fcc0a4f8 --- /dev/null +++ b/source/utils/ripgrep-path.spec.ts @@ -0,0 +1,33 @@ +import {execFileSync} from 'node:child_process'; +import test from 'ava'; +import {resetRipgrepPathCache, resolveRipgrepPath} from './ripgrep-path.js'; + +console.log(`\nripgrep-path.spec.ts`); + +test.beforeEach(() => { + resetRipgrepPathCache(); +}); + +test.afterEach(() => { + resetRipgrepPathCache(); +}); + +test('resolveRipgrepPath resolves a real, runnable rg binary', async t => { + const rgPath = await resolveRipgrepPath(); + t.truthy(rgPath); + + const output = execFileSync(rgPath, ['--version'], {encoding: 'utf8'}); + t.regex(output, /^ripgrep \d+\.\d+\.\d+/); +}); + +test('resolveRipgrepPath caches the result across calls', async t => { + // beforeEach guarantees a cold cache, so this exercises both the uncached and cached branches instead of two already-warm calls that would be equal either way. + // A timing assertion was tried and dropped - Node's own dynamic import() memoizes the module, so a deliberately broken cache still measured "fast" on the second call. + const first = await resolveRipgrepPath(); + const second = await resolveRipgrepPath(); + t.is(first, second); +}); + +test('resetRipgrepPathCache is safe to call before any resolution', t => { + t.notThrows(() => resetRipgrepPathCache()); +}); diff --git a/source/utils/ripgrep-path.ts b/source/utils/ripgrep-path.ts new file mode 100644 index 000000000..088eb809c --- /dev/null +++ b/source/utils/ripgrep-path.ts @@ -0,0 +1,15 @@ +let cachedPath: string | undefined; + +export async function resolveRipgrepPath(): Promise { + if (cachedPath) { + return cachedPath; + } + + const {rgPath} = await import('@vscode/ripgrep'); + cachedPath = rgPath; + return cachedPath; +} + +export function resetRipgrepPathCache(): void { + cachedPath = undefined; +} From e0548372ca50936b931fa83181aeb19ee873d0c3 Mon Sep 17 00:00:00 2001 From: Tushar <157228826+tusharui@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:47:04 +0530 Subject: [PATCH 05/25] feat(export): auto-generate descriptive filenames from first user message (#956) * feat(export): auto-generate descriptive filenames from first user message Replaces generic timestamp-based export filenames with descriptive slugs derived from the first 4 words of the user's first message. Before: nanocoder-chat-2026-08-25T09-54-14.353Z.md After: fix-my-button-2026-08-25.md - Add generateExportFilename() utility with word-boundary truncation - Add uniqueFilename() to handle collisions by appending counter - Add isUnsafeFilename() to block path traversal attacks - Add 16 unit tests covering edge cases and security - Update export command to use shared utilities - Closes #934 * chore: add changeset for export filename feature * fix(export): reuse isValidFilePath for path safety and render export errors correctly - Delete isUnsafeFilename, which weakened the existing validator and rejected valid subdirectory exports (basename(f) === f rejected any path with a separator) - Validate with isValidFilePath(process.cwd()) from path-validation.ts, the same validator used by read_file/write_file/string_replace; restores subdirectory export while still blocking traversal, ~, null bytes, and absolute path escapes - Render path-traversal rejections with ErrorMessage instead of feeding 'Error: invalid filename' through SuccessMessage, which lied that the chat was exported - Assert on rendered text in the rejection test rather than only React.isValidElement * fix(export): harden uniqueFilename, keep overwrite for explicit names, fix i18n slugs - uniqueFilename now falls back to a timestamp-suffixed name after a bounded number of attempts instead of returning the original path that fs.writeFile would clobber, preserving the 'never overwrites' guarantee - Keep overwrite semantics for user-typed filenames; only generated names get auto-suffixed, so repeated /export notes.md overwrites as before - Preserve CJK, Cyrillic, and other non-ASCII in slugs via \\p{L}\\p{N} with the u flag instead of stripping to empty and falling back, which previously caused date-only collisions for non-ASCII users - Add tests for CJK/Cyrillic slugs, emoji stripping, the exhaustion fallback, and user-provided overwrite semantics * test(export): cover subdirectory, null-byte, and home-shorthand paths - Subdirectory export (reports/chat.md) must work now that validation is segment-aware via isValidFilePath, guarding the regression the old basename-based check would have caused - Null-byte and ~ home-shorthand paths must be rejected outright - Assertions are path-separator agnostic so they pass on Windows * fix(export): make generated-name writes atomic and surface write errors Previously a generated filename wrote to the target chosen by a separate check-then-act access(); two concurrent /export commands for the same slug could both pass the existence check and one would clobber the other, violating the never-overwrite guarantee. Replace the TOCTOU-prone uniqueFilename with writeUniqueFile, which creates the file atomically with the exclusive flag ('wx') and retries the next collision suffix on EEXIST until the bounded attempts are exhausted, then falls back to a timestamp-suffixed name. It never falls through to overwriting an existing export. The handler now routes the final write through the same atomic path for generated names while explicit user filenames keep overwrite semantics at the exact path the user typed. A failed write (ENOSPC, EACCES, EPERM) is caught and surfaced as an error message instead of being swallowed by the command dispatcher's catch-less try/finally and leaving the user with no feedback. Tests cover the free-path, counter-collision, exhaustion, and concurrent- writer cases, plus the write-failure surface, asserting on rendered output. * fix(export): enforce project containment and byte-aware slug limits The export path was resolved with path.resolve and validated only lexically, so a subdirectory export pointing at an in-project symlink could redirect the write outside the project, diverging from read_file/write_file/string_replace which defend at the resolve layer. Validate and resolve exports through resolveFilePath(process.cwd()), the same symlink-aware containment check used by the file tools. This both rejects traversal and absolute escapes that leave the project, and keeps subdirectory exports working, consistent with the rest of the codebase. Slug truncation was character-count based, which let 40 multi-byte CJK characters produce a ~120-byte slug. Truncation now also enforces a UTF-8 byte budget so the full filename stays well under the 255-byte filesystem limit. Tests cover traversal, absolute-path escape, and a byte-limited multi-byte slug, asserting on rendered output. * fix(export): resolve paths via session cwd and project root, surface clear errors Follow the read_file/write_file convention of resolving relative paths against the session cwd (which honours bash cd) and enforcing containment against the project root (which does not shrink as cd descends), instead of pinning to the static process.cwd(). Also surface a readable 'Parent directory does not exist' error for user-typed paths (writeUniqueFile already handled generated names) and route all error formatting through formatError so non-Error throws do not leak as '[object Object]'. Export feedback now echoes the file relative to the project root so subdirectory exports are recognisable. * fix(export): drop byte-budget guard and give clear missing-directory errors The manual UTF-8 byte-trim (MAX_SLUG_BYTES=96) guarded against a case that cannot occur: even a 40-char CJK slug plus the fixed date suffix lands far below the 255-byte filesystem limit, and the 40-char bound already caps the slug. Remove the ~25 lines of branchy byte-loop and keep the simple word-boundary character truncation, which is provably sufficient. In writeUniqueFile, translate ENOENT into an explicit 'Parent directory does not exist' error instead of leaking the raw fs path so generated-name exports fail with an actionable message. * chore(export): annotate path.join sinks that semgrep flags as false positives writeUniqueFile receives an absolute path already validated and containment-checked by resolveFilePath in the export handler, and derives dir/base/ext via path.dirname/path.basename/path.extname, so the generated collision candidates built with path.join can never escape dir. Add nosemgrep comments (matching the repo's existing convention in source/config/index.ts) to the two flagged lines to clear the blocking semgrep-scan CI job without silencing a real vulnerability. * fix(export): use rule-specific nosemgrep to fully suppress remaining semgrep finding The trailing inline '// nosemgrep' cleared the loop's path.join but not the timestamp-suffix line. Use the documented form -- '// nosemgrep: ' on its own line directly above each path.join call -- applied uniformly so both sinks are suppressed. Path safety still originates from resolveFilePath in the handler; this join only appends a counter/timestamp to an already validated basename inside an already contained directory. --- .changeset/fiery-hats-pick.md | 5 + source/commands/export.spec.tsx | 235 +++++++++++++++++- source/commands/export.tsx | 79 +++++- source/utils/generate-export-filename.spec.ts | 195 +++++++++++++++ source/utils/generate-export-filename.ts | 126 ++++++++++ 5 files changed, 631 insertions(+), 9 deletions(-) create mode 100644 .changeset/fiery-hats-pick.md create mode 100644 source/utils/generate-export-filename.spec.ts create mode 100644 source/utils/generate-export-filename.ts diff --git a/.changeset/fiery-hats-pick.md b/.changeset/fiery-hats-pick.md new file mode 100644 index 000000000..8575ccf1a --- /dev/null +++ b/.changeset/fiery-hats-pick.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Auto-generate descriptive filenames for /export instead of generic timestamps. Closes #934 diff --git a/source/commands/export.spec.tsx b/source/commands/export.spec.tsx index 623fa9d60..519194fde 100644 --- a/source/commands/export.spec.tsx +++ b/source/commands/export.spec.tsx @@ -2,10 +2,16 @@ import test from 'ava'; import type {Message} from '@/types/index'; import {exportCommand} from './export'; import {promises as fs} from 'fs'; +import path from 'path'; import React from 'react'; import {render} from 'ink-testing-library'; import {themes} from '../config/themes'; import {ThemeContext} from '../hooks/useTheme'; +import { + resetSessionCwd, + setProjectRoot, + setSessionCwd, +} from '../services/session-cwd'; // Mock fs module const originalWriteFile = fs.writeFile; @@ -17,10 +23,13 @@ test.beforeEach(() => { mockWriteFileCalls.push({path: filepath, content}); return Promise.resolve(void 0); }; + // Isolate each test from session-cwd state set by others. + resetSessionCwd(); }); test.afterEach(() => { fs.writeFile = originalWriteFile; + resetSessionCwd(); }); // Mock ThemeProvider for testing @@ -66,9 +75,59 @@ test('exportCommand uses provided filename', async t => { t.true(mockWriteFileCalls[0].path.includes('custom-export.md')); }); -test('exportCommand generates default filename when none provided', async t => { +test('exportCommand keeps overwrite semantics for a user-provided filename', async t => { + // A user-typed name must always write to that exact path (overwrite), never + // be auto-suffixed, no matter what already exists on disk. + await exportCommand.handler(['fixed-name.md'], testMessages, testMetadata); + + t.is(mockWriteFileCalls.length, 1); + t.true(mockWriteFileCalls[0].path.endsWith('fixed-name.md')); + t.false(mockWriteFileCalls[0].path.includes('fixed-name-2')); +}); + +test('exportCommand writes a generated filename that is free', async t => { + await exportCommand.handler([], testMessages, testMetadata); + + t.is(mockWriteFileCalls.length, 1); + // No auto-suffix (matches -2, -3 etc.) when the target does not exist. + t.regex(mockWriteFileCalls[0].path, /hello-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('exportCommand surfaces a write failure instead of a false success', async t => { + const originalWriteFile = fs.writeFile; + fs.writeFile = async () => { + throw new Error('ENOSPC: no space left on device'); + }; + + const result = (await exportCommand.handler( + ['big.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Failed to export chat/); + t.regex(output!, /ENOSPC/); + t.false(output!.includes('Chat exported')); + fs.writeFile = originalWriteFile; +}); + +test('exportCommand generates default filename from first user message', async t => { await exportCommand.handler([], testMessages, testMetadata); + t.is(mockWriteFileCalls.length, 1); + t.true(mockWriteFileCalls[0].path.includes('hello-')); + t.true(mockWriteFileCalls[0].path.endsWith('.md')); +}); + +test('exportCommand falls back to nanocoder-chat when no user messages', async t => { + const noUserMessages: Message[] = [ + {role: 'assistant', content: 'Hi there', tool_calls: undefined}, + ]; + await exportCommand.handler([], noUserMessages, testMetadata); + t.is(mockWriteFileCalls.length, 1); t.true(mockWriteFileCalls[0].path.includes('nanocoder-chat-')); t.true(mockWriteFileCalls[0].path.endsWith('.md')); @@ -185,3 +244,177 @@ test('exportCommand renders Export component with correct filename', async t => t.regex(output!, /my-export\.md/); } }); + +test('exportCommand rejects path traversal in filename', async t => { + const result = (await exportCommand.handler( + ['../../../etc/passwd'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Invalid export path/); + t.false(output!.includes('Chat exported')); +}); + + test('exportCommand allows exporting into a subdirectory', async t => { + // isValidFilePath is segment-aware, so a subdirectory export must work while + // traversal is still blocked. + await exportCommand.handler( + ['reports/chat.md'], + testMessages, + testMetadata, + ); + + t.is(mockWriteFileCalls.length, 1); + t.true(mockWriteFileCalls[0].path.endsWith('chat.md')); + t.true( + mockWriteFileCalls[0].path + .split(/[\\/]/) + .slice(-2) + .join('/') === 'reports/chat.md', + ); +}); + +test('exportCommand rejects a filename with a null byte', async t => { + const result = (await exportCommand.handler( + ['evil\u0000.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Invalid export path/); +}); + +test('exportCommand rejects a home-directory shorthand path', async t => { + const result = (await exportCommand.handler( + ['~/notes.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Invalid export path/); +}); + +test('exportCommand rejects a path escaping the project directory', async t => { + const result = (await exportCommand.handler( + ['../../outside.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Invalid export path/); + t.false(output!.includes('Chat exported')); +}); + +test('exportCommand rejects an absolute path outside the project', async t => { + const outside = path.resolve(process.cwd(), '..', 'outside.md'); + const result = (await exportCommand.handler( + [outside], + testMessages, + testMetadata, + )) as React.ReactElement; + + t.is(mockWriteFileCalls.length, 0); + + const {lastFrame} = render({result}); + const output = lastFrame(); + t.truthy(output); + t.regex(output!, /Invalid export path/); +}); + +test('exportCommand resolves a relative path against the session cwd (honours cd)', async t => { + // Pin the session cwd to a subdirectory, as a bash `cd` would, and confirm a + // bare relative export lands there -- not in the launch dir (process.cwd()). + const subdir = path.join(process.cwd(), 'tmp-session-cwd-test'); + await fs.mkdir(subdir, {recursive: true}); + setSessionCwd(subdir); + + await exportCommand.handler(['chat.md'], testMessages, testMetadata); + + t.is(mockWriteFileCalls.length, 1); + const expected = path.join(subdir, 'chat.md'); + t.is(mockWriteFileCalls[0].path, expected); + await fs.rm(subdir, {recursive: true}); +}); + +test('exportCommand contains writes to the project root even when the session cwd is deeper', async t => { + // A pinned project root is the non-shrinking containment boundary. With the + // session cwd inside a worktree, an absolute path inside the project root + // (but above the cwd) is still allowed, while one above the project root is + // rejected. Relative '..' is blocked outright by isValidFilePath. + const root = path.join(process.cwd(), 'tmp-prjroot-test'); + const subdir = path.join(root, 'worktree'); + await fs.mkdir(subdir, {recursive: true}); + setProjectRoot(root); + setSessionCwd(subdir); + + // Absolute path inside the project root but above the session cwd is allowed. + const insideRoot = path.join(root, 'chat.md'); + await exportCommand.handler([insideRoot], testMessages, testMetadata); + t.is(mockWriteFileCalls.length, 1); + t.is(mockWriteFileCalls[0].path, insideRoot); + + // Absolute path escaping above the project root is rejected. + const outside = path.join(root, '..', 'outside.md'); + const escape = (await exportCommand.handler( + [outside], + testMessages, + testMetadata, + )) as React.ReactElement; + t.is(mockWriteFileCalls.length, 1); + const {lastFrame} = render({escape}); + t.regex(lastFrame()!, /Invalid export path/); + + await fs.rm(root, {recursive: true}); +}); + +test('exportCommand reports a missing parent directory clearly', async t => { + fs.writeFile = originalWriteFile; + + const result = (await exportCommand.handler( + ['no-such-folder/chat.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + fs.writeFile = originalWriteFile; + + const {lastFrame} = render({result}); + t.regex(lastFrame()!, /Failed to export chat/); + t.regex(lastFrame()!, /Parent directory does not exist/); +}); + +test('exportCommand renders a subdirectory export relative to the project root', async t => { + const result = (await exportCommand.handler( + ['reports/chat.md'], + testMessages, + testMetadata, + )) as React.ReactElement; + + const {lastFrame} = render({result}); + const output = lastFrame()!; + // Full relative path is shown (with the platform separator), not a bare + // basename. + t.true(output.includes(`Chat exported to reports${path.sep}chat.md`)); + t.false(output.includes(`Chat exported to chat${path.sep}`)); + t.false(output.includes('Chat exported to chat.md')); +}); diff --git a/source/commands/export.tsx b/source/commands/export.tsx index 0ea605bb9..8ccf11d5d 100644 --- a/source/commands/export.tsx +++ b/source/commands/export.tsx @@ -1,9 +1,16 @@ import fs from 'fs/promises'; import path from 'path'; import React from 'react'; -import {SuccessMessage} from '@/components/message-box'; +import {ErrorMessage, SuccessMessage} from '@/components/message-box'; +import {getProjectRoot, getSafeSessionCwd} from '@/services/session-cwd'; import {generateKey} from '@/session/key-generator'; import {Command, Message} from '@/types/index'; +import {formatError} from '@/utils/error-formatter'; +import { + generateExportFilename, + writeUniqueFile, +} from '@/utils/generate-export-filename'; +import {resolveFilePath} from '@/utils/path-validation'; const formatMessageContent = (message: Message) => { let content = ''; @@ -46,6 +53,17 @@ function Export({filename}: {filename: string}) { ); } +function ExportError({message}: {message: string}) { + return ( + + ); +} + export const exportCommand: Command = { name: 'export', description: 'Export the chat history to a markdown file', @@ -54,10 +72,25 @@ export const exportCommand: Command = { messages: Message[], {provider, model, tokens}, ) => { - const filename = - args[0] || - `nanocoder-chat-${new Date().toISOString().replace(/:/g, '-')}.md`; - const filepath = path.resolve(process.cwd(), filename); // nosemgrep + const userProvided = args.length > 0; + const requestedFilename = args[0] || generateExportFilename(messages); + + // Resolve against the session cwd (which honours bash `cd`) and enforce + // containment within the project root (which does not shrink as `cd` + // descends) -- the same convention as read_file / write_file / string_replace. + let filepath: string; + try { + filepath = resolveFilePath( + requestedFilename, + getSafeSessionCwd(), + getProjectRoot(), + ); + } catch { + return React.createElement(ExportError, { + key: generateKey('export'), + message: 'Invalid export path: outside the project directory', + }); + } const frontmatter = `--- session_date: ${new Date().toISOString()} @@ -70,13 +103,43 @@ total_tokens: ${tokens} `; - const markdownContent = messages.map(formatMessageContent).join(''); + const markdownContent = + frontmatter + messages.map(formatMessageContent).join(''); + + // A name the user typed keeps overwrite semantics (least surprise). Only + // generated names get auto-suffixed so repeated exports never clobber -- + // and the write is atomic ('wx') so concurrent exports can't race. + let writtenFilepath: string; + try { + writtenFilepath = userProvided + ? await fs.writeFile(filepath, markdownContent).then(() => filepath) + : await writeUniqueFile(filepath, markdownContent); + } catch (error) { + // writeUniqueFile already translates a missing parent dir for + // generated names; mirror it for user-typed names that write directly. + const message = + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ? `Parent directory does not exist: ${path.dirname(filepath)}` + : formatError(error); + return React.createElement(ExportError, { + key: generateKey('export'), + message: `Failed to export chat: ${message}`, + }); + } - await fs.writeFile(filepath, frontmatter + markdownContent); + // Show the exported file relative to the project root so subdirectory + // exports (e.g. reports/chat.md) are recognisable rather than a bare basename. + const root = getProjectRoot(); + const displayPath = writtenFilepath.startsWith(root + path.sep) + ? writtenFilepath.slice(root.length + 1) + : writtenFilepath; return React.createElement(Export, { key: generateKey('export'), - filename, + filename: displayPath, }); }, }; diff --git a/source/utils/generate-export-filename.spec.ts b/source/utils/generate-export-filename.spec.ts new file mode 100644 index 000000000..5fa6b3fac --- /dev/null +++ b/source/utils/generate-export-filename.spec.ts @@ -0,0 +1,195 @@ +import test from 'ava'; +import type {Message} from '@/types/core'; +import {generateExportFilename, writeUniqueFile} from './generate-export-filename'; +import {promises as fs} from 'fs'; +import path from 'path'; +import os from 'os'; + +const user = (content: string): Message => ({role: 'user', content}); +const assistant = (content: string): Message => ({role: 'assistant', content}); + +test('generates slug from first user message', t => { + const messages = [user('fix the login bug')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-login-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('truncates to 4 words', t => { + const messages = [user('add dark mode toggle to the navbar')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^add-dark-mode-toggle-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('handles single word message', t => { + const messages = [user('hello')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^hello-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('strips special characters', t => { + const messages = [user('fix: the auth login')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-auth-login-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('trims leading and trailing whitespace', t => { + const messages = [user(' setup react router ')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^setup-react-router-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('handles newlines in first line', t => { + const messages = [user('fix the bug\nin the auth module')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('falls back when no user messages', t => { + const messages = [assistant('hello')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^nanocoder-chat-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('falls back on empty messages array', t => { + const filename = generateExportFilename([]); + t.regex(filename, /^nanocoder-chat-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('falls back on empty content', t => { + const messages = [user('')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^nanocoder-chat-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('skips empty first line and uses second', t => { + const messages = [user('\nfix the login bug')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-login-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('truncates long slug at word boundary', t => { + const messages = [user('a'.repeat(100))]; + const filename = generateExportFilename(messages); + const slug = filename.replace(/-\d{4}-\d{2}-\d{2}\.md$/, ''); + t.true(slug.length <= 40); + t.false(slug.endsWith('-')); +}); + +test('preserves CJK characters in slug', t => { + const messages = [user('修复登录问题')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^修复登录问题-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('preserves Cyrillic characters in slug', t => { + const messages = [user('исправить ошибку входа')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^исправить-ошибку-входа-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('strips emoji while keeping adjacent words', t => { + const messages = [user('fix the 🐛 bug')]; + const filename = generateExportFilename(messages); + t.regex(filename, /^fix-the-bug-\d{4}-\d{2}-\d{2}\.md$/); +}); + +test('truncates a long CJK slug at the 40-character limit', t => { + const messages = [user('修'.repeat(100))]; + const filename = generateExportFilename(messages); + const slug = filename.replace(/-\d{4}-\d{2}-\d{2}\.md$/, ''); + // 40 CJK characters still keep the whole filename well under 255 bytes, so + // no byte budget is needed -- the char limit alone suffices. + t.is(slug.length, 40); + t.false(slug.endsWith('-')); + t.true(Buffer.byteLength(filename, 'utf-8') < 255); +}); + +test('truncates a long hyphenated slug at the last whole word', t => { + const messages = [user('fix-the-login-logout-registration-authentication')]; + const filename = generateExportFilename(messages); + const slug = filename.replace(/-\d{4}-\d{2}-\d{2}\.md$/, ''); + t.true(slug.length <= 40); + // Must not split a word: trimming stops at a word boundary, so it must not + // end mid-word with a break inside a hyphenated token. + t.true(/^fix(?:-[a-z]+)*$/.test(slug)); + t.true(slug.length >= 20); +}); + +test('writeUniqueFile writes to the given path when it is free', async t => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); + const filepath = path.join(tmpDir, 'test.md'); + const result = await writeUniqueFile(filepath, 'content'); + t.is(result, filepath); + t.is(await fs.readFile(filepath, 'utf-8'), 'content'); + await fs.rm(tmpDir, {recursive: true}); +}); + +test('writeUniqueFile appends a counter when the path is taken', async t => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); + const filepath = path.join(tmpDir, 'test.md'); + await fs.writeFile(filepath, 'existing'); + const result = await writeUniqueFile(filepath, 'content'); + t.is(result, path.join(tmpDir, 'test-2.md')); + t.is(await fs.readFile(path.join(tmpDir, 'test-2.md'), 'utf-8'), 'content'); + await fs.rm(tmpDir, {recursive: true}); +}); + +test('writeUniqueFile never overwrites an existing file', async t => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); + const filepath = path.join(tmpDir, 'test.md'); + const originals = [ + 'test.md', + 'test-2.md', + 'test-3.md', + 'test-4.md', + 'test-5.md', + 'test-6.md', + ]; + for (const name of originals) { + await fs.writeFile(path.join(tmpDir, name), 'existing'); + } + + const result = await writeUniqueFile(filepath, 'content'); + + // Every pre-existing file keeps its content — none may be clobbered. + for (const name of originals) { + t.is(await fs.readFile(path.join(tmpDir, name), 'utf-8'), 'existing'); + } + // The writer must have landed in a fresh, distinct file (timestamp suffix). + t.not(result, filepath); + t.true(result.startsWith(path.join(tmpDir, 'test-new-'))); + t.true(result.endsWith('.md')); + t.is(await fs.readFile(result, 'utf-8'), 'content'); + await fs.rm(tmpDir, {recursive: true}); +}); + +test('writeUniqueFile is atomic: the original is never clobbered by a race', async t => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); + const filepath = path.join(tmpDir, 'test.md'); + await fs.writeFile(filepath, 'original'); + + // Simulate TWO concurrent exclusive-flag writers for the same target. Only + // one may win the base name; the other must fall to a suffix, and the + // original file's contents must be preserved. + const [a, b] = await Promise.all([ + writeUniqueFile(filepath, 'first'), + writeUniqueFile(filepath, 'second'), + ]); + + t.not(a, filepath); + t.not(b, filepath); + t.not(a, b); + t.is(await fs.readFile(filepath, 'utf-8'), 'original'); + await fs.rm(tmpDir, {recursive: true}); +}); + +test('writeUniqueFile reports a missing parent directory clearly', async t => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); + const filepath = path.join(tmpDir, 'does-not-exist', 'chat.md'); + + await t.throwsAsync( + () => writeUniqueFile(filepath, 'content'), + {message: /Parent directory does not exist/}, + ); + await fs.rm(tmpDir, {recursive: true}); +}); diff --git a/source/utils/generate-export-filename.ts b/source/utils/generate-export-filename.ts new file mode 100644 index 000000000..242a1630f --- /dev/null +++ b/source/utils/generate-export-filename.ts @@ -0,0 +1,126 @@ +import fs from 'fs/promises'; +import path from 'path'; +import type {Message} from '@/types/core'; + +const MAX_WORDS = 4; +const MAX_SLUG_LENGTH = 40; +const MAX_COLLISION_ATTEMPTS = 5; + +function sanitizeSlug(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function truncateAtWordBoundary(slug: string, maxLength: number): string { + if (slug.length <= maxLength) { + return slug; + } + + const truncated = slug.substring(0, maxLength); + const lastHyphen = truncated.lastIndexOf('-'); + return lastHyphen > 0 ? truncated.substring(0, lastHyphen) : truncated; +} + +function generateSlugFromMessages(messages: Message[]): string { + const firstUserMessage = messages.find(m => m.role === 'user'); + if (!firstUserMessage?.content) { + return ''; + } + + const lines = firstUserMessage.content.split('\n'); + let firstLine = ''; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + firstLine = trimmed; + break; + } + } + + if (!firstLine) { + return ''; + } + + const words = firstLine.split(/\s+/).filter(Boolean); + const truncated = words.slice(0, MAX_WORDS).join(' '); + return truncateAtWordBoundary(sanitizeSlug(truncated), MAX_SLUG_LENGTH); +} + +/** + * Deterministically finds a free filename for a generated export and writes it + * atomically. + * + * The write uses the exclusive flag 'wx' so the free-check and the create are + * a single atomic step: two concurrent exports for the same slug can never + * both succeed on the same path (no TOCTOU race, no clobbering). On EEXIST we + * try the next collision suffix; once the bounded attempts are exhausted we + * fall back to a timestamp suffix. This function never falls through to + * overwriting an existing file. + * + * Unlike /export with an explicit filename (which keeps overwrite semantics), + * generated names must never destroy a previous export. + */ +export async function writeUniqueFile( + filepath: string, + content: string, +): Promise { + const dir = path.dirname(filepath); + const ext = path.extname(filepath); + const base = path.basename(filepath, ext); + + const tryWrite = async (candidate: string): Promise => { + try { + await fs.writeFile(candidate, content, {flag: 'wx'}); + return candidate; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + // Collision: try the next candidate. + if (error.code === 'EEXIST') return null; + // Missing parent directory: report it plainly so the user knows + // the export failed because a directory doesn't exist. + if (error.code === 'ENOENT') { + throw new Error(`Parent directory does not exist: ${dir}`); + } + } + throw error; + } + }; + + for (let i = 1; i < MAX_COLLISION_ATTEMPTS + 1; i++) { + const suffix = i === 1 ? '' : `-${i}`; + // `filepath` was already validated and containment-checked by + // resolveFilePath in the handler, so `dir`/`base`/`ext` cannot contain a + // separator or `..` and this join can never leave `dir`. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const candidate = path.join(dir, `${base}${suffix}${ext}`); + const written = await tryWrite(candidate); + if (written) return written; + } + + // Bounded attempts all collided, drop a timestamp and try once more. If the + // astronomically-unlikely timestamp collision happens, surface the error + // rather than clobber anything. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const timestamped = path.join(dir, `${base}-new-${Date.now()}${ext}`); + return tryWrite(timestamped).then(result => { + if (!result) { + throw new Error('Unable to allocate a unique export filename'); + } + return result; + }); +} + +export function generateExportFilename(messages: Message[]): string { + const slug = generateSlugFromMessages(messages); + const date = new Date().toISOString().split('T')[0]; + + if (!slug) { + return `nanocoder-chat-${date}.md`; + } + + return `${slug}-${date}.md`; +} From 4a78dd4180896fb73720f57a63903f5f925e4e5a Mon Sep 17 00:00:00 2001 From: Rishabh Mishra Date: Mon, 31 Aug 2026 21:48:04 +0530 Subject: [PATCH 06/25] feat(ui): add session auto-save indicator (fixes #932) (#958) --- .changeset/session-autosave-indicator.md | 5 + source/app/App.tsx | 3 +- source/app/components/chat-input.tsx | 3 + source/app/sections/interactive-app.tsx | 3 + .../development-mode-indicator.spec.tsx | 76 +++++ .../components/development-mode-indicator.tsx | 267 ++++++++++-------- source/components/user-input.tsx | 4 + source/hooks/useSessionAutosave.spec.ts | 113 ++++++++ source/hooks/useSessionAutosave.ts | 29 +- 9 files changed, 381 insertions(+), 122 deletions(-) create mode 100644 .changeset/session-autosave-indicator.md diff --git a/.changeset/session-autosave-indicator.md b/.changeset/session-autosave-indicator.md new file mode 100644 index 000000000..8bd03ceaf --- /dev/null +++ b/.changeset/session-autosave-indicator.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Added a visual `saving` indicator in the CLI status line that briefly displays whenever session state is autosaved to disk. Thanks to @rishu685. Closes #932. diff --git a/source/app/App.tsx b/source/app/App.tsx index 84ed4ed3f..1946e8a74 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -592,7 +592,7 @@ export default function App({ }); // Setup session autosave - useSessionAutosave({ + const {isSaving} = useSessionAutosave({ messages: appState.messages, currentProvider: appState.currentProvider, currentModel: appState.currentModel, @@ -784,6 +784,7 @@ export default function App({ handleUserSubmit={handleUserSubmit} userMessageQueue={userMessageQueue} handleIdeSelect={handleIdeSelect} + isSaving={isSaving} /> )} diff --git a/source/app/components/chat-input.tsx b/source/app/components/chat-input.tsx index 2995b824c..388a719db 100644 --- a/source/app/components/chat-input.tsx +++ b/source/app/components/chat-input.tsx @@ -101,6 +101,7 @@ export interface ChatInputProps { * transcript and isn't clipped by the scroll viewport's overflow="hidden". */ fullscreen?: boolean; + isSaving?: boolean; } /** @@ -155,6 +156,7 @@ export function ChatInput({ activeEditor, onDismissActiveEditor, fullscreen = false, + isSaving, }: ChatInputProps): React.ReactElement { const {colors} = useTheme(); const activeToolCall = pendingToolCalls[currentToolIndex]; @@ -253,6 +255,7 @@ export function ChatInput({ currentModel={currentModel} activeEditor={activeEditor} onDismissActiveEditor={onDismissActiveEditor} + isSaving={isSaving} /> ) : /* Client Missing */ mcpInitialized && !client ? ( diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index b2c1b135e..c49fdb5a1 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -50,6 +50,7 @@ interface InteractiveAppProps { * the inline Static-based flow with native scrollback. */ altScreenActive?: boolean; + isSaving?: boolean; } /** @@ -76,6 +77,7 @@ export function InteractiveApp({ handleIdeSelect, clearKey, altScreenActive = false, + isSaving, }: InteractiveAppProps): React.ReactElement { const nextRestoredDraftIdRef = React.useRef(1); // Tune / IDE are launched by closing settings first, so their exit has no way @@ -445,6 +447,7 @@ export function InteractiveApp({ tune={appState.tune} currentModel={appState.currentModel} fullscreen={fullscreen} + isSaving={isSaving} /> )} diff --git a/source/components/development-mode-indicator.spec.tsx b/source/components/development-mode-indicator.spec.tsx index 293467e1b..6d80f5176 100644 --- a/source/components/development-mode-indicator.spec.tsx +++ b/source/components/development-mode-indicator.spec.tsx @@ -601,3 +601,79 @@ test('collapsed task badge keeps the key hint when there is room for it', t => { ); t.regex(output, /Tasks \(2\/5 Ctrl-t\)/); }); + +// ============================================================================ +// Auto-save indicator tests (Issue #932) +// ============================================================================ + +test('DevelopmentModeIndicator renders saving indicator when isSaving is true', t => { + const output = renderWithWidth( + , + ); + t.regex(output, /saving/); +}); + +test('DevelopmentModeIndicator omits saving indicator when isSaving is false or undefined', t => { + const falseOutput = renderWithWidth( + , + ); + t.notRegex(falseOutput, /saving/); + + const undefOutput = renderWithWidth( + , + ); + t.notRegex(undefOutput, /saving/); +}); + +test('saving indicator drops under narrow width pressure without shrinking session name', t => { + const fullSession = 'feature-authentication-token'; + // Render at a tight width of 40 columns + const output = renderWithWidth( + , + 42, + ); + + // saving indicator must drop when width is tight + t.notRegex(output, /saving/); + // Session name must still have room and not be shrunk away + t.regex(output, /feature/); +}); + +test('saving indicator renders when there is sufficient width', t => { + const output = renderWithWidth( + , + 100, + ); + + t.regex(output, /my-session/); + t.regex(output, /saving/); + t.regex(output, /ctx: 40%/); +}); diff --git a/source/components/development-mode-indicator.tsx b/source/components/development-mode-indicator.tsx index 325f17a44..72566a0cb 100644 --- a/source/components/development-mode-indicator.tsx +++ b/source/components/development-mode-indicator.tsx @@ -31,6 +31,7 @@ interface DevelopmentModeIndicatorProps { currentModel?: string; activeEditor?: ActiveEditorState | null; taskInfo?: TaskIndicatorInfo | null; + isSaving?: boolean; } function getContextColor( @@ -58,6 +59,7 @@ export const DevelopmentModeIndicator = React.memo( currentModel, activeEditor, taskInfo, + isSaving, }: DevelopmentModeIndicatorProps) => { const {isNarrow, actualWidth, truncate} = useResponsiveTerminal(); const modeLabel = isNarrow @@ -110,147 +112,164 @@ export const DevelopmentModeIndicator = React.memo( // share whatever room is left, each truncating with an ellipsis; if both // fit fully neither truncates; if both overflow they split the remaining // space evenly. - // The Ctrl-t hint, the line-range suffix and the (Shift+Tab to cycle) - // hint are optional — drop them when otherwise the row would wrap. The - // Ctrl-t hint drops first (the collapsed badge still reports progress - // without it, and an expanded list needs no badge at all), then the - // line-range suffix, then the shift hint. - const {sessionLabel, editorLabel, showShiftHint, taskLabel} = (() => { - const editorFileName = activeEditor?.fileName; - const hasSelection = - !!activeEditor?.selection && - !!activeEditor.startLine && - !!activeEditor.endLine; - const editorPrefix = editorFileName - ? hasSelection - ? '⊡ ' - : '⊡ In ' - : ''; - const editorSuffixFull = - editorFileName && hasSelection - ? ` (L${activeEditor.startLine}-${activeEditor.endLine})` + // The Ctrl-t hint, the saving indicator, the line-range suffix and + // the (Shift+Tab to cycle) hint are optional — drop them when otherwise + // the row would wrap. The Ctrl-t hint drops first, then the saving + // indicator, then the line-range suffix, then the shift hint. + const {sessionLabel, editorLabel, showShiftHint, taskLabel, showSaving} = + (() => { + const editorFileName = activeEditor?.fileName; + const hasSelection = + !!activeEditor?.selection && + !!activeEditor.startLine && + !!activeEditor.endLine; + const editorPrefix = editorFileName + ? hasSelection + ? '⊡ ' + : '⊡ In ' : ''; + const editorSuffixFull = + editorFileName && hasSelection + ? ` (L${activeEditor.startLine}-${activeEditor.endLine})` + : ''; - const shiftHintFull = - isNarrow && developmentMode !== 'headless' - ? ' (Shift+Tab to cycle)' + const shiftHintFull = + isNarrow && developmentMode !== 'headless' + ? ' (Shift+Tab to cycle)' + : ''; + const tuneSegment = tuneLabel ? ` · ${tuneLabel}` : ''; + const taskBaseSegment = taskLabelBase ? ` · ${taskLabelBase}` : ''; + const taskHintSegment = taskLabelWithHint + ? ` · ${taskLabelWithHint}` : ''; - const tuneSegment = tuneLabel ? ` · ${tuneLabel}` : ''; - const taskBaseSegment = taskLabelBase ? ` · ${taskLabelBase}` : ''; - const taskHintSegment = taskLabelWithHint - ? ` · ${taskLabelWithHint}` - : ''; - // Cost of upgrading the badge from its base form to the key-hint - // form. With the list expanded there is no base form, so this is the - // price of the whole segment. - const taskHintExtraFull = taskHintSegment.length - taskBaseSegment.length; - const ctxSegment = - contextPercentUsed !== null - ? ` · ctx: ${ctxPrefix}${contextPercentUsed}%` - : ''; - const sessionSeparator = sessionName ? ' · ' : ''; - const editorSeparator = editorFileName ? ' · ' : ''; + // Cost of upgrading the badge from its base form to the key-hint + // form. With the list expanded there is no base form, so this is the + // price of the whole segment. + const taskHintExtraFull = + taskHintSegment.length - taskBaseSegment.length; + const savingSegment = isSaving ? ' · saving' : ''; + const savingExtraFull = savingSegment.length; + const ctxSegment = + contextPercentUsed !== null + ? ` · ctx: ${ctxPrefix}${contextPercentUsed}%` + : ''; + const sessionSeparator = sessionName ? ' · ' : ''; + const editorSeparator = editorFileName ? ' · ' : ''; - const minLen = 6; - const minSessionLen = sessionName ? minLen : 0; - const minEditorLen = editorFileName ? minLen : 0; + const minLen = 6; + const minSessionLen = sessionName ? minLen : 0; + const minEditorLen = editorFileName ? minLen : 0; - // Width consumed by parts that always render. - const requiredWidth = - modeLabel.length + - tuneSegment.length + - taskBaseSegment.length + - ctxSegment.length + - sessionSeparator.length + - editorSeparator.length + - editorPrefix.length + - minSessionLen + - minEditorLen; + // Width consumed by parts that always render. + const requiredWidth = + modeLabel.length + + tuneSegment.length + + taskBaseSegment.length + + ctxSegment.length + + sessionSeparator.length + + editorSeparator.length + + editorPrefix.length + + minSessionLen + + minEditorLen; - // Decide which optional segments fit. Drop the Ctrl-t hint first, - // then the suffix, then the shift hint, until the row fits within - // actualWidth. - let editorSuffix = editorSuffixFull; - let shiftHint = shiftHintFull; - let taskHintExtra = taskHintExtraFull; - if ( - requiredWidth + - taskHintExtra + - editorSuffix.length + - shiftHint.length + - 1 > - actualWidth - ) { - taskHintExtra = 0; + // Decide which optional segments fit. Drop the Ctrl-t hint first, + // then the saving indicator, then the suffix, then the shift hint, + // until the row fits within actualWidth. + let editorSuffix = editorSuffixFull; + let shiftHint = shiftHintFull; + let taskHintExtra = taskHintExtraFull; + let savingExtra = savingExtraFull; if ( - requiredWidth + editorSuffix.length + shiftHint.length + 1 > + requiredWidth + + taskHintExtra + + savingExtra + + editorSuffix.length + + shiftHint.length + + 1 > actualWidth ) { - editorSuffix = ''; - if (requiredWidth + shiftHint.length + 1 > actualWidth) { - shiftHint = ''; + taskHintExtra = 0; + if ( + requiredWidth + + savingExtra + + editorSuffix.length + + shiftHint.length + + 1 > + actualWidth + ) { + savingExtra = 0; + if ( + requiredWidth + editorSuffix.length + shiftHint.length + 1 > + actualWidth + ) { + editorSuffix = ''; + if (requiredWidth + shiftHint.length + 1 > actualWidth) { + shiftHint = ''; + } + } } } - } - const fixedWidth = - modeLabel.length + - shiftHint.length + - tuneSegment.length + - taskBaseSegment.length + - taskHintExtra + - ctxSegment.length + - sessionSeparator.length + - editorSeparator.length + - editorPrefix.length + - editorSuffix.length; + const fixedWidth = + modeLabel.length + + shiftHint.length + + tuneSegment.length + + taskBaseSegment.length + + taskHintExtra + + savingExtra + + ctxSegment.length + + sessionSeparator.length + + editorSeparator.length + + editorPrefix.length + + editorSuffix.length; - const remaining = Math.max(0, actualWidth - fixedWidth - 1); + const remaining = Math.max(0, actualWidth - fixedWidth - 1); - let sessionMax = 0; - let filenameMax = 0; - if (sessionName && editorFileName) { - const sessionNeed = sessionName.length; - const filenameNeed = editorFileName.length; - if (sessionNeed + filenameNeed <= remaining) { - sessionMax = sessionNeed; - filenameMax = filenameNeed; - } else { - const half = Math.floor(remaining / 2); - if (sessionNeed <= half) { + let sessionMax = 0; + let filenameMax = 0; + if (sessionName && editorFileName) { + const sessionNeed = sessionName.length; + const filenameNeed = editorFileName.length; + if (sessionNeed + filenameNeed <= remaining) { sessionMax = sessionNeed; - filenameMax = remaining - sessionMax; - } else if (filenameNeed <= half) { filenameMax = filenameNeed; - sessionMax = remaining - filenameMax; } else { - sessionMax = half; - filenameMax = remaining - half; + const half = Math.floor(remaining / 2); + if (sessionNeed <= half) { + sessionMax = sessionNeed; + filenameMax = remaining - sessionMax; + } else if (filenameNeed <= half) { + filenameMax = filenameNeed; + sessionMax = remaining - filenameMax; + } else { + sessionMax = half; + filenameMax = remaining - half; + } } + } else if (sessionName) { + sessionMax = remaining; + } else if (editorFileName) { + filenameMax = remaining; } - } else if (sessionName) { - sessionMax = remaining; - } else if (editorFileName) { - filenameMax = remaining; - } - const session = sessionName - ? truncate(sessionName, Math.max(minLen, sessionMax)) - : null; - const editor = editorFileName - ? `${editorPrefix}${truncate( - editorFileName, - Math.max(minLen, filenameMax), - )}${editorSuffix}` - : null; + const session = sessionName + ? truncate(sessionName, Math.max(minLen, sessionMax)) + : null; + const editor = editorFileName + ? `${editorPrefix}${truncate( + editorFileName, + Math.max(minLen, filenameMax), + )}${editorSuffix}` + : null; - return { - sessionLabel: session, - editorLabel: editor, - showShiftHint: shiftHint.length > 0, - taskLabel: taskHintExtra > 0 ? taskLabelWithHint : taskLabelBase, - }; - })(); + return { + sessionLabel: session, + editorLabel: editor, + showShiftHint: shiftHint.length > 0, + taskLabel: taskHintExtra > 0 ? taskLabelWithHint : taskLabelBase, + showSaving: savingExtra > 0, + }; + })(); return ( @@ -291,6 +310,14 @@ export const DevelopmentModeIndicator = React.memo( )} + {showSaving && ( + <> + · + + saving + + + )} {contextPercentUsed !== null && ( <> · diff --git a/source/components/user-input.tsx b/source/components/user-input.tsx index 52cecd97b..64df076f7 100644 --- a/source/components/user-input.tsx +++ b/source/components/user-input.tsx @@ -74,6 +74,7 @@ interface ChatProps { forceFocus?: boolean; // Force focus for testing (bypasses useFocus) onSubmittedDraft?: (draft: SubmittedInputDraft) => void; restoreSubmittedDraft?: RestoredInputDraft | null; + isSaving?: boolean; } export default function UserInput({ @@ -102,6 +103,7 @@ export default function UserInput({ forceFocus = false, onSubmittedDraft, restoreSubmittedDraft = null, + isSaving, }: ChatProps) { const {isFocused, focus} = useFocus({autoFocus: !disabled, id: 'user-input'}); const effectiveFocus = forceFocus || isFocused; @@ -1007,6 +1009,7 @@ export default function UserInput({ tune={tune} currentModel={currentModel} taskInfo={taskInfo} + isSaving={isSaving} /> ); @@ -1177,6 +1180,7 @@ export default function UserInput({ currentModel={currentModel} activeEditor={activeEditor} taskInfo={taskInfo} + isSaving={isSaving} /> ); diff --git a/source/hooks/useSessionAutosave.spec.ts b/source/hooks/useSessionAutosave.spec.ts index 7b882ae90..4b2913e6d 100644 --- a/source/hooks/useSessionAutosave.spec.ts +++ b/source/hooks/useSessionAutosave.spec.ts @@ -394,3 +394,116 @@ test.serial( ); }, ); + +// --------------------------------------------------------------------------- +// Issue #932 — Auto-save indicator & unblocked flush +// --------------------------------------------------------------------------- + +test.serial( + 'Issue 932: indicator hide timer does not block save chain or flush resolution', + async t => { + let isSaving = false; + let hideTimer: NodeJS.Timeout | null = null; + const minDuration = 500; + + const runSave = async () => { + let startTime: number | null = null; + try { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + startTime = Date.now(); + isSaving = true; + + // Fast mock disk write (~5ms) + await new Promise(r => setTimeout(r, 5)); + } finally { + if (startTime !== null) { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, minDuration - elapsed); + hideTimer = setTimeout(() => { + isSaving = false; + hideTimer = null; + }, remaining); + } + } + }; + + const flushStart = Date.now(); + await runSave(); + const flushElapsed = Date.now() - flushStart; + + // flush/save resolution must finish immediately on I/O completion (< 100ms), + // NOT blocked by the 500ms UI indicator timer + t.true( + flushElapsed < 100, + `flush() took ${flushElapsed}ms; must not wait for the 500ms UI timer`, + ); + t.true(isSaving, 'isSaving must be true immediately after save finishes'); + + // Wait 250ms: isSaving must still be true (within the 500ms floor) + await new Promise(r => setTimeout(r, 250)); + t.true(isSaving, 'isSaving must stay true at 250ms (within 500ms floor)'); + + // Wait another 300ms (total > 550ms): isSaving must transition to false + await new Promise(r => setTimeout(r, 300)); + t.false( + isSaving, + 'isSaving must transition to false after 500ms minimum display floor', + ); + t.is(hideTimer, null); + }, +); + +test.serial( + 'Issue 932: subsequent save cancels earlier pending hide timer', + async t => { + let isSaving = false; + let hideTimer: NodeJS.Timeout | null = null; + let timerClearCount = 0; + const minDuration = 500; + + const runSave = async () => { + let startTime: number | null = null; + try { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + timerClearCount++; + } + startTime = Date.now(); + isSaving = true; + + await new Promise(r => setTimeout(r, 5)); + } finally { + if (startTime !== null) { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, minDuration - elapsed); + hideTimer = setTimeout(() => { + isSaving = false; + hideTimer = null; + }, remaining); + } + } + }; + + // First save schedules a hide timer for 500ms + await runSave(); + t.true(isSaving); + t.truthy(hideTimer); + + // Second save starts 100ms later (while hide timer is still pending) + await new Promise(r => setTimeout(r, 100)); + await runSave(); + + t.is(timerClearCount, 1, 'Previous hide timer must be cancelled'); + t.true(isSaving); + + // Clean up + if (hideTimer) { + clearTimeout(hideTimer); + } + }, +); + diff --git a/source/hooks/useSessionAutosave.ts b/source/hooks/useSessionAutosave.ts index d6876a558..443775ba3 100644 --- a/source/hooks/useSessionAutosave.ts +++ b/source/hooks/useSessionAutosave.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {isApprovedPlanMessage} from '@/artifacts/approved-plan'; import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {getAppConfig} from '@/config/index'; @@ -74,8 +74,10 @@ export function useSessionAutosave({ currentSessionId, setCurrentSessionId, }: UseSessionAutosaveProps) { + const [isSaving, setIsSaving] = useState(false); const initPromiseRef = useRef | null>(null); const timeoutRef = useRef(null); + const hideTimerRef = useRef(null); const lastSaveRef = useRef(0); // Serialises saves: each new save is chained onto the tail of this promise. @@ -146,6 +148,9 @@ export function useSessionAutosave({ if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + } }; }, []); @@ -156,6 +161,7 @@ export function useSessionAutosave({ capturedProvider: string, capturedModel: string, ) => { + let startTime: number | null = null; try { // Wait for initialization to complete before saving const initialized = await initPromiseRef.current; @@ -170,6 +176,15 @@ export function useSessionAutosave({ ); if (persistedMessages.length === 0) return; + // Cancel any pending delayed-hide from an earlier save before showing + // the indicator for this save. + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + startTime = Date.now(); + setIsSaving(true); + // Read the live session ID AFTER the await above. Any prior save // in this chain has already called setCurrentSessionId (and updated // currentSessionIdRef.current) by this point, so we correctly take @@ -238,6 +253,16 @@ export function useSessionAutosave({ lastSaveRef.current = Date.now(); } catch (error) { console.warn('Failed to auto-save session:', error); + } finally { + if (startTime !== null) { + const elapsed = Date.now() - startTime; + const minDuration = 500; + const remaining = Math.max(0, minDuration - elapsed); + hideTimerRef.current = setTimeout(() => { + setIsSaving(false); + hideTimerRef.current = null; + }, remaining); + } } }, [setCurrentSessionId], @@ -318,4 +343,6 @@ export function useSessionAutosave({ }); return () => manager.unregister(SHUTDOWN_HANDLER_NAME); }, [flush]); + + return {isSaving}; } From cc706f767a80b13e40efc267e384e162fd6ed65e Mon Sep 17 00:00:00 2001 From: Aditya Mishra Date: Mon, 31 Aug 2026 21:48:50 +0530 Subject: [PATCH 07/25] Feature: theme aware syntax highlighting (#959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): colour syntax highlighting with the active theme Every cli-highlight call site passed `theme: 'default'`, a string where the library expects a token-to-formatter map. The option was silently discarded, so code always rendered in cli-highlight's own palette no matter which of the 50 themes was selected. Derive the map from the active theme instead. getSyntaxTheme() builds it from a palette — keywords take `primary`, built-ins and declarations `tool`, strings `success`, numbers `warning`, comments `secondary`, attributes and variables `info`, and everything else the theme's body `text` — covering every token cli-highlight styles itself, so nothing falls back to the library default. It is memoised per palette, since the diff and file previews highlight line by line. All five sites now pass it: markdown code blocks, both string_replace diff-context branches, the write_file preview, and the file explorer preview. Closes #935. * feat(config): add syntaxTheme to give code a palette of its own Code follows `selectedTheme` as of the previous commit, which is the right default. Someone whose terminal already wears Dracula or Nord may want code coloured to match it while the rest of the UI stays where they put it, so read an optional `syntaxTheme` from preferences and let it win. It names any of the 50 existing themes rather than introducing a second registry, so the two cannot drift apart. An unknown or misspelt name falls back to the UI theme instead of dropping the styling. Resolved once and re-resolved only when NANOCODER_CONFIG_DIR moves, since the diff and file previews highlight line by line. `@/config/preferences` imports `@/config/index`, which imports this module. Neither touches the other at module scope, so the cycle resolves — verified against the built output for the three entry orders that reach it. The specs now pin NANOCODER_CONFIG_DIR at a directory of their own: a contributor who sets `syntaxTheme` must not change what they assert. * fix(themes): re-resolve syntaxTheme on write, and reject inherited keys Addresses review feedback on #959. The override cache keyed on NANOCODER_CONFIG_DIR alone, which only ever moves under tests. In a real session syntaxTheme was read once per process, so a later preferences write was ignored until restart. It now keys on getPreferencesVersion() as well - a monotonic counter bumped on every write and free to read - so a change lands on the next highlight. The dir stays in the key because that is what the spec repoints. Required merging main, which is where getPreferencesVersion lives; the branch was 101 commits behind. `preset in themes` is now Object.hasOwn. `themes` comes from JSON.parse and carries Object.prototype, so a syntaxTheme of "constructor", "toString", "valueOf" or "__proto__" passed the `in` check and resolved to a non-theme whose .colors was undefined, with the ?? fallback rescuing it by accident. Also from the review: - An unknown syntaxTheme warns once, naming the value, instead of silently rendering as though the preference were unset. Through the structured logger rather than logWarning: @/utils/message-queue reaches @/components/message-box -> useTheme -> back to this module, a cycle it is deliberately kept out of, and getSyntaxTheme is called from render, where queueing a message is a state update during another component's render. - The file explorer keeps the plain source and derives the highlight in a memo keyed on the palette, so switching theme with a preview open recolours it instead of stranding it until reselect. - RenderPalette names the shared subset at its declaration; Colors stays as a deprecated alias so the markdown-parser call sites and its re-export are untouched. The markdown-parser spec comment is corrected but keeps the distinct palette object, because that part is load-bearing. chalk re-checks level per call for whether to emit codes, but bakes the colour MODEL in when the builder is made: chalk.hex() picks ansi16 / ansi256 / truecolor from chalk.level at creation. getSyntaxTheme memoises per palette identity, so reusing mockColors reuses builders frozen at the runner's default level - \x1b[91m against the assertion's \x1b[38;2;... Removing the trick fails the test; the comment now says why it is there. Cycle re-verified against the built output for five entry orders, including the new utils/logging edge. --- .changeset/theme-aware-syntax-highlighting.md | 5 + docs/configuration/preferences.md | 3 +- source/components/file-explorer/index.tsx | 40 ++-- source/config/themes.spec.ts | 201 +++++++++++++++++- source/config/themes.ts | 135 ++++++++++++ source/markdown-parser/index.spec.ts | 46 ++++ source/markdown-parser/index.ts | 3 +- .../tools/file-ops/string-replace-preview.tsx | 5 +- source/tools/file-ops/write-file.tsx | 6 +- source/types/config.ts | 6 + source/types/markdown-parser.ts | 14 +- 11 files changed, 440 insertions(+), 24 deletions(-) create mode 100644 .changeset/theme-aware-syntax-highlighting.md diff --git a/.changeset/theme-aware-syntax-highlighting.md b/.changeset/theme-aware-syntax-highlighting.md new file mode 100644 index 000000000..f695230d2 --- /dev/null +++ b/.changeset/theme-aware-syntax-highlighting.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Syntax highlighting now follows your theme, and takes a `syntaxTheme` preference when you want code to keep a palette of its own. All five highlighting call sites - markdown code blocks, `string_replace` diff context, the `write_file` preview, and the file explorer preview - passed `theme: 'default'`, a string where `cli-highlight` expects a token-to-formatter map, so the option was silently dropped and every theme rendered code identically in the library's own colours. Each one now derives its token map from a theme's palette: keywords take `primary`, built-ins and declarations `tool`, strings `success`, numbers `warning`, comments `secondary`, attributes and variables `info`, and everything else the theme's body `text`. Code follows `selectedTheme` by default; setting `syntaxTheme` in `nanocoder-preferences.json` (e.g. `"syntaxTheme": "dracula"`) points code at any other theme's palette while the rest of the UI stays put, and an unknown name falls back to `selectedTheme` rather than dropping the styling. Closes #935. diff --git a/docs/configuration/preferences.md b/docs/configuration/preferences.md index 8a72b7411..59ae3a88a 100644 --- a/docs/configuration/preferences.md +++ b/docs/configuration/preferences.md @@ -40,7 +40,8 @@ Preferences follow the same location hierarchy as configuration files: | `lastProvider` | The AI provider you last selected | | `lastModel` | The model you last used | | `providerModels` | Your preferred model for each provider (remembered per-provider) | -| `selectedTheme` | The theme you last selected via `/settings` | +| `selectedTheme` | The theme you last selected via `/settings`. Also colours syntax highlighting in code blocks, diffs, and file previews | +| `syntaxTheme` | Optional. Name of the theme whose palette colours syntax highlighting, when you want code to keep a palette of its own (e.g. `"dracula"`) instead of following `selectedTheme`. Any theme name from `/settings` → **Theme** works; an unknown name falls back to `selectedTheme` | | `titleShape` | The title shape style (e.g., box, rounded) | | `nanocoderShape` | The nanocoder ASCII art shape | | `trustedDirectories` | Directories you've approved through the first-run security disclaimer | diff --git a/source/components/file-explorer/index.tsx b/source/components/file-explorer/index.tsx index e1b5b210e..d7ca852d9 100644 --- a/source/components/file-explorer/index.tsx +++ b/source/components/file-explorer/index.tsx @@ -3,6 +3,7 @@ import {highlight} from 'cli-highlight'; import {Box, Text, useFocus, useInput} from 'ink'; import {useEffect, useMemo, useState} from 'react'; import {StyledTitle} from '@/components/ui/styled-title'; +import {getSyntaxTheme} from '@/config/themes'; import { CHARS_PER_TOKEN_ESTIMATE, FILE_EXPLORER_TOKEN_WARNING_THRESHOLD, @@ -39,7 +40,11 @@ export function FileExplorer({onClose}: FileExplorerProps) { const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [preview, setPreview] = useState(null); + // The plain, indentation-compressed source. Highlighting is derived from it + // below rather than stored, so switching theme with the preview open + // re-colours it instead of leaving the old palette until reselect. + const [previewSource, setPreviewSource] = useState(null); + const [previewLanguage, setPreviewLanguage] = useState('plaintext'); const [previewError, setPreviewError] = useState(null); const [previewPath, setPreviewPath] = useState(null); const [viewMode, setViewMode] = useState('tree'); @@ -113,10 +118,24 @@ export function FileExplorer({onClose}: FileExplorerProps) { return Math.ceil(totalSize / CHARS_PER_TOKEN_ESTIMATE); }, [selectedFiles, allNodes]); + // Re-runs when the palette changes, so a theme switch recolours an open + // preview. Highlighting failures fall back to the plain source. + const preview = useMemo(() => { + if (previewSource === null) return null; + try { + return highlight(previewSource, { + language: previewLanguage, + theme: getSyntaxTheme(colors), + }); + } catch { + return previewSource; + } + }, [previewSource, previewLanguage, colors]); + // Load preview when entering preview mode const loadPreviewForNode = async (node: FileNode) => { if (node.isDirectory) { - setPreview(null); + setPreviewSource(null); setPreviewError('Cannot preview directory'); return; } @@ -136,24 +155,13 @@ export function FileExplorer({onClose}: FileExplorerProps) { const compressedLines = compressIndentation(lines); const compressedContent = compressedLines.join('\n'); - // Apply syntax highlighting - let highlighted: string; - try { - highlighted = highlight(compressedContent, { - language: lang, - theme: 'default', - }); - } catch { - // Fallback to plain text if highlighting fails - highlighted = compressedContent; - } - - setPreview(highlighted); + setPreviewLanguage(lang); + setPreviewSource(compressedContent); setPreviewPath(node.path); setPreviewError(null); setPreviewScroll(0); } catch { - setPreview(null); + setPreviewSource(null); setPreviewError('Cannot preview (binary or unreadable)'); } }; diff --git a/source/config/themes.spec.ts b/source/config/themes.spec.ts index aa678b26a..6b6d48ce4 100644 --- a/source/config/themes.spec.ts +++ b/source/config/themes.spec.ts @@ -1,5 +1,40 @@ +import {mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import test from 'ava'; -import {themes} from '@/config/themes'; +import chalk from 'chalk'; +import {DEFAULT_THEME, highlight} from 'cli-highlight'; +import {getSyntaxTheme, themes} from '@/config/themes'; +import {resetPreferencesCache, savePreferences} from '@/config/preferences'; + +// AVA runs each spec in its own non-TTY process, where chalk disables colour and +// every formatter becomes a no-op. Force truecolor so the escapes are assertable. +chalk.level = 3; + +// getSyntaxTheme reads the `syntaxTheme` preference, so point every test at a +// config directory of its own — a contributor who sets that preference must not +// change what this spec sees. +const configRoot = join(tmpdir(), `nanocoder-themes-spec-${process.pid}`); + +/** Point the config lookup at a directory holding exactly `preferences`. */ +function useConfigDir(name: string, preferences: Record): void { + const dir = join(configRoot, name); + mkdirSync(dir, {recursive: true}); + writeFileSync( + join(dir, 'nanocoder-preferences.json'), + JSON.stringify(preferences), + ); + process.env.NANOCODER_CONFIG_DIR = dir; +} + +test.before(() => { + useConfigDir('no-preference', {}); +}); + +test.after.always(() => { + rmSync(configRoot, {recursive: true, force: true}); + delete process.env.NANOCODER_CONFIG_DIR; +}); /** Relative luminance per WCAG 2.1. */ function luminance(hex: string): number { @@ -66,3 +101,167 @@ test('themeType matches whether base is actually light or dark', t => { ); } }); + +/** The opening escape chalk emits for a hex colour. */ +function ansiFor(hex: string): string { + return chalk.hex(hex)('x').split('x')[0] ?? ''; +} + +const snippet = `// greet +const greeting = 'hi'; +const answer = 42;`; + +// Every call site used to pass `theme: 'default'`, a string where cli-highlight +// expects a token -> formatter map, so the option was dropped and code always +// rendered in the library's palette. Any token left unmapped reintroduces that +// clash for the constructs it covers. +test('getSyntaxTheme maps every token cli-highlight styles by default', t => { + for (const [name, theme] of entries) { + const syntax = getSyntaxTheme(theme.colors); + const unmapped = Object.keys(DEFAULT_THEME).filter( + token => !(token in syntax), + ); + t.deepEqual(unmapped, [], `${name} leaves tokens on the library default`); + } +}); + +test('getSyntaxTheme colours tokens with the palette it was given', t => { + const colors = themes['tokyo-night'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(colors), + }); + + t.true(output.includes(ansiFor(colors.primary)), 'keyword uses primary'); + t.true(output.includes(ansiFor(colors.success)), 'string uses success'); + t.true(output.includes(ansiFor(colors.warning)), 'number uses warning'); + t.true(output.includes(ansiFor(colors.secondary)), 'comment uses secondary'); + t.true(output.includes(ansiFor(colors.text)), 'unmatched code uses text'); +}); + +test('getSyntaxTheme renders the same code differently per theme', t => { + const rendered = new Set( + entries.map(([, theme]) => + highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(theme.colors), + }), + ), + ); + + // The snippet exercises exactly these five roles, so two themes may only + // share a rendering when they share all five. Collapsing further is what the + // ignored `theme: 'default'` option did — every theme rendered identically. + const palettes = new Set( + entries.map(([, theme]) => + [ + theme.colors.primary, + theme.colors.success, + theme.colors.warning, + theme.colors.secondary, + theme.colors.text, + ].join('/'), + ), + ); + + t.is(rendered.size, palettes.size); + t.true(palettes.size > 1); +}); + +test('getSyntaxTheme reuses the theme built for a palette', t => { + const colors = themes['gruvbox-dark'].colors; + t.is(getSyntaxTheme(colors), getSyntaxTheme(colors)); + t.not(getSyntaxTheme(colors), getSyntaxTheme(themes['one-light'].colors)); +}); + +// These run last: each repoints NANOCODER_CONFIG_DIR, which is what re-resolves +// the cached `syntaxTheme` lookup. +test('syntaxTheme gives code its own palette without moving the UI theme', t => { + useConfigDir('dracula-code', { + selectedTheme: 'tokyo-night', + syntaxTheme: 'dracula', + }); + + const ui = themes['tokyo-night'].colors; + const code = themes['dracula'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(code.primary)), 'keyword uses dracula primary'); + t.true(output.includes(ansiFor(code.warning)), 'number uses dracula warning'); + t.false( + output.includes(ansiFor(ui.primary)), + 'the UI theme must not colour code once syntaxTheme is set', + ); +}); + +test('an unknown syntaxTheme falls back to the UI palette', t => { + useConfigDir('misspelt', {syntaxTheme: 'draclua'}); + + const ui = themes['nord-frost'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(ui.primary))); +}); + +test('code follows the UI palette when syntaxTheme is unset', t => { + useConfigDir('ui-only', {selectedTheme: 'gruvbox-light'}); + + const ui = themes['gruvbox-light'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(ui.primary))); +}); + +// The cache used to key on NANOCODER_CONFIG_DIR alone, which never moves in a +// real session - so syntaxTheme was read once per process and a later write was +// ignored until restart. Keying on the preferences version as well fixes that, +// and this pins it without touching the env var at all. +test('a syntaxTheme written mid-session takes effect without a restart', t => { + useConfigDir('live-write', {selectedTheme: 'nord-frost'}); + + const ui = themes['nord-frost'].colors; + const before = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + t.true(before.includes(ansiFor(ui.primary)), 'starts on the UI palette'); + + // Same config dir, new preferences: only the version counter moves. + resetPreferencesCache(); + savePreferences({selectedTheme: 'nord-frost', syntaxTheme: 'dracula'}); + + const after = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + t.true( + after.includes(ansiFor(themes['dracula'].colors.primary)), + 'the write is picked up on the next highlight', + ); +}); + +// `themes` comes from JSON.parse, so it carries Object.prototype: a `preset in +// themes` check would accept these and resolve to a non-theme whose `.colors` is +// undefined, leaving the fallback to rescue it by accident. +for (const inherited of ['constructor', 'toString', 'valueOf', '__proto__']) { + test(`a syntaxTheme of '${inherited}' falls back to the UI palette`, t => { + useConfigDir(`inherited-${inherited}`, {syntaxTheme: inherited}); + + const ui = themes['one-light'].colors; + const output = highlight(snippet, { + language: 'typescript', + theme: getSyntaxTheme(ui), + }); + + t.true(output.includes(ansiFor(ui.primary))); + }); +} diff --git a/source/config/themes.ts b/source/config/themes.ts index 9dce4c52f..10dbd81c1 100644 --- a/source/config/themes.ts +++ b/source/config/themes.ts @@ -1,7 +1,14 @@ import {readFileSync} from 'node:fs'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; +import chalk from 'chalk'; +import type {Theme as SyntaxTheme} from 'cli-highlight'; +import {getPreferencesVersion, loadPreferences} from '@/config/preferences'; +// The palette a syntax theme needs is exactly the subset the markdown parser +// already declares, so reuse it rather than declaring a second Pick. +import type {RenderPalette as SyntaxPalette} from '@/types/markdown-parser'; import type {Theme, ThemePreset} from '@/types/ui'; +import {getLogger} from '@/utils/logging'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -18,3 +25,131 @@ export function getThemeColors(themePreset: ThemePreset) { } export const defaultTheme: ThemePreset = 'tokyo-night'; + +// `syntaxTheme` lets code blocks keep a palette of their own while the rest of +// the UI follows `selectedTheme` — for a terminal already dressed in Dracula or +// Nord, say. The diff and file previews highlight line by line, so this cannot +// read preferences off disk each time. (`@/config/preferences` imports +// `@/config/index`, which imports this module; neither touches the other at +// module scope, so the cycle resolves. Keep it that way.) +// +// Keyed on the preferences version as well as the config dir: the version is a +// monotonic counter bumped on every write and free to read, so a `/settings` +// change lands on the next highlight instead of waiting for a restart. The dir +// is what moves under tests, which point NANOCODER_CONFIG_DIR at a fixture. +let overrideCache: { + dir?: string; + version: number; + palette: SyntaxPalette | null; +} | null = null; + +// Naming an unknown theme is silent otherwise: the render simply looks like it +// did before the preference was set, with nothing to say why. +// +// Structured logging rather than `logWarning`, on two counts. `@/utils/message- +// queue` reaches `@/components/message-box` -> `useTheme` -> back here, which is +// a cycle this module is deliberately kept out of; and getSyntaxTheme is called +// from render (the diff, write_file and file-explorer previews), where pushing +// onto the chat queue is a state update during another component's render. +let warnedSyntaxTheme: string | null = null; + +function resolveSyntaxPalette(colors: SyntaxPalette): SyntaxPalette { + const dir = process.env.NANOCODER_CONFIG_DIR; + const version = getPreferencesVersion(); + if ( + !overrideCache || + overrideCache.dir !== dir || + overrideCache.version !== version + ) { + const preset = loadPreferences().syntaxTheme; + // Own properties only: `themes` comes from JSON.parse, so it carries + // Object.prototype and a `syntaxTheme` of "constructor" or "toString" + // would otherwise pass an `in` check and resolve to a non-theme. + const known = Boolean(preset) && Object.hasOwn(themes, preset as string); + + if (preset && !known && warnedSyntaxTheme !== preset) { + warnedSyntaxTheme = preset; + getLogger().warn( + `Unknown syntaxTheme '${preset}', falling back to the selected theme`, + {syntaxTheme: preset, source: 'syntax-theme'}, + ); + } + + overrideCache = { + dir, + version, + // An unknown or misspelt name falls back to the UI theme rather than + // throwing the user into an unstyled render. + palette: known ? themes[preset as ThemePreset].colors : null, + }; + } + return overrideCache.palette ?? colors; +} + +// cli-highlight's `theme` option takes a map of token -> formatter function, so +// the string 'default' every call site used to pass was silently ignored and code +// always rendered in the library's own palette. Deriving the map from a theme's +// colours keeps syntax highlighting in step with whichever preset is in play. +const syntaxThemes = new WeakMap(); + +export function getSyntaxTheme(uiColors: SyntaxPalette): SyntaxTheme { + const colors = resolveSyntaxPalette(uiColors); + const cached = syntaxThemes.get(colors); + if (cached) return cached; + + const keyword = chalk.hex(colors.primary); + const accent = chalk.hex(colors.tool); + const quoted = chalk.hex(colors.success); + const numeric = chalk.hex(colors.warning); + const muted = chalk.hex(colors.secondary); + const detail = chalk.hex(colors.info); + const body = chalk.hex(colors.text); + + const theme: SyntaxTheme = { + keyword, + literal: keyword, + type: keyword, + tag: keyword, + 'meta-keyword': keyword, + 'template-tag': keyword, + built_in: accent, + 'builtin-name': accent, + class: accent, + function: accent, + title: accent, + name: accent, + section: accent, + 'selector-tag': accent, + string: quoted, + regexp: quoted, + symbol: quoted, + 'meta-string': quoted, + quote: quoted, + link: quoted, + addition: quoted, + number: numeric, + bullet: numeric, + comment: muted, + doctag: muted, + meta: muted, + attr: detail, + attribute: detail, + variable: detail, + 'template-variable': detail, + 'selector-attr': detail, + 'selector-class': detail, + 'selector-id': detail, + 'selector-pseudo': detail, + formula: detail, + deletion: chalk.hex(colors.error), + params: body, + subst: body, + code: body, + default: body, + emphasis: chalk.italic, + strong: chalk.bold, + }; + + syntaxThemes.set(colors, theme); + return theme; +} diff --git a/source/markdown-parser/index.spec.ts b/source/markdown-parser/index.spec.ts index 248978655..07da3ee7c 100644 --- a/source/markdown-parser/index.spec.ts +++ b/source/markdown-parser/index.spec.ts @@ -1,10 +1,29 @@ +import {mkdirSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import test from 'ava'; +import chalk from 'chalk'; import stripAnsi from 'strip-ansi'; import type {Colors} from '../types/markdown-parser.js'; import {parseMarkdown} from './index.js'; console.log(`\nindex.spec.ts`); +// Highlighting consults the `syntaxTheme` preference, so run against an empty +// config directory — a contributor who sets that preference must not change +// which colours these assertions see. +const testConfigDir = join(tmpdir(), `nanocoder-md-spec-${process.pid}`); + +test.before(() => { + mkdirSync(testConfigDir, {recursive: true}); + process.env.NANOCODER_CONFIG_DIR = testConfigDir; +}); + +test.after.always(() => { + rmSync(testConfigDir, {recursive: true, force: true}); + delete process.env.NANOCODER_CONFIG_DIR; +}); + const mockColors: Colors = { primary: '#3b82f6', secondary: '#6b7280', @@ -165,6 +184,33 @@ test('parseMarkdown handles code blocks without language', t => { t.true(result.includes('Plain code')); }); +// Code blocks used to render in cli-highlight's own palette: the parser passed +// `theme: 'default'`, a string where the library expects a token -> formatter +// map, so the option was dropped. Colour must come from the caller's palette. +test('parseMarkdown highlights code blocks with the supplied colors', t => { + // Two separate things are going on here, both load-bearing. + // + // chalk.level = 3 because the runner reports no colour support, so the + // assertion would otherwise compare two unstyled strings. + // + // A distinct palette object because chalk bakes the colour MODEL into a + // builder when the builder is created: chalk.hex() picks ansi16, ansi256 + // or truecolor from chalk.level at that moment. Whether to emit codes is + // re-checked per call, but which codes is not. getSyntaxTheme memoises + // per palette identity, so reusing mockColors here would reuse builders + // frozen at the runner's default level - emitting  where the + // assertion builds [38;2;... and the two would not match. + const previousLevel = chalk.level; + chalk.level = 3; + try { + const colors: Colors = {...mockColors, primary: '#ff0000'}; + const result = parseMarkdown('```javascript\nconst x = 5;\n```', colors); + t.true(result.includes(chalk.hex(colors.primary)('const'))); + } finally { + chalk.level = previousLevel; + } +}); + // Edge case tests test('parseMarkdown does not create bullet list from hyphen in middle of line', t => { const text = 'This is not - a list'; diff --git a/source/markdown-parser/index.ts b/source/markdown-parser/index.ts index 3af3042b9..34cc0b92f 100644 --- a/source/markdown-parser/index.ts +++ b/source/markdown-parser/index.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import {highlight} from 'cli-highlight'; +import {getSyntaxTheme} from '@/config/themes'; import type {Colors} from '../types/markdown-parser.js'; import {decodeHtmlEntities} from './html-entities.js'; import {parseMarkdownTable} from './table-parser.js'; @@ -63,7 +64,7 @@ function _parseMarkdownCore( // Apply syntax highlighting with detected language const highlighted = highlight(codeStr, { language: lang || 'plaintext', - theme: 'default', + theme: getSyntaxTheme(themeColors), }); const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; codeBlocks.push(highlighted); diff --git a/source/tools/file-ops/string-replace-preview.tsx b/source/tools/file-ops/string-replace-preview.tsx index 04fc87af4..5f696ff34 100644 --- a/source/tools/file-ops/string-replace-preview.tsx +++ b/source/tools/file-ops/string-replace-preview.tsx @@ -4,6 +4,7 @@ import {Box, Text} from 'ink'; import React from 'react'; import ToolMessage from '@/components/tool-message'; import {getColors} from '@/config/index'; +import {getSyntaxTheme} from '@/config/themes'; import {DEFAULT_TERMINAL_COLUMNS} from '@/constants'; import type {Colors} from '@/types/index'; import {truncateAnsi} from '@/utils/ansi-truncate'; @@ -174,7 +175,7 @@ export async function formatStringReplacePreview( let displayLine: string; try { displayLine = truncateAnsi( - highlight(line, {language, theme: 'default'}), + highlight(line, {language, theme: getSyntaxTheme(themeColors)}), availableWidth, ); } catch { @@ -338,7 +339,7 @@ export async function formatStringReplacePreview( let displayLine: string; try { displayLine = truncateAnsi( - highlight(line, {language, theme: 'default'}), + highlight(line, {language, theme: getSyntaxTheme(themeColors)}), availableWidth, ); } catch { diff --git a/source/tools/file-ops/write-file.tsx b/source/tools/file-ops/write-file.tsx index 59de933d3..90aba99da 100644 --- a/source/tools/file-ops/write-file.tsx +++ b/source/tools/file-ops/write-file.tsx @@ -5,6 +5,7 @@ import {highlight} from 'cli-highlight'; import {Box, Text} from 'ink'; import React from 'react'; import ToolMessage from '@/components/tool-message'; +import {getSyntaxTheme} from '@/config/themes'; import {DEFAULT_TERMINAL_COLUMNS} from '@/constants'; import {ThemeContext} from '@/hooks/useTheme'; import {getSafeSessionCwd} from '@/services/session-cwd'; @@ -136,7 +137,10 @@ const WriteFileFormatter = React.memo(({args}: {args: WriteFileArgs}) => { const language = getLanguageFromExtension(ext); try { - const highlighted = highlight(line, {language, theme: 'default'}); + const highlighted = highlight(line, { + language, + theme: getSyntaxTheme(colors), + }); const truncated = truncateAnsi(highlighted, availableWidth); return ( diff --git a/source/types/config.ts b/source/types/config.ts index 7eec7b3eb..3af794a5d 100644 --- a/source/types/config.ts +++ b/source/types/config.ts @@ -443,6 +443,12 @@ export interface UserPreferences { }; lastUpdateCheck?: number; selectedTheme?: ThemePreset; + /** + * Theme whose palette colours syntax highlighting in code blocks, diffs, and + * file previews. Defaults to `selectedTheme`; set it only to give code a + * palette of its own. An unknown name falls back to `selectedTheme`. + */ + syntaxTheme?: ThemePreset; trustedDirectories?: string[]; titleShape?: TitleShape; nanocoderShape?: NanocoderShape; diff --git a/source/types/markdown-parser.ts b/source/types/markdown-parser.ts index 41b15b242..b526dff75 100644 --- a/source/types/markdown-parser.ts +++ b/source/types/markdown-parser.ts @@ -1,7 +1,11 @@ import type {Colors as FullColors} from '@/types/ui'; -// Subset of Colors used by the markdown parser -export type Colors = Pick< +/** + * The palette subset used for rendering: the markdown parser, and the + * cli-highlight theme derived from it in `@/config/themes`. Named for the job + * rather than the source now that it is shared by both. + */ +export type RenderPalette = Pick< FullColors, | 'primary' | 'secondary' @@ -12,3 +16,9 @@ export type Colors = Pick< | 'text' | 'tool' >; + +/** + * @deprecated Prefer {@link RenderPalette}. Kept so the markdown-parser call + * sites and its re-export keep working without a rename sweep. + */ +export type Colors = RenderPalette; From 7bbbfa4d85358fc296e36e1c1906e2938d8cc14d Mon Sep 17 00:00:00 2001 From: Will Lamerton <89926355+will-lamerton@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:27:33 +0100 Subject: [PATCH 08/25] fix(search): reject any failed ripgrep run, not just recognized ones (#1102) runRipgrep only rejected on exit > 1 when stderr also matched one of five hardcoded patterns. Anything unenumerated - an unreadable search root, a rejected argument, a build without PCRE2 - fell through to resolve(''), which find_files, search_file_contents, @-autocomplete and /repomap all report as a genuine "no results". A silent, permanent zero-result search is worse than a loud failure. Reject whenever rg exits > 1 with nothing on stdout. Exit 2 with output is still treated as a recoverable mid-scan warning, so one unreadable subdirectory in an otherwise readable tree keeps its results. FATAL_RIPGREP_ERROR_PATTERNS and isFatalRipgrepError are now unreachable, and their only remaining caller was a test, so both are removed rather than left as a test-only island. --- .changeset/ripgrep-file-search.md | 2 ++ source/utils/file-search.spec.ts | 39 +++++++++++++++++++++-------- source/utils/file-search.ts | 41 ++++++++++++------------------- 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/.changeset/ripgrep-file-search.md b/.changeset/ripgrep-file-search.md index 0f5400fa5..0140dbd83 100644 --- a/.changeset/ripgrep-file-search.md +++ b/.changeset/ripgrep-file-search.md @@ -5,3 +5,5 @@ File search (path matching and content search) is now backed by `ripgrep` instead of a hand-rolled JS walker. Search also respects `.nanocoderignore` and binary files again, matching `list_directory` and file autocomplete. + +A failed search now reports the failure instead of returning an empty result set. diff --git a/source/utils/file-search.spec.ts b/source/utils/file-search.spec.ts index f7c478886..d62189aaf 100644 --- a/source/utils/file-search.spec.ts +++ b/source/utils/file-search.spec.ts @@ -1,4 +1,4 @@ -import {mkdirSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; import {writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; @@ -8,7 +8,6 @@ import { findMatchingPaths, GLOB_TOKEN_CACHE_MAX_TOKENS, globTokenCache, - isFatalRipgrepError, matchesGlob, searchProjectContents, SearchTimeoutError, @@ -1101,14 +1100,34 @@ test.serial( }, ); -test( - 'isFatalRipgrepError recognizes a build of ripgrep with no PCRE2 support (real on Linux ARM)', - t => { - t.true( - isFatalRipgrepError( - 'rg: PCRE2 is not available in this build of ripgrep', - ), - ); +// chmod 0o000 does not stop root, and Windows ignores the mode entirely. +const unreadableDirTest = + process.platform === 'win32' || process.getuid?.() === 0 + ? test.serial.skip + : test.serial; + +unreadableDirTest( + 'walkProjectEntries surfaces an unreadable search root instead of reporting no files', + async t => { + const testDir = createTempDir('test-file-search-unreadable-temp'); + const lockedDir = join(testDir, 'locked'); + + try { + mkdirSync(lockedDir, {recursive: true}); + writeFileSync(join(lockedDir, 'a.txt'), 'hello\n'); + // rg exits 2 with "Permission denied (os error 13)" on an empty stdout. That + // message is in no allowlist, so gating rejection on recognized stderr would + // report this as an ordinary empty result. + chmodSync(lockedDir, 0o000); + + await t.throwsAsync( + () => walkProjectEntries(testDir, lockedDir, () => false), + {message: /ripgrep exited with code 2/}, + ); + } finally { + chmodSync(lockedDir, 0o755); + rmSync(testDir, {recursive: true, force: true}); + } }, ); diff --git a/source/utils/file-search.ts b/source/utils/file-search.ts index bb411e601..17ddf38ba 100644 --- a/source/utils/file-search.ts +++ b/source/utils/file-search.ts @@ -249,23 +249,6 @@ function binaryExcludeGlobs(): string[] { return globs; } -const FATAL_RIPGREP_ERROR_PATTERNS = [ - /regex parse error/, - /error parsing glob/, - /PCRE2: error compiling pattern/, - /PCRE2 is not available in this build of ripgrep/, - /grep config error: unknown encoding/, -]; - -/** @internal Exported for direct unit testing only. */ -export function isFatalRipgrepError(stderr: string): boolean { - const firstLine = stderr.split('\n')[0]?.trim() ?? ''; - if (firstLine.startsWith('the literal')) { - return true; - } - return FATAL_RIPGREP_ERROR_PATTERNS.some(pattern => pattern.test(stderr)); -} - interface RunRipgrepResult { stdout: string; hitMaxLines: boolean; @@ -396,14 +379,22 @@ async function runRipgrep( reject(new Error(`ripgrep terminated by signal ${closeSignal}`)); return; } - // Exit 1 = no matches. Exit 2 can be a recoverable mid-scan warning; only fail if empty. - if ( - code !== null && - code > 1 && - stdout.length === 0 && - isFatalRipgrepError(stderr) - ) { - reject(new Error(`ripgrep exited with code ${code}: ${stderr.trim()}`)); + // Exit 1 = no matches. Exit 2 with output is a recoverable mid-scan warning + // (one unreadable subdirectory, say) - rg scanned the rest, so keep it. + // + // Exit 2 with nothing on stdout means the search never produced anything: + // an unreadable root, a rejected argument, a build without PCRE2 (real on + // Linux ARM). Deliberately no stderr allowlist here - anything rg says that + // nobody enumerated would otherwise fall through to `resolve('')`, and every + // caller reads that as a genuine "no results" rather than a failure. + if (code !== null && code > 1 && stdout.length === 0) { + reject( + new Error( + `ripgrep exited with code ${code}: ${ + stderr.trim() || 'no error output' + }`, + ), + ); return; } resolve({stdout, hitMaxLines: false}); From e1af6d9e5b71bd244312e610be9acbe598dcf7f8 Mon Sep 17 00:00:00 2001 From: Will Lamerton Date: Mon, 31 Aug 2026 17:23:37 +0100 Subject: [PATCH 09/25] fix(export): name the reason a path was rejected, split writeUniqueFile out Follow-up to #956. - `/export` rejections all rendered the same "outside the project directory" string regardless of cause, which was simply wrong for a null byte or `~`. Re-derive the specific reason so the message names what was wrong and, for `~`, what to use instead. - Move `writeUniqueFile` to `source/utils/write-unique-file.ts`. It is a generic path helper with nothing export-specific about it, so `generate-export-filename.ts` was a hard place to find it. Its tests move with it. - Record that the export date is deliberately UTC rather than local. - Two export specs created directories inside the working tree and cleaned up with a trailing `fs.rm`, leaving them behind on a mid-test failure. Use `t.teardown` so cleanup is registered up front. - Bump the changeset to minor (it is a feature) and document the deliberate containment narrowing: `~` is not expanded and absolute paths outside the project root are refused, matching read_file / write_file / string_replace. --- .changeset/fiery-hats-pick.md | 4 +- source/commands/export.spec.tsx | 26 +++-- source/commands/export.tsx | 45 +++++++-- source/utils/generate-export-filename.spec.ts | 84 +--------------- source/utils/generate-export-filename.ts | 72 +------------- source/utils/write-unique-file.spec.ts | 99 +++++++++++++++++++ source/utils/write-unique-file.ts | 69 +++++++++++++ 7 files changed, 229 insertions(+), 170 deletions(-) create mode 100644 source/utils/write-unique-file.spec.ts create mode 100644 source/utils/write-unique-file.ts diff --git a/.changeset/fiery-hats-pick.md b/.changeset/fiery-hats-pick.md index 8575ccf1a..376339f4d 100644 --- a/.changeset/fiery-hats-pick.md +++ b/.changeset/fiery-hats-pick.md @@ -1,5 +1,7 @@ --- -"@nanocollective/nanocoder": patch +"@nanocollective/nanocoder": minor --- Auto-generate descriptive filenames for /export instead of generic timestamps. Closes #934 + +Exports are now contained to the project directory, matching read_file / write_file / string_replace: `~` is not expanded and absolute paths outside the project root are refused rather than written. Rejections name the specific cause (null byte, `~`, `..` segment, outside the root) instead of failing generically. diff --git a/source/commands/export.spec.tsx b/source/commands/export.spec.tsx index 519194fde..bfb808ea3 100644 --- a/source/commands/export.spec.tsx +++ b/source/commands/export.spec.tsx @@ -257,11 +257,11 @@ test('exportCommand rejects path traversal in filename', async t => { const {lastFrame} = render({result}); const output = lastFrame(); t.truthy(output); - t.regex(output!, /Invalid export path/); + t.regex(output!, /'\.\.' segments are not allowed/); t.false(output!.includes('Chat exported')); }); - test('exportCommand allows exporting into a subdirectory', async t => { +test('exportCommand allows exporting into a subdirectory', async t => { // isValidFilePath is segment-aware, so a subdirectory export must work while // traversal is still blocked. await exportCommand.handler( @@ -292,7 +292,8 @@ test('exportCommand rejects a filename with a null byte', async t => { const {lastFrame} = render({result}); const output = lastFrame(); t.truthy(output); - t.regex(output!, /Invalid export path/); + // The message must name the actual cause, not a generic "invalid path". + t.regex(output!, /Invalid export path: the filename contains a null byte/); }); test('exportCommand rejects a home-directory shorthand path', async t => { @@ -307,7 +308,9 @@ test('exportCommand rejects a home-directory shorthand path', async t => { const {lastFrame} = render({result}); const output = lastFrame(); t.truthy(output); - t.regex(output!, /Invalid export path/); + // `~` is not expanded, so say so and point at what does work. + t.regex(output!, /'~' is not expanded/); + t.regex(output!, /absolute path inside it/); }); test('exportCommand rejects a path escaping the project directory', async t => { @@ -322,7 +325,7 @@ test('exportCommand rejects a path escaping the project directory', async t => { const {lastFrame} = render({result}); const output = lastFrame(); t.truthy(output); - t.regex(output!, /Invalid export path/); + t.regex(output!, /'\.\.' segments are not allowed/); t.false(output!.includes('Chat exported')); }); @@ -339,14 +342,19 @@ test('exportCommand rejects an absolute path outside the project', async t => { const {lastFrame} = render({result}); const output = lastFrame(); t.truthy(output); - t.regex(output!, /Invalid export path/); + // Containment is deliberate: name the boundary that was crossed. + t.regex(output!, /outside the project directory/); }); test('exportCommand resolves a relative path against the session cwd (honours cd)', async t => { // Pin the session cwd to a subdirectory, as a bash `cd` would, and confirm a // bare relative export lands there -- not in the launch dir (process.cwd()). + // Must live under the project root for the containment check to pass, so + // register cleanup up front — a mid-test failure would otherwise leave the + // directory behind in the working tree. const subdir = path.join(process.cwd(), 'tmp-session-cwd-test'); await fs.mkdir(subdir, {recursive: true}); + t.teardown(() => fs.rm(subdir, {recursive: true, force: true})); setSessionCwd(subdir); await exportCommand.handler(['chat.md'], testMessages, testMetadata); @@ -354,7 +362,6 @@ test('exportCommand resolves a relative path against the session cwd (honours cd t.is(mockWriteFileCalls.length, 1); const expected = path.join(subdir, 'chat.md'); t.is(mockWriteFileCalls[0].path, expected); - await fs.rm(subdir, {recursive: true}); }); test('exportCommand contains writes to the project root even when the session cwd is deeper', async t => { @@ -365,6 +372,7 @@ test('exportCommand contains writes to the project root even when the session cw const root = path.join(process.cwd(), 'tmp-prjroot-test'); const subdir = path.join(root, 'worktree'); await fs.mkdir(subdir, {recursive: true}); + t.teardown(() => fs.rm(root, {recursive: true, force: true})); setProjectRoot(root); setSessionCwd(subdir); @@ -383,9 +391,7 @@ test('exportCommand contains writes to the project root even when the session cw )) as React.ReactElement; t.is(mockWriteFileCalls.length, 1); const {lastFrame} = render({escape}); - t.regex(lastFrame()!, /Invalid export path/); - - await fs.rm(root, {recursive: true}); + t.regex(lastFrame()!, /outside the project directory/); }); test('exportCommand reports a missing parent directory clearly', async t => { diff --git a/source/commands/export.tsx b/source/commands/export.tsx index 8ccf11d5d..5b341b5e7 100644 --- a/source/commands/export.tsx +++ b/source/commands/export.tsx @@ -6,11 +6,9 @@ import {getProjectRoot, getSafeSessionCwd} from '@/services/session-cwd'; import {generateKey} from '@/session/key-generator'; import {Command, Message} from '@/types/index'; import {formatError} from '@/utils/error-formatter'; -import { - generateExportFilename, - writeUniqueFile, -} from '@/utils/generate-export-filename'; +import {generateExportFilename} from '@/utils/generate-export-filename'; import {resolveFilePath} from '@/utils/path-validation'; +import {writeUniqueFile} from '@/utils/write-unique-file'; const formatMessageContent = (message: Message) => { let content = ''; @@ -64,6 +62,32 @@ function ExportError({message}: {message: string}) { ); } +/** + * `resolveFilePath` throws a single generic "Invalid file path" for several + * distinct causes, which leaves the user guessing (a null byte and a `~` are + * very different mistakes). Re-derive the specific reason so the message names + * what was actually wrong and how to fix it. + * + * Exports are deliberately contained to the project directory, the same as + * `read_file` / `write_file` / `string_replace`. `~` is not expanded and paths + * outside the root are refused rather than silently redirected. + */ +function explainInvalidPath(filename: string, root: string): string { + if (!filename.trim()) { + return 'the filename is empty'; + } + if (filename.includes('\0')) { + return 'the filename contains a null byte'; + } + if (filename.startsWith('~')) { + return "'~' is not expanded; use a path relative to the project, or an absolute path inside it"; + } + if (filename.split(/[/\\]/).some(segment => segment === '..')) { + return "'..' segments are not allowed; exports stay inside the project"; + } + return `it is outside the project directory (${root})`; +} + export const exportCommand: Command = { name: 'export', description: 'Export the chat history to a markdown file', @@ -78,17 +102,21 @@ export const exportCommand: Command = { // Resolve against the session cwd (which honours bash `cd`) and enforce // containment within the project root (which does not shrink as `cd` // descends) -- the same convention as read_file / write_file / string_replace. + const projectRoot = getProjectRoot(); let filepath: string; try { filepath = resolveFilePath( requestedFilename, getSafeSessionCwd(), - getProjectRoot(), + projectRoot, ); } catch { return React.createElement(ExportError, { key: generateKey('export'), - message: 'Invalid export path: outside the project directory', + message: `Invalid export path: ${explainInvalidPath( + requestedFilename, + projectRoot, + )}`, }); } @@ -132,9 +160,8 @@ total_tokens: ${tokens} // Show the exported file relative to the project root so subdirectory // exports (e.g. reports/chat.md) are recognisable rather than a bare basename. - const root = getProjectRoot(); - const displayPath = writtenFilepath.startsWith(root + path.sep) - ? writtenFilepath.slice(root.length + 1) + const displayPath = writtenFilepath.startsWith(projectRoot + path.sep) + ? writtenFilepath.slice(projectRoot.length + 1) : writtenFilepath; return React.createElement(Export, { diff --git a/source/utils/generate-export-filename.spec.ts b/source/utils/generate-export-filename.spec.ts index 5fa6b3fac..82ba98a47 100644 --- a/source/utils/generate-export-filename.spec.ts +++ b/source/utils/generate-export-filename.spec.ts @@ -1,9 +1,6 @@ import test from 'ava'; import type {Message} from '@/types/core'; -import {generateExportFilename, writeUniqueFile} from './generate-export-filename'; -import {promises as fs} from 'fs'; -import path from 'path'; -import os from 'os'; +import {generateExportFilename} from './generate-export-filename'; const user = (content: string): Message => ({role: 'user', content}); const assistant = (content: string): Message => ({role: 'assistant', content}); @@ -114,82 +111,3 @@ test('truncates a long hyphenated slug at the last whole word', t => { t.true(/^fix(?:-[a-z]+)*$/.test(slug)); t.true(slug.length >= 20); }); - -test('writeUniqueFile writes to the given path when it is free', async t => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); - const filepath = path.join(tmpDir, 'test.md'); - const result = await writeUniqueFile(filepath, 'content'); - t.is(result, filepath); - t.is(await fs.readFile(filepath, 'utf-8'), 'content'); - await fs.rm(tmpDir, {recursive: true}); -}); - -test('writeUniqueFile appends a counter when the path is taken', async t => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); - const filepath = path.join(tmpDir, 'test.md'); - await fs.writeFile(filepath, 'existing'); - const result = await writeUniqueFile(filepath, 'content'); - t.is(result, path.join(tmpDir, 'test-2.md')); - t.is(await fs.readFile(path.join(tmpDir, 'test-2.md'), 'utf-8'), 'content'); - await fs.rm(tmpDir, {recursive: true}); -}); - -test('writeUniqueFile never overwrites an existing file', async t => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); - const filepath = path.join(tmpDir, 'test.md'); - const originals = [ - 'test.md', - 'test-2.md', - 'test-3.md', - 'test-4.md', - 'test-5.md', - 'test-6.md', - ]; - for (const name of originals) { - await fs.writeFile(path.join(tmpDir, name), 'existing'); - } - - const result = await writeUniqueFile(filepath, 'content'); - - // Every pre-existing file keeps its content — none may be clobbered. - for (const name of originals) { - t.is(await fs.readFile(path.join(tmpDir, name), 'utf-8'), 'existing'); - } - // The writer must have landed in a fresh, distinct file (timestamp suffix). - t.not(result, filepath); - t.true(result.startsWith(path.join(tmpDir, 'test-new-'))); - t.true(result.endsWith('.md')); - t.is(await fs.readFile(result, 'utf-8'), 'content'); - await fs.rm(tmpDir, {recursive: true}); -}); - -test('writeUniqueFile is atomic: the original is never clobbered by a race', async t => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); - const filepath = path.join(tmpDir, 'test.md'); - await fs.writeFile(filepath, 'original'); - - // Simulate TWO concurrent exclusive-flag writers for the same target. Only - // one may win the base name; the other must fall to a suffix, and the - // original file's contents must be preserved. - const [a, b] = await Promise.all([ - writeUniqueFile(filepath, 'first'), - writeUniqueFile(filepath, 'second'), - ]); - - t.not(a, filepath); - t.not(b, filepath); - t.not(a, b); - t.is(await fs.readFile(filepath, 'utf-8'), 'original'); - await fs.rm(tmpDir, {recursive: true}); -}); - -test('writeUniqueFile reports a missing parent directory clearly', async t => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'export-test-')); - const filepath = path.join(tmpDir, 'does-not-exist', 'chat.md'); - - await t.throwsAsync( - () => writeUniqueFile(filepath, 'content'), - {message: /Parent directory does not exist/}, - ); - await fs.rm(tmpDir, {recursive: true}); -}); diff --git a/source/utils/generate-export-filename.ts b/source/utils/generate-export-filename.ts index 242a1630f..ddd84fd3b 100644 --- a/source/utils/generate-export-filename.ts +++ b/source/utils/generate-export-filename.ts @@ -1,10 +1,7 @@ -import fs from 'fs/promises'; -import path from 'path'; import type {Message} from '@/types/core'; const MAX_WORDS = 4; const MAX_SLUG_LENGTH = 40; -const MAX_COLLISION_ATTEMPTS = 5; function sanitizeSlug(input: string): string { return input @@ -50,72 +47,13 @@ function generateSlugFromMessages(messages: Message[]): string { return truncateAtWordBoundary(sanitizeSlug(truncated), MAX_SLUG_LENGTH); } -/** - * Deterministically finds a free filename for a generated export and writes it - * atomically. - * - * The write uses the exclusive flag 'wx' so the free-check and the create are - * a single atomic step: two concurrent exports for the same slug can never - * both succeed on the same path (no TOCTOU race, no clobbering). On EEXIST we - * try the next collision suffix; once the bounded attempts are exhausted we - * fall back to a timestamp suffix. This function never falls through to - * overwriting an existing file. - * - * Unlike /export with an explicit filename (which keeps overwrite semantics), - * generated names must never destroy a previous export. - */ -export async function writeUniqueFile( - filepath: string, - content: string, -): Promise { - const dir = path.dirname(filepath); - const ext = path.extname(filepath); - const base = path.basename(filepath, ext); - - const tryWrite = async (candidate: string): Promise => { - try { - await fs.writeFile(candidate, content, {flag: 'wx'}); - return candidate; - } catch (error) { - if (error && typeof error === 'object' && 'code' in error) { - // Collision: try the next candidate. - if (error.code === 'EEXIST') return null; - // Missing parent directory: report it plainly so the user knows - // the export failed because a directory doesn't exist. - if (error.code === 'ENOENT') { - throw new Error(`Parent directory does not exist: ${dir}`); - } - } - throw error; - } - }; - - for (let i = 1; i < MAX_COLLISION_ATTEMPTS + 1; i++) { - const suffix = i === 1 ? '' : `-${i}`; - // `filepath` was already validated and containment-checked by - // resolveFilePath in the handler, so `dir`/`base`/`ext` cannot contain a - // separator or `..` and this join can never leave `dir`. - // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - const candidate = path.join(dir, `${base}${suffix}${ext}`); - const written = await tryWrite(candidate); - if (written) return written; - } - - // Bounded attempts all collided, drop a timestamp and try once more. If the - // astronomically-unlikely timestamp collision happens, surface the error - // rather than clobber anything. - // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - const timestamped = path.join(dir, `${base}-new-${Date.now()}${ext}`); - return tryWrite(timestamped).then(result => { - if (!result) { - throw new Error('Unable to allocate a unique export filename'); - } - return result; - }); -} - export function generateExportFilename(messages: Message[]): string { const slug = generateSlugFromMessages(messages); + // Deliberately UTC, not local: the date is only there to disambiguate + // exports, and a UTC stamp keeps a session that crosses local midnight (or + // is exported from a different timezone than it was recorded in) ordering + // consistently. Collisions within the same day are handled by + // writeUniqueFile, so a local date would buy nothing. const date = new Date().toISOString().split('T')[0]; if (!slug) { diff --git a/source/utils/write-unique-file.spec.ts b/source/utils/write-unique-file.spec.ts new file mode 100644 index 000000000..07ef58a2e --- /dev/null +++ b/source/utils/write-unique-file.spec.ts @@ -0,0 +1,99 @@ +import test from 'ava'; +import type {ExecutionContext} from 'ava'; +import {promises as fs} from 'fs'; +import os from 'os'; +import path from 'path'; +import {writeUniqueFile} from './write-unique-file'; + +// Registers cleanup up front so a mid-test failure still removes the directory +// instead of leaving it behind. +async function tmpDir(t: ExecutionContext): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'write-unique-test-')); + t.teardown(() => fs.rm(dir, {recursive: true, force: true})); + return dir; +} + +test('writes to the given path when it is free', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + const result = await writeUniqueFile(filepath, 'content'); + t.is(result, filepath); + t.is(await fs.readFile(filepath, 'utf-8'), 'content'); +}); + +test('appends a counter when the path is taken', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + await fs.writeFile(filepath, 'existing'); + const result = await writeUniqueFile(filepath, 'content'); + t.is(result, path.join(dir, 'test-2.md')); + t.is(await fs.readFile(path.join(dir, 'test-2.md'), 'utf-8'), 'content'); +}); + +test('never overwrites an existing file', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + const originals = [ + 'test.md', + 'test-2.md', + 'test-3.md', + 'test-4.md', + 'test-5.md', + 'test-6.md', + ]; + for (const name of originals) { + await fs.writeFile(path.join(dir, name), 'existing'); + } + + const result = await writeUniqueFile(filepath, 'content'); + + // Every pre-existing file keeps its content — none may be clobbered. + for (const name of originals) { + t.is(await fs.readFile(path.join(dir, name), 'utf-8'), 'existing'); + } + // The writer must have landed in a fresh, distinct file (timestamp suffix). + t.not(result, filepath); + t.true(result.startsWith(path.join(dir, 'test-new-'))); + t.true(result.endsWith('.md')); + t.is(await fs.readFile(result, 'utf-8'), 'content'); +}); + +test('is atomic: the original is never clobbered by a race', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + await fs.writeFile(filepath, 'original'); + + // Simulate TWO concurrent exclusive-flag writers for the same target. Only + // one may win the base name; the other must fall to a suffix, and the + // original file's contents must be preserved. + const [a, b] = await Promise.all([ + writeUniqueFile(filepath, 'first'), + writeUniqueFile(filepath, 'second'), + ]); + + t.not(a, filepath); + t.not(b, filepath); + t.not(a, b); + t.is(await fs.readFile(filepath, 'utf-8'), 'original'); +}); + +test('reports a missing parent directory clearly', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'does-not-exist', 'chat.md'); + + await t.throwsAsync(() => writeUniqueFile(filepath, 'content'), { + message: /Parent directory does not exist/, + }); +}); + +test('keeps every candidate in the target directory', async t => { + const dir = await tmpDir(t); + const filepath = path.join(dir, 'test.md'); + await fs.writeFile(filepath, 'existing'); + + const result = await writeUniqueFile(filepath, 'content'); + + // Suffixes go on the basename only — a collision must never walk the write + // out of the directory the caller validated. + t.is(path.dirname(result), dir); +}); diff --git a/source/utils/write-unique-file.ts b/source/utils/write-unique-file.ts new file mode 100644 index 000000000..3616a444c --- /dev/null +++ b/source/utils/write-unique-file.ts @@ -0,0 +1,69 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const MAX_COLLISION_ATTEMPTS = 5; + +/** + * Finds a free filename next to `filepath` and writes `content` to it + * atomically, returning the path actually written. + * + * The write uses the exclusive flag 'wx' so the free-check and the create are + * a single atomic step: two concurrent writers for the same target can never + * both succeed on the same path (no TOCTOU race, no clobbering). On EEXIST we + * try the next collision suffix (`-2`, `-3`, ...); once the bounded attempts + * are exhausted we fall back to a timestamp suffix, which is cheaper than + * walking a long sequential run. This function never falls through to + * overwriting an existing file — if it cannot find a free name it throws. + * + * Callers must pass an already-validated, containment-checked absolute path + * (see `resolveFilePath`). The suffixes are appended to the basename only, so + * every candidate stays in the same directory as `filepath`. + */ +export async function writeUniqueFile( + filepath: string, + content: string, +): Promise { + const dir = path.dirname(filepath); + const ext = path.extname(filepath); + const base = path.basename(filepath, ext); + + const tryWrite = async (candidate: string): Promise => { + try { + await fs.writeFile(candidate, content, {flag: 'wx'}); + return candidate; + } catch (error) { + if (error && typeof error === 'object' && 'code' in error) { + // Collision: try the next candidate. + if (error.code === 'EEXIST') return null; + // Missing parent directory: report it plainly so the caller knows + // the write failed because a directory doesn't exist. + if (error.code === 'ENOENT') { + throw new Error(`Parent directory does not exist: ${dir}`); + } + } + throw error; + } + }; + + for (let i = 1; i < MAX_COLLISION_ATTEMPTS + 1; i++) { + const suffix = i === 1 ? '' : `-${i}`; + // `filepath` was already validated and containment-checked by the caller, + // so `dir`/`base`/`ext` cannot contain a separator or `..` and this join + // can never leave `dir`. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const candidate = path.join(dir, `${base}${suffix}${ext}`); + const written = await tryWrite(candidate); + if (written) return written; + } + + // Bounded attempts all collided, drop a timestamp and try once more. If the + // astronomically-unlikely timestamp collision happens, surface the error + // rather than clobber anything. + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + const timestamped = path.join(dir, `${base}-new-${Date.now()}${ext}`); + const written = await tryWrite(timestamped); + if (!written) { + throw new Error(`Unable to allocate a unique filename for: ${filepath}`); + } + return written; +} From 2d357693f672da06a83ce812df12788fe08b7fd4 Mon Sep 17 00:00:00 2001 From: Will Lamerton <89926355+will-lamerton@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:46:37 +0100 Subject: [PATCH 10/25] fix(search): prune .nanocoderignore during the walk, type the unsorted visitor (#1104) Two follow-ups from the #928 review. .nanocoderignore was only applied as a JS filter over rg's output, so ignored paths were still traversed and still spent budget against maxRawFilesScanned. A large ignored fixtures directory sorting before src/ could consume the whole 50k cap and crowd real files out of the results. Pass the file to rg as --ignore-file so it prunes during traversal; rg applies those rules after .gitignore, which is the layering loadGitignore already documents. The JS filter stays as a backstop since rg's matcher and the `ignore` package are separate implementations. Omitted when the file is absent - rg warns on a missing path. walkProjectEntries' "onEntry must be synchronous when sorted: false" contract existed only in a throw message. Overloads make it a compile error, and the option is documented. Writing the test for that surfaced a worse problem: emitEntrySync throws from the stdout data handler, which runs on the stream's event loop turn rather than inside the promise executor, so the throw escaped as an uncaught exception and took the process down instead of rejecting the search. Route any throw from chunk consumption to reject. --- .changeset/ripgrep-file-search.md | 2 + source/utils/file-search.spec.ts | 67 ++++++++++++++++++++++ source/utils/file-search.ts | 95 ++++++++++++++++++++++++++++--- source/utils/gitignore-loader.ts | 19 ++++++- 4 files changed, 175 insertions(+), 8 deletions(-) diff --git a/.changeset/ripgrep-file-search.md b/.changeset/ripgrep-file-search.md index 0140dbd83..11b47104b 100644 --- a/.changeset/ripgrep-file-search.md +++ b/.changeset/ripgrep-file-search.md @@ -7,3 +7,5 @@ File search (path matching and content search) is now backed by `ripgrep` instea Search also respects `.nanocoderignore` and binary files again, matching `list_directory` and file autocomplete. A failed search now reports the failure instead of returning an empty result set. + +`.nanocoderignore` directories are skipped during the walk rather than filtered afterwards, so a large ignored directory can no longer crowd real files out of the results. diff --git a/source/utils/file-search.spec.ts b/source/utils/file-search.spec.ts index d62189aaf..3d11fdf56 100644 --- a/source/utils/file-search.spec.ts +++ b/source/utils/file-search.spec.ts @@ -408,6 +408,73 @@ test.serial( }, ); +test.serial( + 'a .nanocoderignore directory is pruned during traversal, not just filtered from results', + async t => { + const testDir = createTempDir('test-file-search-nanocoderignore-prune-temp'); + + try { + mkdirSync(join(testDir, 'fixtures'), {recursive: true}); + mkdirSync(join(testDir, 'src'), {recursive: true}); + writeFileSync(join(testDir, '.nanocoderignore'), 'fixtures/\n'); + // Sorts before src/, so a JS-side filter alone would let these 20 spend + // the whole scan budget and crowd keep.ts out of the results entirely. + for (let index = 0; index < 20; index++) { + writeFileSync( + join(testDir, 'fixtures', `f${String(index).padStart(2, '0')}.txt`), + 'noise', + ); + } + writeFileSync(join(testDir, 'src', 'keep.ts'), 'export {};'); + + const seen: string[] = []; + const result = await walkProjectEntries( + testDir, + undefined, + entry => { + seen.push(entry.relativePath); + return false; + }, + {includeDirectories: false, maxRawFilesScanned: 5}, + ); + + t.true(seen.includes('src/keep.ts')); + t.false(seen.some(entry => entry.startsWith('fixtures/'))); + t.false(result.truncated); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + +test.serial( + 'walkProjectEntries rejects an async onEntry under sorted: false rather than racing it', + async t => { + const testDir = createTempDir('test-file-search-async-visitor-temp'); + + try { + mkdirSync(testDir, {recursive: true}); + writeFileSync(join(testDir, 'a.txt'), 'hello\n'); + + // The overloads make this a compile error; this guards the runtime + // backstop for callers without type checking. + const asyncVisitor = async () => false; + await t.throwsAsync( + () => + walkProjectEntries( + testDir, + undefined, + asyncVisitor as unknown as () => boolean, + {sorted: false}, + ), + {message: /onEntry must be synchronous/}, + ); + } finally { + rmSync(testDir, {recursive: true, force: true}); + } + }, +); + test.serial( 'findMatchingPaths lets .nanocoderignore un-ignore a DEFAULT_IGNORE_DIRS entry', async t => { diff --git a/source/utils/file-search.ts b/source/utils/file-search.ts index 17ddf38ba..44f7b0c98 100644 --- a/source/utils/file-search.ts +++ b/source/utils/file-search.ts @@ -6,7 +6,11 @@ import ignore from 'ignore'; import {LRUCache} from 'lru-cache'; import {BINARY_FILE_EXTENSIONS} from '@/constants'; -import {DEFAULT_IGNORE_DIRS, loadGitignore} from '@/utils/gitignore-loader'; +import { + DEFAULT_IGNORE_DIRS, + findNanocoderIgnoreFile, + loadGitignore, +} from '@/utils/gitignore-loader'; import {getLogger} from '@/utils/logging'; import {resolveRipgrepPath} from '@/utils/ripgrep-path'; @@ -234,6 +238,23 @@ function defaultIgnoreGlobs( return globs; } +/** + * Hands .nanocoderignore to rg so it prunes during traversal. + * + * The JS-side `projectIgnore.ignores()` filter downstream would drop these + * paths anyway, but only after rg had walked them and after they had already + * spent budget against `maxRawFilesScanned` - a large ignored fixtures + * directory could crowd real files out of the results entirely. + * + * rg applies `--ignore-file` rules after .gitignore and after `.ignore`, which + * is the layering {@link loadGitignore} documents. Omitted when the file does + * not exist: rg warns on a missing path, and every search would carry it. + */ +function nanocoderIgnoreFileArgs(cwd: string): string[] { + const ignoreFile = findNanocoderIgnoreFile(cwd); + return ignoreFile ? ['--ignore-file', ignoreFile] : []; +} + async function assertPathExists(candidatePath: string): Promise { await lstat(candidatePath); } @@ -284,6 +305,21 @@ async function runRipgrep( child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { + // This runs on the stream's event loop turn, not inside the promise + // executor, so a throw from onLine would escape as an uncaught + // exception and take the process down instead of failing the search. + try { + consumeChunk(chunk); + } catch (err) { + // Reusing killedForLimit to ignore any chunks still in flight. The + // promise is already rejected, so the close handler's resolve is a no-op. + killedForLimit = true; + child.kill(); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + + function consumeChunk(chunk: string): void { if (killedForLimit) { return; } @@ -340,7 +376,7 @@ async function runRipgrep( newlineIndex = lineRemainder.indexOf('\n'); } - }); + } child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk: string) => { @@ -534,15 +570,34 @@ async function walkEmptyDirectories( } export interface WalkProjectEntriesOptions { + /** Emit directory entries alongside files. Defaults to true. */ includeDirectories?: boolean; signal?: AbortSignal; maxRawFilesScanned?: number; - // Unsorted streams results early but isn't guaranteed faster - rg's discovery order is non-deterministic. + /** + * Sort entries by path. Defaults to true. + * + * Unsorted streams results early but isn't guaranteed faster - rg's + * discovery order is non-deterministic. + * + * `sorted: false` reads entries off rg's stdout as it arrives, so `onEntry` + * MUST be synchronous there: returning a promise would let the stream run + * ahead of the callback. The overloads below make that a compile error, and + * {@link emitEntrySync} throws if a JS caller slips one through anyway. + */ sorted?: boolean; } +/** `onEntry` shape accepted when entries stream in unsorted - no promises. */ +export type SyncProjectEntryVisitor = (entry: ProjectEntry) => boolean; + +/** `onEntry` shape accepted when entries are sorted and emitted one at a time. */ +export type ProjectEntryVisitor = ( + entry: ProjectEntry, +) => boolean | Promise; + function emitEntrySync( - onEntry: (entry: ProjectEntry) => boolean | Promise, + onEntry: ProjectEntryVisitor, entry: ProjectEntry, ): boolean { const stop = onEntry(entry); @@ -558,7 +613,7 @@ async function walkUnsortedFileStream( cwd: string, rootPath: string, args: string[], - onEntry: (entry: ProjectEntry) => boolean | Promise, + onEntry: ProjectEntryVisitor, includeDirectories: boolean, projectIgnore: ReturnType, signal: AbortSignal | undefined, @@ -574,6 +629,8 @@ async function walkUnsortedFileStream( } const relativeFile = normalizePathForMatch(path.relative(cwd, file)); + // rg already pruned these via --ignore-file; kept as a backstop because + // its matcher and the `ignore` package are separate implementations. if (projectIgnore.ignores(relativeFile)) { return false; } @@ -644,10 +701,29 @@ async function walkUnsortedFileStream( return {truncated: hitMaxLines || hitDirCap}; } +/** + * Walk every non-ignored file (and, by default, directory) under `startPath`, + * calling `onEntry` for each. Return true from `onEntry` to stop the walk. + * + * With `sorted: false`, entries stream straight off rg's stdout and `onEntry` + * must be synchronous - see {@link WalkProjectEntriesOptions.sorted}. + */ export async function walkProjectEntries( cwd: string, startPath: string | undefined, - onEntry: (entry: ProjectEntry) => boolean | Promise, + onEntry: SyncProjectEntryVisitor, + options: WalkProjectEntriesOptions & {sorted: false}, +): Promise<{truncated: boolean}>; +export async function walkProjectEntries( + cwd: string, + startPath: string | undefined, + onEntry: ProjectEntryVisitor, + options?: WalkProjectEntriesOptions & {sorted?: true}, +): Promise<{truncated: boolean}>; +export async function walkProjectEntries( + cwd: string, + startPath: string | undefined, + onEntry: ProjectEntryVisitor, options: WalkProjectEntriesOptions = {}, ): Promise<{truncated: boolean}> { const { @@ -667,6 +743,7 @@ export async function walkProjectEntries( '--no-require-git', '--no-config', ...(sorted ? ['--sort', 'path'] : []), + ...nanocoderIgnoreFileArgs(cwd), ...defaultIgnoreGlobs(projectIgnore), '--', rootPath, @@ -1000,7 +1077,11 @@ export async function searchProjectContents( if (include) { args.push('-g', include); } - args.push(...defaultIgnoreGlobs(projectIgnore), ...binaryExcludeGlobs()); + args.push( + ...nanocoderIgnoreFileArgs(cwd), + ...defaultIgnoreGlobs(projectIgnore), + ...binaryExcludeGlobs(), + ); const normalizedContextLines = Math.max(0, contextLines ?? 0); if (normalizedContextLines > 0) { args.push('--context', String(normalizedContextLines)); diff --git a/source/utils/gitignore-loader.ts b/source/utils/gitignore-loader.ts index 2b048aa19..ed30962cd 100644 --- a/source/utils/gitignore-loader.ts +++ b/source/utils/gitignore-loader.ts @@ -37,6 +37,23 @@ const DEFAULT_IGNORE_DIRS = [ '.hg', // Mercurial ]; +const NANOCODER_IGNORE_FILENAME = '.nanocoderignore'; + +/** + * Absolute path to the workspace's .nanocoderignore, or undefined when there + * isn't one. + * + * Exists so callers that hand the file to an external tool - ripgrep's + * `--ignore-file`, say - resolve it the same way {@link loadGitignore} does + * rather than re-deriving the filename. + */ +export function findNanocoderIgnoreFile( + workspaceRoot: string, +): string | undefined { + const candidate = join(workspaceRoot, NANOCODER_IGNORE_FILENAME); + return existsSync(candidate) ? candidate : undefined; +} + export interface LoadGitignoreOptions { /** * Whether to layer .nanocoderignore on top of .gitignore. Defaults to true. @@ -76,7 +93,7 @@ export function loadGitignore( const {nanocoderIgnore = true} = options; const ig = ignore(); const gitignorePath = join(workspaceRoot, '.gitignore'); - const nanocoderignorePath = join(workspaceRoot, '.nanocoderignore'); + const nanocoderignorePath = join(workspaceRoot, NANOCODER_IGNORE_FILENAME); // Always ignore common directories ig.add(DEFAULT_IGNORE_DIRS); From 6387c0100e475d9e5e0380091567d52d9981c091 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 31 Aug 2026 22:41:40 +0530 Subject: [PATCH 11/25] fix: spawn cmd.exe with /c for custom tools on Windows (#1031) * fix: spawn cmd.exe with /c for custom tools on Windows execute_bash already used /c; custom tools always passed -c. Closes #1028. * link quoting follow-up --- .changeset/custom-tool-windows-cmd.md | 5 ++++ docs/features/custom-tools.md | 12 ++++---- source/custom-tools/handler.spec.ts | 40 +++++++++++++++++++++++++-- source/custom-tools/handler.ts | 12 +++++++- source/custom-tools/template.ts | 5 ++-- 5 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .changeset/custom-tool-windows-cmd.md diff --git a/.changeset/custom-tool-windows-cmd.md b/.changeset/custom-tool-windows-cmd.md new file mode 100644 index 000000000..3cfc71c28 --- /dev/null +++ b/.changeset/custom-tool-windows-cmd.md @@ -0,0 +1,5 @@ +--- +'@nanocollective/nanocoder': patch +--- + +Custom tools on Windows now spawn `cmd.exe /d /s /c` instead of `-c`, which cmd does not accept. `/d` skips AutoRun; `/s` makes quote stripping deterministic. `{{ }}` substitution stays POSIX-quoted and is not shell-safe under cmd. Closes #1028. diff --git a/docs/features/custom-tools.md b/docs/features/custom-tools.md index 12b3a06b6..5b0321ad8 100644 --- a/docs/features/custom-tools.md +++ b/docs/features/custom-tools.md @@ -90,7 +90,7 @@ timeout_ms: 30000 # default 30000, max 300000 cwd: ./scripts # default: project root; supports ${VAR}; must stay in the project env: FOO: bar # extra env vars; values support ${VAR} -shell: bash | sh # default: bash if available, else sh +shell: bash | sh # default: bash if available, else sh; Windows: ComSpec/cmd.exe --- # Body is a shell script. See "Template Syntax" below. @@ -122,11 +122,13 @@ This is containment against misconfiguration, not a sandbox. The script body is The body is a shell script with two placeholder forms: -- **`{{ name }}`** — substitutes `args[name]`, shell-quoted. Arrays expand to space-separated quoted tokens. +- **`{{ name }}`** — substitutes `args[name]`, POSIX-quoted. Arrays expand to space-separated quoted tokens. Not cmd-safe; see below. - **`{{# name }}…{{/ name }}`** — section: included only when `args[name]` is truthy (non-empty string, non-empty array, non-zero number, `true`, etc.). Nested sections are supported. - **`{{^ name }}…{{/ name }}`** — inverted section: included only when `args[name]` is falsy/empty (the complement of `{{# name }}`). -All substituted values are wrapped in POSIX single quotes and any embedded single quotes are escaped. This blocks shell injection through parameter values: +All substituted values are wrapped in POSIX single quotes and any embedded single quotes are escaped. That is shell-safe under bash/sh. It is **not** shell-safe under cmd.exe: cmd does not treat `'` as a quote, so `type {{ file }}` becomes `type 'notes.txt'` (file not found) and `&`, `|`, `>`, `^`, `%VAR%` in a value can break out. Per-shell quoting in `renderValue` is a follow-up (#1084); until then the default Windows shell is cmd. + +This blocks shell injection through parameter values on POSIX: ```markdown echo {{ name }} @@ -145,7 +147,7 @@ echo '; rm -rf /; #' When the tool runs: 1. Parameters are validated against the declared schema. Validation errors (missing required params, wrong types, pattern mismatch, etc.) come back as `⚒ Missing required parameter: foo`-style messages without invoking the script. -2. The body is rendered, then handed to the chosen shell via `-c`. +2. The body is rendered, then handed to the chosen shell (`-c` for bash/sh, `/d /s /c` for cmd.exe). `shell: bash` / `shell: sh` still spawn `/bin/bash` or `/bin/sh` even on Windows, which typically fails with "Custom tool failed to start" if those binaries are missing. 3. `cwd` and `env` are resolved (with `${VAR}` and `${VAR:-default}` substitution against `process.env`). See [Working directory](#working-directory) for the containment rules. 4. The script runs with `timeout_ms` enforcement. 5. On exit code 0, stdout (and any stderr) is returned to the model, truncated at the standard output limit. @@ -168,7 +170,7 @@ When the tool runs: ## Security Model -A custom tool runs with your full shell privileges. The trust boundary is "you wrote this file or you trust the repo it came from" — the same model as `.nanocoder/commands/`, `.envrc`, or `package.json` scripts. Parameter values are shell-escaped, but the script body itself is whatever you wrote: if you put `rm -rf /` in there, it will run. +A custom tool runs with your full shell privileges. The trust boundary is "you wrote this file or you trust the repo it came from" — the same model as `.nanocoder/commands/`, `.envrc`, or `package.json` scripts. Parameter values are POSIX-quoted, which is not a cmd.exe injection barrier. The script body itself is whatever you wrote: if you put `rm -rf /` in there, it will run. Project tools sit in `.nanocoder/tools/` and travel with the repo; personal tools sit in `~/.config/nanocoder/tools/` and don't. Treat custom tools from an unfamiliar repo with the same skepticism you'd apply to running its install script. diff --git a/source/custom-tools/handler.spec.ts b/source/custom-tools/handler.spec.ts index 6994cf27d..729cf8604 100644 --- a/source/custom-tools/handler.spec.ts +++ b/source/custom-tools/handler.spec.ts @@ -1,8 +1,15 @@ -import {mkdirSync, rmSync, symlinkSync} from 'node:fs'; +import {chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join, resolve} from 'node:path'; import test, {type ExecutionContext} from 'ava'; -import {buildHandler, expandVars, mergeEnv, resolveCwd, runScript} from './handler'; +import { + buildHandler, + expandVars, + mergeEnv, + resolveCwd, + runScript, + shellArgs, +} from './handler'; import type {CustomToolMetadata} from '@/types/custom-tools'; console.log('\ncustom-tools/handler.spec.ts'); @@ -54,6 +61,35 @@ test('expandVars replaces $VAR and ${VAR}', t => { else process.env.NCT_FOO = prev; }); +test('shellArgs uses /d /s /c for cmd.exe and -c for posix shells', t => { + t.deepEqual(shellArgs('cmd.exe', 'echo hi'), ['/d', '/s', '/c', 'echo hi']); + t.deepEqual(shellArgs('cmd', 'echo hi'), ['/d', '/s', '/c', 'echo hi']); + t.deepEqual(shellArgs('C:\\Windows\\System32\\cmd.exe', 'echo hi'), [ + '/d', + '/s', + '/c', + 'echo hi', + ]); + t.deepEqual(shellArgs('/bin/sh', 'echo hi'), ['-c', 'echo hi']); + t.deepEqual(shellArgs('/bin/bash', 'echo hi'), ['-c', 'echo hi']); +}); + +// Prove runScript forwards shellArgs, not a hardcoded -c. A POSIX script +// named cmd.exe is enough: isWindowsCmd keys off the basename. +const spawnArgTest = process.platform === 'win32' ? test.skip : test; +spawnArgTest('runScript passes shellArgs argv into spawn', async t => { + const bin = join(testDir, 'cmd.exe'); + writeFileSync(bin, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); + chmodSync(bin, 0o755); + const result = await runScript('echo hi', { + cwd: testDir, + env: process.env, + shell: bin, + timeoutMs: 5_000, + }); + t.is(result, 'EXIT_CODE: 0\n/d\n/s\n/c\necho hi'); +}); + test('mergeEnv overlays configured vars onto process.env', t => { const env = mergeEnv({CUSTOM_VAR: 'value'}); t.is(env.CUSTOM_VAR, 'value'); diff --git a/source/custom-tools/handler.ts b/source/custom-tools/handler.ts index 5bdcdd2ba..6cc9292a3 100644 --- a/source/custom-tools/handler.ts +++ b/source/custom-tools/handler.ts @@ -54,7 +54,7 @@ export function runScript( options: RunOptions, ): Promise { return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(options.shell, ['-c', script], { + const child = spawn(options.shell, shellArgs(options.shell, script), { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], @@ -190,6 +190,16 @@ export function expandVars(value: string): string { }); } +/** cmd.exe: /d (skip AutoRun), /s (deterministic quotes), /c. POSIX: -c. */ +export function shellArgs(shell: string, script: string): string[] { + return isWindowsCmd(shell) ? ['/d', '/s', '/c', script] : ['-c', script]; +} + +function isWindowsCmd(shell: string): boolean { + const name = shell.replaceAll('\\', '/').split('/').pop() ?? ''; + return /^cmd(\.exe)?$/i.test(name); +} + function pickShell(configured: string | undefined): string { if (configured === 'bash') return '/bin/bash'; if (configured === 'sh') return '/bin/sh'; diff --git a/source/custom-tools/template.ts b/source/custom-tools/template.ts index f73c063aa..977b87c72 100644 --- a/source/custom-tools/template.ts +++ b/source/custom-tools/template.ts @@ -14,8 +14,9 @@ * in single quotes and escapes embedded single quotes. Arrays are joined into * a single space-separated string with each element individually quoted. * - * Substitution happens *before* the body is handed to the shell, so the - * shell sees a complete, safe command line. + * Substitution happens *before* the body is handed to the shell. Under + * bash/sh that yields a POSIX-quoted command line. Under cmd.exe the + * same quotes are not quoting, so this is not an injection barrier. */ import {expandSections} from '@/utils/template-sections'; From 109df6a130ed026c71cb4c1150823688d6d957c8 Mon Sep 17 00:00:00 2001 From: raheebgill29 <140135207+raheebgill29@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:13:21 +0500 Subject: [PATCH 12/25] feat(init): add bundled preset configurations (#1026) * feat(init): add bundled preset configurations * fix(init): address preset review feedback --------- Co-authored-by: raheeb-gill --- .changeset/fresh-pandas-initialize.md | 5 + docs/features/commands.md | 2 +- docs/features/index.md | 2 +- docs/getting-started/index.md | 29 +++ source/cli-integration.spec.ts | 59 +++++- source/cli.tsx | 56 ++++++ source/commands/init.spec.tsx | 64 +++++++ source/commands/init.tsx | 107 ++++------- source/commands/lazy-registry.ts | 2 +- source/init/init-args.spec.ts | 32 ++++ source/init/init-args.ts | 48 +++++ source/init/initializer.spec.ts | 237 +++++++++++++++++++++++++ source/init/initializer.ts | 108 +++++++++++ source/init/preset-registry.spec.ts | 90 ++++++++++ source/init/preset-registry.ts | 80 +++++++++ source/init/presets.ts | 20 +++ source/init/templates/preset-nextjs.ts | 55 ++++++ source/init/templates/preset-react.ts | 50 ++++++ source/init/templates/preset-rust.ts | 43 +++++ 19 files changed, 1014 insertions(+), 75 deletions(-) create mode 100644 .changeset/fresh-pandas-initialize.md create mode 100644 source/commands/init.spec.tsx create mode 100644 source/init/init-args.spec.ts create mode 100644 source/init/init-args.ts create mode 100644 source/init/initializer.spec.ts create mode 100644 source/init/initializer.ts create mode 100644 source/init/preset-registry.spec.ts create mode 100644 source/init/preset-registry.ts create mode 100644 source/init/presets.ts create mode 100644 source/init/templates/preset-nextjs.ts create mode 100644 source/init/templates/preset-react.ts create mode 100644 source/init/templates/preset-rust.ts diff --git a/.changeset/fresh-pandas-initialize.md b/.changeset/fresh-pandas-initialize.md new file mode 100644 index 000000000..f2916cbd0 --- /dev/null +++ b/.changeset/fresh-pandas-initialize.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added bundled React, Next.js, and Rust project presets for `nanocoder init --preset ` and `/init --preset `. Presets seed analyzed `AGENTS.md` guidance, stack-specific context ignores, and a `/check` command skill while preserving existing files. Closes #1008. diff --git a/docs/features/commands.md b/docs/features/commands.md index 26d6a066e..15861ca1f 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -13,7 +13,7 @@ Type `/` in the chat input to see available commands. All commands start with `/ | Command | Description | |---------|-------------| | `/help` | Show available commands | -| `/init` | Initialize project with intelligent analysis, create AGENTS.md and configuration files. Use `/init --force` to regenerate AGENTS.md if it already exists, or `/init --lean` to skip merging `CLAUDE.md` content into the generated AGENTS.md | +| `/init` | Initialize the project with intelligent analysis and create `AGENTS.md`. Use `/init --preset ` for bundled stack guidance, ignore patterns, and a `/check` command skill; `/init --force` regenerates `AGENTS.md`, and `/init --lean` skips merging `CLAUDE.md` | | `/setup-config` | Open a configuration file in your `$EDITOR` (lists project and global config files) | | `/clear` | Clear chat history | | `/model` | Switch between available models from any configured provider | diff --git a/docs/features/index.md b/docs/features/index.md index 3973e1a14..104aa7a83 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -168,7 +168,7 @@ The AI also has a task tool and will proactively create and update tasks when wo ### Project Setup with `/init` -Run `/init` to analyze your project and generate an `AGENTS.md` file — a project-specific prompt that gives the AI context about your codebase, conventions, and tooling. Use `/init --force` to regenerate it. +Run `/init` or `nanocoder init` to analyze your project and generate an `AGENTS.md` file — a project-specific prompt that gives the AI context about your codebase, conventions, and tooling. Use `--preset react`, `--preset nextjs`, or `--preset rust` to add bundled stack guidance, a `.nanocoderignore`, and a `/check` command skill. Use `/init --force` to regenerate `AGENTS.md`; existing preset files are preserved. The `AGENTS.md` file is automatically loaded every session, so the AI always knows how your project works. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 256d1d5b5..b848a61fa 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -60,6 +60,7 @@ nanocoder -h | `--no-alt-screen` | | Force inline mode (the default), even if `alternateScreen: true` is set in your preferences file. | | `--continue` | `-c` | Resume the most recent [saved session](../features/session-management.md) for the current directory; starts a fresh session if none exists. Interactive only — errors with `run`. Mutually exclusive with `--resume`. | | `--resume [id]` | `-r` | Resume a [saved session](../features/session-management.md) by session ID, 1-based list index, or `last`. With no ID, opens the session picker at startup. Errors if the session is not found. Interactive only — errors with `run`. | +| `init [--preset ]` | | Initialize the current project. Bundled presets: `react`, `nextjs`, and `rust` | | `run` | | Run in non-interactive mode | **Provider/Model Flags:** @@ -85,6 +86,34 @@ nanocoder --mode normal run "refactor db module" If `--mode` is omitted, interactive mode starts in `normal` and `run` mode starts in `auto-accept` (the previous defaults). +## Project Initialization Presets + +Initialize a project from the terminal with automatic project analysis: + +```bash +nanocoder init +``` + +Add `--preset` to seed stack-specific guidance, context ignore patterns, and a +`/check` command skill: + +```bash +nanocoder init --preset react +nanocoder init --preset nextjs +nanocoder init --preset rust +``` + +Every preset creates an analyzed `AGENTS.md`, a `.nanocoderignore`, and +`.nanocoder/commands/check.md`. The selected preset supplies the project type +and fills in stack defaults while detected languages, package scripts, and +commands remain authoritative where applicable. Existing files are never +silently replaced: an already initialized project is refused unless `--force` +is passed, `--force` only regenerates `AGENTS.md`, and existing preset files are +preserved. + +The interactive `/init` command accepts the same options, including +`/init --preset nextjs`, `/init --force`, and `/init --lean`. + ## Interactive Mode To start Nanocoder in interactive mode (the default), simply run: diff --git a/source/cli-integration.spec.ts b/source/cli-integration.spec.ts index 559be75b1..6b0b2f347 100644 --- a/source/cli-integration.spec.ts +++ b/source/cli-integration.spec.ts @@ -1,5 +1,7 @@ import test from 'ava'; -import {execSync, execFileSync} from 'child_process'; +import {execFileSync, spawnSync} from 'child_process'; +import {mkdtempSync, rmSync, writeFileSync} from 'fs'; +import {tmpdir} from 'os'; import {join} from 'path'; import {fileURLToPath} from 'url'; @@ -96,4 +98,57 @@ test('CLI integration: help flag takes precedence over other arguments', t => { // Should return help text, not start the app t.true(output.includes('Usage:')); t.true(output.includes('--version')); -}); \ No newline at end of file +}); + +test('CLI integration: init help exits successfully with preset guidance', t => { + const result = spawnSync(process.execPath, [cliPath, 'init', '--help'], { + encoding: 'utf8', + }); + + t.is(result.status, 0); + t.true(result.stdout.includes('Usage: nanocoder init [options]')); + t.true(result.stdout.includes('--preset ')); + t.true(result.stdout.includes('react, nextjs, rust')); +}); + +test.serial('CLI integration: init preset succeeds and creates files', t => { + const projectPath = mkdtempSync(join(tmpdir(), 'nanocoder-cli-init-')); + try { + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({scripts: {build: 'vite build'}}), + ); + const result = spawnSync( + process.execPath, + [cliPath, 'init', '--preset', 'React'], + {cwd: projectPath, encoding: 'utf8'}, + ); + + t.is(result.status, 0); + t.true(result.stdout.includes('Preset: react')); + t.true(result.stdout.includes('Created: AGENTS.md')); + t.true(result.stdout.includes('Created: .nanocoderignore')); + } finally { + rmSync(projectPath, {recursive: true, force: true}); + } +}); + +test.serial('CLI integration: init invalid preset exits with an error', t => { + const projectPath = mkdtempSync(join(tmpdir(), 'nanocoder-cli-init-')); + try { + const result = spawnSync( + process.execPath, + [cliPath, 'init', '--preset', 'constructor'], + {cwd: projectPath, encoding: 'utf8'}, + ); + + t.is(result.status, 1); + t.true( + result.stderr.includes( + 'Unknown preset "constructor". Supported presets: react, nextjs, rust.', + ), + ); + } finally { + rmSync(projectPath, {recursive: true, force: true}); + } +}); diff --git a/source/cli.tsx b/source/cli.tsx index 8bd5c58c3..027f0417f 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -72,12 +72,67 @@ if (args[0] === 'daemon') { process.exit(result.exitCode); } +// Handle `nanocoder init` without booting the interactive app. The shared +// initializer is also used by /init, so both entry points keep identical file +// generation and overwrite behavior. +if (args[0] === 'init') { + if (args.includes('--help') || args.includes('-h')) { + console.log(` +Usage: nanocoder init [options] + +Options: + --preset Apply a bundled project preset (react, nextjs, rust) + -f, --force Regenerate AGENTS.md if it already exists + --lean Skip CLAUDE.md when merging existing project guidance + -h, --help Show help for the init command + +Examples: + nanocoder init + nanocoder init --preset react + nanocoder init --preset nextjs + nanocoder init --preset rust + `); + process.exit(0); + } + + const [{parseInitArguments}, initializer] = await Promise.all([ + import('@/init/init-args'), + import('@/init/initializer'), + ]); + try { + const options = parseInitArguments(args.slice(1)); + const result = initializer.initializeProject({ + projectPath: process.cwd(), + ...options, + }); + + console.log('Nanocoder project initialized successfully.'); + if (result.preset) console.log(`Preset: ${result.preset}`); + for (const file of result.created) console.log(`Created: ${file}`); + for (const file of result.preserved) { + console.log(`Preserved existing file: ${file}`); + } + process.exit(0); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown initialization error'; + const suffix = + error instanceof initializer.ProjectAlreadyInitializedError + ? ' Use nanocoder init --force to regenerate.' + : ''; + console.error(`${message}${suffix}`); + process.exit(1); + } +} + // Handle --help/-h flag — fast path, no heavy imports if (args.includes('--help') || args.includes('-h')) { console.log(` Usage: nanocoder [options] [command] Commands: + init [options] Analyze the project and create AGENTS.md. + Use --preset for bundled defaults. copilot login [provider-name] Log in to GitHub Copilot (device flow). Saves credentials for the "GitHub Copilot" provider. daemon Manage the per-project skill daemon. Subcommands: start, stop, status, logs, install, uninstall. @@ -115,6 +170,7 @@ Options: run Run in non-interactive mode Examples: + nanocoder init --preset nextjs nanocoder --provider openrouter --model google/gemini-3.1-flash run "analyze src/app.ts" nanocoder --provider ollama --model llama3.1 --context-max 128k nanocoder --mode yolo run "refactor database module" diff --git a/source/commands/init.spec.tsx b/source/commands/init.spec.tsx new file mode 100644 index 000000000..34881bc0c --- /dev/null +++ b/source/commands/init.spec.tsx @@ -0,0 +1,64 @@ +import test from 'ava'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {render} from 'ink-testing-library'; +import React from 'react'; +import {themes} from '@/config/themes'; +import {ThemeContext} from '@/hooks/useTheme'; +import {TitleShapeContext} from '@/hooks/useTitleShape'; +import {initCommand} from './init.js'; + +function Providers({children}: {children: React.ReactNode}) { + return ( + {}, + }} + > + {}}} + > + {children} + + + ); +} + +test.serial('init success renders the selected preset and preserved files', async t => { + const originalCwd = process.cwd(); + const projectPath = mkdtempSync(join(tmpdir(), 'nanocoder-init-render-')); + try { + writeFileSync(join(projectPath, '.nanocoderignore'), 'keep-me\n'); + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({scripts: {build: 'vite build'}}), + ); + process.chdir(projectPath); + + const result = await initCommand.handler(['--preset', 'react'], [], { + provider: 'test', + model: 'test', + tokens: 0, + getMessageTokens: () => 0, + }); + if (!React.isValidElement(result)) { + t.fail('Expected InitSuccess to return a React element'); + return; + } + + const {lastFrame, unmount} = render({result}); + const output = lastFrame(); + unmount(); + + t.truthy(output); + t.true(output?.includes('Preset: react')); + t.true(output?.includes('Existing Files Preserved:')); + t.true(output?.includes('.nanocoderignore')); + } finally { + process.chdir(originalCwd); + rmSync(projectPath, {recursive: true, force: true}); + } +}); diff --git a/source/commands/init.tsx b/source/commands/init.tsx index f84d15f4a..810c4977b 100644 --- a/source/commands/init.tsx +++ b/source/commands/init.tsx @@ -1,23 +1,27 @@ -import {existsSync, mkdirSync, writeFileSync} from 'fs'; import {Box, Text} from 'ink'; -import {join} from 'path'; import React from 'react'; import {ErrorMessage} from '@/components/message-box'; import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; import {getColors} from '@/config/index'; import {useTerminalWidth} from '@/hooks/useTerminalWidth'; -import {AgentsTemplateGenerator} from '@/init/agents-template-generator'; -import {ExistingRulesExtractor} from '@/init/existing-rules-extractor'; -import {ProjectAnalyzer} from '@/init/project-analyzer'; +import {parseInitArguments} from '@/init/init-args'; +import { + initializeProject, + ProjectAlreadyInitializedError, +} from '@/init/initializer'; import {generateKey} from '@/session/key-generator'; import {Command} from '@/types/index'; import {formatError} from '@/utils/error-formatter'; function InitSuccess({ created, + preserved, + preset, analysis, }: { created: string[]; + preserved?: string[]; + preset?: string; analysis?: { projectType: string; primaryLanguage: string; @@ -42,6 +46,7 @@ function InitSuccess({ ✓ Nanocoder project initialized successfully! + {preset && • Preset: {preset}} {analysis && ( <> @@ -78,6 +83,21 @@ function InitSuccess({ ))} + {preserved && preserved.length > 0 && ( + <> + + + Existing Files Preserved: + + + {preserved.map(item => ( + + • {item} + + ))} + + )} + @@ -99,72 +119,14 @@ function InitError({message}: {message: string}) { export const initCommand: Command = { name: 'init', description: - 'Initialize nanocoder configuration and analyze project structure. Use --force to regenerate AGENTS.md, --lean to skip CLAUDE.md when generating AGENTS.md.', + 'Initialize nanocoder configuration and analyze project structure. Use --preset , --force to regenerate AGENTS.md, or --lean to skip CLAUDE.md.', handler: (args: string[], _messages, _metadata) => { const cwd = process.cwd(); - const created: string[] = []; - const forceRegenerate = args.includes('--force') || args.includes('-f'); - // --lean: skip Claude-Code-specific source files (CLAUDE.md) when - // generating AGENTS.md. Keeps the generated AGENTS.md smaller and - // reduces duplication for users who already have CLAUDE.md. - const lean = args.includes('--lean'); try { - // Check if already initialized - const agentsPath = join(cwd, 'AGENTS.md'); - const nanocoderDir = join(cwd, '.nanocoder'); - - // Check for existing initialization - const hasAgents = existsSync(agentsPath); - const hasNanocoder = existsSync(nanocoderDir); - - if (hasAgents && hasNanocoder && !forceRegenerate) { - return Promise.resolve( - React.createElement(InitError, { - key: generateKey('init-error'), - message: - 'Project already initialized. Found AGENTS.md and .nanocoder/ directory. Use /init --force to regenerate.', - }), - ); - } - - // Show progress indicator for analysis - // Note: In a real implementation, we'd want to show this as a loading state - // For now, we'll do the analysis synchronously - - // Analyze the project - const analyzer = new ProjectAnalyzer(cwd); - const analysis = analyzer.analyze(); - - // Extract existing AI configuration files (skip AGENTS.md when force - // regenerating; skip CLAUDE.md in lean mode). - const rulesExtractor = new ExistingRulesExtractor( - cwd, - forceRegenerate, - lean ? ['CLAUDE.md'] : [], - ); - const existingRules = rulesExtractor.extractExistingRules(); - - // Create AGENTS.md based on analysis and existing rules - if (!hasAgents || forceRegenerate) { - const agentsContent = AgentsTemplateGenerator.generateAgentsMd( - analysis, - existingRules, - ); - writeFileSync(agentsPath, agentsContent); - created.push(hasAgents ? 'AGENTS.md (regenerated)' : 'AGENTS.md'); - - // Report found existing rules - if (existingRules.length > 0) { - const sourceFiles = existingRules.map(r => r.source).join(', '); - created.push(`↳ Merged content from: ${sourceFiles}`); - } - } - - if (!hasNanocoder) { - mkdirSync(nanocoderDir, {recursive: true}); - created.push('.nanocoder/'); - } + const options = parseInitArguments(args); + const result = initializeProject({projectPath: cwd, ...options}); + const {analysis} = result; // Prepare analysis summary for display const analysisSummary = { @@ -179,16 +141,21 @@ export const initCommand: Command = { return Promise.resolve( React.createElement(InitSuccess, { key: generateKey('init-success'), - created, + created: result.created, + preserved: result.preserved, + preset: result.preset, analysis: analysisSummary, }), ); } catch (error: unknown) { - const errorMessage = formatError(error); + const errorMessage = + error instanceof ProjectAlreadyInitializedError + ? `${error.message} Use /init --force to regenerate.` + : `Failed to initialize project: ${formatError(error)}`; return Promise.resolve( React.createElement(InitError, { key: generateKey('init-error'), - message: `Failed to initialize project: ${errorMessage}`, + message: errorMessage, }), ); } diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts index 9f4dfa6d9..8b44ccb66 100644 --- a/source/commands/lazy-registry.ts +++ b/source/commands/lazy-registry.ts @@ -104,7 +104,7 @@ export const lazyCommands: LazyCommand[] = [ { name: 'init', description: - 'Initialize nanocoder configuration and analyze project structure. Use --force to regenerate AGENTS.md.', + 'Initialize nanocoder configuration and analyze project structure. Use --preset , --force to regenerate AGENTS.md, or --lean to skip CLAUDE.md.', load: () => import('@/commands/init').then(m => m.initCommand), }, { diff --git a/source/init/init-args.spec.ts b/source/init/init-args.spec.ts new file mode 100644 index 000000000..2a519517f --- /dev/null +++ b/source/init/init-args.spec.ts @@ -0,0 +1,32 @@ +import test from 'ava'; +import {InitArgumentError, parseInitArguments} from '@/init/init-args'; + +test('parseInitArguments parses --preset for the init command', t => { + t.deepEqual(parseInitArguments(['--preset', 'react']), { + forceRegenerate: false, + lean: false, + preset: 'react', + }); +}); + +test('parseInitArguments parses fused --preset syntax and existing flags', t => { + t.deepEqual(parseInitArguments(['--force', '--lean', '--preset=nextjs']), { + forceRegenerate: true, + lean: true, + preset: 'nextjs', + }); +}); + +test('parseInitArguments preserves init behavior without --preset', t => { + t.deepEqual(parseInitArguments([]), { + forceRegenerate: false, + lean: false, + preset: undefined, + }); +}); + +test('parseInitArguments rejects --preset without a value', t => { + const error = t.throws(() => parseInitArguments(['--preset'])); + t.true(error instanceof InitArgumentError); + t.regex(error.message, /Supported presets: react, nextjs, rust/); +}); diff --git a/source/init/init-args.ts b/source/init/init-args.ts new file mode 100644 index 000000000..cb873a692 --- /dev/null +++ b/source/init/init-args.ts @@ -0,0 +1,48 @@ +import {supportedPresetNames} from '@/init/preset-registry'; + +export interface ParsedInitArguments { + forceRegenerate: boolean; + lean: boolean; + preset?: string; +} + +export class InitArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'InitArgumentError'; + } +} + +export function parseInitArguments( + args: readonly string[], +): ParsedInitArguments { + let preset: string | undefined; + + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === '--preset') { + const value = args[index + 1]; + if (!value || value.startsWith('-')) { + throw new InitArgumentError( + `Missing value for --preset. Supported presets: ${supportedPresetNames.join(', ')}.`, + ); + } + preset = value; + index++; + } else if (argument.startsWith('--preset=')) { + const value = argument.slice('--preset='.length); + if (!value) { + throw new InitArgumentError( + `Missing value for --preset. Supported presets: ${supportedPresetNames.join(', ')}.`, + ); + } + preset = value; + } + } + + return { + forceRegenerate: args.includes('--force') || args.includes('-f'), + lean: args.includes('--lean'), + preset, + }; +} diff --git a/source/init/initializer.spec.ts b/source/init/initializer.spec.ts new file mode 100644 index 000000000..1ee739c4f --- /dev/null +++ b/source/init/initializer.spec.ts @@ -0,0 +1,237 @@ +import test from 'ava'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parseCommandFile} from '@/custom-commands/parser'; +import { + initializeProject, + ProjectAlreadyInitializedError, +} from '@/init/initializer'; +import {UnknownPresetError} from '@/init/preset-registry'; + +function createTestProject(): string { + return mkdtempSync(join(tmpdir(), 'nanocoder-preset-')); +} + +function removeTestProject(projectPath: string): void { + rmSync(projectPath, {recursive: true, force: true}); +} + +const presetExpectations = { + react: { + projectType: 'React Web Application', + ignorePattern: '*.tsbuildinfo', + commandText: 'React project quality checks', + }, + nextjs: { + projectType: 'Next.js Web Application', + ignorePattern: 'next-env.d.ts', + commandText: 'Next.js project quality checks', + }, + rust: { + projectType: 'Rust Application', + ignorePattern: '*.profraw', + commandText: 'Rust project quality checks', + }, +} as const; + +for (const [preset, expectation] of Object.entries(presetExpectations)) { + test.serial(`initializeProject generates the ${preset} preset`, t => { + const projectPath = createTestProject(); + try { + const result = initializeProject({projectPath, preset}); + const agents = readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'); + const ignore = readFileSync( + join(projectPath, '.nanocoderignore'), + 'utf-8', + ); + const command = readFileSync( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + 'utf-8', + ); + const parsedCommand = parseCommandFile( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + ); + + t.is(result.preset, preset); + t.true(agents.includes(`**Project Type:** ${expectation.projectType}`)); + t.true(ignore.includes(expectation.ignorePattern)); + t.true(command.includes(expectation.commandText)); + t.true(command.includes('description:')); + t.truthy(parsedCommand.metadata.description); + t.true(parsedCommand.content.length > 0); + t.deepEqual(result.preserved, []); + } finally { + removeTestProject(projectPath); + } + }); +} + +test.serial('initializeProject keeps existing behavior without a preset', t => { + const projectPath = createTestProject(); + try { + const result = initializeProject({projectPath}); + + t.true(existsSync(join(projectPath, 'AGENTS.md'))); + t.true(existsSync(join(projectPath, '.nanocoder'))); + t.false(existsSync(join(projectPath, '.nanocoderignore'))); + t.false( + existsSync(join(projectPath, '.nanocoder', 'commands', 'check.md')), + ); + t.is(result.preset, undefined); + t.deepEqual(result.created, ['AGENTS.md', '.nanocoder/']); + } finally { + removeTestProject(projectPath); + } +}); + +test.serial('initializeProject rejects an invalid preset before writing files', t => { + const projectPath = createTestProject(); + try { + const error = t.throws(() => + initializeProject({projectPath, preset: 'unknown'}), + ); + t.true(error instanceof UnknownPresetError); + t.regex(error.message, /Supported presets: react, nextjs, rust/); + t.false(existsSync(join(projectPath, 'AGENTS.md'))); + t.false(existsSync(join(projectPath, '.nanocoder'))); + } finally { + removeTestProject(projectPath); + } +}); + +test.serial( + 'initializeProject only includes React preset commands backed by package scripts', + t => { + const projectPath = createTestProject(); + try { + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({ + dependencies: {react: '^19.0.0'}, + scripts: { + build: 'vite build', + test: 'vitest run', + }, + }), + ); + + const result = initializeProject({projectPath, preset: 'react'}); + const agents = readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'); + + t.deepEqual(result.analysis.buildCommands, { + Build: 'npm run build', + Test: 'npm run test', + }); + t.true(agents.includes('npm run build')); + t.true(agents.includes('npm run test')); + t.false(agents.includes('npm run dev')); + t.false(agents.includes('npm run lint')); + } finally { + removeTestProject(projectPath); + } + }, +); + +test.serial( + 'initializeProject includes React preset commands whose package scripts exist', + t => { + const projectPath = createTestProject(); + try { + writeFileSync( + join(projectPath, 'package.json'), + JSON.stringify({ + dependencies: {react: '^19.0.0'}, + scripts: { + dev: 'vite', + build: 'vite build', + test: 'vitest run', + lint: 'eslint .', + }, + }), + ); + + const result = initializeProject({projectPath, preset: 'React'}); + const agents = readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'); + + t.deepEqual(result.analysis.buildCommands, { + Development: 'npm run dev', + Build: 'npm run build', + Test: 'npm run test', + Lint: 'npm run lint', + }); + t.is(result.preset, 'react'); + for (const command of Object.values(result.analysis.buildCommands)) { + t.true(agents.includes(command)); + } + } finally { + removeTestProject(projectPath); + } + }, +); + +test.serial('initializeProject preserves existing preset files with --force', t => { + const projectPath = createTestProject(); + const existingAgents = '# Existing instructions'; + const existingIgnore = 'keep-this-ignore\n'; + const existingCommand = 'keep this command\n'; + try { + mkdirSync(join(projectPath, '.nanocoder', 'commands'), {recursive: true}); + writeFileSync(join(projectPath, 'AGENTS.md'), existingAgents); + writeFileSync(join(projectPath, '.nanocoderignore'), existingIgnore); + writeFileSync( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + existingCommand, + ); + + const result = initializeProject({ + projectPath, + preset: 'react', + forceRegenerate: true, + }); + + t.not(readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'), existingAgents); + t.is( + readFileSync(join(projectPath, '.nanocoderignore'), 'utf-8'), + existingIgnore, + ); + t.is( + readFileSync( + join(projectPath, '.nanocoder', 'commands', 'check.md'), + 'utf-8', + ), + existingCommand, + ); + t.deepEqual(result.preserved, [ + '.nanocoderignore', + '.nanocoder/commands/check.md', + ]); + t.true(result.created.includes('AGENTS.md (regenerated)')); + } finally { + removeTestProject(projectPath); + } +}); + +test.serial('initializeProject refuses an initialized project without --force', t => { + const projectPath = createTestProject(); + try { + mkdirSync(join(projectPath, '.nanocoder')); + writeFileSync(join(projectPath, 'AGENTS.md'), '# Existing instructions'); + + const error = t.throws(() => initializeProject({projectPath})); + t.true(error instanceof ProjectAlreadyInitializedError); + t.is( + readFileSync(join(projectPath, 'AGENTS.md'), 'utf-8'), + '# Existing instructions', + ); + } finally { + removeTestProject(projectPath); + } +}); diff --git a/source/init/initializer.ts b/source/init/initializer.ts new file mode 100644 index 000000000..ae857894b --- /dev/null +++ b/source/init/initializer.ts @@ -0,0 +1,108 @@ +import {existsSync, mkdirSync, writeFileSync} from 'node:fs'; +import {dirname, isAbsolute, join, relative, resolve} from 'node:path'; +import {AgentsTemplateGenerator} from '@/init/agents-template-generator'; +import {ExistingRulesExtractor} from '@/init/existing-rules-extractor'; +import {applyPresetToAnalysis, resolvePreset} from '@/init/preset-registry'; +import {type ProjectAnalysis, ProjectAnalyzer} from '@/init/project-analyzer'; + +export interface InitializeProjectOptions { + projectPath: string; + forceRegenerate?: boolean; + lean?: boolean; + preset?: string; +} + +export interface InitializeProjectResult { + created: string[]; + preserved: string[]; + analysis: ProjectAnalysis; + preset?: string; +} + +export class ProjectAlreadyInitializedError extends Error { + constructor() { + super( + 'Project already initialized. Found AGENTS.md and .nanocoder/ directory.', + ); + this.name = 'ProjectAlreadyInitializedError'; + } +} + +function resolvePresetPath(projectPath: string, relativePath: string): string { + const projectRoot = resolve(projectPath); + const destination = resolve(projectRoot, relativePath); + const relativeDestination = relative(projectRoot, destination); + if (relativeDestination.startsWith('..') || isAbsolute(relativeDestination)) { + throw new Error(`Invalid preset file path: ${relativePath}`); + } + return destination; +} + +export function initializeProject( + options: InitializeProjectOptions, +): InitializeProjectResult { + const { + projectPath, + forceRegenerate = false, + lean = false, + preset: presetName, + } = options; + const preset = presetName ? resolvePreset(presetName) : undefined; + const created: string[] = []; + const preserved: string[] = []; + const agentsPath = join(projectPath, 'AGENTS.md'); + const nanocoderDir = join(projectPath, '.nanocoder'); + const hasAgents = existsSync(agentsPath); + const hasNanocoder = existsSync(nanocoderDir); + + if (hasAgents && hasNanocoder && !forceRegenerate) { + throw new ProjectAlreadyInitializedError(); + } + + const detectedAnalysis = new ProjectAnalyzer(projectPath).analyze(); + const analysis = preset + ? applyPresetToAnalysis(detectedAnalysis, preset) + : detectedAnalysis; + const existingRules = new ExistingRulesExtractor( + projectPath, + forceRegenerate, + lean ? ['CLAUDE.md'] : [], + ).extractExistingRules(); + + if (!hasAgents || forceRegenerate) { + const agentsContent = AgentsTemplateGenerator.generateAgentsMd( + analysis, + existingRules, + ); + writeFileSync(agentsPath, agentsContent); + created.push(hasAgents ? 'AGENTS.md (regenerated)' : 'AGENTS.md'); + + if (existingRules.length > 0) { + const sourceFiles = existingRules.map(rule => rule.source).join(', '); + created.push(`↳ Merged content from: ${sourceFiles}`); + } + } + + if (!hasNanocoder) { + mkdirSync(nanocoderDir, {recursive: true}); + created.push('.nanocoder/'); + } + + for (const file of preset?.files ?? []) { + const destination = resolvePresetPath(projectPath, file.path); + if (existsSync(destination)) { + preserved.push(file.path); + continue; + } + mkdirSync(dirname(destination), {recursive: true}); + writeFileSync(destination, file.content); + created.push(file.path); + } + + return { + created, + preserved, + analysis, + preset: preset?.name, + }; +} diff --git a/source/init/preset-registry.spec.ts b/source/init/preset-registry.spec.ts new file mode 100644 index 000000000..eed11b6b5 --- /dev/null +++ b/source/init/preset-registry.spec.ts @@ -0,0 +1,90 @@ +import test from 'ava'; +import { + applyPresetToAnalysis, + resolvePreset, + supportedPresetNames, + UnknownPresetError, +} from '@/init/preset-registry'; +import type {ProjectAnalysis} from '@/init/project-analyzer'; + +test('preset registry exposes the supported preset names', t => { + t.deepEqual(supportedPresetNames, ['react', 'nextjs', 'rust']); +}); + +for (const name of supportedPresetNames) { + test(`preset registry resolves ${name}`, t => { + const preset = resolvePreset(name); + t.is(preset.name, name); + t.true(preset.files.some(file => file.path === '.nanocoderignore')); + t.true( + preset.files.some(file => file.path === '.nanocoder/commands/check.md'), + ); + }); +} + +test('preset registry reports unknown presets and all supported names', t => { + const error = t.throws(() => resolvePreset('vue')); + t.true(error instanceof UnknownPresetError); + t.is( + error.message, + 'Unknown preset "vue". Supported presets: react, nextjs, rust.', + ); +}); + +for (const inheritedName of ['constructor', 'toString', '__proto__']) { + test(`preset registry rejects inherited property ${inheritedName}`, t => { + const error = t.throws(() => resolvePreset(inheritedName)); + t.true(error instanceof UnknownPresetError); + t.is( + error.message, + `Unknown preset "${inheritedName}". Supported presets: react, nextjs, rust.`, + ); + }); +} + +test('preset registry normalizes case and whitespace', t => { + t.is(resolvePreset(' React ').name, 'react'); +}); + +test('preset registry preserves the user-provided value in errors', t => { + const error = t.throws(() => resolvePreset(' Vue ')); + t.true(error instanceof UnknownPresetError); + t.is( + error.message, + 'Unknown preset " Vue ". Supported presets: react, nextjs, rust.', + ); +}); + +test('preset commands require package scripts and detected commands win', t => { + const analysis: ProjectAnalysis = { + projectPath: '/project', + projectName: 'example', + languages: {primary: null, secondary: [], all: []}, + dependencies: { + frameworks: [], + buildTools: [], + testingFrameworks: [], + buildInfo: {scripts: {build: 'custom-build'}}, + }, + projectType: 'Unknown', + keyFiles: {config: [], documentation: [], build: [], test: []}, + structure: { + totalFiles: 0, + scannedFiles: 0, + directories: [], + importantDirectories: [], + }, + buildCommands: {Build: 'pnpm run custom-build'}, + }; + + const result = applyPresetToAnalysis(analysis, resolvePreset('react')); + t.deepEqual(result.buildCommands, {Build: 'pnpm run custom-build'}); +}); + +test('Rust preset keeps Cargo.lock available as project context', t => { + const ignoreFile = resolvePreset('rust').files.find( + file => file.path === '.nanocoderignore', + ); + t.truthy(ignoreFile); + t.false(ignoreFile?.content.includes('Cargo.lock')); +}); diff --git a/source/init/preset-registry.ts b/source/init/preset-registry.ts new file mode 100644 index 000000000..822cbc795 --- /dev/null +++ b/source/init/preset-registry.ts @@ -0,0 +1,80 @@ +import type {PresetDefinition, PresetName} from '@/init/presets'; +import type {ProjectAnalysis} from '@/init/project-analyzer'; +import {nextjsPreset} from '@/init/templates/preset-nextjs'; +import {reactPreset} from '@/init/templates/preset-react'; +import {rustPreset} from '@/init/templates/preset-rust'; + +const presets: Record = { + react: reactPreset, + nextjs: nextjsPreset, + rust: rustPreset, +}; + +export const supportedPresetNames = Object.freeze( + Object.keys(presets) as PresetName[], +); + +export class UnknownPresetError extends Error { + constructor(name: string) { + super( + `Unknown preset "${name}". Supported presets: ${supportedPresetNames.join(', ')}.`, + ); + this.name = 'UnknownPresetError'; + } +} + +export function resolvePreset(name: string): PresetDefinition { + const normalizedName = name.trim().toLowerCase(); + if (!Object.hasOwn(presets, normalizedName)) { + throw new UnknownPresetError(name); + } + return presets[normalizedName as PresetName]; +} + +export function applyPresetToAnalysis( + analysis: ProjectAnalysis, + preset: PresetDefinition, +): ProjectAnalysis { + const existingFrameworkNames = new Set( + analysis.dependencies.frameworks.map(framework => framework.name), + ); + const presetFrameworks = preset.frameworks.filter( + framework => !existingFrameworkNames.has(framework.name), + ); + + const primary = analysis.languages.primary ?? { + name: preset.primaryLanguage, + extensions: [], + percentage: 100, + files: [], + }; + const detectedPackageScripts = analysis.dependencies.buildInfo.scripts ?? {}; + const presetBuildCommands = Object.fromEntries( + Object.entries(preset.buildCommands).filter(([action]) => { + const packageScript = preset.packageScripts?.[action]; + return ( + packageScript === undefined || + Object.hasOwn(detectedPackageScripts, packageScript) + ); + }), + ); + + return { + ...analysis, + projectType: preset.projectType, + languages: { + ...analysis.languages, + primary, + all: + analysis.languages.all.length > 0 ? analysis.languages.all : [primary], + }, + dependencies: { + ...analysis.dependencies, + frameworks: [...presetFrameworks, ...analysis.dependencies.frameworks], + }, + buildCommands: { + ...presetBuildCommands, + ...analysis.buildCommands, + }, + }; +} diff --git a/source/init/presets.ts b/source/init/presets.ts new file mode 100644 index 000000000..ade638ce1 --- /dev/null +++ b/source/init/presets.ts @@ -0,0 +1,20 @@ +import type {ProjectAnalysis} from '@/init/project-analyzer'; + +export type PresetName = 'react' | 'nextjs' | 'rust'; + +export interface PresetFile { + path: string; + content: string; +} + +export interface PresetDefinition { + name: PresetName; + description: string; + projectType: string; + primaryLanguage: string; + frameworks: ProjectAnalysis['dependencies']['frameworks']; + buildCommands: Record; + /** Build-command label to the package.json script required for that command. */ + packageScripts?: Record; + files: PresetFile[]; +} diff --git a/source/init/templates/preset-nextjs.ts b/source/init/templates/preset-nextjs.ts new file mode 100644 index 000000000..b2a42e460 --- /dev/null +++ b/source/init/templates/preset-nextjs.ts @@ -0,0 +1,55 @@ +import type {PresetDefinition} from '@/init/presets'; + +export const nextjsPreset = { + name: 'nextjs', + description: 'Next.js application defaults and quality checks', + projectType: 'Next.js Web Application', + primaryLanguage: 'TypeScript', + frameworks: [ + {name: 'Next.js', category: 'web', confidence: 'high'}, + {name: 'React', category: 'web', confidence: 'high'}, + ], + buildCommands: { + Development: 'npm run dev', + Build: 'npm run build', + Test: 'npm run test', + Lint: 'npm run lint', + }, + packageScripts: { + Development: 'dev', + Build: 'build', + Test: 'test', + Lint: 'lint', + }, + files: [ + { + path: '.nanocoderignore', + content: `# Dependency lockfiles and generated framework metadata +package-lock.json +pnpm-lock.yaml +yarn.lock +next-env.d.ts +*.tsbuildinfo + +# Next.js and test output +.next/ +out/ +coverage/ +`, + }, + { + path: '.nanocoder/commands/check.md', + content: `--- +description: Run the available Next.js project quality checks +aliases: [verify] +category: quality +--- + +Inspect package.json and the lockfiles to determine the package manager. Run +the available type-check, lint, test, and production build scripts in that +order. Do not invent missing scripts. Pay attention to server/client component +boundaries and report each failure with the relevant file and line information. +`, + }, + ], +} satisfies PresetDefinition; diff --git a/source/init/templates/preset-react.ts b/source/init/templates/preset-react.ts new file mode 100644 index 000000000..0d36c8a32 --- /dev/null +++ b/source/init/templates/preset-react.ts @@ -0,0 +1,50 @@ +import type {PresetDefinition} from '@/init/presets'; + +export const reactPreset = { + name: 'react', + description: 'React application defaults and quality checks', + projectType: 'React Web Application', + primaryLanguage: 'TypeScript', + frameworks: [{name: 'React', category: 'web', confidence: 'high'}], + buildCommands: { + Development: 'npm run dev', + Build: 'npm run build', + Test: 'npm run test', + Lint: 'npm run lint', + }, + packageScripts: { + Development: 'dev', + Build: 'build', + Test: 'test', + Lint: 'lint', + }, + files: [ + { + path: '.nanocoderignore', + content: `# Dependency lockfiles and generated TypeScript metadata +package-lock.json +pnpm-lock.yaml +yarn.lock +*.tsbuildinfo + +# Generated test and build artifacts +coverage/ +dist/ +`, + }, + { + path: '.nanocoder/commands/check.md', + content: `--- +description: Run the available React project quality checks +aliases: [verify] +category: quality +--- + +Inspect package.json and the lockfiles to determine the package manager. Run +the available type-check, lint, test, and build scripts in that order. Do not +invent missing scripts. Report each command and summarize any failures with +the relevant file and line information. +`, + }, + ], +} satisfies PresetDefinition; diff --git a/source/init/templates/preset-rust.ts b/source/init/templates/preset-rust.ts new file mode 100644 index 000000000..ea44d00d6 --- /dev/null +++ b/source/init/templates/preset-rust.ts @@ -0,0 +1,43 @@ +import type {PresetDefinition} from '@/init/presets'; + +export const rustPreset = { + name: 'rust', + description: 'Rust project defaults and Cargo quality checks', + projectType: 'Rust Application', + primaryLanguage: 'Rust', + frameworks: [], + buildCommands: { + Build: 'cargo build', + Test: 'cargo test', + Lint: 'cargo clippy --all-targets --all-features', + Format: 'cargo fmt --check', + Run: 'cargo run', + }, + files: [ + { + path: '.nanocoderignore', + content: `# Generated Cargo build output +target/ + +# Generated coverage and profiling data +coverage/ +*.profraw +*.profdata +`, + }, + { + path: '.nanocoder/commands/check.md', + content: `--- +description: Run the standard Rust project quality checks +aliases: [verify] +category: quality +--- + +Inspect Cargo.toml and repository instructions, then run cargo fmt --check, +cargo clippy --all-targets --all-features, and cargo test. Add --workspace when +the manifest defines a workspace. Report each command and summarize failures +with the relevant crate, file, and line information. +`, + }, + ], +} satisfies PresetDefinition; From c673eff2363cdd81b0794d7bf70a42b02d0056df Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 31 Aug 2026 22:44:18 +0530 Subject: [PATCH 13/25] chore: label PRs by changed path (#1068) * chore: label PRs by changed path * fix labeler review * fix labeler followup --- .changeset/pr-path-labels.md | 2 + .github/labeler.yml | 28 +++++++++++++ .github/workflows/pr-labeler.yml | 70 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 .changeset/pr-path-labels.md create mode 100644 .github/labeler.yml create mode 100644 .github/workflows/pr-labeler.yml diff --git a/.changeset/pr-path-labels.md b/.changeset/pr-path-labels.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/pr-path-labels.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..d16d096b7 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,28 @@ +area:tools: + - changed-files: + - any-glob-to-any-file: + - source/tools/** + - source/custom-tools/** + +area:tui: + - changed-files: + - any-glob-to-any-file: + - source/hooks/** + - source/components/** + - source/app/** + +area:ci: + - changed-files: + - any-glob-to-any-file: + - .github/** + +area:docs: + - changed-files: + - any-glob-to-any-file: + - docs/** + +area:vscode: + - changed-files: + - any-glob-to-any-file: + - plugins/vscode/** + - source/vscode/** diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..94842a8bd --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,70 @@ +name: PR path labels + +on: + pull_request_target: + types: [opened, synchronize, reopened] + branches: [main] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + # Skip the automated "Version Packages" PR. + if: github.head_ref != 'changeset-release/main' + steps: + - name: Color area labels + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const colors = { + 'area:tools': ['1d76db', 'Tool implementations and tool-calling'], + 'area:tui': ['fbca04', 'Terminal UI'], + 'area:ci': ['e99695', 'GitHub Actions and CI'], + 'area:docs': ['0e8a16', 'Documentation'], + 'area:vscode': ['5319e7', 'VS Code extension and host integration'], + }; + const {owner, repo} = context.repo; + const {data} = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/labeler.yml', + ref: context.payload.pull_request.base.sha, + }); + if (data.type !== 'file' || typeof data.content !== 'string') { + throw new Error('labeler.yml is not a file'); + } + const yaml = Buffer.from(data.content, 'base64').toString('utf8'); + const names = [...yaml.matchAll(/^(\S+):\s*$/gm)].map(m => m[1]); + if (names.length === 0) { + throw new Error('labeler.yml had no top-level label keys'); + } + for (const name of names) { + const meta = colors[name]; + if (!meta) { + throw new Error( + `labeler.yml has ${name} but no colour map entry`, + ); + } + try { + await github.rest.issues.getLabel({owner, repo, name}); + } catch (error) { + if (error.status !== 404) throw error; + const [color, description] = meta; + await github.rest.issues.createLabel({ + owner, + repo, + name, + color, + description, + }); + } + } + + - name: Label by path + uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 + with: + sync-labels: false From 77b43dfb4ff9e68fca41e8249702a37aa00a3f1a Mon Sep 17 00:00:00 2001 From: Will Lamerton Date: Mon, 31 Aug 2026 18:15:08 +0100 Subject: [PATCH 14/25] fix(init): drop the verify alias from preset check commands The generated .nanocoder/commands/check.md declared aliases: [verify] in all three presets. CustomCommandLoader registers aliases with a plain Map set, so the last writer wins silently: initializing a project would hijack an existing user command aliased verify with no warning. The command is still reachable as /check, and users can add their own alias if they want one. Pins the absence with a spec over every supported preset. --- source/init/preset-registry.spec.ts | 10 ++++++++++ source/init/templates/preset-nextjs.ts | 1 - source/init/templates/preset-react.ts | 1 - source/init/templates/preset-rust.ts | 1 - 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/source/init/preset-registry.spec.ts b/source/init/preset-registry.spec.ts index eed11b6b5..c20faed7e 100644 --- a/source/init/preset-registry.spec.ts +++ b/source/init/preset-registry.spec.ts @@ -88,3 +88,13 @@ test('Rust preset keeps Cargo.lock available as project context', t => { t.truthy(ignoreFile); t.false(ignoreFile?.content.includes('Cargo.lock')); }); + +for (const presetName of supportedPresetNames) { + test(`${presetName} preset check command declares no aliases`, t => { + const checkFile = resolvePreset(presetName).files.find( + file => file.path === '.nanocoder/commands/check.md', + ); + t.truthy(checkFile); + t.false(checkFile?.content.includes('aliases:')); + }); +} diff --git a/source/init/templates/preset-nextjs.ts b/source/init/templates/preset-nextjs.ts index b2a42e460..d3c30af2d 100644 --- a/source/init/templates/preset-nextjs.ts +++ b/source/init/templates/preset-nextjs.ts @@ -41,7 +41,6 @@ coverage/ path: '.nanocoder/commands/check.md', content: `--- description: Run the available Next.js project quality checks -aliases: [verify] category: quality --- diff --git a/source/init/templates/preset-react.ts b/source/init/templates/preset-react.ts index 0d36c8a32..ecbfb70b4 100644 --- a/source/init/templates/preset-react.ts +++ b/source/init/templates/preset-react.ts @@ -36,7 +36,6 @@ dist/ path: '.nanocoder/commands/check.md', content: `--- description: Run the available React project quality checks -aliases: [verify] category: quality --- diff --git a/source/init/templates/preset-rust.ts b/source/init/templates/preset-rust.ts index ea44d00d6..ba602c699 100644 --- a/source/init/templates/preset-rust.ts +++ b/source/init/templates/preset-rust.ts @@ -29,7 +29,6 @@ coverage/ path: '.nanocoder/commands/check.md', content: `--- description: Run the standard Rust project quality checks -aliases: [verify] category: quality --- From 01cabab2277a2898754d748c7a7bd903de3ade6e Mon Sep 17 00:00:00 2001 From: Sk Akram Date: Mon, 31 Aug 2026 22:52:29 +0530 Subject: [PATCH 15/25] fix(vscode): migrate to Tailwind v4 @theme and add CSS verification step (#1092) * fix(vscode): migrate to Tailwind v4 @theme and add CSS verification step * docs: add changeset for vscode tailwind fix * fix(ci): create assets directory before building vscode extension * fix(ci): suppress semgrep regex false positive in verify-theme-css --- .changeset/fix-tailwind-v4-vscode.md | 5 + .github/workflows/pr-checks.yml | 33 +- .gitignore | 1 + assets/nanocoder-vscode.vsix | Bin 176959 -> 0 bytes package.json | 2 +- plugins/vscode/.vscodeignore | 2 +- plugins/vscode/media/chat-panel.css | 1 - plugins/vscode/package.json | 2 - plugins/vscode/scripts/verify-theme-css.js | 109 ++++ plugins/vscode/src/styles.css | 618 +++++++++++++++------ plugins/vscode/tailwind.config.js | 51 -- pnpm-lock.yaml | 97 ---- 12 files changed, 561 insertions(+), 360 deletions(-) create mode 100644 .changeset/fix-tailwind-v4-vscode.md delete mode 100644 assets/nanocoder-vscode.vsix delete mode 100644 plugins/vscode/media/chat-panel.css create mode 100644 plugins/vscode/scripts/verify-theme-css.js delete mode 100644 plugins/vscode/tailwind.config.js diff --git a/.changeset/fix-tailwind-v4-vscode.md b/.changeset/fix-tailwind-v4-vscode.md new file mode 100644 index 000000000..aa6b464c7 --- /dev/null +++ b/.changeset/fix-tailwind-v4-vscode.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed a regression where the VS Code extension webview rendered without theme colors after the Tailwind v4 upgrade by migrating custom color variables to an `@theme` block in the CSS. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 13d28f2d2..23844ce30 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,8 +1,6 @@ name: Pull Request Automated Checks -# The shared org workflow covers the checks every repo runs. Everything -# genuinely nanocoder-specific — the VS Code extension — stays in this file -# rather than becoming an input on the shared workflow. +# Nanocoder-specific checks (VS Code extension) that sit outside the shared org workflow. on: pull_request: @@ -18,25 +16,15 @@ jobs: pr-checks: uses: Nano-Collective/.github/.github/workflows/pr-checks.yml@main - # `changeset-check.yml` only asserts that a changeset file was added, never - # that the package name inside it resolves. A wrong name is accepted on the - # PR and then breaks `release-prepare` on every subsequent push to main, - # which is what #1065 did. - # - # This runs scripts/validate-changesets.js rather than `changeset status`: - # status also exits 1 when packages changed but no changeset was added, which - # would turn changeset-check.yml's deliberately non-blocking nudge into a hard - # requirement, and it needs a local `main` ref that this detached checkout - # does not have. + # Validates changeset package names to prevent main branch breakages. + # Uses a custom script instead of `changeset status` to keep the check non-blocking. changeset-validation: name: Changeset Validation runs-on: ubuntu-latest - # The automated "Version Packages" PR consumes changesets rather than - # adding them, matching the skip in changeset-check.yml. + # Skip for the automated "Version Packages" PR. if: github.head_ref != 'changeset-release/main' steps: - # The validator is dependency-free and reads only the working tree, so - # this job needs no pnpm install and no history beyond the head commit. + # Dependency-free check, no pnpm install needed. - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -50,9 +38,7 @@ jobs: - name: Validate every changeset resolves to a workspace package run: node scripts/validate-changesets.js - # nanocoder ships a VS Code extension alongside the CLI. It has its own - # tsconfig, so the root `test:types` does not cover it, and it produces the - # .vsix that the release consumes. + # Tests and builds the VS Code extension. vscode-extension: name: VS Code Extension runs-on: ubuntu-latest @@ -75,16 +61,19 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # The root tsconfig only includes source/**, so the extension's own - # sources and specs are checked by their own project or not at all. + # Type check extension separately from the root tsconfig. - name: Type check the extension run: pnpm test:types:vscode - name: Build the extension run: pnpm run build:vscode + # Verify .vsix existence. The next step will verify its CSS theme tokens. - name: Verify the .vsix was produced run: | set -euo pipefail test -f assets/nanocoder-vscode.vsix echo "VS Code extension packaged" + + - name: Verify the theme tokens compiled into the CSS + run: node plugins/vscode/scripts/verify-theme-css.js diff --git a/.gitignore b/.gitignore index 762da40b8..d7a2a3d59 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ benchmarks/.module-* # Auto-generated CSS plugins/vscode/media/chat-panel.css +assets/*.vsix diff --git a/assets/nanocoder-vscode.vsix b/assets/nanocoder-vscode.vsix deleted file mode 100644 index 90b14e6f6590b76052b102eb9674122a7ea84a26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176959 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%0DirJj~eRzf|HN4(a`rgI3b3HFf&#`H~iL{&tQ9% zn7}2(SdqPRJF`;Zgo4vCP6M;{tgT!1ifXdX36xM1nZSxul`uJGlORGB+>pd7ngA)X zaZ?cifT=?&l*Sj>_u*RU{k>Om*R&b22G&B;i^BU3OkL<4qYJ@3!9j_^alxvVm8uEkACjBw zk$2NyT60kf=llDjOx~K*ZFwke3$t;{N8L6g^&D6c@=@2e9s1}|7FLviKQSG#aOW1G zK-iU9nc6v+)6?0kL&44t$rtSguDiPs4hGNWogECPf;RV-RfQ|$Y1R)wZACGO6d}0< zS$x2l&5j-+vRFpEheE8wKwDlLk(SqH_X+8K((7h0t)T&lZU~hVKT^i>Xz0(1UfiA^ zCoQk-@=L#kqfO>;7OndZz`*qqpaDJlmhQos$a>|Nwded3sC@rJw|X3YEubBT!bKo) zgI3rEwuOHvJrbTIeq~C8C6wSPg%~aDVq!$|`SRhUC%+Tv5W)tVne{{dp@5h%OwvQBjF5^|->&%XR+ZhK4Kxv`K%>}1t75YO z4w2X= zR}l;m>G4l2G=C&Kku+w?eRJOiFyZ*xXCdi@1g^06+_JVkqdxEKj=0yGSXp+n=U3@Q zlJ7hi8br4-N8~N#PFg=Ki(GiIguJQ2Dx3~nk(qNjnC_t(lgb2IE z{F*4bqN}Ho(|( zZ+G{Y9mO&*Ib5e1L<`+S;;fs<_Q1MDA?m@Q^!C7-(C2y9H>7au20Mb+0~&_|lm_3n z9qp&4L);CR4k3mGTQV{JinAq9w*2LPbrr(0?hjk8= za9=ClMT#latwK5l8SPfEQS4Iv0lVJUp?sN(YQrvY;hi1tshij{k#vY}7QN%^h~V3K z5!#*%r~|4eed?zWUZ$tq5i5$|Y4FG}GpNj2_UG6AnQ)zV34%a(q$-01|1ZL_{>rr&Vx z65Lh8nkZ%b+KE=ieeUfUraSI@ZHTNR=ciNLmD25I$Tl(8yx;(qce&k5+weFeNlQ@o?duA>XuAQDX+zxah!Xqxo$$u zbrWf>lP*DQ1J_NrY`WXr*yX-XXexNWkWvW+p%H7kz{_>YMBT)FI-6wWN>s2wyJ(Wi z6%lN4+;%5?f!gYU&tgE+LO@gD&`niC-Cr!Rnb%?0zQ4D8*N5;v$Y)TywtejH?D*n? zTwUGWFLOJJYJC2j1&dKvil*lNOnN@-&BBiXxNgEyrV*a9ev-n$kCwnnkdY&w$LGOl zKq62ED;@aoBoOaOi-6`YSQA-i<}x$;1Fi|al5r{vc6NsRo-Ve5`k6JuIJbNZ{AM1u z^J6K}Lmq4_`Y7VN7INH!wJYVbR%GHu$P+7*p9W&dLVN zWdkmg4ZS520cRybk?a%_fybH~!vXRt3mKzvej)qp-?lqecKdNMrdn(p`L zhcvbW9)8ky+tTioyu`!hAMpWnx#k!ASiS3_SfE=m!tK-sy+}gD87o|OA)l;rIpY4l zFi;+69nBI)jc>vd*F)nry*&!vpJTW{ff5uThzxE!AK^|#S1sK5ZpBB1H>1gxo4moUZ z$pq4pF`UUVoMq4ul30Pf|IXJaXw0wDyPss)71?!`9USqLEW6C-T)7HLik(f$mz}B+ z>jyuJ7E)%}9peQ{1~X>%-KV$oW)W;tZ{fT!`h-<*%lD$ocZ!n9#jH_+wd7k zt_IQEmqrj8>`0M&(&XcgbJv&5+(gUfM&dVMJ~cu*>C+I}PDuY%=fdGn;j(Edv_8t! z4%6bT8`_*bWGrl8!-LuRS!dxe#2Q+1llHB|^i70=7@}}2 zM3?zFCL3C^F$ydBcVh*Vl{Ou)-Xe+ z2Y(%K+<88*JUB(qu#&v%8abt1(cC5JXB1L%fK@|juio?f2P*zn<36^oT2*HqCYq-+J17c!NNUZl=rq=!fT zVg)-VPu66zwZ9zFR^rM_}UH)fpwbQRa5q35|eEnTc%a76DG%ux-0;))EIK{+fo}7hL2Ijj+MM4-X z{-Bk(b(bt$eo&#UTd}etdA1K8*fKq&z3l7pFkJHnxF)b|=M8X8pmWU|jcc}Yd#^kj z1&j3872+_64L8*gX+|K*et)K0ZWfz=y?86DDT1Z~-hQA(DfzI#?HW`)M^;D*E5K*= z+cYd5c-(Ee@Yehw{Y~=o!O70~DQAdE1q8iwM8yO3kKrEUO7@5ap6PQvkP&QYcy&0i z)HHHCJKy}G&E}XTDrO$V%-a+*V6<;i@rPfn5&uaTGI&>8$_c6-k4X3v<@0G4##~e+ zMM)ex5ix38ZbN?bhD-IhENho3o*8{_B}FeBC1oor>$sl6Oy6v@@6>&VME2sGM_uZ}!57@eOd1{2`gBY^Im+&<*7wI?Fe7 z(qV>W!mUr?&O*o<^V~@0DTp*B`AKCT(9$8L*lJ|a4MBpjs67|;{T06qbHHas8t18HQ1)>uuY5K+SiG z8xrn@fnO5XZSktmcgxD+*x2|`yyw?Kfvm9W*F%GRp?cHlz;;dtR^~S0DGgqh**9_r zkX}T{l*qFcuB<;x?YU$y^l?IR=L7kJ7S+?8Ie-r#!>;131=e_eZ)Rr)_DL)-H;u2Y zn}SNOA|)IjdAb07AUIdxO^seS3cBE}OueodSQd7zU12#6sh_g1=F1gr70NQ3OjEDG z4Kt2j%7QShi8ElRVJDD+q}EEhWAhKwZq}SCf|H;$3(q#q!Xw-a9T|6!`}-%{u@w%R zZJEe0ltE5k#QGVo&D*^pkOm=~U)d}T}1i@;3kqv90D zDs<@%%hb*V8O`9B#da(mRV^P<<`v6R#aBx1pr<5j@e1L}n@v+t^Sv+DnMisR6fq!0 z9Wnr=CqyFmt5~2`M(5YEii#iJV8{;KY2@{Ep+;M3Hrd=cpKNB?#$XgD#x*g(Rg84m zDIYe{#zJg~5}GkKz10kORVeA2iD#+5VRAy`xx%Fl4~PE5ifAreC3PTg5{?ob1}lKe zao&8c4wd?5Z8+W&mT7b4LDM6q6^738b_T@+U@zAqLl$0^82L|EZvQ6-)6513UThJP z?aJc=J33lma#DUu4y8dN;Tt++A35pY=4NM8?n<448uBHg>C-7zF3sg@%Pce60iqzO zey1dYXd2Y&7g{sGEI(9uIq}3>ex-z7V{xE0g8Ktb)T*^%`O>jE+Q{d&vk*Q7Qrr+G zl|3uDa|%bbcmx{zv7RCd-L}#9iZaM1WM%t8_83)6iA-uZ|hT9GnO_?NJ_el`FxeO$&8lMY%0& zNUJoPi-V*rXdI4`v~|(!X4~dYgnr9p1u0I!zKn^a4@gOlCK?C~=8O}vvhYyOcQYPv`?jD;ep|G-`jHZ88vXLmS zSz3}~twDqbG-W)0baO8iA3Z9dzoR!eSkjm<0vGX@#0n!c!x4?S`gVZuls>&2pld>( zUJTIn4So83fI65yT?|ksrcZwkP?wc`J3w95;+p{?K7IOSfJi`}UVDgy^y%FI5!UwE z0QFeg?*^#n(WmbRsK;>m6`oE{=<{y_)Mv1N8lXOVPx`1oqEAU5^(XWx>Lag9pS(Wu zSTA88c`TxA+(#c+f4Gls*;A*FZu|6U)JL}i`t;F5w+vUmk9^j4*hhm~`qb;A0fRi~ zqkxUG-$wy!anna(!a$GvXvkh)_tB6wdEZAv25{O(5o(6bPo8F(eQYZ%QjOokHY!rBjV_uG`hxIe~yI+I9jT%jje9hnN)3QT@ z{&SL6ZXB@^bB@51NP|XS)gLQOPS%A@}MY4*M~57=G}0hX-j_3_Av^ekx&&ysl%i?91>xZm?gFMUehJIQ4SMNKK5I<4|XqDDAiiVdSi>#&hQtn%?ij$HbxO@le(bvOp-#QRvPB95^&*nT#Eu zK*02mUeW;@7|_F>b#P4d?W!Yucw0$D>|EQ;X8Hrg?rVstLxa{L?1rtl-E0c)k4JyB zPWFCpKN3l@MJu~`RfeugAtPlhK8uW8d}8Yg>5i1vPaNf2)2LpEZivKeK2 zwrvTd!RjGql;d@-P)A?SYvZe8${{LWz%Rraic zYA6)XeZU3v!Iu4A(gD#+0awR#o<-aYAxDxl5MI;#+BF^XYZI=zTSu8y z3=zx{4z5s_mQ3~EObIR*(wHuV9P<=7=4m;{q_}hH6)dg?Bol95Rj4t~)2~rXKS2u? zx~7mT^qK!B4=E$|`SFgNW9c3tvaQq0NN}?E9!?BQ{uL-t&Nh~zr|f;%*tj;yg#Ny& zq*WoZBE$nAJ@0OAUO6h#aP0VZp{&V$uA$}-_bTzISAaNOWL*;f9i(O;4^xN-wY2xf zAwydrm_?kL1pWrB_f=hc^~$KUUikOa&0^E`8ZJ*3<_ZN$G)<~y5oQjD$XskN%;KQQbfZ zGaLYciXyH^P5oTePfW)&Rf21#iKDW{PiQlt_dpFqUsO+xRQFLYm@mU}{rNCbf;CQ5 zX`Shvg*4^;mu?c*@88tB5!cc41O3#F77Uc%cK8f~o2xd-w*yPqBxiou!9EM*=lBzg zA0BiUnTBI;Foq1q%p@B61MFXpsmLm9OIR*NnJ_)VZ~k7|5osO%OBe>h z(w0(ySpdN>mX--qAFUbT;D)4@Ia-sK2}7E05Hp0gj$#+f*|%gR&CCUMD*1+)tEhF! z5DDlP8JXBbf0-7g-*CVg8U7wz7{JfL!??FZv-+8_f z`Zy2D6pmXR2 ze2m5}k^zSnh^~lH7s?s{oAc%A@S-k~&J~%h$saJ)ld_aNF=8eYwCBQ1m*FZ&|H1 zu-m{Lr$J3Vf_cyB-$rIbsNmJ0O{!@LxJ%{si3<5oRk-x;W43U<%x$ECXH}g7gCR}- zp(a{o`00kTthKrcErg(LN$IiNOFR@nO!z}${u%FRc-65RC}hN zcA)@ytW5q{W|zEvbHy`7!;Dj$)jMGX-e>=)b5T|JQK9Jm0rxM-x~hDRK1PvWw5ea* zo8p@y#MXYUpX!R9CdKPrw+kR@q=1_SjGbuww1>lmv$3K zCUXMeR+(+05(Y#(_Q*%^2-=abjWE)xV?E0bN&8we5^{oRLX17HKjDnc=~08|>OAL9 zAwF9Qa;y)o5UGC;*a?>Y)l=C+tz4ys98dw5nZW${)%<^Hri`klEFi8TeJ(p*G0!u@ z*=!rYKVK#dPE(xts!}(-VnwX#A@rqk(h_IoJ|AEM_>l5pfyhxy?>r69M)YZ-zH?HlI z#D~mHY?>h?hj~tWQAl3oha_YJ8_apSIvUSRr?kSlXBH1NZw06b1IQZQOP_1sw%ncR zlX~WL4ph;$lA6j9Ugr1o*~p@u+Po+^wHZoBsNRx0vV!fI>^jYL$W> zC+UlZx^DRwFn^nNfm!_1`^w(36%uEqGlNg>UcZm~*LJ?iQ33zD9cX-}+}zc<;~<@} z{byySWhPzA&JZ*zsAmXl7)M|58A3ffNI{iN&I=YoTYPq)P_3Z|azR0*SC#wD(zcwT z>Z_b7$k|+erXZ`9oGI+9O49mg3ad8HIO);ilN`GpDTwp$yQ-_Rz8;tO^nD`C;?sg` zE!7<`JA>L;VRiWlLE$oAa6-Tp`=gXwgg(3Ew1A^u{{l}7_-e=>=^rUmsHvv~MB_TH zqTgvdLlyYhvJ-*}1u)YC_7$)Klib>uQ9%HqSN zboE4~h+0S3l-Jj!R`o_z=4CYnPcYSg4?*{H1f{)vZg<+1X_&Jki?8;lQSSj*Yxzyn zFK&p7CwqPSx7V}EY~OZ?(Po`bf~HRK(*yM&`yw5aMhtz-D&drmpKkZ%M9dhuvja@+ z++XJKa*W2Unni<@M(pe$r5*dr8Dm}H`kZ-Aw#%BWvjbz6>n~{%_SsvDk< zS%yw?voV-O=T__%+0Kr&K3M*xF>Ry*ly|=#80M$It7hOmIDX>Z+9%?8wLOdHpMEm1 z&Pdd=*@8{u#!I*8i<7SAvI7F48m9-=4|Gnz#ihCl%A1etWd{UD@_uunG%9PRT7nNY zxaY9hGGBa7uwUQcoZvxrbt|6@ti5MjM;9(6Lg`7s!wg)r{{LQL{qITZ|6q~zzk{R8 zyYp%%AyF*s&Jfy_1YYh2^duogb&Bpi7uCz(b}p#3uIU;QW8*HCmTKIM=v7H<5C}IO zUEW>P1FwVsITxP0IKhTz^uq|N(Hd#75^hm>FO9_T)WvEj#FQLr)I+UQ32+FPn=|1E zMUfIbu!FlKz(yzRdOfcL9g?8P&3Zw-ysI>?tcL3gF7GZ*1|$ks@qQM3@MT_te&Y>C zvEc<{SZlxyM*MFQ4@rc_c!6;+{&yc65xE{E!uYok5VOu@8?9}9dx!lnD(@q29{XX~ z@hKw&9LLzWiEyAH z^5=-?rVrvnCif8T{<479H>1nD^C$7j@xi1d143-jYcE{f*J^f>ltO0#qYxX#auN+m z;w1~PEvL&8yTz*Dbpm__-?l&7e62!S>fKca z2&#mmh^*#6wP1K&(1E+n6eR))Zs_MQ=X{idU<^rQ;7#`pucX+{;}nw!xb)(LHDf|y z4VC=V$D;!6IA;2E5E{@hAUC5fF0Hnn&I~=Oa*fhuHGTJ-eUjAHOLW$A_FJ6_yil_& z>O#{FFG{q-fz%HBi?qXOz#>Z=UI=@rO5_%{UN)eO*z;(wN+I0CT`w5p!r*ccN7mM9D+8D(_G z(V*zHjNDxgAXP5IYiuMDr4=KmqG$5RI*fo?U>#N@HOz1t<9=4hl!bbT>e|5*Liwup zY)U%D6yJjQ>V{(ss^M7%-QtUWo509xr+GknyG)enTJ;PF6#FZwuH!fv4Huljr_fAP zijaxK(x6#!ZaYMt&ksYNd@-8vD*~hEw1Vgz_q)qKScZo`1};HAKqYU?Y)4zFX} z563uJO?zJzYIjJLl(Q~QjPW##Mp-3Z1tdRA8>{eAK0PtC50H~(7b?Tz@cNbv*3;x) zktuCUH>$=h@aP$red{Uc>cCYn#&~5#JtxYSsOhTs<_js?TSiaM_n>L%f0k(JeW|72 zEYi{wauWzuxsS(PvdC~NDim35AUyMTh_GaRDy&Kr{ zL-suo-eXu|U9vz2?s>OD^Ig!MDZ8@n+A1yw4cq4P!>H?JfampEiOU%KH(G5H7`SKj zF7Gam{2LPFjKQCz#Zq#MUvyU3qoSx?gpm;^VU!sHZZ=sqc5|M&%)Ex}mqaEvqb=)# z%HGeFaA8F%_@3{RERMyaUt-~104+Pv&!9{S=43L$G2&b78?*{B2Bf>H%2)`7GN=Ty zR5dJwA-;?&nsEzZ3~VXQ4HHydVkDmLV?Y_? z|FV#cSxK42C8G}N{p-5mwKPGwboS#&%NLVUlXYkp6|WHp1*fesuP}nu{z=28MU~Mg08XmwjWK2r|EQ^Iu{K{T=sxsq4|4=3kBu7eJ)tQ$O);h{1>97$o`#U!!D! z9!H`+_7ft*m@fgu6gs`p`dZzxJ___YwaE`d%Ajcr;swMQhrwquO#VJHZldt=?(8D% z69Tr!v2N1(g+$5f0mP-)0%aw+avri9FHRoDy!iXbxb=w_EWl{R=-}xfKMi0yv`^67 z6mAl7gc_D&W8fv3z~I;92J*583pBVDz{&XEpxFiGid;FUY^-k*By1IysB`6zx@2Iy z9Dz2cNBIUXl3_&P>#Z+2XJh3~UA7=TIR?ZFjCUUKd$`jlF<-h$B=4Ab!A446a7}J; z5G2HS-RaW^;BicRQ1#@SNPo4i&Zm=Afn<2W>gr#C1YD-zX&r+M$aR?2@rpXpG`(+1 zG`+FZ^xiLt+pvEMPRIMtYV^65K0iC5CE!oGe{!z8J)qBZCBne|voqGtpq0-Qu(a~C zCyT6!s{+UdW7t34PceLU!RlpOp0M)Tx}E8v7d~rO13Xl_Vsz*8(NLagyVED>Oz>IR zSK)0mHygxy(8kPBVB=S41c;>3AH=IC3Kuclj_C72iY*8D*r2WdS*BcuIsJR8jI^eZ z=v1k5rm~KaWq@b&@MQl~U4Pk#(i``di6W8jB^xkF4o*)wJFFI(dsUhH zH~OzrOIO%4()v_&r2&nBdy z)?XZWgEI#I6dx)x{8(Yz110drmpV)tmt(CVZ4#H`%cVeJ*p7|S<#OstR(`O?C+DAt z?qyck)f9NF@6*%I7s+G)L>0qyc&7|04SzP)Q{H_t6 zW4%p)V4i;*m9%lF{Vt@{gT>-lN-#woU~BI5^ub8AHq$H*yz!-tXO-#wJkh5&kfRTw zlQCj)d011Xw5m;Mo>1q?`x9lV*dFRsTf}Qbr2dDIf%DdZ(vu4PTxp=e&c->Uav*6i;eBzYxi`? zc6KR&mTdwzmQ&>dU1_dz!!re`YFfe#I{ONm7upoDL9QJDWB=$-Ar%|b6NPmf#jz?t ziQ?Pd3!$f+DEOW0F7MN|$0Vo>wo=diWB2}9!=j_?)Sl)b%h4CZTl zRfC2WbYk@`t`~OjFjfG-hBhz6r(mQ@cHt|)yIu(GlXt&@apKg8BVmrl2Gm$}_gDyq z1YHHl(w$Zz4E}z#eM%t&>i%kQ)jX@FixwN|-l948;$t0N6NpyAYa84)zp`5TR(WIG zlF0)_dJvf2lF1s*Bt}wU5+&Z*p-zbvFQ)%m@`b&#BcBD1)pRPlUfhYiffwK;j10P5 zE5^CT>%8p%53SgJdi8Gz>-X;~yYeCQ0?Rb+>Bt;Y6NgLmK~b0Xx6GGC2dwE_uf1ZO z>ssnu(?vShEspSQ5``bs3v6CNc!_+Cq6=*KTKUUsxjO5I9P@5MsZT{Dp|{q;VqJ<} z<~;MlFH)nT7t=s%2p8Q}6BRVqQ zJrzY;;7!6|$ICYv1tW-#^qKU(D5UYgARt<%-&>MX)WRrax5P$kYJwossJ8SOx_cnK zFbW|Q!79jQQCg-Ut^=9G$wJ5v5aWO|TPkTBqf5p(Hf|x_SVy2ngKVuF*Ps+=&kOPp zR|sZM3eXE~{n6ALd#eU8STNq;CK`Fh?I1oErTRBn5`C6h+!QMp3CP00v!X(w`j@L!p#Ej+d}_qmt{0IG3xFB0 zxO-7V!ZiHse&BUVl*2F4(AFcJJ~k$~__M5hg#fgI6iRe3jYO(*X!b=W7HwD%j5gdb z8tvPkUzAk(3*ykqhz1F{OF~6!6rl5A4N_eaM-jOJWm1i708nUTgH9NH7zOZ?#WuiE zr|*qJZQ4w94NE&w$3nlC5FT_gxDI_5A>j-jfmeer>3IR^vH&0Ojp<5dHW)z_E!r5L zL6cgq%>f=_6M=k;U8E%!Ds;F8V!i#8R(h990j645|%%hXc&b9 zFD4KtFdWd}a$~wE#<&AR06fW<5_c5PxX;0D5Pk&aBylGU!VVn^4koZbu>a@CBVUkW zu19pN@ld+NVDVG07exz>NXNVOx-ur(Qc%(ct2A3%Azz`F@`%Afd9%Wp!Jj)!#2Fdj z*u!11Jam*;=vpk4XE3Uc#=t%6YU|8!yKZN_(UPt{Z}5l|-68pNL@w_xYGEPD1!P&G z!mj~3Lxj(u!Jo1(fX0|za?RrfPjLhhkyq8+OMxXM0+U|^AEMQC_EJ!^ju)+_uP+## z2p0^)Q^ExoS%i7p@S$P^Q(znsJx1{Q3j7Nnk4bru;Pfl-P$7b8hbl#xLS49?)`=Kg zTOnew8mpbn$iluCh7Ydg!v{YrQST>Gy}w&>Me*(f*9vLMHoy<(O3_l@jy(4;2fxlx=Xx}X8?_EdEHrR|?m>Z?4@=Z-qh z95#;VKQy-s9~Ga23z5rq=3M)?4V;F3`}~94QB1ERj602EkO#0q`t@ma7w#WrSAR{s9SLx7KxC$ghUPJA-`0mbOPpi%*SW#-FgoP&>pBZQ`anGgpgy1n%- z@6H=0ibwnalofsmgD@M|Ho@Z&R%#|5>4tF<=7QT||JH*9C}~v)ACEJ^ZS%E0gri#u zjc<~9MHS9yJc0`R$TeRf9*rUr7`Gufns~9OUtBp`i&}y5)rLSJ53INi$f(hP{~cUjY4M@&djSCto>5G0)9AdkRv)ry9S*}d@xnOt5+zFSo+$XT-5tP}6??bX zC*A<7Ua+MURb6ADW!5wHmc)tf6e}p9W^Uj`no}&@!s_~R zIiJbx^@Zo*zyLw#Idrqd1=T!1lI6SU`Bf!}yn$sqI$Rt<v< z=Rb%stt7sQNrJOyB)|@kaMdQ$*DjV%6qwy9p zfUdoMU!uKkOYQaba{t!LyYnN(#TAc3FYDqOb`p~0YUroUcjr$aW7HFN^)pjdJuzNX zaT9M>fR!qKJV%$oGIsrQ99!YZAc@f@kXu{Nj!QJhR;7aK*-FWvy0r>Qs2;6Knc~bk z^zjYJIJ3$!)tR-AFYnIJ14x+E3A?PvVt>}fRr6so)B2ik>xd@cTiv&n@dDNGa*#8( zp+MseSR4ZnE~;xDt`ts?I;K(=I73vGPO0=?lJ!W1#^98yxTN~h?eha3U@w+lpEfDOxecz@kw`5s8Ok(2aynwjOzm$G|a{Y@r9tCOifpy(WPwA>E_b&~?09@+BAQ&z1LS4_$&H7A-)5Sr& zvdKCx7?=B$f(TsDTY(#CrUy-l{|a(VcJwYWI>pn`o?zUL3^C3D8bs|4FJEwoarQqQFh&~_C zzlXHs#6&b*c;uKp<DXEmkwQ~LJ_{ad9~ ztE?mgnxFA=$j`i+=U^RDtFQxmwd)fF>XFjViQbUwt@)v9sOxRGElX=uRfbhlx}$&3 zDdEpD6r)zPCFD|3KYwgk(8kumq(-}Cq-W%=8VW}+glXNgeb&OTB*aWl++L`4qmK1F zKf#9enl{Qnl7=`9RwHf67=h94{7~tmp|dItD)kv=ck9=h-zVvu@?q6=s8rut z08^_yN2K7|p;%=H%3s#+nTg_l7$q;Vlw!mM;o*nD&5P^`(uC7V{=#9w;yOE8rC(m_ zBHwR4OQ^Hcf-8tp>V58x^-GC=S+_YytA~I(N0HlgpLUx;tJ`+qsKBVA5Q^xxs^>;l zS1PT&X0O7SvD<7070&dmXpRzSb;TCu_AIoz_WTNYP-p>=W`!|pvuO@Y8+56j*)&y# zQ;7DAuWx11G?dqDwVaqvS^JY^Em6#dOB!opTjAVCqEUYH-%~K z)z1?9`#96@K(stuspX;GlAG1m(b-jXnhi$zG@jRt!wgYUi`>KZq6)(jG7G`DREnr} zz_qYsmO@P+)e7mUZ()QjQJo5~D~fj1yr>CeVLVt_gp0lY$_@PRBZ**(xpE7|M6z<5 z(Qvq4FLUG<8B)8cA+@{2klJ&*spWL+`mJ8GX`1b3Gqid(n!2y8t}vxeT)#DiPbd6k zDnPS2Ewrn4GdGblW5{d9j6HcRCX-kuxlKOxy0NyKNqxziXg#t80T*s2JU`E0jPdqt zydrjm_Trh>v$}a>v7KJ2Sh_bU9}mZIn`KQV<9ebtOxgu3dlq$Ue+}buG_VNSh$vDeDJ6F5=Wl<{;Da2HAVtb@>g;>ZX&s6L zW(I@7U@#b9-ri%Iu*YAv-byy?Cd)TV+0f^$w=cffcAkVTThMybe%?}dwR4YPy6rpc zT~BrumXomV#20k!CQ3WrHeEn&8dJ->)!~x-)^(^+ih{X~=&$_$M z8_v0Fl_@GT!78NPmU@sWZFojxQf4aLb7@P6#npuSyohpZ8%-LN$mIh2*Mw0%N z$M12KpK<%LlJYYuGOYZJ+VNmbmoMm*^qpw8(su=;M=!3;_a-Iz@1*ZU*T}YmwfgKC z>JEW%HmRpk+I#vQsYBgCi@Qc?$xu|8L^uod)qqPj|}SF^s+6?%?FjJ@a&KvonY))?UOG%$=rqnsV0Kcg={|t*8OY!#zIOvw`|@n&4Fxu-zbxqbj+GwJmftkO4^c z0usDLtleiDElKPnLF?v0Qrnr*-s-Buk50+MkEqQZ#D=|{fc@L$VmS6(Tyl^%Z~r|d z2RUVA@k*;WUg>2~9QvI{R{oBtSa7x`kug(UwurW6 zA4X)iV(B7ibKO{=YRLK4gG48(oWB82m++-Tn#=UQ+qK24UBKScOy=M18mInE%4pet zqkz08-g}zL{wsK)CI1z#Qki}sq%o7~SG?4(<=_eE0^%_%qN^}6-^h_El)V8~e!$ng zUIqU9;;?ew-MKkOI6!qaQvge+8;Q?FdhAX=6S<1y5yx<~-KgV@B5~k~p!k3240W1Q z{EN^PjFU|rxGRqin4GMkR5?T>3%r2c%H}RIbChv!wvQY zk@Cda1Z?fTr=D-Ur%3|6Jpe_}ys7d5Df9#`kBA}Tczzfmj1c_p=Va20YeFu)_asw` zL$iG6Gw1F2F`zr@vX!49h-Vh#Sv2CN*6uB~u9&lfM1-H9tcvJr>pa#O%5rR36E~06 z6dU$L+3$m;xAajm>%~KaYqYVoh7449WFskJ-g^=;D=eQ?F9+Z*;JHm(WQX9D$f;)p z7ZLz|K!Lx9gB#2RxRP1*jIey?+TRhF+^p@LAFQ_7YumzM@$Wnm86xPJwQ}EYrEu+7ZCpbyswdQon~?yqwuhzVNGa#N z+4T(SwGeeC-=2<6vtj9t*@k@?+pzQf`Ig~c0%2Xc^!k>jFVTa1dzk{6e0!M^sa$*M z?^P|>D_|@5IyEDf=lEKGSw<{#cv{ES6+cvZORqIE7Ba7-ELl|{ZOW?m%!_ESBd!b8 zkX5UggRENhO0#M~uljIkvE|*SY+LkmbG%#7i{K+cYt?Y zZ0Rbo$jMm{!%)|Jz^y*(t0!YQUVfa70=QLh+KIHFa;h*+%+Px)C1k;GZm0hYv6_Zok*MYGS#Xy!~TP_iiPV8e}PugZo)EgCuC<~r{JE1#-tibMxDmY=M{Y{7;DJq2Km$A>?wZ_rjO1K0o(o>P^n1;`h|fr5B_OZq^^&^O<)j;+9BBI* z$Pu&2SifVB&nZ_|@bRJ#Ir91;uS6wzF7<%2edh4Xa7_am0iM;?U4%zUN0)5{hhF}Q z>paE1&lQ}hhh$=0eD!oLO3n6-a%|r@w|(Pc-c8-JsU5^7=JzDJIU!eJ-D~iJ_yPV7+w$kIS9@y*6Yq_e ziD$IDztm|wg6cs-Y&Q)0a-*R?tTnhj|GL1Q|KxijsaJ2n_c8_pL9v8nuX&o=yzj~1 zKHpjxHsSD{$|@JPC0$HDb0GbhzII3m>bDUpUavpG1ev}+oD{rDLCm>GiVd3*K4y;L z`@?BT)kYaY44kxK7iaqG^@TvqPWTqgpo#kQoELAYkz-2lC#z#zgQ*%= zPjMxOn%7MlDLE~~m(xGsnfQ9cfv9nY>*2*;d3bnvXoRUP6j6T-5O3S*J{35`@YAI! zou{v`Z}n__g+Jfz3N4N8l6Z26B?Lq?4qO=8Qoh4X+-06Kl*PPfW9ea)nQL95s{fi8IgWb8u&*k2~yZ`?c z(`JcWOBPKZ%N=KyWZ~m_R7u9>jL?Tt7yKDrbD*>u9gYu`)+foBLWz6~kLdm>Dp{f= zjYnKjGp(B56IRGxMr`R%bw}%S1b<$bWbzS+>|+}Rb-4tn5v;tn1qADPB_D&SXBkSf z0|+m%rsYB!9*NqV#f~;&@KP{C0hWI)&t$3fn;Jt!X4s+L=pu&cORg&7h}>>MXYFB| zSW{mV>5arS{#?8(70oR)=>X|aEL?)s`Z6)o0Nv3gaGNtyrv04YRzInlY_gy`_7IiU z!f1di?Y%GUIuch|wWZbvvWm8r(4+Vo za#aR@5w+iwwO$_IN=`zieUw*|3pQytlgF>n#pD6?0I#K$SB}c;M|4%WkSoif-Zk+o z>aBc-n(iqtD3`sYTo%SRloRiIMatYxF688C-c0@yYFH6-$cpDTbjFs$Zb(2jD^irb z(SgQ^NLTLgDuyH6O?sWmUZi~}vVkr?GMC*zv}aD8khtW=3Q4rJ91@r^(;Owbg$6Eh zx7HO6NsyvBN}pG~x0F7r7D_E5TdHF6-V>(~(UF2!BwZ`IU~UdwriSl}RVfKJ)yhX$ zi3v!$oZXOM8(I!qZ(YKamMIxToe%Gd24r=^0uA8aDC&H8YwF!XV#Ae!wQt3d4=SYL zmN0AMyP3p|xi^#}FnbL5Gp=n7Xjjx_8xj#ujo}Kh(|_N33nqa&Y(+id3J6Dt7{Rdl zSIc3g!$mT>ZeMsb10gFymNE;o2sC4kb9LY&vzZt z$b%v4U-L|-=WC`P_)`V&W_VIiKlR676Brmy>f|5KE|WU?C*P&c{(09lbM%kDOT|U= z>@n1V=r!tHZ-bn;=xi}#T(ta#_jP&1nY)$!U1Y(DA~~D2kiPb84J#M9MZ3IpVL9-(%eW-Ut$p&boKt{M1ld<^I? zF;}++HN@96h%Cse(Bfd1w*d=sLZ8n&*UXJT=Z$%+Y)cS9Srl1hY0+iGf+c|@FOOvp z{;;$l)A2NyzdUc<5!Q095; zhPVzX>C-L)b9w7tzbDrE)&oVKwl{@xIM7A>6HE(W;u4o+5p!*^CIQ4sI}B7tC5sap z2fC21R*}ilWP#=0u3Qq-#>W%#Yi>bRNSKca53oy6dyBk+@fkfo-liM$z!<#1AnveMIJ#q&Uof{k6RAi zQH$%CYHmPj?3Kp>DM3G1Lh6lgZP;Sr!+i(Xq%t+OM0UbLaOrkEn$wSH62x&ckKHj6 zKpxtP9zO#xk(E$`R?^*Pq>Ps-o0l*b4x}YyJS`!>BCaKEue2SO9g&A#pdd6XhJ?F0 zNm1x4$5;6jg*S}sOqN(}T%#&QAzS#KA1>I|bjJd%jccT;jmFVp^#e0bHTPh`jr}e5ID(+&!Fjt`SWC;rRjy9;Q$W2IZ0Gmdqv_?g*KFgHKbemXLq`x%@=ATVa_Q?nXh{`>A}_c7LF?jjJL-)(*b|x0vcxl)Uj}CqVYKB4wdhZBh=WMokD=>bFPpamt(* zt*;48eGaS3M{n;e!O@*&bv{rt>#6xcF;ad#LzIomWi{{4h30D-aMi@DFdQo2y;<`j zVMMo^Z*X63d!x1hb}q=_&-1tu&2~3^*@hh5oQ{i}O2+|R@Q^_G?8r~geyM9hR44XEvuQ$0 zrO{mw@?M%8wUkrj49+F0i-(b;1+cNvtQB?Tw zYpnC-YwaEO|aV7tA+?eGe|qx`ud|ctR~$^lJK}*AkOD@FL5-gor&c z+(m}Po^^2ra-bUxvDJmhwM8?@Sv0?j7tQCzi{=(z9JZQ}O`_F=i{M*)sI;2r=lt)P zcurQUt>(PL-_7`6@eIeLTFtot(oF!)MfE2C+d~z)0(E9R9%FAJAZ_vdcr>II_Hq3) z^gKn+v(H18<~evGL{)c9=y>4L_ZW5)5!OU5i=?g3d7W+|kV0HlGsPPV@nVQ%x{2QN z=ec+@P&_g?JW{L`I4|M~Pt5s&nu8lCn9vu`&y#MHpU;((q)c_?IWa>6llX3zr`=+8x62`|JHWB`)R|!j0zHIg zOd(b)m}X_`9D{OE-a?F$^WO99tGKP{cm-roC_0Z3!oVS6dFr6{b`d88&Q5kl?HrF$ zX%ArxZ-#^)>&`3;-L4Ul24IxmmkPd!Me5&}BdFp4llJ(5BZqMo*=qQ+8t+HuvQc98 zM6i4r?b_e(k8-cc;rdx?ieN|XqFOd=hMI?_M~zQ%QJuK~B_6(mB6sj-YL4i3v`7>{ zB-~E<2BI5N@*1c)m_JYZs{26FUTxu`XyrMm|Ac0AGh;Nn>fP662q*@D2m34;wKzPh za+44Cjvrzq?peE)8{BEFrQNEM@=HnQQ8!Jfnwv|w+7&@kGVv0)g~bNzZq(ztk#6+9 zrvyS|EEg-niZ_}cB=4mNkEJD^NS-4h9qw`HKiYy~@1-n9j0xuFE!!uNUiW`yCn(;EzVbai9TcI#{^(6w0vQYwBBL#6! zww6B+=N!%XliUT6gF&`nn)HQejp#wq7qa!dYH2zam8;-ViY8y;;ku zpFx3!O7?ph&n{RSVwEiBBTZuplpU)MFd9#3U*YJiM?4v%c_JYT;T$C;4PON`{`Z{k z7Vase>D{y39SJLGg3zkdX}aM1BiV$nHjU{_NW$)?4>DLcUv^@yxHWJl!Rmea<) zl)-S2VAVizGi-G6XHh>u)DKZ#k1o?QMkkROVNMD5&x8#xtVwBRJ<_RA4!oTp#Ws>8 z^`IWpHv$Uue@(cqtrAB~6IxS?oLJF3;f5S-S3)&=mdMMo+lpJVe>>q%C+HZC{{v0S zKSRCr5R=5zBlI+0L5UujBgAJ&nT$%`-SQD#m}x6@gx3nd|YzvYgFYvuq)4 z+r(I;`aZ~v+ZxkhyNC8K1f|ono}e01AIyeuvTW8G&WxK7ss^0FpN(eCr?-(H@{vxP zpps`J*vgx=;qTe3naYKll+m-DYUNXTPS56@ruwEu`D4CmvHSQ#Dk6V68*#=rvzbQm zr%V>n*#ORN&n}?broHya!ZLcfo%Jrdxp_iM)ip)8XG`gE?b8{Y@;&c&_QtOxQGA+L z|MO-JLCir7PP;8WFk-49zGBvI0ymm7*mgQ^&YG&gY|t&vn>n4erm~o!rnq!K;%dXL z(6l}8a--~lG^ya{gYX~V>)T82*{s!$v8McImeMq{-jx64y6K$fAu7R^eh1pq>J77Z z8%+DHCe$8vEs-#9HfnKx_23Zbvvu4Zzm{aXshhZ7Z-MamSRR0*s$l?{a%~F3qs> zbk@7`P8%p_b-H383cU}7=e_-;ndms!&tRHI+U==mQTm>O*`PP%b17JZvv#ZGq*1ik zmxfszqSelO{iY=V!#_Rn+VCk0wBuQ~YB-~zX$nl)rF7wJzIUx_s10gnyk~V;j8t3x zxjDgkKgGd$ESPwcZ@iPm4GGSodb4p7`M+#7G?}DFqvKl0)>ppE!kWFLAL5^c)og$pzqs5sTLTEV`x;_mOY)wbZz6=4xk+~GfB*1)fW_~I~!h8_#ckhvxt#T->A$r3}&VpKX=zzyt6 z5bTg659%~~k}LAvHdKs3h05?I+6JNPmjP+;)A#-U{}({Cw9N^7c)5T8l}q zjcb^y+S~h#ORCvwU+icYfqIiJDe9hB8{WKrEAlM@d2(QUXFO5w$u^9(EB@^XI-vN4 z?~t*8Fis=RDijgyZi-9j>4bvqYAyONl}$)8M9U^5-)Y{Zh&5>K3|EBD zlFf4lqx}j@4oJtfqriR^TB!nxJiVpr!UNa8W@>T@C|e1EjXP}XIdsW93W7$-hF!^_ zD0mVPQ=Q~qgyq&-E!cZfv%_1M-LVxQ!yeZpyxl>U`&aMjA{y7=-xZ$;MZBZ8bqff5 zDt@+a$u=UP6|oHox#!8Yd@B;vwgwTTNqt_FVyRU9!;&cd7%;dF9?ieTG@6-GJ4QVc zT1+t95E5l=NXP=_t=L-3GCBSv;I^=gIj_Z|7IkAgH}zLsyz8-hq|vak&*Su~mR*yx zSFQ1aE$iVa*Vc{ro-Y>ES%Tp7w3Zc@hJK2-?1lSk5`FgqT10ebOvcUvq0U%DFDjCm zGMRXOtpb8iWsp#w`>1Lk2oH#<55&xV%q}R1dMe)q29}R&dcPd43(N%TU2wRAXrAb4 zYa>VH1`;hhu@zJ@qR0q|gzSp2YXxO*K#mEBn5LqDY*z}-zOWY)7!t9Ft)fh3r&lzh zEQG8C;h|2DpYa?RBgCDV_&}@`2_ipY9t%(QdN*EC5^a>#>X|YHKu!S?*6uCHb#C}h zRDnvqkJ?Wpgc)Mx66Ofj0DKbGio6|vSEVh@vdD3A6zPhC29F|EtW*#U;_2Oe%1@F* zu4d+Ma@-@o#O{&ri}%2r-OhmjJ?DQR=Y6-^=g;V#zGfF3dGOs5pZMQdQbks5XLin; z-A*eh&>MWGBEd*4Q7SCr7L8I-}KN6DayZtd*(Oo@38ru1fk#HKiO( ze>_J+ZjRVL%&ZYmE4J3YnTA?LWHp?HvsWmSxnz2zCi_pI_D_e-6 zT}jqwxR~w`u42n~dJIlh5UssO$ewJ23Oo2w4x%-?Ei|We@JgqjyqC>7!?YSKgE3~+ z9C5g5XzKu>!fOz;Fg0jq$@6*n6J<}Mg-8L&Ro1??l{hB$=QRzlf~QGy1gcciN*)RO z^BRNgd^{U8v-LC+BkR@D>{z+X&kF+gRl0IOu$!lC9lxcTkp4STuk}N;&O6mzL2kkh&Y@9kG>98M6Kw8xkjQ6xgNa?)N~6UVIe3WVfk9azN2+TgZ0)W zmP5U3;+0)=6+k1a8y0Bb_O5gCpshI&XX{;;+<(sl6jB&8o}y~u2V|8t*z*ka1lk^x z{gu$P2>FHv?$&}P5aLxcl>ff<7EHo&*ou0@Wz|ec3?U>p7Kqrov{jhWUVH4kS1?kG zaivVNG)@?*H1q@bJl-N+JRL024mZ@%I z?R**}F}3gy4-)-yOe`5GyIorp=CJopDui~s#@nW{fVMg6Y_|d4-h3`OknCToH+X~c z`4xr041FUqewo^Eyry8N(7>TqL|0*C*ekSt`RO}Dx$Hil_@V9UJ@>ZjHS}Y#k;VHu z$I7~<5pkc4k@bUjOz z>xA{&H<&&U5;(M0(I5i_h722SvK$8$3m;&ZnHjeWe0rV3xs@M8wQ+6bQs!0R zTuEE55{rsq#*8`Ko=ZfT>!`7{_Gx6TX{4KQ))l>Go=3fWTh0v0-{{7hMVSYMyj&~J z3@2r`SyyxiHkW5M;Bsi$(STBK<*%v7o46rS;QLV$nJX%-;vq(TrY;Lzvnv=u^KT`oH1%FT_|G)K389=n}G$swg@Il z6{F>{FrrV!B>d(2mg`3u@3ZuS9gK}4gK)`KD`gGlRu{HlQna!ms37lX;4mF>)(ngb z6ZaaIrJhB0kK|gr@B=H1{Gc8+bn=iXh}cnc;97B8IZpv1SGm&t2u5YGxp75-n~h z{J0o;VMGHlim^GSDjsNzsp_B(71rJ5$9X@X7MQxC%62Wr?87#pe_6c;k+D2z&I(T7 zjpaM}YDL|u2AZNj24Jz$Hkzg=_ZVoJ>PzED8D~0N%`&^rvCMvrEwhUv%j|{(OJyPX z?wYu8xTVu_#6Ll1r}H){F0C1CTQA%O#pRL3w!d^$lkJ)X;N?>$8@Jf5FF(%PH(!>; zc0)Z*_F8ZPJLzCI^oC(C-5psIFD|G#>tNK+9EV0sJCWe=(`da5KoQqO2M~fJGyr4AOvZ?h)|TD=Rl45$m61rhV|!~Zh{vpzlf0SD@bWgDi!XxGJgWF_^z9Prlm z7B?@ceV~OH*N@e-e(#&LB9VA+?$>>W;%hbkd24b@Jd1ebCL)1#Bo7{5o-hzr=2=k= z${h(#3N6NOK^LOxWKC2%_t=kR$XH3%f%8?6l0MXo3Ydw|jLlt+v3U_2o4@Zl-`sCQ z1Z1BF^Yzb0{266pRbRRgM%d>;uJAo`jJePoH&fag!@h_0pv zL>G5^RxuOCmRb3>FN(5C#!zGp8};VdRB9G}g-z1G5_>m8;sE#YP#haOa#LhR$Hyf5 zfScZr-^%Q5@aZP+W3N<`{e%Hh*9l!QEI@ymo?`^lx&zZ~UhrkuOW;d(rieYxso(x^ zl+Y%pJW1P7d`Kb|fENJBp-AWZBIL*&GXx%N|!@(8A7v0j3UHLQ` zhlo5o+G$u*+oT=FQ4RYEtMqo3X+`1MwmQ=vl`sQ)A9NPAK=Wu*@}A+|zDTX16L4yV zMplee4%(c4R(PQF$YjP#^FlHe05l0`+DQ%@K@I;r>YLi4A0RzKQ_{YHd>PF-_jx>a zrmmwma#O(U3=${;B0s^|Z=V&7vD_f=!d8emayHw4rZXRgZuynr1oCsBX7`g8wTJrn z-G8Tj4t?fRn@aPbb>9%_qIf15=N3NTbYwmQO$gBF;^u&^4MmOf=Dy>RMw~grGiB=U zJs!DFAoO{+RxrMW#TzN&SI#52`2I7vy8TA-V4oN~Rm&Jd4IlF`60&MHrckY-+tPd7fd<#bL ziAi24{Yli%tr+_$7I$#=eq zKW)cZZvIN-t=#i2^eifVdfzd!UuT=bBCHTIx@jo!)C=4I&{9xgK$liXP z1}f$1(5+yD0u>XYr0YuY_XDG@zY6@wi#4dcxVcgc;VVW1|8C>od7$lA?sjoUkF+gp z;5UbFeJCA1SZ-F@d#E?V(#()f)0A3xl%zvjVO-^`F#aoEVSHP(!dQ@iEFc^;J_N#p zqVvy)h|k)Rf<@^3u=tKv5!tU*7Y-ZZz!`>)pM(^84h<-5zWbI#;ewSfZ}mW}8$TeK z^#WQXpzrar0G>pk&b;H}bpd7xULB;~qcy^ku#a$&L28wNU!!$GL_bEpweTIbWDDZ> z7G0ZG1i$?BT~dhPLYmnxplKJcTzw< zs(z|C3P_OhLQAkTCi~Wwv?E zM|Ye~_@~Cl9vn}TonPZ)w-(aq^BU0aUJ5>25~!eNY!SiWh;AaH4nl7Nw&r2+hh02a z3Xjp?6}`3f_SD{b#BxAx=p)&_KL(WWj!k1;kgPpQ0!j|Qj}T7R^VE>lF)Br{WS_wl z76h_IA$^BjP*@VMv9XVHV|}|dWPy^vu@wdMYRhO$!R5#K+*?qxk-rz)3U|ftfgLJa znZ{ttT+_Y>swgafl)Wu|KcyQ#3fPX4aFK9g?K{-nx@kN5f@4Ua9qMLH)NU?eARy{+ z_5lVpg`C*|U2Hu+Tx=s+)jXywrM!f^RJi%jpgv1j$(cGPyqQ}NO}I3J`8LO3{%>wD z$HhM0)IFQpf$fXL;L+rJ65X7TtFZ1hY&MDhZ>wpC{GVp5RKA(CTF;(^|2b)mPW>_C z(+3^1Zzk<$&)9!Xx~IX|=QjC23v6ihInqP zMDVpt6`W;lrY{wecHqp0JPHSr`X#iRK4*$6?DF+0 zvpiWj{d+a+0ZAV*!mfS~sD-4+khCu+gQINb48y%lG8lYOc^udq=;aIBEK(Ui3jWi`}t@HCMi2+=q<<#DW3 z9W871_M)^TdPWzSM5K%O)I>6?rCiBb9zPzY>@qvf?YIvoI!mUfn!-AxS!P+Ds~-xo zJ<2pE!-P^bG3q%VanoCS}Gi`_a zTiCK5isb^Cyy9x49y>4^wmbQFQzy1?6rA`TRgX*hK|O+HrJNL;sco!vgdP%<G|jK0nXjFf+0j{XT*N3+Qj z1#d1&nbu>xFu`7*J_cyzaDJG1-%h$vqE>V~F!ku?YbMS=dWN3QGY!Y4OJ2ic*bmB1 z`q^6F#j{v9gS($?@q-eT{yFWMqv4I|Xs3~C#LO=Fid86Zl?LEHF#ESizD4k4Z3JSane!oe4dj_RT9;c=}lFq zD3b{14VCGba{%!SsRt!6t-P=@5T%?qmC~4&N8H2yzaC{WA^)o9;VG?C(?r6@v>14QGh6B}}1S*;3i~u1uM#4*#9! zhg#YP*zW7MB9B=f30;8yrG#fC>Asl94}Ue!Nm!Wj3l-A*>QO-66Yo9M?-7>lz*h&$ zSIW=rUxoBK6>Am0>!$^KmVyW*3&n}EKjQF2aqFDMlNg{oCQlv@Ie z;S8><Pm$L3(}`H+JsQ=3AQ@8BB#M~i;iQJ1a!EN;~a zS3Lr-QxQ;yV*MP!Piyl2#w$fzh70=xpsE0W)T642N#MB7N?}dCGGdG3&1M zNz@!*E8mliOrY&QWoiPp3+a`=12E-IlA2&_A+N+8z}UfA(QW3WXj19Wpd~f1eXO;8 ziT{cLYH>#Td=-$$W2=g1LFp!-;wY%WlxEh-eZLjpS#4ZHKP#UMO<0itv3BK;h3a~7 z-}%o(AF83A#+5$qnS!sGdq3xxd(+t5`&)75uf;0Kv`V6APwKC@gl6wS(D5q5Wq2|a z$SvZ+jEn8QePyfKe*wMTy6-PP&bvWmZKEi-U9600z`iIu@VT%9{lyJr^G&7y(rm!z z;(I#p7g0Iwup0rl>nr& zgDnot4ps_>As}8bi1Oqg{op;Rl4-06Ug&NkQX%13 z5iC4=ma`X&pz-`;gdg7QdtgGU#JAFNtR>^O9vytGNUQ-wI#`7qrY#u$q6&47k%H`p z6>pKVus|3qyqHJC28#ra}m<+(o>S)wr|@Is{N%&{6G&3OEf zV?6#XHXi@JCtt*L2)@hd6kL?k8MxV}XX5!BJ@faYUh-Z%PdnlbO2VK@o$=ZoRO5oz z5PzF_P%Zvhyc(E6NFwJFbtZMi^o--44N;X$^`}ICI$Oq+Ul%%^wovIa)Fo$6vl)7_ zjxVzj2L!9Dh9cPVxry=Myde^ypr7APs<^_RM{`6bZ~L?@-dKoQ%(`B}i0Q=_wM-H% z>Y_gCidKjZ!{mc#UfflBRJ##G%FkX5EgjXaSSxyp2ii=BNKmC*wbFs>CAvosUoTN1 z9mK8`I78B+MpZU&y+oD#bR$X%Q#{+3bR?&mF@QX$Y-(pgPF82JsyH~8vopn3RY7NTe*^b%r)JfDrM@|tUmIp57FUL-|wu%P!@lOa0^WT$9l zbQDLKD^RE_DgjU8&cAp*#gXer;mIMf?EeNVG)BYkYnS|xu|%@Cl~Vgt+i1%BG98K; z5!)D}_GQX6QM&F(IbbvsNSbPz>^76nXId-%RQ~C)*Rm0q3X_dnyb+knLvy&@EIYH0 zI66eO&QUC6H7NeK^O8g`kInL9W$#J#+GT8XsWE?s`lI!vd$8ms9)H$=`1fV)XA}1a z#n|tgv`uS`boSv(^Ta4QhWI%eJ*>S&QS=XJ2+pl!YzSU;Vs}apQ92Yt`oKo%vGEUR zlb&m7I%*c0mLoJ8kJLRV6#lSgr1Las-5}@uVS1eB`Y<*B5uSw+ZrPk+0^+QDu$Iej z^5LQFdshzHDm|6RIuULI8-%<|)jYp^Or=#Kwq5!Z^pTa2CS+Ct#JVQOq>@b196}d~ zx10+20hV!g75EyvLA%8PX z*J_RMMuee?1l~@75gQ^hYtm!3%M!Pk(TD88GSKUax_3}r{ zM5%W0p?YQgDq6#+!{fSxq&fv^YP^}}q3}4-JSphBBXr6%(JxZ6&MZC<)0g2Mjv8N} zo5+m#EIW|n=LcuyNN-`iH$>FUldqz@q`iq+Ge|m2kR@cbHBFb7ln7iytIoHH8gf%8 zO^hfk+T0ph`IieFQ`P2gJ{jg!IT(9_2D}}mbZI-o?Iz;0AbV!j&!A!hZCc6sRd(+X zHK~^{MRDA!L%qgR+Q-W6`!Z zs~oKJg-CW0R((7t`&3^RAHa|Fpf(?}50w(;&)8Vw_R*eU-a!VnAmnopdl~Cm@yri? zOKj3E=)}!BB#77C5UGKMFiJgKlgeoY>3kK2d@z3HUe-s+AsI|C^h5*sT8 z7CjgAa=SbG*APl@o?1i7Z9}9x&@hb~x>`y>8*!qEmzNd#zltQ4XJ>iF?FpcMnj{{> z=dL7sg!GMONSc*GCGA$)gf+oPAV_^TLagj%-r%`Ou(Ms^`kOC;8`9Ji!*Y<6!P+UG z4`uGb`7+obM~G>UA=e$EX2|j+eXK={j0mt79!D=ot?={`f^*G69rK9(sGLQ%^!q|>`9CKDa23ym*jwm;O`|123Ff=sn; zHIr{xxguHVVwOmYq4K>vxzrrmv6s39UHYD$&#-he)IO35&{SkIkx0zeJQ>Yb7Kmm! zLG8a!=uz32-?!Nvokt}^-{^Unv(fWE@kYUkr0dk8UA!lFK*@%b)?8bt0jD$q^!~_3; zk2lBy+98JU^5exU&~Fe@TtG(<)GO_f0SOfUMbF5-(2o`56-wPP#9Yenm*`!vWk6l( zt(o{~!F^F)&b>o>Yh{lSIP8wM1Roj*$*mwORMhRcTfWtslPlNv6g1t1`&68w-|`%& zGzi(9=?!k?Aa9^{cpT3jI_7%4|?Egv1* zk>X1QT`M3-q=(KUt$YHuBf)&IcLNI`S3c=jLC{rKQXE|pOI*H(AP-_3w8tT`XiZ&~ zyS~l?f>y9hl*ocn%TEIgEkwPj2cN=c`pyMPN3S&P&AE_A;~ zub9i5vv0}qns`!Mt;sF%EaH)yhy;e9C~>6xAyH7A&CGMCb&XmjF&0|=W`$TGO392m z0hO^!b1-Vw7C(>}8_Aq9EHzAT2IBxRKw|L`PbUmCAUWH_tX zBxR+{QRRL@S@+_BG$Y`MNAbn?kLAPwuHdXG=ja&`tMWpi3-WQkAUTip9>xQi!DkeU z?jaekgqeI%GiH9@8>e%K5gtp|zbPO2&sKt1<(Byj&ZUZ6H6*8cBu*NoYkv&TlWooZ zKK>k8v8t+M@m}Oi$+tbnnYEz?787uuT}BQvMKeomU55`2&X)C5nvZ4l3<=qJ!jSAT zA)g^?`aCd;)>GliEa8NfXG-%k9GFq-sk9MKY+Z9@?W@-Ff|}!G)H;+Q$I7%-6@SUx zTg6?WZ1#kAGW#%QaB&7MwGuc=1}>a$>jL$C#(XeHWre(2z5X%sYCU8K<>uSsl6qji ztFbS_>IvP2+F9X--=9+$Nnwj@M{=xS@;$p9pZ4zFE?~Kro#XBQ?9>CM* zxw4aoZF)vWXGpPjjSC>0WNu~34 zh71X(JbSbaDpOGdbUA*98mLP?ip7u~C8Y$cZVzo?9fF0Qp_Z@kP;bxCzSkjur9KlC z&r;iD2La^jqJ5zN#(QNZU_$w2#&Ss=7w@W-2i-3T8tuPTr5l5i^TbTsk*i9r86euD zlT&NlCwBOnjuUje<7h^(CBIgYc7^wwMsnz9qjk!VWq-AToouM6~Xgh}e} zMHX-SB{QkP7jqhj3oHxjBi7s%A#5aJxjsH z)?s@fJOdTzR!7f2Cyj?}u2pJMYA>sL`wH0DPLT2CQNPBCA@&21sHmmETM1cSeaSkTyq8YLWYlB#B~@l@<7Q z-j?0_h|O*f_T7bRT-F^R0>|1mGI4|7Un)@vN|c3}IZtiq?tS;oK6+B297H$1q*bY3Y3$7r-+RZQ zSl?Yah+zG6wB)XwvBV6Sof{9L6>X7JCG-+40J5brSFq~xf+x3O|M0e$2tRyN!o&iT zRhqpWHBkXc=?8PP(DL$F?(ae7#=E(Aivg}vn?Ar_|ZQ@nCnzSU9`4hXz1z(<4&0RjrIW9mys^bDtkyHGNy4Os( zhOhj~ZM)eTx-7h5)`|vXRd52;Tw3#PD?S$1KpyN;<=14R%HR5T+g)Mlsy^)%3s`uQ z9AI@JTd(g~8-H;FNhgVS$J{UMBx^_@TQ>and&KZbR``GqvORI>Xop)JMmFTm_fp4Mlh>&OtSjGfiFZvrD(|1Nq=JyFR?(hxh^J+7GQ2)c_lz9B7@JKykI4vc)00y0rpL?Xr+r21{YDDgDY{ z6JBfNZr9d|xGZ{<us)U%PrzkWI9rgsgky0xwu&;VdSpTB>8j!!(QZ|Hzys#t7z zu5NtJrjB=QMK_`hv#uHOQim2zEK40z%qfBnCOQHaOC31GuhBum#m$xzG+;dSddIo} zX&|OEfW<|#$M;Zqd{i&wFIy$geO6h7TpJMkd3#MyM4 zK7^3B5XeWgXNmB$oIFbr>Vggx3_l++OHI5BUS&Y41@!-2&h{~8ljcw61z~Qqyz6Rf zhYXf<`=lPO5JF#JQrWYx_%@~WD-oG>kyjy1TYmhIv;6Sln)$gG-$e;(& z5xr@zdAS`mA)i`ze$h8x+%g>YhnL%abJi1#0eUV5Pj5-`^5-8exBcF{ce(8k;HtWQ z4{miE^)9-|7mkY{mw2i)_KYW=1{c^he!1-p;Cw@G&~0M>xc(md#4opli(cz;+n-K* zx?5cTqI<^S8{Aep7{WcJiGV_vLA1ZgQNFKE#&2#agq-N2&jq}F3lk)7x#52O-k>Q^ zq7XmvaxkBDliF@&bkoHEf8Ea^l7R?1Us$`t+@{D4=7Mw90oVS}b#h1rIf2`mx z1BJ8ZMInV%4(T5o{1Jkqu&kz)q9Uw!0kh#~IFJ&X36SbtG@)IiPPZtY>2OpirfD!g zhv7Ku%nJgMs$xn^ja&T7tM?8>E#5| z!LdG=*kmp;lG$%xq~m=C{UOYV&E~nNT_QZE*Sa{n+zvXhAm|T37YqiV=me-yuRX7? z=xyU6IfA?V*76iU+|)hG0kev)6fh%5NMt7^6)=l)72Yry{W<70^Zr$ic^|~)Ju740 zgBnh(>wXu4t@?xJC}ZAtFS_T6)`s(j#!49@zsY-cKF=wiGWCl~A7b#s0p66sKWbj| zgpse?r(ep31B9rg%zGXrJL-)(IMgM_zPD24wa^y@QG7$eFR=uqO#IXbE$#*M-i$8} zn&+w57yVNz{skm#J_HkcI#Yt0Z{Qac&&I^)-*IdV*I#mI?zih>v3i=3Nw*TYdI>JIAEBA>SO`D349m%*581;rzD9}DHS_mAD3NHlC z!JD^dAy5@l^@TuH4AQ6Ah3Ebdgu!g~r{NwR>7S)gilSi+z$p~6RU&PP5snzElAF(x}~IO7^T6_Rlm2tRC{H z67*!24JByMmJsJTO9&P(A^gH6#K(Da9mmmommgspI1`x^rVD5*1aC#WbOE#^Fnq4Q z258NvaCD;6Y>F+JbL;Zse0Bx9G#|tZ*aAZrxO|*99Y7YKx~&i@@tSM=8mi8|oTUS_ z4FbzL@TaZc%?_M+Fa8m2)2?MVRZQ-h(7#6TXl;P2TI^B*W3>RL;&S+U^^f!BHT2|A z{7qkMC?n)?BEh;zV|#2Q2wRmc{Okll!wu;ulPo7seq`m zBA>G5x%VF8 zxU!9Vj}?hjOxhxY-O{2sDBWB?7;zfvaaJmShvo#lP4Bl=+g=diq&K<}mNcXztUy|G zevmMuFHN#|eT5a6qif`hc2qw^kE&mg{pqayjk?DoZ$rfJ&!eTpHMYkhMRahr6iG&iNnDideOvs;R&LM#;=m zW@s}uMHTL98zxzx*`^8+@VNAqj-@I&1MOK3y}YGW2uiPo$`YZihu-I`hx~Xw^wW*~ zkHzHY+x3<2)@#4|&JWvYvyEyEn{M&zm!5e0l1&ya3Bykd-wUH)yNLXt-uM*Vu<+!! zHQ7u&`oa2dN><;KjV<0XN4@`^Z0?EU)Tw=PauPJSvh*jxo95ft?ox10LK@Yn?Kf=y z*)y=V{H5hjCN=-+JzYe#XU}TPsWH!juL%}j%Rga`O&c8h{lcXrNbVRdO{b_G(6zs# zbt*^=CJA}hKwdm&1u>0*fyO|m(V;GlXgxS#PQy+h%GZi;fk5J=C%@hK&dG)Z)QeuQ zWj$uzr<0Qt4g<;ujnmUl_xCRZC<_?z0*Xbvm~X4Hs0Fdb`vWJM54C#KkS5q^G~W5l zv6}ap{*l#hNC;$RK8yESM!e)kSPPi^lHJ#Tx|v+N{uOaw->|T8@*A}BH}U2tdd&2zPzvAYv+|joV$~%BY9r|iE=zu z$MRDJEQ3hpXA1j(-zkbnd55F8iPaP$ws z5tNv#6pl4n9D?HaDzeLcn}=Zeuc}xMV%N77uqaEJUHcocsL}|zeNv$z z+XC>yc`t#5<2P?j0NgSFV!ON$lv9=#^)oBSiW4DOQs$YPyg444rR$Rj!oCb>mPba@ zHyh6DE*i7u4GEg_FsTQBXYt>fxik!ai*8U|oA2h0Jv-j08h_Q&`tAq-o7!?C3vitPybdUdXi$;J&2eCAbpDMY zQT&zGo3@9({vCh)F8%d?@YfINum8edH|ek2DASgoN!%8^-;r*LW*}styhKpQ$KOJB zmq@KGw=#7g7g@H>5fJ93frxEF9mwk}C9e}80N^+J`85rdZRa=WP$&(7dYOjPPohW zjCJUXEcEa9LQl4t6EWv|z&h-I@Fk`lXwc6R=$pXb&>*^nMT~eFdv>_Ep=&ax0&<`) zHL}Ehhedu(MOcpwk7Lj1;H0U(|1|;N?`ZHLU@(_OsiwGCWX@@wPQIZh9P^1!PNIOV>$uRyRLK7hH^t$@^hz=#|8XfrzS_ zEw?_+-9SDTK@eN7w!eY0p&7!W8U#F58s$QQv;9&41KVNYB4BIgkqEA67BGE%Ra!_Q z^CB9AbRpKtE?okv0(N~9jRS2#iYOENcma;?I=9&*I`zka9ZsUtfG^=+nD9m0jZ14b zi|85{63{Rjhk|UTFHQHb0`~I*r)IC{?Q3{J#e~bB0}~&XdjPJIuaUlrKb5dLg5uz&lQUnvTR7lOjC z+419;YHQbm+G40dOPJ@;Ky;_JZY`(`qkvp-c{f+SEo7pMyU=Re*EF)^hkpWZQ9wbm z?XM8jPJ?=5d=>(awH0b1UeEW8%{Fz{*9FZFw~GZ0!`#;YTu8-&M2j08KYztrJvKLX z3<33;-uh68W5YJQbH>#6w<(RcDri|iiQ{|jZEn+E7NSi@71!(ySUF}6&kN{q$=z)Y zL?WVrPQdGeTARRMvoK#O&$qf}0*aKL@lUuXi;!A75b-V!T{apos2kEau9BTBNkrUd&+6=C;d>GDwsi7Ut6?)9rhR%^ z7Y`FfIDFv&*!&ZJnn*O_Q>ea#(IgOX1Xu}6;Yer_ebBHo4PcGN{k`qgRWtlX!$v_u zXy^9^GY#1P_t=h9439N#Twf5ti%mQmgX6v;07g^5915n&6|E&;WGs>I!Cm8vo5`C% z9*6NKAw^+CFG32vSPyIwJS0GI6~(RFw>HHD@l!STHbpznxm2xbM2;7=0&BjnK;)?xvWV?y?b)-sKk>OmaXa}&j*8o4RGi-G zhWz-pVMmjwQia9~F1hzy?!-Hp3NQ8PX%yJb#5)lO zEl*G9k-g+Ko%EIu6<0Hy@PF?c_Kp{GGj8v6xjSBtWafe;p9^-#TBz4=?Ug#J^?Guf z379V z!nZb8DdUwK@}Ne>ceYX++&gD~x^LV!>;U|*A$^@`;q7EH`Ko#9ziGbx;}0Q><4F7v zg4Rg0QSssqS+<_Cv?rhL)p7w0Xq)lqKmKT5m>W0qC}{kg37<^93B;)t(%?abaTKAp zKKY4W&p&Rss+O)9P_SaMVR)GnAw_X|5|QAV!m(>7Aj{}^yV-6&Z?$W&wtz5g@u>?P zPt~tx7Z{a%9q3;NwTAs^6Zl{q`9V1LPLdy>U2M=w4vB~g86113C+gklX-I8B*qGW7 z*t`j7NWBQgIuuO3Iz6pLH-Z15W`kQGU=A;&o=;EfLQ6Gl=EDc(pPtry5qWDb{4J~x z$4xt=)EldS)Wz+>1@F%3iFi6atp#*hvje&fsRQrD6P*3%!i(~w_;ug-#1Br&NK!AJ25mN>CqN=s(Ov`MQdvXx63m=iFZ#=0 z?BFB;7l+-~y@r^=p(&F9j@MPQ)Opr0DHYZoqy?-G|fdF97 z4uuLNirUDBLufy9aHl7APAX}H65WI(HpY?_ld<)pVx@~aj5`Jh<3H5hxa2wx_tyjMHToXyYRI#Wh z#fv&3y8^tuf7f_%%Z1?1UN-n~)16|Sj5*#tdsYum0=oR;5ApA0*Ra2>?`&!>8#a=I zUE_p>Kk`aorE*~Igr1~Fml7H&FYdEva_J2jc&@#A%mx?0GhZAEfdTUD*^&zRk#Wwd z=n4Hu)*F|GcKU-{yJaEOegn5AU+^(6F8}OVz3#^y(NNX7{Vkv4uO~5tMk9ka{$^_< zMSezpmq@b*ld_bH>Q_OgziYpEQZ}e#No*EGKU*xch;_BSwwP!A>N_VjF&2Ck+5Teb z2X#KcEPrW9T+-`MZwMM-;21lMpQ1a3xN<&%K8fD-`8UF8b)P+RznV;V!HkfeZ#OPm z5MU7}*}hYAz4#^{^5ptvOWNh{A(KB(BR}<l)6@ED^394fb0^j5r+_ZU zclBN4p7-VLBz)6)3!?baIX&giQUsWDdiqX0fBnfjkz@TYf4P4bAFqpRPHyc_{9$}+ z1N?Vif4ZyJe*29x(Wy1=|KqRFHGlp3)9wAc`+K&mL*2<_5@4!o|4|b}Ig@v?!aI98 z$q@D1Z$)HDLYABm=P%3qdzKZ)R_CYj3&# zsS+?=<=HcsyH3Pw);s3Bv*IoI#*66WYv!aZnL26Q>jO5j1EHhT zdMb8Rnf1CN3O~il#xIFkFw!G{N&mp z^7$g+q}IT*GsUwrJw5#tJ5he75!VKRkOA-qdJQ>)e${#*UxS0$i6y-ZlT3N9RRc~a9s_mvf{z%@% z`-}J`-gngAt8G;LGTws2FV&7IeiyH*HEQFnuMD|bM1)ocq9g2}@y8$h7Y~xi)Rtih zm;eYY(BQ}*g9KDQo4He3RaPFPfUrn7k!xT7>O0(jUgNH1*|Y?*zs-^;&xAKVDRmUV zr0So9H)KN_Y+2_u`L8$8TRUhZf~6$%UIQqiQZPI?A%7yHR3yZx1RxO{k^#hnMT)?D z1>Hfzh`VFNBNV%jv4Gd`yX^$VBeN7ZBDJCJ%NeDR!p(FwR zz2~RRXCEh@#8qbEU@pHgYa1|pEg3vL7bkDDYqG>7E?w>NLbuwz{p)rT2g*n;MU&u# zaL>hiJ)o67sW?m)2X=x4S9QdO6&+-s!~IfwRme2E_%S9%(n(# zrAH^J227lW4bG(#h>88C*^us9um@dOpQB5oqIkby`KI~yB=olsTE2|0T;$-z0tBir z`R>|o@(q^}YF^_QUUyTk@fzn;-!HodC$@@D-ARh>8x3$g*%!vnw~KpPg+KoQjt}}# zhPZt%3p(hPgtFB7Uv?}>HWHsbBZ+$+6mqR`s^kyU^EC}>WA#D`!wcc$lE9W&N|xVD z>g@FN75{zmYX0rn>zDtW|Mv3L#miSOU;hhs0%bJ^S9AVzQV-*5KmPFZ%TzV+idX81 z7rWEj(|UY!(`0GGooG69z*9Xoyk3s;2MI608upz8FE~GOH1P6e8oVky&L3aAKp2le zH(es`{)yYTw}00~D3INxwqft>|I!P9rzO2-g}hlA8*Iw1UwV;pvtCgvu1Ou3$dI?T z*0{I7eHb=z?@U-4-GA%oa>ryn(DkVuoG3GA@>SCgPQX5$yn%Ob8{;6Bb=R%k+y8u6 zn|@J;iAuL>`Y?q5gauPsTz^Z;kLSbU`QHA!{^8#KpY)4=J&eqA*bOL4=08lDQrnUI zFAr`~6wABD3w8p2f!lf*M^2!T20KWBuFqf#(o{o*ETJ0em695EZ&P<4b#LD4yD#br zL9nyHn=BT&jmk{?PK2~s@8TnC@5=HNb{?(78i3ZyLwVK7kXk|p*p(f>FPL!o37uaL z8;U6LIuskra`OIaGHEu@Z<4cCI$-{$0gj#fM#d2jmG5ol?y)%hhl$mUhe7h7h4O|#_%NIo@f~56u#$&ijRV5U zV_ZB8ITGy$}a$fyggYm7yONmY|K*8^~QP2 zTa<+K;y9brCHG5tv4u0{FZ^v3zqyB~Rk^h1y%Esgw=AHJJfZ^!a_TM*>f~->;<~3v zKz8i3_?-hkENiw4w7y`c!3qB` z3CDq)8p3;fv)8EowjK#DZ9aqeMxxz(dk>MwP+s_P_f=>?1u}6a-^e$xPZ%K>FK#_ zF>0TLkU*LMjdN%75@D5X1zB<=`2xo0x1VRNRaZ$iHc8K(eLf^uRjXFrS5>X69}Auz zk=YQp&ay|mgqq_T^J>EQt0RW;q0cPAF!-I>H*ro$ykVwSBbjW)E)V!`&* z88oRogL=!(pq@H|CdoY5@)_p60>C)-K8%^-t$Odr3|6Z5 ze9Z90;k_QSAi42=10ZzH<$WAe6sk{SRC@7#A2S)vh{q5Z%PZn)pra(FXk%H-gdMjR z#f&UMy)LFeqHLNiz!6asqrg)bz*@$~rF$2{J?-r1eqKJ0`fT)V%&0?ld zB`E`C7H>N?9Cl_Y>Fz{5r%Ar@;liIti|B+EQjTv)?RmODBR74kh)IP(_}jOYz0O0{ zyYS=PS5A)=WS#;y5*A>5H8T84**J?lj=FQu<=l%FyM>P@6iXS zV3^OCNb814m9%10`vY)%PrCd5{5-z!qJ_}K5O!3#moG#pxn;v>YCmMC{g4Um2lz|A zSyK@J0(>HrmG(-37P`f(qSpt34I0$58feNum=cqz2Dk))rIdnc6+H&K^lGS5oSv?xLf46hN>QUB=({9bU zRE0rrD_om9?(EFvPfwd}Ed*3)+;?{7WrN5Hi%=GobFi~BSB8;X&y0YI(w&_-DCfY8 zEZl`?v$N7jQ7v7rI?OV6!G{q95&>rico!l=3AhxiNh4bhndRcK3B~{>kT4IBStvgd zFf@g3oWU{6vBQNnJur*D`Enq7#fcJ}DrXPEVQ#951W3 z7g*aV+(xj?2yvPj3n2_ek{~X$*hC^-Sa3OG%9*^*;5>^Yye?rWY-fiP;smp4Wn#i@ zq7#g6JjYaV%frP=1?k=i37>I{-ik}2Ia@afYIqU=ZAePP>l}RlPNi?e5S+mm1XZOz z;P}Yx-^1Z~Cl|{%j-Tqf3u!N7N|Vd2PesIS7g9bT1ZbU5_we(hKzWPxeN1U$e~a0r zJw~IHU53W&%vnOo$bk2h@G0DW=6#dI0_6n9$Rh+zr|L0{hJai03>s*gKHTwAd_ zq414>B(yKXvEqL1%jl~(%6-;VwCSi%VQjHa0};j+3CTcS+Hqw1^cKLa-e=M$3vHIZ z=}yihMtgmVnTlJZFGBg^vKWYvyg1AQdSBqiNExqCpCF_s1+IZU6CT59!HG{%$)L~5 zL*AFYdpTf(Ag8hF_1S>WXxjR0kmf}a45@;!&%jvaof!W21E$?U&}r0XayAlhmLZ$c z$8^zHO4J4?UzIef@Mu>^q4O?$Dgp3m_420lkTJJWR@hVp@r4D(;VI%I4sb^+Qz9vgszQ zPb2%8v%a8PBd#6HBB@QJ8(&;11yfFQX%8k$tdt|pVoCcrQwl9a;tELUfRAIG$b!^m zaJME*DaOz+a!63HFS)4!jU@_}@IG!PS_Bd79V_V*CXG_^8bkyFl7u5xYDB`6Cs$gN zoLMDaN7~^m4sIm$_;LRyul8EV?B={US?3}1ua6mUkN_i_sI9I7M=sAv}OjU5*)HuCjZQ_7y zEY=~@aa+YqNcO&9KZAHmqRW@#!}y^app5eU;i$Or<`&yY;Z!OFPZq#$ zU?(#%O=7^F4P4ge}~9Sq+ffn|A~H`#{Td0>m>HG z2!9>Nenh`s#C}S@zQ%q|zs_PmpkMD|ze~T~#QvCmJ&XMqe_`E612e3tUNfW2_AM#q zzt4-)EG<$H!JYJ+1U$_h4Ulb~qS9>vqH?JM#Eu3OUX_8To2^2G_;lwe8U^nMk|_!y z=o^{T%FWKBi`{xd)RaXGbPjt_e=Mv*pOkDwX}*NliPlw=gvd3y$HEmr>Tqj|Q@frg zB5*)}cBl;f!npGvjvLmN2)irR(pz*mF1D@n!q~O8&Shd-=FV$Pa}#NrYg*$b${H7{ zXNlzt%Rd6wh;vK_XDJ-=L7#x*pISlzj-s0ISW~9M9Lu9r(RWBtvmXO#U@4n-gE;2g z6Zr@U!I4f)=sf|g&^*g;lCB@X{{rOg5`+k8W33iGHSp&nu!FaO!CpQ=uc4yAiBTZr zX3nvZLuIPfy{MF~!hAKG6!iGMRsUHj_l4DlceQ1!b-by?QiXe_;E+)0bxHm@9Wz)n zvMfQQXK}UTG~v#)HRM^Jzu>*xNk!RQs6o&Ny7@qFYQm$o;T2Z<7o3A|&lA2v3y{Bf zCEutECVhnh>v!Q5E+~r4$yo*kFJa)+Mzq{&s_qe}(L%dT=j1V`{S9dgc9jK5Xmjuv zjHE<7v&v;i1F>i-0yv8&js<2k;X1?|P+Hb+lV!k%3RvAuo!XAQfOG&8uRGoNI3vQj zuz3U;AP6OZd{gSYFf;R_d|=^~xsa`2t40HRVrcNLfX>KO)`3KvW=4S%=bQ>$K#3$Y zvc7VSOTEsF{5YljTceu!Ij(} z$KF!36xFdNJl&??p-a2n1qZ4rIjR&(8{HY`+l#A2zEpr<<*6OB^AMx zIvOqU!iF-(l?78OgElC-L@*k~H^$h>)wOV!pE$-iG?{&k6TMAb@cN;kz-gp)Lc=Km z!Wg`QYyP9Ye(x8?`Fm#FpzEDD`fwRy5xkV)9Hoz4=@?3>118cGIuk$IZFInt7`7jo zG%3}2%Jw6Z_Mjq6RaSawM@6%qPz-aw1g*?@eNt$qY2dt_bV}uZ1$HHW?3QS>s|ofR?A@8qg8pwBo$ffb|GWuqkRmh6sWVjBgWhYhAfZcFja*{)nz>&ir@u~nyQRFd$eE-@-p^*{yU4jz z;~Luq-$c%p%p(bjnfoeouI=2?ynKk9LkTdmF+Yo(TbYF%bu;rQa&BZMu5KA_By-;LbOxyttD(7-U`!U z5MgMtsSY4^3NAl$iYf=q9_TfZevNd)pRdvI=Vr@@u8P6vOROaN*Jxj_f%dhDmdKw# zJCSJjtX1*b8c2ULkrHhbNH0yKKWnTX*TDMG#7aa}V7-!9L5!}Eep&IM{Cy3azneIT4hx(&5+^EvXsmn<{xa{I;XILm3#>E6yFQ)#qLHR+AT3O!MEM2M zqgWt)^K4J!4A#Jzm^ibsa}-OQ+eSnGrjh1rAk9pqbPNfk9krxT*GZ%8u7Nf((bD-P z&`vG1Mr+4wpiND*bnpqZ*Agx1d1obgK}U_Z7{0 zYBsmG2GXvHl#Y0T^sPkt`omj|bGQc1v5AxJo^o%=OK?>KO;<5kql&oiC|_o>ra|J| zB#e0VMtlsjF$B_-heDIVaUnjeGiFp*3c{sA0Y0-}dws|9nh31TcALOc|I79~SzK<< zv&9!y_#J+pQWS!s>cIM40#g2d65aB!0U9F>Ay8^;&r=99V6_Jx%v=x`IjqMboVy&# ztLVXx@|XHit~r|$|GZD4jG8CUXHG(n1xUY_0gV(8JZA3&ikH@QWWX$~ZK`VvX7>X{ zNm2h8O*lP9fi<%lLdv}Bp)d-xd{Rppdp+n#gRX=-GEuIseX2mU4#2WO85eR;@`8`< zheCz=(rbCKsO9PmqXf;C{5KHV1SZDd;w*D~hKj?so3RyM(oAlUSyz-e23KgAw=}D+ z`+#*_QGS>dzN5KS&8Ay+4R?1Z@I-kh=eaHWRhGrEUSr!|n!Dq2>~z$^+)J&6 zS>E>(bKiHC?E5Pd`|a4-ORcSWYi8fbY^eORe4mhBwyyTF`{14Vs(xqQn2nf?ozLn) z^HMfK>0}%CqldTSeSJG-W;?dW&P&yfZ?U4lu`v|44{ySU`X(HhN=}`z^G!A3m2ASZ zmZMsk@#xFxWlp(ZdT5wNAoXpYp|u`^U`*Vk&t zkS{AaRM!umtvFV*1zdJ=h~;zT8cRL4M3r;Auhy$JeBx5IK?&z-H5*Vem&q5<5@dUY zL?&c{OW;lw+&GzL!Kftp68%7_mqC>1jV$@dW9N0u@SM?lpM4{crS?ump1WkZ)H3`9 zUl7Q%mFxLLxB-*XbolUT3XN%+3XwXPfWhQoo0<$2*@Y8&J#DfLb2OOHgHvidj+Na@ zS1L^eaa@^4R6amA>}Ej)kCDLHD9CeQD~$VtvOMy>@oMtnrVOsqzgqD2#v7T;Dd8|iddtIA=OyUZzg8;3T@{=b=;#2{ zjEs=+eQQ$-i26G0`CeL^8Abj<*oP~TqbNqnr4mbjJbCzQu}DplE?mm_T>dhrmv%%s-UF@V)xG*CdsQ_v zUed|8-TG(Cp5PH%jr_Q*k=!&TOtWR9pUHN9lI=8&iFFJ29)9wS;@a!nS6&(MPpUIk z2p9$q1-*o!pFmJB#s>c`8;n-2#`S-54ux!K&$`NhA3Pz!S7h684FuVEN}2Mq(6Myu zxfucb1uOitoq3!^e%j{b70>AX@2d9{g@vL2Ip{ak6xGjPA?NQ>a%{+GPmjmx!)I=2 zjfe1*=5ysUEz$1Ie6D|+BQmhp>=wVf+u~R;-efNm*=G^9hY8$X*KwJ`xQcdotMMcT z7QSK+=$B64p2L_Mk%?2&Ih*iS%2qcZ-LwHGb^CnX4h}JVJvs5aXU7+zl(q&Vy&Cta<=BH4 zqK#J;jCCT%y2Fj@{PDxbdTfpLzs>l)s2-z<8a)VZw%(p54GT%pz#Li++KY$3>VLOh z_2}}ROFvD<1wlM(Xp=i!N01P&`5OPo8pz$nkbhawRlMXmIU+NBQenJ@)J27Du$;4och zNx4b1nmMjYS#P$^6X0ugcPztq-{#09K@m3UdVhRB&;dAmo`8 z;s7DHbK^qig1%}gdz?6SKQp(TdFR;$a>R_0J2QET$73f7B~LMxUDggURlnaoyx)P< zZy5kt+X>DwaA6o_^hyAyXIDY@dJ0C7#xjEnTZV%*w10MY(MAgmi~Wdj8Nt0|#Ffl4 zGoI3y6S0ie7vrvr6i$Ym+09$Mpg<>Jc}s>12Y2oaK11GaJM_bs#%qv+W}29VzkGq6 zvT~V|7k&bc#_C!G)>aD<8`wa_>OpRSAwwAl{{RhE8WnL@L~8@)r)5j z@5LZ~U@yY*gmhIeRD9Y}FI3rgCiNmK#Y8aM$Fi5gGo0?^(Zf4A*`$-mYZ#^zE?VHr z64r7ID@WH@^n3WF5NL6UCM z^eBCP_$XaIbSaFggAuC1P~nFEu+jLcwBNN~J3SyTA3h-chYm=uIv`&axiX_VTVt~N zDl=4XRx;JpJ^c3Y9!3xC;hTPG_Yd{EQns&3!UnPr=u^@#UU_&At{$|tvHseP-LFpF zKy|H4%{C`Zdr0@{S1viE5(6+%G(0#q%xoUs{i2?m`^=o4)3GyA*TadFPk;kPLq*}? zNBXTb15pCL*AsZZkKy{0jth%!rj_BHC6i8ad=%!aUMG*UFc7S67O|LlwiA>AHEVTxqox#??{| zlyNMG30pE+A1t8;y9yPzS_F~T7F7>ln>P<7F1O_i(p9M0t+Iht>T;uAKDNU+!^-MA zZhQ3`?PZmRaHH?nB_Hxll+51GO_(WeLU3zL#m9%YetV6n=<@#6XTn zrWDp%d*I*SPgrISQVOPi#0{{jxVuX&2F1LK;;1M=KHg=4j)eGZ#JdF#J@Q2-7Mnna zK4QI0M@`D~mV-3#8idglGtwF8qg)~Z$3 z9papxIOkxN%|1IW`{cOnAcp@O?y`Xc5=rh}xO&~_S^ZR{%9ml~^hM5yb<=n;^XKO1 zl*g(_)61)M&MhkTYctxuhM&2MzxHNVk0LYYqIiUbWoUc{Q#XHz;*R(sb_BbdP?7TlRr9ri_9|DCEy^5`7zU`6Z^-ve~d9k9*YDoYD$grOraz})!{M_o9~F%`WP6SIqOkyT<_02jn;66i-U{|E z*6prTRtBxq+{6W7;nZYcJ;WM*DfXzRP~F`nC<(F>fWWM%Y%fhDO&3z!yx~N>$AITC zly$2y`DE2)qGi=vAzv#RQAX4VlCV)hu)HC#p6c*lTkvGXdnM8s8)0Q*>_u4@ZUI0&j2)8kJdGzDH%Vb_F{3352*GJI1RYSy z2zbwC5E0L1xgUV^l9y3{huebXF0)XO8VBfB(9bU>9IeI+1I+W+ic2s(imH$9k@ zMhggiTtJJV6J=q&qTSfcNT}=NczaDSurP$H6Zn7cLkLRaoo5bhTaBq_4rseD`&D53 zw=&)0Ah~gbTglyg8VMJf-~mq#ttXneFMM)4V%O1i%oX}Y@_RRe^@EP$n(YE2f})&IK&rpwRd*omHq?q9?qi5? z$H;e8pmEcmXr)jvbBuhlUZ*FNfFSpxaB!XR{{C#_C;%7Oe}1uhmGR(ufiyrM_wXYk z#}JAlWE3!$5`q38umk=j3di+y!mim{c#19WD_TU_J*_Vw`&lkfehBK4I8}YnCo03Z zUR^ml>h5bfBCKh6`Sn?9M{Ku4nS$1oE45?EfFQz}@h}_%RK`#{TruIQt3X8BYI^h3 z-*1U8$F&zq9E*H$^c$JthA-F6e1UQAHDBZIZoXhzRWVVfWOsMx7b+IPdCbC#v?s#^ z$N;U8D+8^&Toq!B7?LByqp8KuFy|v!^Im@QX5TP zy=&Ttw+@DJ*d%lUmtx4;PahmsG8}MX7Rs@uUhlW4A}C`ST(XJ3-!kG`QBgo;n&&t@ zHjxNvY|8v2IX*R6EcDG*$1dyn=NC%5_!hK_Ln?m*;~SsfUcjJU8@GunLArGz<~?$t z?G9YC8<)cTPN14)WA^Klot=p+zhrP+Hc`<>dhlTkcdEy9z9^L=mFTGFOw3zr>QBy} zTxey&+wveoHk+J3)vLL2+QVb)dh`yjHa%=D#Z1Kgk4#3q+3)Pw_u|5p0Y~Wi z9{az-;(;nz;74lfMJD%P%f=D8ZSh$E;}07EuCEVV33Ejqk-XSAXiwqKXOc>nlUlw2ocqiC)u7Ql!>P1fA4@rrRS}B zFf-UO6s#b(FC*0o13%EW5mCiGZNwt-J|c^xen1UNRo#Nt%S|Fem#JXXu~fWKk!55u3LwFL?b7QHMHc0K zE0SFqChPYm1Up`0!cQ^CXc8Tm|EOxz^jw6(bR4c+{qaysSt!S2JYgB_G zmzw@Svg>LPiaAyfu&D55R`76~Ox(;cwgcIPNg0ADjUX!Ag>Z#UYgA1>LS-`9Q2@eMin!aI z|45;kzD@*iorv53c)IwSIS(%U&@ z>1Siv4F$M&5Pc@QL3Uq8+A4-nN{zJ$GGb-AF3U6-;FIqn1G`0bGtVgS8X9RhtN5k?k=diJyW1G&yn(Wf$0(5jZhXeO z;|v}@k{R8k>t5==0Y5RkE!W#yjCBt|&axKbxVy_*hJXZL6p=p{`@u^gc1-RS5XdJ- zljAqRh-r4ICj+Bt(TtZdIYAz8tI2A&bFMgY0od+D!a;2)qBgz_+1!X!{B34MDtftEooR-4JCn|2w3efa{HI3I3^!%ldnOYn zg|~2IknT)7{)}AU;x8K;(%#`0YLLEc&-tRUJui`k$X&pAWjyEEgoll*n?}&UDQZlD zEDDl>HLk`*qZn{8x?#$RGz?*O8^s_fBo3Tt~r7n{6`5PcIN+@ZTUVxkS2cNpE&2BSJidg3s+wyT=voj)vD9bA35_J$Z47! zhTcnBMUW-VTl{^AakR${>0^P$VM~P}Ll0JS3{P{i99s-=pU2?a*ac^y&J!0e-@p9) zU%&j<-~RsdfBlusaX!7~At@oz((@YoLUFA@p`IH>+PLD4fBFqww7IBh4vV8P1osq_kQM`;HQfuBMY3{`*P<*A@}mUSZ9fB*a!lx^!x1z@w67OLG5o%8*@u}x z0P!(F8BsKRoQoP}87zii97Z4_^2W}Q^I=vyQ9)k3NyDfY@z5K?(A5BqvM9}>;>H_0 zpKAfu9Q*e(Y*mt^ML<4##?CCO!B_CvMr;51x`=`7AY!l#R$f=eS+un8YqsF^_qAZn zT7Fx*79(ulvYx31ECax(E6^T{>Ah_3i>Qc~ z!4GQv@3<{L&e*5fh0FN%nv(_k4&$uLz29f+*7PETn%>V^5FK(P)NX+k@fk$Abzw`C z^P*wU=al)nk_n_DrOe+dnX-YGF%mie;@iuBF#p131$JK^WG}*XyOv-%b#|q3_!ee+ z=_2LRw;NK6X+lQ;R#fl|9EQb*p*Ky|#gHNaFDhdodP_r;RIHmXE_XFCL4vd_Q?4{P>(WW> zS(kRzMm%!zT9_=D9^&P747MGH6wHJ;A%l;lAZrd?cY^CM+Lnhos7zj_pwoe;IeiE5 zG`JBrf{epu)&LO+5(c7};;N=b9fn?aqRBW;OSHXi^S)Ltlya86V4}S49XRRs+C3iQ zVo?pECAz`RJ*zYcIAuW+B9b8M)6{}~M|ob(fg&dp%pXr`v%AyfS0J+Nd42;HZ*;Z& zuA9)i>exPn4!!v?XrBETG)F%M&33kI-5tXG9EY`~I#>#%;5;u5LvK(3vDxK$-knZJjJ$ur zNuh*G|BA|lAlU&iSjb ze4Ug^zHr&R%~Vbo3{#~|)wzVafHqvzrhoWx%LB!{)%8)AOnzXy+s4!!l{f5$von@s z^?Bu3y{zO4p*7}ytK>>uKFmI-WGi_RWPhk+lfK72lSDvF;bN*%CL71}j9omqB#Y@m zL=WP8+qfbr>7_nPVPy5!>II_J);yY}@wQusogTv^Tc}d#A9&op5B;wk7Q=SNI@yKG z^woifLY5V8!?cX(j{dx+0FR??L{Iy}&>KzIIE%d@`oeeF>kyV3m3gn`9evdTD-Rrk z(KEf02THMAjlZCu|H0;`{@gkV33~w_b*i^=JgbZMv0Z6P_K0UT?DY zojt|!LwSu#NJM`adS@vc@?wzE?R|ts*ke!9>UC7>q>{-Z;LiRdj;Wisi*l3##CKpB}3zzjLzIjK2P=|l}>tFx! zSC{p$&B&V(2T;w%0Ec>LfdABG$f;Qmln<|X81fKoMu~^AMxix_Yl!Ve`^2@-=J~q) z>#m7$-Fd}^t_j@V1bDIu@Z~1JSDOIeZUTIRz;TqrY8Y(Sp5`EP@WxKRE?29*y+A*d zDf(_5JCi!#UYcEdW9PCiFXQSO6xn@^qn~cjgjZ4~3k+C!Tj(R(r z)H~e-_*%YSd!RZlght&A{Rsf)@WN$>M5b?&?r*^Dxn7&wv%g70-fjZCw+Zkm0*6s} zGU9N|PivQG6z&az1S#g>G61oH_Lt;$f(c~5U6TDW?IM?Q?>iR43H2rIj)%mUJpB%2 z$A}Ca0F0A=Ptod&O(y#5zkh$u{{5TyZj&K@vkCCCZQ`eOc~XF9XV?tK^@JS!Z-t!% zl$Z(tdr=1l()EKVFVYOe$Y1L~KzjYGD>?LMbs(UjDO>Zde(MJPB+ZMc3m|X4W5XTc z0PqPT=X72{hQ!R7QIJ*`C0$|2a@;C;+P7;4)I6VyDEVz zu2HcUjDo8ujtV-md$lh-aTVQqDlVE^Pb=5Rt)~k@y)LlJhTT!A_F;YPc%j^Siq}Oz z+Z~zJEy;D6;1-waxT&v0p@WjaxEOe2XL}9MQI<}kkY|9pQ#aoU)_PQzMbT^1M5@`s zm8nhuw(h>CN7c)1OP&%3S3F+&HqLoBK=QU$ zORU+e?F*M(+VZ{CTp_7wyn+1hJ?RF3c3(G*-wOo&2@Co>Dbu&l^;_B`o?B1rNaK1` ztwPh5P^VhjG+z+mnJ*ND`L}Bq(Z)B(1EID4p`L9A%Uv#eoQT7u2ONsa?;vo`H@U0) zMml{TLn%m4K<9oGb%o*`m~{v#UvN9lgqUx_vx3j)_Z1JbvkLlZN~ds|qGTgsQrbBu<{9qo-zEmd#!_eX~<`V>>75RzQ_$^XcdR=@9|Byk+I;56@ zW8crx@yN?Mx+bakfunLe*?3e^9F7Aldf-GIRCekZ^YC7FPS%um;J~c`LDuSZB3Scz zry|(lF^VUUmQ`|1*$i>bqtLsWY&NFdpx}La(ns}ETHqJ>XcJ*+=%5EyVW6gqx zq$J!gNM4fgKTOGqbq4_iPXr(De^eFg3IP(Dg&-@ue!+MW!k=~JEjZK}^5yrFhbU%m z%j@1@qEzpJZwPnTSqE>$d77OHRtkuyitiB9)Kn(9G#m7KdJmd}Pmkq=VqGy$4N5XT z!k-x*#X*-aM)?`Ig7Iv{rFEA$(x`29F+%-dWe5egl1?S>>v%Y#ylEvb<8XDN-0Mm% z_25|&o>BYdy=$L#=(ykKxl}1W?5y31x0_J-8{q1;=I569B_44Bjvv{rvQozyI=&zx?f&f84uy&At1p-!O`TbO0!U zO8NUn$o}a!bjjud#(P^RVuak=>o8MM1Wl8cDYe6EX%EIrX-9wrH!eUW;{7&zzRf;v zvrpUX_ie^ItlMGZ4huVM)M3328+O>B!}=XI>9E@lyXmm~4tv{SHxo9ST;P>QVx`V) zPq3}0^c2|(OQs~E)u<1A`W>gkNvCSOp!IuKnj2^vhZ@G7Y2;9oT{cyyxzko{6>5O~ zmBtFk@JhOZ&Jfus4&e51l(O$d`>upc#FE1n#E~BKET4jD)UIt&qCNQW4 zq~%vG<$E93roX?F^+H*vdEHmgn%cdq5v~0|qWk+1s_ND%>3&Yx?;)E`*mYSG_Y=KR zfyKdRsM)ffjC+c_j*W_}JMx>nVE7a**oAkbvGeSfY|^0aCcxuOfMW!P2fsIVg1Y|DgC7p} zy4>=E-y1vQ?--SkMjn>vr%tV8-`dn$PfJ_YP$Rk@&}Eyg%Uo$=6?I+2yYSda>hgpF zQZ}valWK<1d@Idm2F(V$ow_z6J&mxw=e^v! zf1;Q%7D98u933@eKL(jAI`9MZ8;X%WyADyaXI&CI+JwZ)i7-?UmMAq0c!1gh^%_g- z(%qW+)}_Lm^y~0D{RoVvR{3#h3=W*b^^GulKM>c@#`OSetAUN7;vPX*rh<0b0r){! z`<&(G}%ym zHnB-yI!$;c1ZTsv*i?(Jk3E{E*>ye&y8PV=S8)pHWf}|G-=CU*8OPfesS8cM<1TzT zm70#@A{_>W)O6I=4^q7#84JU(D{D;SAnA`uf&!nL`rEsmDmn4dA1WH>N%+TBuxZb4 ztYL%*iP;F{)3?+04HGRK;1p~lAvu-~x{6O-Cv^tzHF*&XM{h{|V_li}szox1z#BC` z6NY#DNzM8Ncams2$HO<@trIeps~l&TR`fK;5<{#+{$C)B7$$%m09X69)e)tWkb?_v z5GzfmY;zNNb~r8DL>)L+r5$b?LmfCbR=#Nkb>PgbJk$K?z}dF)wB6Hz)3I_)gQo-M zdisEi!K6a8<-kc5{m-T4MT(X1VXcB9i~4<@q2=l2R9QzIPB(AUDn@5i^I2( z6R;Opl+U69BHaILZ7=hvPmG`Ml39I~g9cJWJY=(R0_wt~;SXKwNHu{M-*Fc>+hhs8 z`(Hn-Z#L=fGhF`w@>%?Yp{F-6+=)i!h9q3iruGg$nrcTbS0lMu#PN$SSqPF$k69}t zH>M%05ro%PE^p*v%@Pq1%U^95+jk!SY4OI2AX& zKYp8Uo_wdrg45ioHTt~Pgw^4rSMUW5* z-TSFrm(CV;SuQ`drCb%9-AL33p4SYTS}35|TRZxPL#CZz$;!!NRQ?Y~Xe+-^HN zP1J{{x{?-rz~1_thS=675H<{;BS6ZL^h9)>ot-AJIm)f3RNrsPwi#RYbW%4)U9LM; zq-VH-DUvSsN@jL>E22yPDIO3ibUn~@qZ~~kP6x$9Gm3+=-XNxkXS{91N#m{uA!0Qe z5hiU};d#i5Ac}L3+og10KbkXA>MsmD?FgTg_S=cq<#JQ)$+Op@NuQ=>SgqgbORmeT zof)n0aGZ5f0W3FnE4V^y@4LG#ehmXGNBHB^fkwcYZj?qCGa{WC8cWjM@&T3T9V1uR zGpLo;*xenM{ck>descEqa{ug%_V?S7-;IMjpGV+02G$CW2(mPeF{nAAVud>|22tLMLZ37IrK|HZk%FLp zN?%sxezEAoOi=wiboA~6SlzUR0CBhtA(B5t#X!v4T)a1UyNw5+#)t>Z2D*W>+LK`Lh1Bfl%tOfv|@jot^GA#NurPF{m`$=#go<5Wmev z;%_}EkGaEL7~vp}Z-~5B$GqJ_AES!}dz*+=5=Qwb&G`XR`D_W~@2#(Vt>P2MD;g~_$>M&G*5yf*6*Kbux34;ZQuBqm% zELetJ1Y!7s!imZmSdO3$7I!P{XJ>iMn2(_7>#@A64N%I5Ih0FL*p|o*nEN6+CvK@H*v5eFs2tyU|)V1c`!X=q%HBM_MCB>)3)I)Y98E-9i|6Jxx|S0Kf9(e`br9iGmSu#}yN-62TYFg0c z&Uwm;i<%}djRMNnZ#bAsBysk-#K5=QW#XWbt4IaOb>;IWgXc{dDj&sB7kIrc=7?af zhho>fp{uTwx#I4f`o9`YOZSPR)$d-8b^ZF!E85@iO(d?gY@74^hVD7)SD9;g;SxMN2S* z@C@i+o7%EbJ3Eoc2Kyv&(a5=dNc@=o!3~zs7?j5-giGLN#LJhHD}rnU8ONxlIxc%k zL($Nry-D?OD?<2sH8h6fyZ}^15G9SEXv93oi$f+loRg9iE;Qg-Fem;jq`ys7i7sWTC|^`hhcu<;qlwVw<`yQu`q9zkV&d_PC~QEEb!waAUP_cPZk#9G@VLo%0;d1R$0~jnCmTzHt3tW3`Qf(nbmH zzv)zNRBtED;9K&=Iybb{+o-){|9G_{Rlk$SR|WHuhbGeW;U?750cl+Rr(qkUnx4_;W0qP*Y<&zvTXeUs&E4w2A2Jk(X4 z)Y^JfWIDD48#Dm-V(E6-%7GEcdd4wdUW+Q;LTQ4d(2HtjnaQ2$1#-6mrX08$bk=T4 z#s#f|_pE*}KHEDrh^;tNo9=EcTKsl0y_HjSfv5}!N`iC8byI7iQ_024L>-la0G&B? zb(C-JW*w?&^^gLoyb>9=W=Y=kjYf(hj4LYcF!B1M%f8}L*pCFksjzP1Y1{g4x2@Zq zysKDyvXFPCWfQw>xkKB51Glid>HvK<@8|{v6FgZ#e!Kwu!porFQO`-$(I@3FlE?(p zASxQ%N##M5%7dtIcPTTKd=8$;Ul84#$2o5hL{?v1?4fJkLhkZnvA}jURDXyyj2uN; z*2dfb(<}s|&Q_lnG|P@y7}nJL8J35Iw!5{)KH9}vg+6S`!ZhJtu@K{sVXed{T?5zM zUEyA3Jh)IE^#oc_dgTikGcVhr=;0ed6$)W&=_7|yWruQ#+)Q+j#5sMjD1at z`JxuJ?)Rcp*}Qxeov(9-N1+ESM4^MAIp_LHQsoyeO616VO=tdxDL(N0to_7;>ti_X za>wbhiR=H``^lMR&ctm$@t(TuiOV8?Dacdr34-MCY7XEE9Q#B8pw|5EuGsSZCwF%d z{Rxb&-`zRDWni@YYlk6#13M&yjh_GOJfJ&f&t=6U|EVY%JMKbJ`<}jZXYTyf)akkQ z{*btP#w8i1et(3ZThq+xxo|_-a1MIw%zTes>t0~~Fx5;K^l`gxU^PLF9$+M)>550t;uvr~HkXSU*9&*^K*9s<+ z%PbUm0lIidE941Yw5%I|BknMgKh|!WLR~oCB2hqL%Y4PEXtzPsliuBJZAHXYTBPkV ziwTK9=6YG>Zn3kYPt(%HL5p3wTwEEB)8b`1PC_RW39fvj6q^nR(3_gpY}GV`hwjQo z3IWQesZ%f$gerF3UAX>;JJjjKDcoH{Ghk<@v|O>v4IJ<&={GF?0|O@FltofyWU;8< zelQkto6*gwRvNeD?hH>G-ikt&(zCqqHBYYs+!w&E@B8v*Qvs5kKUBpL2OIf-Q$w#_ zL?s>ymzTsWn0j35i>6YHs7Bkvo5zS69*O5|?pF^51ZcHd(ZZ14I{A{vIXnPUEwcm? zZ8YAVoV9weSX3yUd7-Vmj@0<#igo28ufUl@%rffgqJ=Nk@~)~8=BCTxk-;KYJO@h4{#7caxj6^*;c;@E zq|>CK)(0(~m8TV5)@by%eE;plrorz)zD{#Nqv`wd=4`;F{_=&Mxw~8G-CbI_+spT8 zofd(?T*`o>VKar+Ip0YqV4Y{YIJ?TLnEw#&6{Q+NuhKX1>GW-KO@h2sg0fqE=s{)j zp@)uU@u8R9%q4-w*1$bpK6WZkB6&5*kfhmFJDJNe`IOcj1J>86lobyQ`JkQwaOb%T zsus)Tq5vYe2#aY{43-E|C4M?I#T9IkCTHcRidD))So^>Seun?6e8R(5yM4`DNeX=? zFK1UAIZidrZnvA|1~eD0Zj{BC+^}Il62xM`^LWcRkMHgZy`7bs5;eP^^^yCTQ>a}n zS#iXSaB^1Y^O!}{WnwVceg3jtmh_8m%a$y>?Kxk3F*{x65dV;#TPMo*cmE+>wwj>( zE#Rz23CgCS^VZ4*4Fv%W*Q2_z(a2J_r6js=qhVc68?BKv_^jYbm=n#z<7O9*n5vxH zo#*2b&tNGF^r75pr2o_uE0pw~ghokF1b!;T&8UYaw0ufEOoTI7_T?0S8hB!D&$IRy zudzMP7GJz(bMb|7CAu!3u66kS2KeODM69w^I7=5`UWf51`_iP)EXZT&0b~>K@x>P= zRlHHaVYO4}^DSxS&$^kk%vSf@Qt_+D_!q!PaQBa8IsbMnE=#twLRyAmMgS7J7};9lcp66 zB_o$u8*~p->t5f-AVqx8)l4mWd^nVN9}?+V zkZUELBYzSP&=j~>ZH_O6cTrOOLohatN}!5t(Tf3GzbOACIe=S+Fj}^;gzbszF8)-Q zY=S@4iL$xm3mDC{wa`UbH~YztOd$Q5Gin9G=Rc`OkP2B<;j*HHOO6^n`4EB%;dGhm z*(VcI{T1I@d{&@;C@yt9A>{?96ws(BT;!Z<&0Yed zai`h2jz%MRX9K1Nn7lQLK~OZJ9JghVG)ULhYqA^3Yq+j3oL>e}%tLs#U|R^8+&N8l z!>-|EoA|bXBJw9%9%0EEZb;T}EoBW8Q`TT`Fs_A20Y^nKmfphbhqUHzTzE3}oVPBM z1s4kw3lzBZ!_VS^dzFI!G5=c=$>fJc8`_q^I^M&>NWXV&ZC!Pvf zi{m18fR`kD(VK;ML}V{Q z5Xq^3P=PQbD6#C+|4@NIT1)xSssCzKEAd=OPyPFhZ1Yopshw?5%G0K{n!KF}@+OV0 z=u<1+22!Urpgf$D292OGo0LJT5#R5Io zE0im_s9vgxdO|OlhD>T8xrV&J<=sE^=jcn=n`=qAH#0Ol(LbKJEK-$@mSu0oU9{dd|t2Fl6l+{#HvMmJea+IZ1<1g47r> zY9&1_^ztZzx6E1z&oA*e6QMO^8htU^QX-K)q|Ohe(TD0HB;oG`QxVNooc38urd@_n zcnRK0j3Di$+%Bf-813b3977qQy_A^;6RyLvmoj^h2+vA%(&)|AA`uUZQ*cB5@`J z1Sd0tQkXcd%p8%#;Iw<0X8W_qDhSE2jxWLQ1G|pm*vy6Rh2=z4H_Awr?UIoj%DEzW zv0&2gr#J7K3SZu7-mc4k#D!Ghs+1zRP;UC2&$iRxFbx%Eq%btVCuHwhoysu?o50Mq^fQo3UWmS%vBH zA)QyolDI5nf4_hy^?R zWBPOcY;sZG2-$r5*a4|8@l%7M2)YBYhMm*}Y-r&ilX&?GVHlpapQkO+FhIu82P8MW z6P{lzT(1NLRy$U$*7NOb#Dt`yvYM5+i{yS;s~1GElWrh}a2LC&5+{H~WV&2~t&nQ- zA0|$Hdt5PkIP&62DGd$MmFk;?p`Q6}WRDKZ*m=bAizTb{X!OUg()Z~D?9cufLn@_V zTANQq;)M`_s4ewAl`WSF3cGa7f@x?6NcWzdU1KNepI@kbsJORwA*r8$sQv3q&Lh62 z6$QpET&YnT1=cp`?#@}24R6wqDJ__~rlB$9*ENO(@6CV6s32zl@ZW4u^vmi9pHkQ# zgPy(^l^$51Yr(z>FnE5PNdX+`zyOC4?l3|U*Fz2rx^f7jc%1QF$?`EcaRg)wg-KLH zh+6p|>VXYA*mEnvx6llrYK6@NiT^RFDLhScwBto_H#amTMV+b*vMObpHLn_Hspcrl zEG0?jw*F#Gqe!(xsJ%g>7N`r21Y6C#7()|BFN%Rj!u?;l(u<)Ex(hr2>Uw2-C>u60 z0Y2Z|uxE=!LU$=SR-$EksK~h8%u_Qa&D~|wX&BliR}z9_=XzzeFHQ*Eg-Ir zCtc67tN^+$HDzR1X>O#I1()wBFzc{3%CW&ln73N3WQo6Gw6CqEw?UO`N#2D}O|)(d z2(A+@h==t&l@}+tU5k_P-b=NB^N%Oax>qTXX3$xxa*rpDiOoeR`LntXRgL+n{<+kf z6A4R(5c zd5ntF1%fd*wW(6UjOffoOMfiDaH@<;5>O27fW)36Sd~Ufg41cB3_*y0A}vAuW^>mN zoY*_UL~Uz&V*4#2++1*W(uJSxrgGq@G?b_dxwf;DwuBeG7ChGg4lZ2HogIh&S{p)^xK4Lffr!Xs%kb#rOv|t_2oqA5rW86Df6&gnpFnARKdxdg5@ldPwEK0 z$q8erNSR1+^Cj7~zMR1I0=O#ZPSv;6eqb(du24IorbVi0u4`2BTdJLL%@_7PPWZPI zn&^q^@^h(;ec@;7B8In#;@UT51+N`TTEIsTgh-N2%r%)O`N&1SeO*2>@PiS{xXZ@i zi3cM30av~b7`gQ^V2koX0OU40;Qe<;uHgUIArXRrG1W<|{yI1z1PDhiamnjoh?_kk zR5_PzLivCRrz8ZuGN>glC>OCM2E&nS%VhxElFSsPafH!0Rni#%FG*&SkpZ1^1zds! zflXGzErc~oN{C!=!40K^$OvGB&?u!0ai+blo`VCcX>c(*=F|r*`{-LRB>J$RgfwJg z!r`2mP&A2`*q{YVu6@DT_71pH7Q?XHku?S=AbBArCRm^X)Ys|G@ zBhPv5td!_{4a)%>p+Jr3<}?(0-jLu3P+Nw>PC}Q_9rmph<^W(>jB^(1P;w#?w*@AI zI`2YP3U=zzNfFN*YM_wxNLZBkSn#&Y=UM2Y+@~HlJ-;5ORwQqa_zEL6Zn-~yDZ1F z=-QC=2iN-i6c+<=9{|skQdK{drY4yb9h$d=?se%-qA;u~HQuyl61STu;`=LBh2R8! zBe`aQDMZsguxv{(X+p6?8`-d}C{LrUoXn-}{^wNQ=%kJ^OMUs2un;tvS(%qffos;1 zf>z<7$?gc5W$5P)sYvbYfDq%B8?KK?CC-i1gC;eN&aur4L(DKY-d!~{8V^gC1R~%T zOg*K*ACuHYE|t#h(k#V0kRi_yhCG>zc1O4Hws;~G8B3^?$&YT!0{Qe4Qk&wzHB7N~ zfjiN+x)&4dhv8cF?#>~-k~bsvVuJ02yR+qF^Yr5dr3?ACEG1?M_SxujG5VK&ai+Cy1@ZkMm z8D@i%pcMLHNrQa?(LxuDC-!7}Ynyto)cC~`(Se%^w}wxr{Fs}5Rg@JYiS&?%EsT!z zEBebjVQ&T!P3cRMkTw1c3fX^dHBNzD76>hP()7$4y)4lTifMG98SV`SuN!?{FvCC?3#?WiYmR>9wPSXTd5=l+j_A{-1Ytg36 zq)kT?!;;-5k|sMUyf#%i!6+IW+2c|3Z1l&IduHXVun`j$dmkonK$Z;lOBQ=gqOXs{ zJ>U`Dm>y;6xZoKW>a99rUafz`+#8Uoyy7Tsk5o*o%X#pzL0PLC8%kH|j)Hxtq|V{K)byPMy0dPMs4 zDC&-urKbm+7?C(V@Fb}@Jpy@p$XWuI)!+=E*J4!iKl1eWyz2B|lT+#Y**Uj(*dm2kY2?RgPbkxmUx+Tgs^`v*qeo zZWk`=)pkU%+3Hzn7Y1K!c&*t3;kDzo^x3#_STg@t6_7@AU!6>Hd;pPkGF94!7%Wbq zU0OGzW&(tGSo$^~0~f{9ZMQTkFx#o1D@|Pmo5}Sxk=cmK^Ibt>bA{JHB$i19k`M8pAe;W39eLW{=UD;uRC#8!Q#zR_zf8yr=_Bz#K@C}aqA0%_qKoFhJ+z}GZguaz-c~BE>Dk5|v%O~k z_Y_{1ukX|J)=G6+*L!&Fkg~e#macgjTxu+TwQGeSv8C_sLJQy5MqpVtxC=u*zcC8D z-f$G3)`{>p#)tP8XpK}f5_eL<&x5-LblhL;*-_R=1 zMhpI^7-J|3K27{Lj`08FG$%Ru3~9RTsmnf2%$)8Zio;9cJeUB!jZA=^Nd06L8wJGx z)3LJm_o3Y5bts=(#Zr-a#%n8Lm)v*22*mY&J>#vfX_U~X<_HX~_27u=TcbEA!0802 zJWr$KFF!Zi=glNYQuO=N>JGyu)8EYt@7(;-ssw%HHdyM=3An1+<1Idm@*?kmRxNk6 zuC{#eSwV{@2GatX%Y>&X*<7AXTF39-T)sFwySuvtb>Luq)ibjJcnlE zobFayQMk(GQ0(uKTa7TvMBZ-P%E9-i6S5w-T5rW^H;DOOI)s=lPRNAu%{&Hy(5kl8v>G|ps7}X(bUPa&rUqnF_v3ATpy+1j6`|R@IhrA;%6r2)8o@O*s``L zU7P88bt@_ivT2kw+q&L_HIJgJEXZz}tT{?XLx^W-WmlRmpqLGHHBNMU?X2Y)qv4}| zoL(XNQdecOx70@1*%76ikJC}{c#sbHRmP`}p&)BM9_QI(Y^qEEAe|sXAe94BWv;bS zxErV@LLeXQ^bsGLm)HJzv&CmH4q`L5u7aX@p?CF_v1Jk<=nlT3$-$N@?Es&X8=s}I zxL_OFis6^nQfrEX=lc-!d5Tt%9;H*B?FAqpXn}n!3IsEX@})#msbuo=^CuU=Y~c>26rvr9$bx@K?~N=5)X_*wnnC$&uHb4x;YBNZEZq0T@u{pYvqmB@5;Vy?*e6 zes{N(%YjNA47{tIdk4PELo1HL`SO3hxVx#dqE%PdsNR46Z&iVgs z{`tT2i=Y0xb>1Cbw7=%=V|H`x|LvFmeEQ_)U-S~5$(YEG73B8gs8ztVK}W2%8-wS|PslLEk~C~J<3-ane9 z;MO=B(i`BA+^$_yEm@^mDNK>noU>*Nql273y%3A!W-WOp`{?*gHCGz#U75Aq1eH5vJp-m^Z$S(}IJ8&t#$G z-Kxo7nvmZ7LXGCKwNDnx>AZwYN+3^3Q}X3#w?sI%jSJk%k#cvwv*WxR)lkxe$lR)N zPXyHyM(9K}OhVK-*grbDyKBOk_V_j*G$qW+l70v+QD?V{c;X#AYwO&-sRottzbE@|<>%8JDDy$)I1KCPYDs);Nn=y&P&~%`gI&{LqTl*PI$*w;+-&_=0p` zdmb&mEQo;7fWWXx;`17GoXUgq`-D6J5ID3yO2Ty7$_MEbccIjVY97nM!Of?<>9!hg z2fU#;^U`^5-Y5pfB>>A5Y2!+SbO*n|ttM+8(nk~GTIdgBwx~6>T+fk^jblAwyBymL z7Tx$5Ml054G8)PX8;|;p|A!hB#~gLMAL|KcTTg{1^xI4d5#T|1N(&7=3+|G%5V$-E zk1>!ne-k7@A7iqQNbi~AOYf!Q1YE@qgV`A`z#QKH>u!|45LTn$K_d?GqEm!C%fQIC z#@RM$m6%nU(eY%>%0r24fS+1s@a|lD<3zwX7$@CnUWb^OqzhJ~IQ3v6Dda#M zG*1QHnQE(A!3g4H97PFtzGxq-(NX=I^SgN{!FHO4B z_$Oi7scb8~Z@}W{ z4QMaGfU(tmSw?gkQG$(ZHC++y4Ct8Uvt{}Cptl|9ZfR|%i^iizL3i{;YUUXv6t0PB z-NkF?|Euj=yW2LFEkBQc1&HOv3s^0PO5!uQU|?Q{b{r+Kt;lkGa%2q&kxdCE2v9&% zvP@xq`>d*dg8(JjndJ`_fkwZ(y1IJTuI_)9KxmDNXc+ySD_t@7`wX|Ui2pYjKVA>3 z$Lqh*`6O8YZ%Ord&0kjIa3~|Xi}`Rpb^Y7hx7&7-nwoX2aGQH4K1apD`A>$rXek5Gvk zxQ=&~#9W#4U1VREC-OB{vqVY)=R!t+f-R|3@JCA|(@}uxpr;Baykrvlq_dGeDA_b$ zvD@3`S4q|+ajnUyWgB;4g1;~uIg1>~ClA~Qi8yQ4)@wzJVi17L00wxM422GM84UxL z3!%J4CVS%(ST{VQQ|IyeQpsOq`|)ZIeVt=;W=ps4pV+pYbc~KWwyjQ{*tTukwmY_M zc5HXpvD3lLxp%zp*?Yg|+*)I;u|BNv8~-_L&JR_!YL*Lwx4prN_u5&XHzg;>Y+>R1himTSYLH`E{?(|k8Sa$F%3rel2Tptxz*aL@G z3n{q+7;X2i6q;o{5p1GNnCoJ;Bhlj1+wqFpy~DGgYKBari)-GCa7a5U{>?!-8giL0(3)(xCWV5~AmZy-?V&Ge z#mt@rjQ+K={U7^}8yg+JfH z2;0EISUUv6(Ho0YjsY$sw}I@ zpzTCmZ(azi`#slT;AJp_8&r;Qu1Ai;Ph0^Fg}W}o@+*u(1;}P{@*;1DHhb}mD>>H+ zz5`8LosI3cA&JbWXG`5*ntDB%W6x-}YO-rJ@won`0>rYTiQ~x9jVhc4_mF#qn>EhQ!v^S{S(kYp)4dWLx4-M4yn+&`@zCEO4R2h;% zT`^l__y9|zQ~iv7gbr27TKRCO^(5PGAH?v|AgUl4aOLa3WhnXMv?i$kbcXmGJWi@$ zx#*yzIj5+*T!~VY;w^JP8Zv$5NcI#{zOtn?&Mff{{8rC1RBo#fY$%ta3l&TFMy|Y{ zu-EV!!B>mj!iYz=m*BTrrZHGtl z6=)I2`_kV2*5(-yHAgpLotH9P0AwU0fiCLV5s(@iSrUaq%Krh|hcYl^*tJ74nZH6M zc(S$KRE6}F(FWZKeiX&kf!t#RZGCF|5pSRa`C7*x=Nn8_O0GlMGoR_26r4S6-0Q2_ z9b4nnfc};FL9$)(ma{9ycKk>{2h5qnM~WB;8|Depp_~Jm&IC7mR8=0xX?PwHr#aX) zHjf{tQu{-Gj1HBH++V<_uY#jhuPk%X+A6i>u zsIfI-_jIaiRCW-ObWJoNyXUnts)0Hi@iYG%@T>BNu|uG3qVFdYJY>eQkBn%3c#4Mn z^dP;^JZAmvg!=Z&KBx2-acha`r5R9$0n&kG6rzO8IGOU&NhaziqOt*EUljHmt`A8C z^N1)s+^rbo1kHe9ffQn>>X0f!oz!A*u3vv`$`DOqd50JQkf~Q-RF=nk&z0x$Xc#r+ z6=9F6j)fRk55oUsi1=`r4%2nP9l^N7P}JpuW8j`g^>OLUfvxXP>U8(coVvcmE;$2voRwd>$5TYBs2I96>OdepC-ojDdeRoRK>oadBDAzXX^yR3RcP z@q6@AEAt~HB}$HiyKi)nqWTBH&X`ta-qM{I*H4RrUwrG7Ou{)r<}+MZE?`-G#sM3N|S07!oKD~2=TMbTuZVB zV+tK>e)sjWJBZMJL%4CU^W0qyxW1zJ4hFzpZ0 zi($n2@7mPCi!Rp~7+a(JAgXG-)~XwT+_H^ezz}zZNn=c>Q7H8@-`?a&G~j!gwygI; zC98HGXND~(p9aBIpH+97%ldQp0;fEIx9s>gP)l?Ny99am%y<=`X;V_IXW3c5o1{jP z$V^hKFic9-;LK0j^pWDnpY?+2Pm)qSN>w+F=M!IasY4hWDwma79*C{(wTZq!Ja^eW zE$un2NvKt!usH)&)ZVZnkxG9+@g$xI8f6${73Z;eLl0jC$^J zDa8SL(14%mNipnrF&@x@nO8JtraEwr8MeOprpRUF7L8rjVM=gNaR`jTidf-kYo`4g z6pFHzG45d5EssQ(Gg4x6iVWc=WM!+G3wW#r$;on{-6^rgsttp-(g9Wa0GBs}le$r^ zT2&-7ieTwTN0NmDmZo&Y!%Lyw-LV?-C`3KC+d@|*D$DLt$w_U&EYNtQRB|7*>k(<~ zkhtd7Ob0!m)30-4vOrIE1i_hNmi3U7snyba)?OYUr!m^mVQO|FsTgJjnFtuHDnmC? zEh6zze?H!BCb5y}t>)bhwcUPoxPr~{Dl)&Qb^?9U(V=4c{JmSxmp5FRCayQX%PE(L zu-nb&)r%=E!9N-ITzKPX;uqW?daJm5=Z+sMVHpJ32`t4b+%05B)sBxsd?BM3qB(-OpCU3$MiCCnqQ$mhULro znSunhxfSWN`EP zWb0@3-P6_p?1t*XHn*y2_ng!CBrOYjd`u*0YLm{e1ePVDwyB27qvX&Vyy9h;%y{BR zUl0(mA`&B1j5gNUoCI!WTm?`m3cwOV5GB^QiOZe+?w=*?*OCT49~m%rxCe&(++*@& zOAD#tzonI}--o%3j6OG5g>Uk{XL@{v7(vaX>&!oW{wS?DQbbh6TGkzsB)JH3d6bt# z#Q>#VCi7FEE(E%t4Jp+g zM8ftx^FM8ST#FBX@z#3z^?rUH0U6nL*E&UF_hzKpD~axHE(#Tue;Y7zW`vGz&pw59 zxlPo0^-7}``AfsX6B-6+T)n&izn2|qHOa^4 z+buC%v5=@@V+Vg82}WY|$|}bVV-MFyZoyWsv+4uy)r0yf(PSGOuKIf&AYtI6v79(2 zErmvrgfZ52gIC=$sG0Ytfci4N=9@pzL`KG!pU!DYXBuxrkB#&KZTZJWA^~<1)nVRX z;gao?(TE93r(+fFX_D~7WlL2hHPJX-Qn&j8?l_V(Z5kGMrBVs;sEJ>vLmu(si$ZZf zlWuKFGx_mdlmZ@^qf;hdFq*4lxyX=F9#~qTQ*XhW4kO(pN%6a%D1X0jAuJf?TP4=v znm!H@Z?L#a1(64d0Z}30SU`~%mn?P~txLgWPDnZ^+$1t4Qki%&8gK9y=nC&pngXcQ z;6xOuVQnkdK90DUUAOE=oXSkP1BNPIg%t27k6xjDu*f_CZ&UbNU1@^yAS^1(^!7x* z$k(VH66m3kb9yq;+&C0OLy~&_{tamTo93mR=3q@CDPk81(j*F=8{N5cN*B@#I%3xX z)3)C*zS%LIgSMIWb{D1_m3;9)7z!^yHq!Cj;E{MVIgP5{N_)?m(_kh>S0)^hl49gp zo443B8L%Np^ctG9FVGMIKM=7Vm1nW{;zgPZd-6ym*qp?k6^p6G@VJHxRM?9pDxt4# zv*M~%w;Z=@fV^L!h`km;jKDdd%Foc#OPc>s;e@3z}m^= z0joT(yZDpCg{-&3P4Gq*Y+6VZF=}bKYyTLeo-||Ebt09gJYh{u1(jh!Z0~OM$>tW? z`%B|eE90ZZv`-h88X*Vls7qAe%^?EURFd+vZro23L7>KHK}bPaK6haWp=+t{Vwk<# zS16oN!rOx(e6|?*+hg&Ut|f$+-`?fG>>)E5|D@seV;ofd@cC~ZWDE)M5jO?c_A-#{D!A zyFg7aqHgxtDzh5!#H6Wo$)kRroM^->HukORh8zTI`NcSmi$$cXC;gFvb}U*TLpb5u z-@g)y3H%LHPGD+;P)2n8x}QlQ`lmg1l&7u4|AlY-XI3&`j6)#W-Rb~O;47~VRqKeF z=YHIWF?dHAj2Z>|NTzn792txKFS?rm?tT_m*bBbTMLGrRteH#y&V#W;-YY$@h(1-R zi7qO6AShDcZBRMUj_(hj1KKx^Io~r|V&oq$!E%AuT)|MK2lqTLIqw1?!7+gwxLa;T zonhNpk|NK>cJI!>I`ExkI=H@Bg=Gq$Ny?MUG^nN2sa8{FZM3chqLA5B43Vm}QwZu3S%po-e zi}Mxu!S_a5_*v2C;1yC%ShN>4MG5d68A-wokt#27g>R+>3*BKKN3U+?_JjNyLO~dx zreAf-$f=8m6$)Cx4+!&4eiswh zcKewfQ+<+t?JjAiS}e4o*?c99tNY1_+u1t|4xnkJ6cBVA9L$Monvs?TFL~IDN+y^7 zjpMn(?n{n^N>I*O+E+Hz2WegeR9VetA6bcJ(T@Z$lpyjzHq?LBXEy=7FX1( zqR(A;Wf(n6=(P=P&n_$o;Z?ZB8=O>i7S0Ua=2nUBLE4~+q&@=Z4Y!G-BUOjz%M`A= zbsz25*)ubWbRCJ5zS z&%m|QDQ|l}NoSFcNPxJ=wwNi>p$^*{`dr;fde2A#s_LHMN}+^XY_n}kX4E%1_|c)* z`Ia>!@(MO#n(N;NH}-nFNbezF%zNmlyIb^Vxw<72sGpVyTvFr&-GeUbV$9^qGfHY( zZt3^af1hb*M;vub8o?ejmpv$jnglSOSi_xl}BH&&3 z$`nzSz#76KuH~{bBd7WnTtSn+e@Q7?Z)A5zVRj1c z5vSTWMQiLEQBtV)Y3^v} z>@8trj`t?c^9I0R=iU`I3@PX?Ul1b5Av3ypRH+HO}Ki*Ti@ z-{&+s+tF*-5%LdpRoe-r?$QRckXfpvtdH~86vo2vb@_pFv7t!muZyhn_ZnrK#4ljF zQ2L$9%mjE5<&)#P>!`WQO&Kb(Xm`7nTN(M~IRz&V!v^{WhsC<0`=%V7hH0dfTS5H~p}{4#874Wof}bgg)MoNt;XLP#0GCxt=BcRiEAelh6fW^Z3g0~ zU{ug3N^1#^nmlOka}n~;UAFCZCYjYWz>8gnWQV*Y&naAb|JylhvllGw%y-a~6=Cy8 zg8W2H$Zm(A5Txp)Z@Zz>4M(1#6E!FTHqJoKUuo8Niw&k^iCjUK*9mtN=~*Q0COl5< zg-|=ZRjSJRpcw(e?~n2o;TP#=QuMENhR%<& z7a+0tD7xb{3$+bWbKzGm+e?+b`~$8S(z@Wei_pFfj9ptA8-2%BRok=b7*?8X zTyW{p&4VoM8Qu3`c%hiGW{S#*IAb)GJC7CEZQZa!^b=LP_?Am*IkNSOFwVD97O$s^ zZAv=aRWD;}tLht16%p3>6h5*;MB&AqT{UiJNP${(LE(k64_Z2{h`bAKK zuLhWsCzp_EWP9U3XOTpE#Aa*>I1~gX#FV}jMEAEe9Y$j%AaNHySibr__SlbAJ}|Fv zmTi&LS2PDkI@vs7)-jY%TeL>z4QDhKGxH!kHYWA-7bq<@$MR5u|Is>~(mb{PI(7j4 zthV8xby{Qd=*Q}ZdXhW?dYu|)8s#Wbh?eVS*tTnZIr7@~1|3FvM`W{{Gl`AXJ3zdO z^bUu)>1)pxrS1XL+HV5Ob1`{l=gn^;v$*3WW+e;SWK{@9aEJ^d8+2MbA~HDt5>m5a z6fbaMujc6A)pb8Dqs}m#w2gIpW}|&zHLqQ%^Xm3??Ax|Bu#S-Jd|&3lia2sMZntv- z5goa)?OfUq)2lR=zO7!q*62cg(L4$69VNm8c)1@M9S1^8Py7I4;EM$4_;$v|LAo{W zPz|K!q68vbY^yke5jH4;*TjPn-AOxy>>cH?uNbv%Yh7bsGnh5J$WbQKgkZ+zu$Le z5MoANuBoWA;4v-q5KKji`w?XL^8yGj`xd!Py^IMLO@ujkb0E#4R=+Tmp{$sOKT}b5yF68$gyp`ZzUe-8E%U^eFUO6^sAa=#$Ct;sGx6h=`P=68 z<217ej=qB&1xn(A@vMBmqecdSm%e^xG^>@z>wM5MShR9H93(YXTOUA}dXk>OE-a;& zga(x#Po2ih7>7Rk)$a^5xuI`UT2!yCz(bv?T9_T0oDQd{BqkQeTqO>S%P_yQ zi)*$Ld1$Eqt$0Vr%&^bON^3}SSh&(sYxJeE^ZW|d1dSj_MRQ^^y2e;Vb)|GCxr_&M zi1X$%DZ?*wb+CfICO=@0i7In1%aoOqXAz zU->~bD`+6h3Jo9!1~>OJI_voxwJn-*Kq?-!lClsV)t|OB^R{->My`iU=@q%RPTo9%w1H`L6bi!+?tE?QFp; zoaxvI$r5$uq^MRqy{g8Q|C<`VaBYnKHJIgWzMIQJ ztW>)DqKUj1rOrI!copX;R!&M2w{wlbjELb#}FUoy8&x7`11Zb zQhOR!a&zg%gJR}p_7jhH`bbJ!ZPu#q*As}OnVME7O)o~>%N{TUyi^A!u*DNPX6@mT~JlSw)m0#~Q1CR*@v@%i(d9d$cK>n$Fy z*E-+7Jp@8v6X~Wdt~>-9V_ezI6Q?{x7aE$u~=DSZmpCe`z;{>B#*1EVcGC? zMPrx4)#X*9`}!Ye(7CBEm1#=)4d!jXxS(J;azlulZ>h1orA$6anz{}V-za!)TbB&D zOm^oVtHy79|B($D+4&MeI>E}o=rZJdo*mp$JZ#|=o-^Y-y2mq)FdlI~a$f}O7LI!! zIW=r@l{3rB>Gh~7p6-p5D0-c{>Net__SPjQm9(PUDp?3~T^^e_AA9zi8=L=@aGQQb z*e>kLy7~UILgKTK--Q|2U017kjkmk8?|0RVQ9uZ|u?A)vLw@|9*Q4FY)Sk|l`<<5U z54X3&^W9wW%b4wt``vBd4=TP)nUmkIq|dDKSl_!l>7<(5+uyxzi(5W6ANhISmOtLF zd$f(`s0(y@o=*N;z6?#rGA)D;S1Jj z$>gI&50;WznU1Mf6;&)+1y9H=FsKy^t&4)Dfl$yLuN7x)6EN+8buR$M<1*6I=`nrPQlZ-FP<7?roi=F#R75sC~&>)=b16%30#M!DSMnBr1Lf>O8mx;f^ zXa%!mK_Z>R;7*@jAQrX#$bMZ1*sILyGYwxUl(NvXu(dSAZ-k7G2-B{<73E|H7e(h? z9ymJ&hCg#wij_EP!vbs&QK|fh(J~j$FbeOhzY&6?!dZlTu`kl#5UqS?e-v%NaFzB= z&}P)7u2hNka20=+THAuSiwI?DVT{h4pVk|*ZYN-;TI#!c;u6R!aE7&66m&uJnEA36 zRABslP!^Mmgd#>3hMbK1sF6T(kRsHJj*;xiL4CzZk=VlkD*Ub?HvQz<*Hf!-*0^bn zyth$BtgcTxyT3oS?%sDYUgyg%!Rhu*)*Un3FQDy7a0%v40jea_5~GeDYgo!^lc1|r zI&Y5kVx1Ppph2*&)}f=f4C(d=Zlu3pp8m9Qj=X;vF#nb4iFFfUvcoHqZKzpaUzg?> zFH%$sdqX>lXGo99iU%(zN0L;~Slpkjrlk6YtGF-&*@6jK-`*8GdcahEacpii7t} zaPwfKdPW7>05wBM$0{*kU<)cj=;o;ZkC3;+pqt+==x9GG1HUihD2DGV$7kA}By!;J zabhBkVg#D91krMBl(8bMzc>Q%ncA-q!S4UKXJnZ&4fZKI`1FnX)Ie=ZryDVA*lA=+ zBf`}*#Fp!=d9bk4E|_V!eb+nrrTfL{5#{!e1ozOr8qZ;YRi4OVW8RVESQ(FNU`x3} zFb-zKrk_GRCTDj`KsC5r+84~(ojM4&^mnx#q&SSQ>e|&=H?b0Y2 zxQI3%@xa`sJ&T)DbVgQT0b(qej6hp#kf3J+kHi!Dw-`2uOOp9+0K+a;<;%szRSviR z0%Ub5!wv}|kpl=bwgwh*Z#rfw5J5VFtdBZ_-Q0zLgK|}xdKF2l4{5Hfi?5U8N#DQ) zLR>IuT@m?_07=iVEi65aLS2UMs`p!mf34qaRjO>XZObO>VsK}8p|@}i*L5H?*0~qZ zdwKmsP+z%DWg5$2`w&+5WSC7Kb83-qeScr-oA7o|CjurcbQmb4LnDDUwH!&Vm^9#|uJf z5DetV+*E;4#>IL|Y~R@i{HOl(!$$a>xYVad`oa=#&0^x{=7#Z|6wrHKA0f2ju>j2$ zm^!a$Ij^%K)a;$oSBvO_^%6?!>jGWA~CRk@zLPJ zbU%_aFanJxVsWcldxpgyYq$jWz3;+|iF-_pY0^4V*NtHo`1Tb184D86Hdp>q-XH~S z0&_X?^SQx%c-By<`Q+jOg`pP-yF^e7v19dznnkUl7Apa#cFfT6^<}M292$o=0zmy+)OtI%cOrf{!`)6dpJwDW0lh z-17NA-IU@PPrLMIg|M<7G7u4|t4qyZR{~+Kd?$<(p7BQrOu*#3U5Gr75%9dTMp4w25sou^^E9_>E3kNVsXgs-O@3BXQ381;CB)B*fI7m zYE))Wtl$(vRY}afq_E?Lc|V7ik4(*l~S7NRk@ytL+kKA~04wwMx64MQ1-+169Lg z*YEpi@K-i>4K@IvS6aplmoeiVQ-E>s4LuEXz;+5Y)#$Qbm%(}qENT=<#jIriIGkUI z&%J-alo(CR1Aamo@ji8Xq1mpw;fFYn8OYCC^hg6r#xOxGvFd@}S!qP)Y%KlKnP9E;|_hufL=-LE+ zMH(=C-cpLW9xvlNqrQ;z->{e5 z#GYm^69KKbNaJI9gIH;kcvbq^oAC^>BbM^7g)6h>K9Qo1JwK;IsMKxh% zFAC=-FKKsE<1UKLznrV$dl$y2NkS&H?QmDjf}*Y`%m9wpM1{<&k4<%dP*P^4g5Gy+ zB}><|bi+^qC&W_*=u5z$mjB$BHH#8RR$rkuG7}Bg4w@6sI#GsroVq&)P!}RH74!h28zfBDe}JMV>p4i?ex{vxgeFO?yY-MiAk8yZ@#xcNy{iMay(n$4})i0$05 zWq{61T1sri#OoI3bY@pF1*axv=`Ka0O!M*M^}X-|N==z zK2u#%@l=Hjd`DHViI~=5E&dHs&VA zj!yq%n2(lY?tOmE0SW-1{Wk*8=r8n1))Kb0`J_1Ms<_)3J8CnySzG-J0R1V#u7E&W zOB%^90ulf?{#5W+5k>y0;C~UpXs2&zsc&k`VBu(M^DoA~3@IzwxYa=b01n9hDS`~{ zuM7uceIsjQ25X~#0sbWd6+yZV_EUuFCj|d5RuuWyWB(OkZER$&&*O`i^GwhPKw9?lt;fncM-wbB&+Oh);FIe`7w8 z|0mP>)8C)H_P^51x^#uFm;iw7OaOr3Z?qSh|AD6OU} diff --git a/package.json b/package.json index 3c694e33b..9d1e5b851 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "changeset:version": "changeset version && node scripts/normalize-changelog.js", "format": "biome check --write .", "format:check": "biome check .", - "build:vscode": "cd plugins/vscode && pnpm run build && pnpm exec vsce package --allow-missing-repository --skip-license --no-dependencies -o ../../assets/nanocoder-vscode.vsix", + "build:vscode": "cd plugins/vscode && pnpm run build && mkdir -p ../../assets && pnpm exec vsce package --allow-missing-repository --skip-license --no-dependencies -o ../../assets/nanocoder-vscode.vsix", "prepublishOnly": "pnpm run build && pnpm run build:vscode", "generate:system-prompts": "tsx scripts/generate-system-prompts.ts", "prepare": "husky" diff --git a/plugins/vscode/.vscodeignore b/plugins/vscode/.vscodeignore index 1605242de..77177bf79 100644 --- a/plugins/vscode/.vscodeignore +++ b/plugins/vscode/.vscodeignore @@ -5,5 +5,5 @@ test-stubs/** node_modules/** .gitignore tsconfig.json -tailwind.config.js +scripts/** **/*.map diff --git a/plugins/vscode/media/chat-panel.css b/plugins/vscode/media/chat-panel.css deleted file mode 100644 index c94888bfd..000000000 --- a/plugins/vscode/media/chat-panel.css +++ /dev/null @@ -1 +0,0 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.\!block{display:block!important}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-\[1\.2em\]{height:1.2em}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[28px\]{min-height:28px}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.65em\]{font-size:.65em}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.7em\]{font-size:.7em}.text-\[0\.82em\]{font-size:.82em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}#timeline-strip.timeline-disabled{opacity:.45;pointer-events:none}#timeline-nodes{position:relative}.timeline-line{position:absolute;left:10px;right:10px;top:50%;height:2px;background:var(--vscode-panel-border,hsla(0,0%,50%,.35));transform:translateY(-50%);pointer-events:none}.timeline-node{position:relative;z-index:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:none;padding:0;cursor:pointer}.timeline-dot{width:10px;height:10px;border-radius:50%;border:2px solid var(--vscode-focusBorder);background:var(--vscode-editor-background);transition:transform .12s ease,background .12s ease}.timeline-node[data-kind=edit] .timeline-dot{border-color:var(--vscode-focusBorder)}.timeline-node[data-kind=execute] .timeline-dot{border-color:var(--vscode-editorWarning-foreground,#cca700)}.timeline-node[data-kind=other] .timeline-dot{border-color:var(--vscode-descriptionForeground,hsla(0,0%,50%,.8))}.timeline-node[data-kind=now] .timeline-dot{width:8px;height:8px;background:var(--vscode-button-background);border-color:var(--vscode-button-background)}.timeline-node.is-selected .timeline-dot,.timeline-node:hover .timeline-dot{transform:scale(1.35);background:var(--vscode-focusBorder)}.timeline-node[data-kind=now].is-selected .timeline-dot,.timeline-node[data-kind=now]:hover .timeline-dot{background:var(--vscode-button-background)}.timeline-node:focus-visible .timeline-dot{outline:1px solid var(--vscode-focusBorder);outline-offset:2px}#timeline-hint{white-space:nowrap}.timeline-confirm-actions{display:flex;gap:8px;margin-top:8px}.timeline-confirm-actions button{border:none;border-radius:4px;padding:4px 10px}.settings-tab,.timeline-confirm-actions button{font-size:.85em;cursor:pointer;font-family:var(--vscode-font-family)}.settings-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--vscode-editor-foreground);opacity:.6;padding:.5rem .75rem;transition:opacity .15s,border-color .15s}.settings-tab:hover{opacity:.9}.settings-tab.active{opacity:1;border-bottom-color:var(--vscode-focusBorder);font-weight:600}.settings-section{background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);border-radius:6px;padding:.75rem}.settings-section-title{font-size:.8em;font-weight:600;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-bottom:.5rem}.settings-list{display:flex;flex-direction:column;gap:0}.settings-list-item{display:flex;align-items:center;gap:.5rem;padding:.375rem 0;font-size:.9em;border-bottom:1px solid var(--vscode-widget-border)}.settings-list-item:last-child{border-bottom:none}.settings-list-item-name{font-weight:500;flex-shrink:0}.settings-list-item-detail{opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.settings-list-empty{font-size:.85em;opacity:.5;padding:.25rem 0;font-style:italic}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.375rem 0;border-bottom:1px solid var(--vscode-widget-border)}.settings-row:last-child{border-bottom:none}.settings-row-info{display:flex;flex-direction:column;flex:1;min-width:0}.settings-row-label{font-size:.9em;font-weight:500}.settings-row-desc{font-size:.78em;opacity:.55;margin-top:.1em}.settings-toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;cursor:pointer}.settings-toggle input{opacity:0;width:0;height:0}.settings-toggle-slider{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border,transparent);border-radius:20px;transition:background-color .2s}.settings-toggle-slider:before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background-color:var(--vscode-editor-foreground);opacity:.6;border-radius:50%;transition:transform .2s,opacity .2s}.settings-toggle input:checked+.settings-toggle-slider{background-color:var(--vscode-button-background);border-color:var(--vscode-button-background)}.settings-toggle input:checked+.settings-toggle-slider:before{transform:translateX(16px);opacity:1;background-color:var(--vscode-button-foreground)}.settings-select{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground);border:1px solid var(--vscode-dropdown-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;outline:none;min-width:100px}.settings-select:focus{border-color:var(--vscode-focusBorder)}.settings-number-input{background-color:var(--vscode-input-background);color:var(--vscode-input-foreground);border:1px solid var(--vscode-input-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;width:70px;outline:none;text-align:center}.settings-number-input:focus{border-color:var(--vscode-focusBorder)}.settings-action-btn{display:flex;align-items:center;gap:.5rem;background:transparent;border:1px solid var(--vscode-button-secondaryBackground);color:var(--vscode-editor-foreground);border-radius:4px;padding:.375rem .625rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;transition:background-color .15s}.settings-action-btn:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.settings-action-btn-danger{border-color:rgba(241,76,76,.4);color:#f14c4c}.settings-action-btn-danger:hover{background-color:rgba(241,76,76,.1)}.settings-badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.78em;padding:.125rem .375rem;border-radius:3px;font-weight:500}.settings-badge-ok{background-color:rgba(137,209,133,.15);color:#89d185}.settings-badge-off{background-color:hsla(0,0%,80%,.1);color:#999}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-vscode-focusBorder:hover{border-color:var(--vscode-focusBorder)}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json index 0c5944c36..68c1d3961 100644 --- a/plugins/vscode/package.json +++ b/plugins/vscode/package.json @@ -215,11 +215,9 @@ "@types/vscode": "^1.125.0", "@types/ws": "^8.5.10", "@vscode/vsce": "^3.9.1", - "autoprefixer": "^10.4.19", "concurrently": "^8.2.2", "esbuild": "^0.28.1", "eslint": "^10.1.0", - "postcss": "^8.4.38", "tailwindcss": "^4.3.3", "typescript": "^7.0.2" }, diff --git a/plugins/vscode/scripts/verify-theme-css.js b/plugins/vscode/scripts/verify-theme-css.js new file mode 100644 index 000000000..c523bf2f2 --- /dev/null +++ b/plugins/vscode/scripts/verify-theme-css.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// Asserts that utility classes used in the chat-panel HTML/JS are present in the compiled CSS. +// Dependency-free script to prevent silent failures where the .vsix builds but lacks theme colors. + +import {readFileSync, existsSync} from 'node:fs'; +import {join, dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = join(here, '..'); +const cssPath = join(pkgRoot, 'media', 'chat-panel.css'); +const htmlPath = join(pkgRoot, 'media', 'chat-panel.html'); +const jsPath = join(pkgRoot, 'media', 'chat-panel.js'); + +if (!existsSync(cssPath)) { + console.error( + `verify-theme-css: ${cssPath} is missing. Run \`pnpm run build:vscode\` first.`, + ); + process.exit(1); +} + +// Extract vscode-* Tailwind classes (including variant prefixes like hover:). +// 'vscode-…' must precede 'vscode' to avoid partial matches. +const tailwindClass = /(?:[a-zA-Z-]+:)*[a-zA-Z][\w-]*-(?:vscode-[\w-]+|vscode)\b/g; + +const referenced = new Set(); +for (const path of [htmlPath, jsPath]) { + if (!existsSync(path)) continue; + const text = readFileSync(path, 'utf8'); + for (const match of text.match(tailwindClass) ?? []) { + referenced.add(match); + } +} + +// Spot-check critical tokens whose absence would be visually obvious. +const requiredTokens = [ + 'bg-vscode-bg', + 'text-vscode-fg', + 'bg-vscode-input-bg', + 'border-vscode-input-border', + 'bg-vscode-button-bg', + 'text-vscode-button-fg', + 'border-vscode-border', +]; + +const css = readFileSync(cssPath, 'utf8'); + +// Escape colons for variant selectors (e.g. .hover\:bg-vscode-foo). +function compiledSelectorFor(token) { + return token.replaceAll(':', '\\\\:'); +} + +function compiledAsClass(token) { + const selector = compiledSelectorFor(token); + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + return new RegExp(`\\.${selector}(?![\\w-])`).test(css); +} + +const missing = [...referenced].filter((cls) => !compiledAsClass(cls)); + +const missingRequired = requiredTokens.filter( + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + (token) => !new RegExp(`\\.${token}(?![\\w-])`).test(css), +); + +if (missing.length === 0 && missingRequired.length === 0) { + const scanned = referenced.size; + console.log( + `verify-theme-css: ok — ${scanned} theme class(es) found in ${cssPath}`, + ); + process.exit(0); +} + +console.error( + 'verify-theme-css: the compiled CSS is missing theme utilities referenced', +); +console.error(' by the webview templates. The webview would render unthemed.'); +console.error(''); + +if (missingRequired.length > 0) { + console.error('Required tokens that did not compile:'); + for (const token of missingRequired) { + console.error(` - .${token}`); + } + console.error(''); +} + +if (missing.length > 0) { + const preview = missing.slice(0, 20); + console.error(`Other missing classes (${missing.length} total, showing first ${preview.length}):`); + for (const cls of preview) { + console.error(` - .${cls}`); + } + if (missing.length > preview.length) { + console.error(` ... and ${missing.length - preview.length} more`); + } + console.error(''); +} + +console.error( + 'This usually means the Tailwind theme moved (e.g. tailwind.config.js no', +); +console.error( + 'longer being read by Tailwind v4) or @theme tokens were deleted. Check', +); +console.error('that src/styles.css declares every `--color-vscode-*` used by'); +console.error('media/chat-panel.{html,js}.'); + +process.exit(1); diff --git a/plugins/vscode/src/styles.css b/plugins/vscode/src/styles.css index bfa0aac2c..868d72657 100644 --- a/plugins/vscode/src/styles.css +++ b/plugins/vscode/src/styles.css @@ -1,290 +1,538 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; -/* Custom base styles for webviews that Tailwind doesn't reset out of the box for our specific use-case */ -html, body { - height: 100%; - width: 100%; - margin: 0; - padding: 0; - overflow: hidden; - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); +/* ── VS Code theme tokens ────────────────────────────── + * Tailwind v4 reads theme from CSS, not tailwind.config.js. + * Each `--color-vscode-*` token below becomes a `bg-`, + * `text-`, `border-` (etc.) utility that resolves to the + * matching `var(--vscode-*)` CSS variable VS Code exposes + * to webviews. Adding a token here is enough to make a + * utility class available in `media/chat-panel.{html,js}`. + */ +@theme { + --color-vscode-bg: var(--vscode-editor-background); + --color-vscode-fg: var(--vscode-editor-foreground); + + --color-vscode-input-bg: var(--vscode-input-background); + --color-vscode-input-fg: var(--vscode-input-foreground); + --color-vscode-input-border: var(--vscode-input-border, transparent); + --color-vscode-input-focus: var(--vscode-focusBorder); + + --color-vscode-button-bg: var(--vscode-button-background); + --color-vscode-button-fg: var(--vscode-button-foreground); + --color-vscode-button-hover: var(--vscode-button-hoverBackground); + --color-vscode-button-secondary: var(--vscode-button-secondaryBackground); + --color-vscode-button-secondaryHover: var( + --vscode-button-secondaryHoverBackground + ); + + --color-vscode-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.2)); + + --color-vscode-list-hover: var(--vscode-list-hoverBackground); + --color-vscode-list-active: var(--vscode-list-activeSelectionBackground); + --color-vscode-list-activeFg: var(--vscode-list-activeSelectionForeground); + --color-vscode-list-error: var(--vscode-list-errorForeground, #f44747); + + --color-vscode-error: var(--vscode-editorError-foreground, #f48771); + --color-vscode-focusBorder: var(--vscode-focusBorder); + + --color-vscode-dropdown-bg: var(--vscode-dropdown-background); + --color-vscode-dropdown-fg: var(--vscode-dropdown-foreground); + --color-vscode-dropdown-border: var(--vscode-dropdown-border, transparent); + --color-vscode-dropdown-foreground: var(--vscode-dropdown-foreground); + + --color-vscode-symbolIcon-fileForeground: var( + --vscode-symbolIcon-fileForeground + ); + + --color-vscode-toolbarHover: var( + --vscode-toolbar-hoverBackground, + rgba(128, 128, 128, 0.15) + ); + + --color-vscode-widget-bg: var(--vscode-editorWidget-background); + --color-vscode-widget-border: var(--vscode-widget-border); + --color-vscode-widget-header: var(--vscode-editorWidget-border); + + --color-vscode-editor-bg: var(--vscode-editor-background); + + --font-vscode: var(--vscode-font-family); +} + +/* ── Webview shell ───────────────────────────────────── + * Tailwind v4 doesn't ship a Preflight reset for raw HTML + * the way v3 did, so keep the body/html sizing we rely on. + */ +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); } /* Custom VS Code-native Scrollbar Styling */ ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 10px; + height: 10px; } ::-webkit-scrollbar-track { - background: transparent; + background: transparent; } ::-webkit-scrollbar-thumb { - background: var(--vscode-scrollbarSlider-background); - border: 3px solid transparent; - background-clip: padding-box; - border-radius: 5px; + background: var(--vscode-scrollbarSlider-background); + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 5px; } ::-webkit-scrollbar-thumb:hover { - background: var(--vscode-scrollbarSlider-hoverBackground); - border: 3px solid transparent; - background-clip: padding-box; + background: var(--vscode-scrollbarSlider-hoverBackground); + border: 3px solid transparent; + background-clip: padding-box; } ::-webkit-scrollbar-thumb:active { - background: var(--vscode-scrollbarSlider-activeBackground); - border: 3px solid transparent; - background-clip: padding-box; + background: var(--vscode-scrollbarSlider-activeBackground); + border: 3px solid transparent; + background-clip: padding-box; } /* Style native selects to blend with VS Code themes */ select { - background-color: var(--vscode-dropdown-background); - color: var(--vscode-dropdown-foreground); - border: 1px solid var(--vscode-dropdown-border, transparent); + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, transparent); } select option { - background-color: var(--vscode-dropdown-background); - color: var(--vscode-dropdown-foreground); + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); } /* Basic Markdown styling since Tailwind resets all headings, lists, and bold text */ .markdown-body p { - margin-bottom: 0.5em; - margin-top: 0; + margin-bottom: 0.5em; + margin-top: 0; } .markdown-body p:last-child { - margin-bottom: 0; + margin-bottom: 0; } .markdown-body strong { - font-weight: 600; + font-weight: 600; } .markdown-body ul { - list-style-type: disc; - padding-left: 1.5em; - margin-bottom: 0.75em; + list-style-type: disc; + padding-left: 1.5em; + margin-bottom: 0.75em; } .markdown-body ol { - list-style-type: decimal; - padding-left: 1.5em; - margin-bottom: 0.75em; + list-style-type: decimal; + padding-left: 1.5em; + margin-bottom: 0.75em; } .markdown-body li { - margin-bottom: 0.25em; + margin-bottom: 0.25em; } .markdown-body code { - font-family: var(--vscode-editor-font-family, monospace); - background-color: var(--vscode-textCodeBlock-background, rgba(0,0,0,0.1)); - padding: 0.1em 0.3em; - border-radius: 3px; - font-size: 0.9em; + font-family: var(--vscode-editor-font-family, monospace); + background-color: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.1)); + padding: 0.1em 0.3em; + border-radius: 3px; + font-size: 0.9em; } .markdown-body pre { - background-color: var(--vscode-textCodeBlock-background, rgba(0,0,0,0.1)); - padding: 0.75em; - border-radius: 4px; - overflow-x: auto; - max-width: 100%; - margin-bottom: 0.75em; + background-color: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.1)); + padding: 0.75em; + border-radius: 4px; + overflow-x: auto; + max-width: 100%; + margin-bottom: 0.75em; } .markdown-body pre code { - background-color: transparent; - padding: 0; - font-size: 0.85em; + background-color: transparent; + padding: 0; + font-size: 0.85em; +} +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4 { + font-weight: 600; + margin-top: 1em; + margin-bottom: 0.5em; } -.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { - font-weight: 600; - margin-top: 1em; - margin-bottom: 0.5em; +.markdown-body h1 { + font-size: 1.5em; +} +.markdown-body h2 { + font-size: 1.3em; +} +.markdown-body h3 { + font-size: 1.1em; } -.markdown-body h1 { font-size: 1.5em; } -.markdown-body h2 { font-size: 1.3em; } -.markdown-body h3 { font-size: 1.1em; } /* ── Context Chip ───────────────────────────────────── */ .context-chip { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 200px; - padding: 4px 8px; - border-radius: 12px; - border: 1px solid var(--vscode-dropdown-border, rgba(150, 150, 150, 0.2)); - background-color: var(--vscode-editorWidget-background); - font-size: 0.85em; - font-weight: 500; - cursor: pointer; - user-select: none; - white-space: nowrap; - transition: all 150ms ease; + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 200px; + padding: 4px 8px; + border-radius: 12px; + border: 1px solid var(--vscode-dropdown-border, rgba(150, 150, 150, 0.2)); + background-color: var(--vscode-editorWidget-background); + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + user-select: none; + white-space: nowrap; + transition: all 150ms ease; } .context-chip:hover { - border-color: var(--vscode-focusBorder); - opacity: 0.95; + border-color: var(--vscode-focusBorder); + opacity: 0.95; } .context-chip .chip-name { - overflow: hidden; - text-overflow: ellipsis; + overflow: hidden; + text-overflow: ellipsis; } .context-chip .chip-remove { - margin-left: 2px; - opacity: 0.5; - font-size: 1.1em; - line-height: 1; - cursor: pointer; - transition: opacity 150ms ease; + margin-left: 2px; + opacity: 0.5; + font-size: 1.1em; + line-height: 1; + cursor: pointer; + transition: opacity 150ms ease; +} +.context-chip .chip-remove:hover { + opacity: 1; } -.context-chip .chip-remove:hover { opacity: 1; } /* ── Drop Overlay ───────────────────────────────────── */ #composer-box.drag-over::after { - content: 'Drop files or folders'; - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - border: 2px dashed var(--vscode-focusBorder); - border-radius: inherit; - background: var(--vscode-input-background); - opacity: 0.92; - font-size: 0.85em; - font-weight: 600; - pointer-events: none; - z-index: 10; + content: 'Drop files or folders'; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--vscode-focusBorder); + border-radius: inherit; + background: var(--vscode-input-background); + opacity: 0.92; + font-size: 0.85em; + font-weight: 600; + pointer-events: none; + z-index: 10; } /* ── Action Timeline ────────────────────────────────── */ #timeline-strip.timeline-disabled { - opacity: 0.45; - pointer-events: none; + opacity: 0.45; + pointer-events: none; } #timeline-nodes { - position: relative; + position: relative; } .timeline-line { - position: absolute; - left: 10px; - right: 10px; - top: 50%; - height: 2px; - background: var(--vscode-panel-border, rgba(128,128,128,0.35)); - transform: translateY(-50%); - pointer-events: none; + position: absolute; + left: 10px; + right: 10px; + top: 50%; + height: 2px; + background: var(--vscode-panel-border, rgba(128, 128, 128, 0.35)); + transform: translateY(-50%); + pointer-events: none; } .timeline-node { - position: relative; - z-index: 1; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - background: transparent; - border: none; - padding: 0; - cursor: pointer; + position: relative; + z-index: 1; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: transparent; + border: none; + padding: 0; + cursor: pointer; } .timeline-dot { - width: 10px; - height: 10px; - border-radius: 50%; - border: 2px solid var(--vscode-focusBorder); - background: var(--vscode-editor-background); - transition: transform 120ms ease, background 120ms ease; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--vscode-focusBorder); + background: var(--vscode-editor-background); + transition: transform 120ms ease, background 120ms ease; } .timeline-node[data-kind="edit"] .timeline-dot { - border-color: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); } .timeline-node[data-kind="execute"] .timeline-dot { - border-color: var(--vscode-editorWarning-foreground, #cca700); + border-color: var(--vscode-editorWarning-foreground, #cca700); } .timeline-node[data-kind="other"] .timeline-dot { - border-color: var(--vscode-descriptionForeground, rgba(128,128,128,0.8)); + border-color: var(--vscode-descriptionForeground, rgba(128, 128, 128, 0.8)); } .timeline-node[data-kind="now"] .timeline-dot { - width: 8px; - height: 8px; - background: var(--vscode-button-background); - border-color: var(--vscode-button-background); + width: 8px; + height: 8px; + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); } .timeline-node.is-selected .timeline-dot, .timeline-node:hover .timeline-dot { - transform: scale(1.35); - background: var(--vscode-focusBorder); + transform: scale(1.35); + background: var(--vscode-focusBorder); } .timeline-node[data-kind="now"].is-selected .timeline-dot, .timeline-node[data-kind="now"]:hover .timeline-dot { - background: var(--vscode-button-background); + background: var(--vscode-button-background); } .timeline-node:focus-visible .timeline-dot { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; } #timeline-hint { - white-space: nowrap; + white-space: nowrap; } .timeline-confirm-actions { - display: flex; - gap: 8px; - margin-top: 8px; + display: flex; + gap: 8px; + margin-top: 8px; } .timeline-confirm-actions button { - border: none; - border-radius: 4px; - padding: 4px 10px; - font-size: 0.85em; - cursor: pointer; - font-family: var(--vscode-font-family); + border: none; + border-radius: 4px; + padding: 4px 10px; + font-size: 0.85em; + cursor: pointer; + font-family: var(--vscode-font-family); } /* ── Settings UI ────────────────────────────────────── */ -.settings-tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--vscode-editor-foreground); opacity: 0.6; padding: 0.5rem 0.75rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: opacity 0.15s, border-color 0.15s; } -.settings-tab:hover { opacity: 0.9; } -.settings-tab.active { opacity: 1; border-bottom-color: var(--vscode-focusBorder); font-weight: 600; } -.settings-section { background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-widget-border); border-radius: 6px; padding: 0.75rem; } -.settings-section-title { font-size: 0.8em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 0.5rem; } -.settings-list { display: flex; flex-direction: column; gap: 0; } -.settings-list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.9em; border-bottom: 1px solid var(--vscode-widget-border); } -.settings-list-item:last-child { border-bottom: none; } -.settings-list-item-name { font-weight: 500; flex-shrink: 0; } -.settings-list-item-detail { opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } -.settings-list-empty { font-size: 0.85em; opacity: 0.5; padding: 0.25rem 0; font-style: italic; } -.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.375rem 0; border-bottom: 1px solid var(--vscode-widget-border); } -.settings-row:last-child { border-bottom: none; } -.settings-row-info { display: flex; flex-direction: column; flex: 1; min-width: 0; } -.settings-row-label { font-size: 0.9em; font-weight: 500; } -.settings-row-desc { font-size: 0.78em; opacity: 0.55; margin-top: 0.1em; } -.settings-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; cursor: pointer; } -.settings-toggle input { opacity: 0; width: 0; height: 0; } -.settings-toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 20px; transition: background-color 0.2s; } -.settings-toggle-slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: var(--vscode-editor-foreground); opacity: 0.6; border-radius: 50%; transition: transform 0.2s, opacity 0.2s; } -.settings-toggle input:checked + .settings-toggle-slider { background-color: var(--vscode-button-background); border-color: var(--vscode-button-background); } -.settings-toggle input:checked + .settings-toggle-slider:before { transform: translateX(16px); opacity: 1; background-color: var(--vscode-button-foreground); } -.settings-select { background-color: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border: 1px solid var(--vscode-dropdown-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; outline: none; min-width: 100px; } -.settings-select:focus { border-color: var(--vscode-focusBorder); } -.settings-number-input { background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; width: 70px; outline: none; text-align: center; } -.settings-number-input:focus { border-color: var(--vscode-focusBorder); } -.settings-action-btn { display: flex; align-items: center; gap: 0.5rem; background: transparent; border: 1px solid var(--vscode-button-secondaryBackground); color: var(--vscode-editor-foreground); border-radius: 4px; padding: 0.375rem 0.625rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: background-color 0.15s; } -.settings-action-btn:hover { background-color: var(--vscode-button-secondaryHoverBackground); } -.settings-action-btn-danger { border-color: rgba(241, 76, 76, 0.4); color: #f14c4c; } -.settings-action-btn-danger:hover { background-color: rgba(241, 76, 76, 0.1); } -.settings-badge { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.78em; padding: 0.125rem 0.375rem; border-radius: 3px; font-weight: 500; } -.settings-badge-ok { background-color: rgba(137, 209, 133, 0.15); color: #89d185; } -.settings-badge-off { background-color: hsla(0, 0%, 80%, 0.1); color: #999; } +.settings-tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--vscode-editor-foreground); + opacity: 0.6; + padding: 0.5rem 0.75rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + cursor: pointer; + transition: opacity 0.15s, border-color 0.15s; +} +.settings-tab:hover { + opacity: 0.9; +} +.settings-tab.active { + opacity: 1; + border-bottom-color: var(--vscode-focusBorder); + font-weight: 600; +} +.settings-section { + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 6px; + padding: 0.75rem; +} +.settings-section-title { + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; + margin-bottom: 0.5rem; +} +.settings-list { + display: flex; + flex-direction: column; + gap: 0; +} +.settings-list-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0; + font-size: 0.9em; + border-bottom: 1px solid var(--vscode-widget-border); +} +.settings-list-item:last-child { + border-bottom: none; +} +.settings-list-item-name { + font-weight: 500; + flex-shrink: 0; +} +.settings-list-item-detail { + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.settings-list-empty { + font-size: 0.85em; + opacity: 0.5; + padding: 0.25rem 0; + font-style: italic; +} +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.375rem 0; + border-bottom: 1px solid var(--vscode-widget-border); +} +.settings-row:last-child { + border-bottom: none; +} +.settings-row-info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.settings-row-label { + font-size: 0.9em; + font-weight: 500; +} +.settings-row-desc { + font-size: 0.78em; + opacity: 0.55; + margin-top: 0.1em; +} +.settings-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + flex-shrink: 0; + cursor: pointer; +} +.settings-toggle input { + opacity: 0; + width: 0; + height: 0; +} +.settings-toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 20px; + transition: background-color 0.2s; +} +.settings-toggle-slider:before { + content: ""; + position: absolute; + height: 14px; + width: 14px; + left: 2px; + bottom: 2px; + background-color: var(--vscode-editor-foreground); + opacity: 0.6; + border-radius: 50%; + transition: transform 0.2s, opacity 0.2s; +} +.settings-toggle input:checked + .settings-toggle-slider { + background-color: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} +.settings-toggle input:checked + .settings-toggle-slider:before { + transform: translateX(16px); + opacity: 1; + background-color: var(--vscode-button-foreground); +} +.settings-select { + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, transparent); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + cursor: pointer; + outline: none; + min-width: 100px; +} +.settings-select:focus { + border-color: var(--vscode-focusBorder); +} +.settings-number-input { + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + width: 70px; + outline: none; + text-align: center; +} +.settings-number-input:focus { + border-color: var(--vscode-focusBorder); +} +.settings-action-btn { + display: flex; + align-items: center; + gap: 0.5rem; + background: transparent; + border: 1px solid var(--vscode-button-secondaryBackground); + color: var(--vscode-editor-foreground); + border-radius: 4px; + padding: 0.375rem 0.625rem; + font-family: var(--vscode-font-family); + font-size: 0.85em; + cursor: pointer; + transition: background-color 0.15s; +} +.settings-action-btn:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} +.settings-action-btn-danger { + border-color: rgba(241, 76, 76, 0.4); + color: #f14c4c; +} +.settings-action-btn-danger:hover { + background-color: rgba(241, 76, 76, 0.1); +} +.settings-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.78em; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-weight: 500; +} +.settings-badge-ok { + background-color: rgba(137, 209, 133, 0.15); + color: #89d185; +} +.settings-badge-off { + background-color: hsla(0, 0%, 80%, 0.1); + color: #999; +} diff --git a/plugins/vscode/tailwind.config.js b/plugins/vscode/tailwind.config.js deleted file mode 100644 index 4fefe4678..000000000 --- a/plugins/vscode/tailwind.config.js +++ /dev/null @@ -1,51 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./media/**/*.{html,js}"], - theme: { - extend: { - colors: { - vscode: { - bg: 'var(--vscode-editor-background)', - fg: 'var(--vscode-editor-foreground)', - input: { - bg: 'var(--vscode-input-background)', - fg: 'var(--vscode-input-foreground)', - border: 'var(--vscode-input-border, transparent)', - focus: 'var(--vscode-focusBorder)' - }, - button: { - bg: 'var(--vscode-button-background)', - fg: 'var(--vscode-button-foreground)', - hover: 'var(--vscode-button-hoverBackground)', - secondary: 'var(--vscode-button-secondaryBackground)', - secondaryHover: 'var(--vscode-button-secondaryHoverBackground)' - }, - border: 'var(--vscode-panel-border, rgba(128,128,128,0.2))', - list: { - hover: 'var(--vscode-list-hoverBackground)', - active: 'var(--vscode-list-activeSelectionBackground)', - activeFg: 'var(--vscode-list-activeSelectionForeground)', - error: 'var(--vscode-list-errorForeground, #f44747)' - }, - error: 'var(--vscode-editorError-foreground, #f48771)', - focusBorder: 'var(--vscode-focusBorder)', - dropdown: { - bg: 'var(--vscode-dropdown-background)', - fg: 'var(--vscode-dropdown-foreground)', - border: 'var(--vscode-dropdown-border, transparent)' - }, - toolbarHover: 'var(--vscode-toolbar-hoverBackground, rgba(128,128,128,0.15))', - widget: { - bg: 'var(--vscode-editorWidget-background)', - border: 'var(--vscode-widget-border)', - header: 'var(--vscode-editorWidget-border)' - } - } - }, - fontFamily: { - vscode: 'var(--vscode-font-family)' - } - }, - }, - plugins: [], -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 423355136..f2cff4ef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,9 +206,6 @@ importers: '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2 - autoprefixer: - specifier: ^10.4.19 - version: 10.5.4(postcss@8.5.25) concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -218,9 +215,6 @@ importers: eslint: specifier: ^10.1.0 version: 10.4.1(jiti@2.7.0) - postcss: - specifier: ^8.4.38 - version: 8.5.25 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -1992,13 +1986,6 @@ packages: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - autoprefixer@10.5.4: - resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - ava@8.0.1: resolution: {integrity: sha512-YlwwL5HX2EJRE75e2LR8nio8lKAJ702sFbv0QYnhzngAjyo9wB+Xg4JZh5f432khlWfVD5OQ93rrRopEXJptpg==} engines: {node: ^22.20 || ^24.12 || >=26} @@ -2019,11 +2006,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.43: - resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} - engines: {node: '>=6.0.0'} - hasBin: true - binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2063,11 +2045,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -2111,9 +2088,6 @@ packages: resolution: {integrity: sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==} engines: {node: '>=12.20'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} - cbor2@2.3.0: resolution: {integrity: sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==} engines: {node: '>=20'} @@ -2437,9 +2411,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.393: - resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} - emittery@2.0.0: resolution: {integrity: sha512-FLtgn/CGBXiX3ZtPAm5q4LWWepHChOt55J9u01WFu3dyap2U7IwptlrqoE1COR/kxwdy/DOxIBALSxIW449I1g==} engines: {node: '>=22'} @@ -2712,9 +2683,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3466,11 +3434,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -3504,10 +3467,6 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} - engines: {node: '>=18'} - node-sarif-builder@3.4.0: resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} engines: {node: '>=20'} @@ -3720,13 +3679,6 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} - engines: {node: ^10 || ^12 || >=14} - powershell-utils@0.2.0: resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} engines: {node: '>=20'} @@ -4338,12 +4290,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6018,15 +5964,6 @@ snapshots: auto-bind@5.0.1: {} - autoprefixer@10.5.4(postcss@8.5.25): - dependencies: - browserslist: 4.28.6 - caniuse-lite: 1.0.30001806 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.25 - postcss-value-parser: 4.2.0 - ava@8.0.1(@ava/typescript@7.0.0): dependencies: '@vercel/nft': 1.10.2 @@ -6086,8 +6023,6 @@ snapshots: base64-js@1.5.1: optional: true - baseline-browser-mapping@2.10.43: {} - binary-extensions@2.3.0: {} binaryextensions@6.11.0: @@ -6137,14 +6072,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.6: - dependencies: - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.393 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) - buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -6189,8 +6116,6 @@ snapshots: callsites@4.2.0: {} - caniuse-lite@1.0.30001806: {} - cbor2@2.3.0: dependencies: '@cto.af/wtf8': 0.0.5 @@ -6516,8 +6441,6 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.393: {} - emittery@2.0.0: {} emoji-regex@10.6.0: {} @@ -6881,8 +6804,6 @@ snapshots: forwarded@0.2.0: {} - fraction.js@5.3.4: {} - fresh@2.0.0: {} fs-constants@1.0.0: @@ -7574,8 +7495,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.16: {} - napi-build-utils@2.0.0: optional: true @@ -7599,8 +7518,6 @@ snapshots: node-gyp-build@4.8.4: {} - node-releases@2.0.51: {} - node-sarif-builder@3.4.0: dependencies: '@types/sarif': 2.1.7 @@ -7857,14 +7774,6 @@ snapshots: pluralize@8.0.0: {} - postcss-value-parser@4.2.0: {} - - postcss@8.5.25: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - powershell-utils@0.2.0: {} prebuild-install@7.1.3: @@ -8499,12 +8408,6 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.6): - dependencies: - browserslist: 4.28.6 - escalade: 3.2.0 - picocolors: 1.1.1 - uri-js@4.4.1: dependencies: punycode: 2.3.1 From 9685adc0e3e8d39ac806e1c07725526c3d0609c3 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 31 Aug 2026 22:53:13 +0530 Subject: [PATCH 16/25] ci: fail PRs that drop coverage vs the base branch (#1101) Wire FAIL_ON_COVERAGE_DROP to a local c8 comparison against the PR base. Keep the 80% floor. Closes #1052. --- .changeset/coverage-drop-vs-base.md | 4 ++++ .github/workflows/pr-checks.yml | 2 ++ 2 files changed, 6 insertions(+) create mode 100644 .changeset/coverage-drop-vs-base.md diff --git a/.changeset/coverage-drop-vs-base.md b/.changeset/coverage-drop-vs-base.md new file mode 100644 index 000000000..29e6ecfa7 --- /dev/null +++ b/.changeset/coverage-drop-vs-base.md @@ -0,0 +1,4 @@ +--- +--- + +CI: pass fail-on-coverage-drop to the shared PR checks workflow. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 23844ce30..47e1265d1 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -15,6 +15,8 @@ permissions: jobs: pr-checks: uses: Nano-Collective/.github/.github/workflows/pr-checks.yml@main + with: + fail-on-coverage-drop: true # Validates changeset package names to prevent main branch breakages. # Uses a custom script instead of `changeset status` to keep the check non-blocking. From 645d3d511e0bc538f6d2e5ed7836370d9a97f759 Mon Sep 17 00:00:00 2001 From: Aditya Mishra Date: Mon, 31 Aug 2026 22:54:45 +0530 Subject: [PATCH 17/25] fix(file-ops): keep replacement text literal in string_replace and diff_edit (#1086) Both tools passed the model's replacement straight to String.prototype.replace. That second argument is a substitution template, not a literal, so the engine ran GetSubstitution over it and rewrote four token sequences before the bytes reached disk: `$$` collapsed to a single `$`, `$&` injected the matched text, and the backtick and quote forms injected everything before / after the match. The last two duplicate a whole half of the file into the middle of the edit, so the damage scaled with file size. Those are ordinary characters in shell scripts, Makefiles, docker-compose files, CI YAML and anything that builds a regex. The tool reported success and nothing warned the model or the user, so the approval gate was bypassed by construction: the confirmation renders old_str / new_str directly, so the diff the user approved was not the diff that landed. replaceFirstLiteral splices by index instead. That sidesteps substitution parsing entirely and avoids re-scanning the string for a second pass. Both write paths use it, as do the two previews that synthesize post-edit content - the VS Code diff in the string_replace formatter and the ACP whole-file diff - so what is shown stays what is written. Every call site is guarded by an existing uniqueness check, so the helper's not-found branch is a defensive no-op rather than a silent skipped edit. Closes #1057 --- .../fix-literal-replacement-dollar-tokens.md | 5 ++ source/acp/acp-tool-call.spec.ts | 21 ++++++ source/acp/acp-tool-call.ts | 3 +- source/tools/file-ops/diff-edit.spec.tsx | 40 ++++++++++ source/tools/file-ops/diff-edit.tsx | 3 +- source/tools/file-ops/string-replace.spec.tsx | 74 +++++++++++++++++++ source/tools/file-ops/string-replace.tsx | 5 +- source/utils/literal-replace.spec.ts | 54 ++++++++++++++ source/utils/literal-replace.ts | 28 +++++++ 9 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-literal-replacement-dollar-tokens.md create mode 100644 source/utils/literal-replace.spec.ts create mode 100644 source/utils/literal-replace.ts diff --git a/.changeset/fix-literal-replacement-dollar-tokens.md b/.changeset/fix-literal-replacement-dollar-tokens.md new file mode 100644 index 000000000..54ac6c1f1 --- /dev/null +++ b/.changeset/fix-literal-replacement-dollar-tokens.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed `string_replace` and `diff_edit` corrupting edits whose replacement text contains `$`. Both tools passed the model's replacement straight to `String.prototype.replace`, which treats that argument as a substitution template rather than a literal: `$$` collapsed to a single `$`, `$&` expanded to the matched text, and ``$` ``/`$'` spliced a whole half of the file into the middle of the edit. Those are ordinary characters in shell scripts, Makefiles, CI YAML and anything that builds a regex, so the bytes on disk silently diverged from the diff the user approved. Replacements are now spliced by index, so the approved preview - in the terminal and over ACP - is what lands. Closes #1057. diff --git a/source/acp/acp-tool-call.spec.ts b/source/acp/acp-tool-call.spec.ts index ea4565030..eb95aee94 100644 --- a/source/acp/acp-tool-call.spec.ts +++ b/source/acp/acp-tool-call.spec.ts @@ -222,3 +222,24 @@ test('buildToolCallMeta - withDiff true is the default', async t => { rmSync(dir, {recursive: true, force: true}); } }); + +test('buildToolCallMeta - string_replace diff shows $ tokens literally', async t => { + const dir = mkdtempSync(join(tmpdir(), 'acp-tc-')); + const file = join(dir, 'run.sh'); + writeFileSync(file, '#!/bin/sh\necho "old"\nexit 0\n'); + const replacement = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + try { + const meta = await buildToolCallMeta( + makeCall('string_replace', { + path: file, + old_str: 'echo "old"', + new_str: replacement, + }), + ); + // The previewed diff has to be the diff that lands on disk. + const diff = meta.content[0] as any; + t.is(diff.newText, `#!/bin/sh\n${replacement}\nexit 0\n`); + } finally { + rmSync(dir, {recursive: true, force: true}); + } +}); diff --git a/source/acp/acp-tool-call.ts b/source/acp/acp-tool-call.ts index 9c1db320a..25e958fe5 100644 --- a/source/acp/acp-tool-call.ts +++ b/source/acp/acp-tool-call.ts @@ -6,6 +6,7 @@ import type { ToolKind, } from '@agentclientprotocol/sdk'; import type {ToolCall} from '@/types/core'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; export interface AcpToolCallMeta { title: string; @@ -169,7 +170,7 @@ async function buildStringReplaceDiff( type: 'diff', path: absPath, oldText: current, - newText: current.replace(oldStr, newStr), + newText: replaceFirstLiteral(current, oldStr, newStr), }; } diff --git a/source/tools/file-ops/diff-edit.spec.tsx b/source/tools/file-ops/diff-edit.spec.tsx index 54f57aa81..b4562ebbb 100644 --- a/source/tools/file-ops/diff-edit.spec.tsx +++ b/source/tools/file-ops/diff-edit.spec.tsx @@ -365,3 +365,43 @@ test('diff_edit description tells models not to wrap diff in code fences', t => /do not wrap.*code fence|code fence.*do not wrap/i, ); }); + +// `$$`, `$&`, "$`" and `$'` are substitution tokens to String.prototype.replace +// but ordinary characters in the shell scripts and CI YAML models edit. +test('diff_edit writes $ substitution tokens literally', async t => { + const filePath = await createTestFile( + 'dollars.sh', + '#!/bin/sh\necho "old"\nexit 0\n', + ); + const replacement = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + + await executeDiffEdit({ + path: filePath, + diff: diffBlock('echo "old"', replacement), + }); + + t.is( + await readFile(filePath, 'utf-8'), + `#!/bin/sh\n${replacement}\nexit 0\n`, + ); +}); + +test('diff_edit keeps $ tokens literal across multiple blocks', async t => { + const filePath = await createTestFile( + 'multi.yml', + 'first: OLD_A\nsecond: OLD_B\n', + ); + + await executeDiffEdit({ + path: filePath, + diff: [ + diffBlock('first: OLD_A', 'first: "$&"'), + diffBlock('second: OLD_B', "second: \"$`$'\""), + ].join('\n\n'), + }); + + t.is( + await readFile(filePath, 'utf-8'), + 'first: "$&"\nsecond: "$`$\'"\n', + ); +}); diff --git a/source/tools/file-ops/diff-edit.tsx b/source/tools/file-ops/diff-edit.tsx index d31b08c39..c1bdc311c 100644 --- a/source/tools/file-ops/diff-edit.tsx +++ b/source/tools/file-ops/diff-edit.tsx @@ -10,6 +10,7 @@ import type {NanocoderToolExport} from '@/types/core'; import {jsonSchema, tool} from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import {getCachedFileContent, invalidateCache} from '@/utils/file-cache'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; import {validatePath} from '@/utils/path-validators'; import {hasSeenFile, markFileSeen} from '@/utils/read-tracker'; import {createFileToolApproval} from '@/utils/tool-approval'; @@ -162,7 +163,7 @@ function applyBlocks(fileContent: string, blocks: DiffEditBlock[]): string { ); } - newContent = newContent.replace(block.search, block.replace); + newContent = replaceFirstLiteral(newContent, block.search, block.replace); }); return newContent; diff --git a/source/tools/file-ops/string-replace.spec.tsx b/source/tools/file-ops/string-replace.spec.tsx index 2cd78f99f..d1eaab1b6 100644 --- a/source/tools/file-ops/string-replace.spec.tsx +++ b/source/tools/file-ops/string-replace.spec.tsx @@ -1029,3 +1029,77 @@ test('string_replace formatter: normalizes tabs to 2 spaces', async t => { t.regex(output!, /string_replace/); t.regex(output!, /Path:/); }); + +// ============================================================================ +// Literal Replacement Tests +// ============================================================================ + +// `$$`, `$&`, "$`" and `$'` are ordinary characters in shell scripts, +// Makefiles and CI YAML, but they are substitution tokens to +// String.prototype.replace. The replacement must land byte for byte. +const DOLLAR_TOKENS = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + +test('string_replace: writes $ substitution tokens literally', async t => { + const filePath = await createTestFile( + 'dollars.sh', + '#!/bin/sh\necho "old"\nexit 0\n', + ); + + await executeStringReplace({ + path: filePath, + old_str: 'echo "old"', + new_str: DOLLAR_TOKENS, + }); + + t.is( + await readFile(filePath, 'utf-8'), + `#!/bin/sh\n${DOLLAR_TOKENS}\nexit 0\n`, + ); +}); + +test('string_replace: $` and $\' do not splice the rest of the file in', async t => { + const filePath = await createTestFile( + 'halves.txt', + 'BEFORE\nTARGET\nAFTER\n', + ); + + await executeStringReplace({ + path: filePath, + old_str: 'TARGET', + new_str: "$`$'", + }); + + const newContent = await readFile(filePath, 'utf-8'); + t.is(newContent, "BEFORE\n$`$'\nAFTER\n"); + t.false(newContent.includes('BEFORE\nBEFORE')); +}); + +test('string_replace: $ tokens in old_str still match and are removable', async t => { + const filePath = await createTestFile( + 'makefile', + 'all:\n\t@echo $$HOME $(shell pwd)\n', + ); + + await executeStringReplace({ + path: filePath, + old_str: '@echo $$HOME $(shell pwd)', + new_str: '@echo $$PWD', + }); + + t.is(await readFile(filePath, 'utf-8'), 'all:\n\t@echo $$PWD\n'); +}); + +test('string_replace: numbered group tokens stay literal', async t => { + const filePath = await createTestFile('groups.sh', 'run "old"\n'); + + await executeStringReplace({ + path: filePath, + old_str: 'run "old"', + new_str: 'printf "%s\n" "$1" "$2" "$<" "$@"', + }); + + t.is( + await readFile(filePath, 'utf-8'), + 'printf "%s\n" "$1" "$2" "$<" "$@"\n', + ); +}); diff --git a/source/tools/file-ops/string-replace.tsx b/source/tools/file-ops/string-replace.tsx index 1a0027e04..f08da0dd3 100644 --- a/source/tools/file-ops/string-replace.tsx +++ b/source/tools/file-ops/string-replace.tsx @@ -8,6 +8,7 @@ import type {NanocoderToolExport} from '@/types/core'; import {jsonSchema, tool} from '@/types/core'; import {formatError} from '@/utils/error-formatter'; import {getCachedFileContent, invalidateCache} from '@/utils/file-cache'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; import {validatePath} from '@/utils/path-validators'; import {hasSeenFile, markFileSeen} from '@/utils/read-tracker'; import {createFileToolApproval} from '@/utils/tool-approval'; @@ -88,7 +89,7 @@ const executeStringReplace = async ( ); } - const newContent = fileContent.replace(old_str, new_str); + const newContent = replaceFirstLiteral(fileContent, old_str, new_str); await writeFile(absPath, newContent, 'utf-8'); invalidateCache(absPath); // The model now knows the file's current contents, so a follow-up edit is @@ -162,7 +163,7 @@ const stringReplaceFormatter = async ( const occurrences = fileContent.split(old_str).length - 1; if (occurrences === 1) { - const newContent = fileContent.replace(old_str, new_str); + const newContent = replaceFirstLiteral(fileContent, old_str, new_str); const changeId = sendFileChangeToVSCode( absPath, diff --git a/source/utils/literal-replace.spec.ts b/source/utils/literal-replace.spec.ts new file mode 100644 index 000000000..46ccffe9f --- /dev/null +++ b/source/utils/literal-replace.spec.ts @@ -0,0 +1,54 @@ +import test from 'ava'; +import {replaceFirstLiteral} from '@/utils/literal-replace'; + +console.log('\nliteral-replace.spec.ts'); + +test('replaceFirstLiteral replaces the first occurrence only', t => { + t.is(replaceFirstLiteral('a b a b', 'a', 'X'), 'X b a b'); +}); + +test('replaceFirstLiteral returns the content unchanged when absent', t => { + t.is(replaceFirstLiteral('hello', 'nope', 'X'), 'hello'); +}); + +test('replaceFirstLiteral does not collapse $$', t => { + t.is(replaceFirstLiteral('pid=X', 'X', '$$'), 'pid=$$'); +}); + +test('replaceFirstLiteral does not expand $& into the match', t => { + t.is(replaceFirstLiteral('a MATCH b', 'MATCH', '$&'), 'a $& b'); +}); + +test('replaceFirstLiteral does not expand $` into the prefix', t => { + t.is(replaceFirstLiteral('BEFORE|X|AFTER', 'X', '$`'), 'BEFORE|$`|AFTER'); +}); + +test("replaceFirstLiteral does not expand $' into the suffix", t => { + t.is(replaceFirstLiteral('BEFORE|X|AFTER', 'X', "$'"), "BEFORE|$'|AFTER"); +}); + +test('replaceFirstLiteral keeps group tokens literal', t => { + t.is(replaceFirstLiteral('X', 'X', '$1 $ $99'), '$1 $ $99'); +}); + +test('replaceFirstLiteral carries every token through in one pass', t => { + const replacement = 'echo "pid=$$ match=$& pre=$` post=$\'"'; + + t.is( + replaceFirstLiteral('#!/bin/sh\necho "old"\nexit 0\n', 'echo "old"', replacement), + `#!/bin/sh\n${replacement}\nexit 0\n`, + ); +}); + +test('replaceFirstLiteral handles an empty replacement (deletion)', t => { + t.is(replaceFirstLiteral('keep DROP keep', 'DROP ', ''), 'keep keep'); +}); + +test('replaceFirstLiteral matches String.replace for $-free input', t => { + const content = 'alpha\nbeta\ngamma\n'; + + t.is( + replaceFirstLiteral(content, 'beta', 'BETA'), + content.replace('beta', 'BETA'), + ); +}); diff --git a/source/utils/literal-replace.ts b/source/utils/literal-replace.ts new file mode 100644 index 000000000..343fc918b --- /dev/null +++ b/source/utils/literal-replace.ts @@ -0,0 +1,28 @@ +/** + * Replace the first occurrence of `search` with `replacement`, treating the + * replacement as literal text. + * + * `String.prototype.replace` runs GetSubstitution over its second argument, so + * `$$`, `$&`, "$`" and `$'` are rewritten before the result is produced. Those + * are ordinary characters in shell scripts, Makefiles, CI YAML and anything + * that builds a regex, so an edit tool that passes model-supplied text straight + * to `replace` silently writes bytes nobody approved — and "$`" / `$'` splice + * a whole half of the file into the middle of the edit. + * + * Splicing by index sidesteps substitution parsing entirely and avoids + * re-scanning the string for a second pass. + */ +export function replaceFirstLiteral( + content: string, + search: string, + replacement: string, +): string { + const index = content.indexOf(search); + if (index === -1) { + return content; + } + + return ( + content.slice(0, index) + replacement + content.slice(index + search.length) + ); +} From 497f1b05654b305a8bc1a5e8351b69eec5b7d1b3 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Mon, 31 Aug 2026 22:55:59 +0530 Subject: [PATCH 18/25] fix: merge main and tighten memory retrieval (#1078) * feat: add semantic memory storage foundation (#649) * feat: add semantic memory storage foundation * fix: align semantic memory schema with phase 1 * feat: add manual semantic memory creation * test: cover semantic memory error paths * feat: inject semantic memories into prompts * fix semantic memory * feat: add semantic memory management * fix semantic memory category (#709) * feat: add semantic memory setting (#711) * feat: add provenance and warnings to semantic memory proposals (#716) * fix: address review feedback on semantic memory * fix: address round-4 review feedback on semantic memory (#882) * fix: close leftover semantic memory review gaps (#991) Subagent/daemon recall, cross-instance write locking, and a 500-entry store cap. Refs #619 * fix: format settings and memory files * fix: drop dynamic regex from reversal detection * fix: keep /clear from wiping tasks and loosen recall * fix: cache memory managers and repair store edge cases * fix: drop a dead ranking branch and log recall failures properly --------- Co-authored-by: Sk Akram Co-authored-by: Luis Edward Miranda <36224337+llupRisinglll@users.noreply.github.com> Co-authored-by: Will Lamerton <89926355+will-lamerton@users.noreply.github.com> --- .changeset/semantic-memory-remaining.md | 5 + .changeset/semantic-memory-retrieval.md | 5 + .changeset/semantic-memory-setting.md | 5 + docs/configuration/preferences.md | 3 + docs/features/commands.md | 2 + docs/features/index.md | 1 + docs/features/semantic-memory.md | 114 ++++ source/acp/acp-agent.spec.ts | 32 + source/acp/acp-agent.ts | 29 +- source/acp/acp-session.ts | 7 + source/app/components/settings-selector.tsx | 126 ++++ source/app/components/settings-tabs.tsx | 13 + source/app/utils/app-util.ts | 4 +- source/commands.ts | 1 + source/commands/lazy-registry.ts | 10 + source/commands/memory.spec.tsx | 473 +++++++++++++ source/commands/memory.tsx | 327 +++++++++ source/commands/remember.spec.tsx | 137 ++++ source/commands/remember.ts | 79 +++ source/config/preferences.spec.ts | 188 ++++++ source/config/preferences.ts | 92 +++ source/hooks/chat-handler/types.ts | 6 + .../chat-handler/useChatHandler.spec.tsx | 131 ++++ source/hooks/chat-handler/useChatHandler.tsx | 29 + source/hooks/useAppHandlers.tsx | 2 + source/memory/project-context.spec.ts | 178 +++++ source/memory/project-context.ts | 108 +++ source/memory/proposal-store.ts | 58 ++ source/memory/semantic-memory-manager.spec.ts | 274 ++++++++ source/memory/semantic-memory-manager.ts | 433 ++++++++++++ source/memory/summarizer-service.spec.ts | 619 ++++++++++++++++++ source/memory/summarizer-service.ts | 510 +++++++++++++++ source/plain/shell.spec.ts | 80 +++ source/plain/shell.ts | 25 +- source/subagents/subagent-executor.spec.ts | 78 ++- source/subagents/subagent-executor.ts | 28 +- source/types/app.ts | 1 + source/types/commands.ts | 1 + source/types/config.ts | 6 + 39 files changed, 4213 insertions(+), 7 deletions(-) create mode 100644 .changeset/semantic-memory-remaining.md create mode 100644 .changeset/semantic-memory-retrieval.md create mode 100644 .changeset/semantic-memory-setting.md create mode 100644 docs/features/semantic-memory.md create mode 100644 source/commands/memory.spec.tsx create mode 100644 source/commands/memory.tsx create mode 100644 source/commands/remember.spec.tsx create mode 100644 source/commands/remember.ts create mode 100644 source/memory/project-context.spec.ts create mode 100644 source/memory/project-context.ts create mode 100644 source/memory/proposal-store.ts create mode 100644 source/memory/semantic-memory-manager.spec.ts create mode 100644 source/memory/semantic-memory-manager.ts create mode 100644 source/memory/summarizer-service.spec.ts create mode 100644 source/memory/summarizer-service.ts diff --git a/.changeset/semantic-memory-remaining.md b/.changeset/semantic-memory-remaining.md new file mode 100644 index 000000000..d12ec78ee --- /dev/null +++ b/.changeset/semantic-memory-remaining.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Wire semantic memory recall into subagent and daemon runs, serialize writes across manager instances and processes, and cap each repo memory file at 500 entries. diff --git a/.changeset/semantic-memory-retrieval.md b/.changeset/semantic-memory-retrieval.md new file mode 100644 index 000000000..eaba199cd --- /dev/null +++ b/.changeset/semantic-memory-retrieval.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Rank recalled memories by how much of the query they cover, skip tool-call narration in reversal detection, and drop noisy `/memory propose` candidates. diff --git a/.changeset/semantic-memory-setting.md b/.changeset/semantic-memory-setting.md new file mode 100644 index 000000000..01489cb5b --- /dev/null +++ b/.changeset/semantic-memory-setting.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added a dedicated Semantic Memory toggle to `/settings` under Advanced, backed by the `semanticMemoryEnabled` preference. The setting defaults on to preserve existing behavior, and can be turned off to keep agents from persisting reusable context across sessions. diff --git a/docs/configuration/preferences.md b/docs/configuration/preferences.md index 59ae3a88a..460fca658 100644 --- a/docs/configuration/preferences.md +++ b/docs/configuration/preferences.md @@ -46,6 +46,9 @@ Preferences follow the same location hierarchy as configuration files: | `nanocoderShape` | The nanocoder ASCII art shape | | `trustedDirectories` | Directories you've approved through the first-run security disclaimer | | `lastUpdateCheck` | Timestamp of the last update check (used to avoid checking too frequently) | +| `semanticMemoryEnabled` | Enables semantic memory across sessions. Set to `false` or use `/settings` → **Advanced** → **Semantic Memory** to keep agents stateless. | +| `semanticMemoryTokenBudget` | Approximate token ceiling for the recalled `## Project Context` block. Default `240`, clamped to 40-4000. Adjustable from `/settings` → **Advanced**. | +| `semanticMemoryLimit` | Maximum memories considered for a single prompt. Default `8`, clamped to 1-50. Adjustable from `/settings` → **Advanced**. | | `alternateScreen` | When `true`, starts in fullscreen mode (alternate screen buffer with in-app scrolling) by default. The `--alt-screen`/`--no-alt-screen` CLI flags override this for a single run. See [CLI Options](../getting-started/index.md#cli-options). | ### Paste Configuration diff --git a/docs/features/commands.md b/docs/features/commands.md index 15861ca1f..290e53adc 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -46,6 +46,8 @@ Type `/` in the chat input to see available commands. All commands start with `/ | `/explorer` | Interactive file browser to navigate, preview, and select files for context | | `/tune` | Configure runtime model behaviour — tool profiles, compaction, native tools, model parameters (see [Tune](tune.md)) | | `/ide` | Connect to an IDE for live integration (e.g., VS Code diff previews) | +| `/remember` | Save a durable project memory (see [Semantic Memory](semantic-memory.md)) | +| `/memory` | List, delete, propose, and accept project memories (see [Semantic Memory](semantic-memory.md)) | | `/privacy` | Inspect what the prompt scrubber will remove from your prompts | | `/credits` | Show project contributors and dependencies | | `/copilot-login` | Log in to GitHub Copilot via device flow. Saves credentials for the "GitHub Copilot" provider | diff --git a/docs/features/index.md b/docs/features/index.md index 104aa7a83..71d49c9e8 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -247,6 +247,7 @@ Extend Nanocoder's capabilities by connecting [MCP (Model Context Protocol) serv | [Checkpointing](checkpointing.md) | Saving and restoring conversation snapshots | | [Session Management](session-management.md) | Automatic session saving and resumption | | [Task Management](task-management.md) | Tracking multi-step work | +| [Semantic Memory](semantic-memory.md) | Save durable project facts and recall them automatically across sessions | | [File Explorer](file-explorer.md) | Interactive file browser for context selection | | [Image Attachments](image-attachments.md) | Send screenshots and images to vision-capable models | | [VS Code Extension](vscode-extension.md) | Editor integration with live diff previews | diff --git a/docs/features/semantic-memory.md b/docs/features/semantic-memory.md new file mode 100644 index 000000000..19ebe70da --- /dev/null +++ b/docs/features/semantic-memory.md @@ -0,0 +1,114 @@ +--- +title: "Semantic Memory" +description: "Save durable project facts and recall them automatically across sessions" +sidebar_order: 13 +--- + +# Semantic Memory + +Semantic memory lets you save durable facts about a project - architectural decisions, conventions, known issues, rejected approaches - so you don't have to re-explain them every session. Relevant memories are automatically recalled and injected into the system prompt as project context. + +Memory creation is always manual and explicit. Nothing is ever saved automatically after a session; you decide what's worth remembering. + +## Commands + +- `/remember [--category ] ` - Save a memory directly. `-c` is a short form of `--category`. +- `/memory list` - List all saved memories with their short IDs and categories. `/memory ls` is an alias, and a bare `/memory` with no subcommand does the same thing. +- `/memory delete ` - Delete a specific memory. `/memory rm` is an alias. +- `/memory clear` - Delete all memories for the current project. +- `/memory propose` - Scan the recent conversation for durable-sounding facts and print them as numbered proposals for review. +- `/memory accept ` - Save proposal `n` from the most recent `/memory propose` output. + +### Example + +``` +/remember The auth module uses Clerk and avoids middleware in the edge runtime. +/remember -c codingStyle Use camelCase for all variable names. + +/memory list +/memory delete 18d51c0d +/memory propose +/memory accept 2 +``` + +### Memory IDs + +`/memory list` prints an 8-character short ID for each memory, which is what you pass to `/memory delete`. The full UUID still works, as does any unambiguous prefix of either. If a prefix matches more than one memory, the command reports the ambiguity and deletes nothing rather than guessing. + +## Categories + +Memories are grouped into: `architecture`, `bugFix`, `refactor`, `todo`, `codingStyle`, or `project` (the default, for anything that doesn't match a more specific category). `/remember` infers a category automatically from the content unless you pass `--category`. + +## Recall + +When you send a message, Nanocoder ranks saved memories by relevance to that message (keyword overlap, with common words filtered out) and injects the most relevant ones into the system prompt under a `## Project Context` heading, up to a token budget. Low-relevance memories are dropped rather than injected as noise. + +A memory is kept when it covers at least 10% of the query's keywords, and either the category matched, at least two keywords overlapped, or a single overlapping keyword is at least half the query. That last rule is why `auth` and `fix auth` both recall a Clerk/auth memory, while a one-word hit in a long prompt still does not. Memories that do not fit the remaining token budget are skipped so a later, shorter memory can still be injected. + +Retrieval is keyword-based, not a true embeddings/vector search. The "semantic" in the name refers to the kind of facts stored (durable project knowledge), not the matching technique. + +### Where recall is active + +Recall runs on: + +- the interactive TUI +- `nanocoder run` / `--plain` +- `--acp` +- subagent runs (the `agent` tool) +- daemon-triggered skill runs (they use the same subagent executor) + +The TUI, plain shell, and ACP print `Recalling N project memories...` when memories are injected. Subagent and daemon runs inject the same block silently, since there is no chat UI to attach that notice to. + +### Tuning the budget + +Two settings bound how much of the context window project context may consume. Both are adjustable from `/settings` -> **Advanced**, which cycles through common presets, or by editing `nanocoder-preferences.json` directly for any value in range. + +| Preference key | Default | Range | Meaning | +|---|---|---|---| +| `semanticMemoryEnabled` | `true` | boolean | Master switch for recall and writes | +| `semanticMemoryTokenBudget` | `240` | 40 - 4000 | Approximate token ceiling for the injected block | +| `semanticMemoryLimit` | `8` | 1 - 50 | Maximum memories considered for one prompt | + +Values outside the supported range are clamped rather than rejected. On a small local model the 240-token default is a meaningful slice of the window, so lowering it is often the right call. + +## Proposals + +`/memory propose` looks back through the recent conversation for lines that read like durable facts (matched against the category keywords above) and prints them with their source (`explicit-user` or `conversation-inferred`) and a short evidence snippet. Nothing is saved until you run `/memory accept `. + +The scan covers the last 40 messages and prints at most 20 proposals, so a long session doesn't produce a list too large to review. Proposals without warnings are listed first. The printed numbering is fixed for as long as that list stands: accepting one proposal does not renumber the others, and accepting the same number twice is refused rather than repeated. Running `/clear` discards the list, since its evidence refers to a conversation you can no longer see. + +### Warnings + +Proposals inferred purely from assistant text carry an `Inferred from conversation, no explicit user statement.` warning. + +A proposal is additionally flagged `Possible assistant position reversal.` when the assistant turn looks like a concession to pressure rather than to evidence. That means the turn was preceded by a user message carrying no code, file path, or error output, and the turn either contradicts an earlier assistant turn on the same subject or opens with an agreement phrase. Tool-call turns (including ones that also have narration) and short "let me look at the file" turns are stepped over, so the check still works in a normal agentic session where the assistant reads files between turns. CamelCase words and the bare word "error" in ordinary prose do not count as technical evidence. + +This catches the case where a model agreeing with a user's stylistic preference gets summarized into a "project convention" that was never actually decided. If you explicitly restate the same line yourself, the reversal warning is cleared, since your own statement is what actually resolves the ambiguity. + +The check is a heuristic tuned to over-flag rather than miss: it only adds a warning to a proposal you are already reviewing by hand, so a spurious warning costs you a moment's attention while a missed one costs you a false project convention. + +## Turning It Off + +Semantic memory is on by default. Toggle it from `/settings` -> **Advanced** -> **Semantic Memory**. Turning it off disables both recall (memories are no longer injected into prompts) and writes (`/remember` and `/memory accept` are refused while it's off). + +## Storage and Scope + +Memories are stored per-repository in a local JSON file under the Nanocoder data directory: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/nanocoder/memory/` | +| Linux | `~/.local/share/nanocoder/memory/` (or `$XDG_DATA_HOME/nanocoder/memory/`) | +| Windows | `%APPDATA%\nanocoder\memory\` | + +Setting `NANOCODER_DATA_DIR` overrides all of these. + +The filename is a hash of the repository's `git remote origin.url`, or of its absolute path for non-git directories. This means: + +- All branches, worktrees, and local clones that share the same `origin` remote share one memory pool. +- Forks with a different `origin` get their own, separate pool. +- This scope isn't currently configurable. If you work across branches with genuinely divergent conventions in the same repository, they'll share memories. + +Each repository file is capped at 500 memories. Saving past that drops the oldest entries (by timestamp) so the file cannot grow without bound. Writes to the same file are serialized across manager instances in one process, and locked across processes (TUI and daemon). + +Files are written atomically (temp file + rename) with restrictive permissions (`0600` on the file, `0700` on the directory), and nothing ever leaves your machine. Memory content is fenced when injected into the system prompt, with the fence widened as needed so content containing backticks cannot break out of it. diff --git a/source/acp/acp-agent.spec.ts b/source/acp/acp-agent.spec.ts index a3e7fa13c..11c8adbb2 100644 --- a/source/acp/acp-agent.spec.ts +++ b/source/acp/acp-agent.spec.ts @@ -12,6 +12,7 @@ import { } from '@/message-handler'; import {convertToModelMessages} from '@/ai-sdk-client/converters/message-converter'; import {sessionManager} from '@/session/session-manager'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; console.log('\nacp-agent.spec.ts'); @@ -946,3 +947,34 @@ test('AcpAgent.extMethod - timeline/list throws on missing session', async t => ); }); +test('AcpAgent.prompt - recalls relevant project memories scoped to the session cwd, without accumulating across turns', async t => { + await new SemanticMemoryManager({cwd: '/tmp'}).addMemory({ + content: 'Auth uses Clerk and avoids middleware.', + }); + + const capturedSystemPrompts: string[] = []; + const conn = createMockConn(); + const initContext = createMockInitContext(); + initContext.client = { + ...initContext.client, + chat: async (messages: Array<{content: string}>) => { + capturedSystemPrompts.push(messages[0]?.content ?? ''); + return {choices: [{message: {content: 'Test response'}}]}; + }, + } as any; + const agent = new AcpAgent(initContext, conn); + const session = await agent.newSession({cwd: '/tmp'}); + + await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'refactor auth middleware handling'}], + }); + await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'unrelated question about docs'}], + }); + + t.true(capturedSystemPrompts[0]?.includes('## Project Context')); + t.true(capturedSystemPrompts[0]?.includes('Auth uses Clerk')); + t.false(capturedSystemPrompts[1]?.includes('## Project Context')); +}); diff --git a/source/acp/acp-agent.ts b/source/acp/acp-agent.ts index 2bfdd710b..383e22ce0 100644 --- a/source/acp/acp-agent.ts +++ b/source/acp/acp-agent.ts @@ -46,8 +46,13 @@ import {artifactManager} from '@/artifacts/artifact-manager'; import {isInternalWalkthroughMessage} from '@/artifacts/walkthrough-lifecycle'; import {createLLMClient} from '@/client-factory'; import {getAppConfig} from '@/config/index'; -import {loadPreferences, updateLastUsed} from '@/config/preferences'; +import { + getProjectContextPreferences, + loadPreferences, + updateLastUsed, +} from '@/config/preferences'; import {resolveTune} from '@/config/tune'; +import {appendRelevantProjectContextWithCount} from '@/memory/project-context'; import {TimelineManager} from '@/services/timeline-manager'; import {sessionManager} from '@/session/session-manager'; import {getTuneToolMode} from '@/types/config'; @@ -334,6 +339,25 @@ export class AcpAgent implements Agent { }, ]; + if (session.baseSystemMessage) { + const projectContext = await appendRelevantProjectContextWithCount( + session.baseSystemMessage.content, + userText, + session.getMemoryFinder(), + getProjectContextPreferences(), + ); + session.systemMessage = { + role: 'system', + content: projectContext.systemPrompt, + }; + setLastBuiltPrompt(projectContext.systemPrompt); + if (projectContext.memoryCount > 0) { + logger.info( + `ACP recall: session=${params.sessionId} count=${projectContext.memoryCount}`, + ); + } + } + const config = getAppConfig(); const nonInteractiveAlwaysAllow = config.alwaysAllow ?? []; @@ -840,7 +864,8 @@ export class AcpAgent implements Agent { ); setLastBuiltPrompt(systemContent); - session.systemMessage = {role: 'system', content: systemContent}; + session.baseSystemMessage = {role: 'system', content: systemContent}; + session.systemMessage = session.baseSystemMessage; } private async saveAcpSessionToDisk(session: AcpSession): Promise { diff --git a/source/acp/acp-session.ts b/source/acp/acp-session.ts index ccd12f5ae..559e6d092 100644 --- a/source/acp/acp-session.ts +++ b/source/acp/acp-session.ts @@ -2,6 +2,7 @@ import type { AgentSideConnection, ClientCapabilities, } from '@agentclientprotocol/sdk'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import {TimelineManager} from '@/services/timeline-manager'; import type {DevelopmentMode, Message} from '@/types/core'; @@ -14,12 +15,14 @@ export class AcpSession { messages: Message[] = []; systemMessage?: Message; + baseSystemMessage?: Message; abortController = new AbortController(); developmentMode: DevelopmentMode; /** True while a prompt turn is being processed, to reject overlapping prompts. */ turnActive = false; /** URI of the file currently focused in the editor client (e.g. VS Code). */ activeFile?: string; + private memoryFinder?: SemanticMemoryManager; constructor(options: { sessionId: string; @@ -36,6 +39,10 @@ export class AcpSession { this.timeline = new TimelineManager(options.cwd, options.sessionId); } + getMemoryFinder(): SemanticMemoryManager { + return (this.memoryFinder ??= new SemanticMemoryManager({cwd: this.cwd})); + } + cancel(): void { this.abortController.abort(); } diff --git a/source/app/components/settings-selector.tsx b/source/app/components/settings-selector.tsx index 1a3606017..d76f8c632 100644 --- a/source/app/components/settings-selector.tsx +++ b/source/app/components/settings-selector.tsx @@ -11,6 +11,7 @@ import { getNotificationsPreference, getPasteThreshold, getPrivacyPreference, + getProjectContextPreferences, getReasoningExpanded, getShowUsageFooter, updateCompactToolDisplay, @@ -20,6 +21,9 @@ import { updatePrivacyPreference, updateReasoningExpanded, updateSelectedTheme, + updateSemanticMemoryEnabled, + updateSemanticMemoryLimit, + updateSemanticMemoryTokenBudget, updateShowUsageFooter, } from '@/config/preferences'; import {getThemeColors, themes} from '@/config/themes'; @@ -46,6 +50,7 @@ export type ManagedSettingsPanel = | 'notifications' | 'display-settings' | 'privacy' + | 'semantic-memory' | 'json-config' | 'web-search' | 'providers-config' @@ -984,3 +989,124 @@ export function SettingsPrivacyPanel({ ); } + +/** Presets cycled by the Advanced panel. Any value in range can still be set + * directly in nanocoder-preferences.json; these are just the common choices. */ +const TOKEN_BUDGET_PRESETS = [120, 240, 480, 960]; +const MEMORY_LIMIT_PRESETS = [3, 5, 8, 12]; + +/** Next preset after `current`, wrapping. Falls to the first when `current` + * is a hand-edited value that isn't in the list. */ +function cyclePreset(presets: number[], current: number): number { + const index = presets.indexOf(current); + return presets[(index + 1) % presets.length] ?? presets[0] ?? current; +} + +// Semantic memory settings panel +export function SettingsSemanticMemoryPanel({ + onBack, + onCancel, +}: { + onBack: () => void; + onCancel: () => void; +}) { + const {boxWidth, isNarrow} = useResponsiveTerminal(); + const {colors} = useTheme(); + + const initialContextPreferences = getProjectContextPreferences(); + const [semanticMemoryEnabled, setSemanticMemoryEnabled] = useState( + initialContextPreferences.semanticMemoryEnabled, + ); + const [tokenBudget, setTokenBudget] = useState( + initialContextPreferences.tokenBudget, + ); + const [memoryLimit, setMemoryLimit] = useState( + initialContextPreferences.memoryLimit, + ); + + useInput((_, key) => { + if (key.escape) { + onCancel(); + } + if (key.shift && key.tab) { + onBack(); + } + }); + + const items = useMemo(() => { + return [ + { + label: `Semantic Memory: ${semanticMemoryEnabled ? 'ON' : 'OFF'}`, + value: 'semantic-memory', + }, + { + label: `Memory Token Budget: ${tokenBudget}`, + value: 'semantic-memory-token-budget', + }, + { + label: `Memories Per Prompt: ${memoryLimit}`, + value: 'semantic-memory-limit', + }, + ]; + }, [semanticMemoryEnabled, tokenBudget, memoryLimit]); + + const handleSelect = (item: {value: string}) => { + switch (item.value) { + case 'semantic-memory': { + const next = !semanticMemoryEnabled; + setSemanticMemoryEnabled(next); + updateSemanticMemoryEnabled(next); + break; + } + case 'semantic-memory-token-budget': { + const next = cyclePreset(TOKEN_BUDGET_PRESETS, tokenBudget); + setTokenBudget(next); + updateSemanticMemoryTokenBudget(next); + break; + } + case 'semantic-memory-limit': { + const next = cyclePreset(MEMORY_LIMIT_PRESETS, memoryLimit); + setMemoryLimit(next); + updateSemanticMemoryLimit(next); + break; + } + } + }; + + const title = isNarrow ? 'Memory' : 'Semantic Memory'; + + return ( + + {!isNarrow && ( + + + Toggle settings with Enter. Shift+Tab to go back, Esc to exit + + + )} + + + + Semantic Memory recalls saved project context and injects it into + future prompts. Turn it off for stateless agent behavior. The budget + and per-prompt count bound how much of the context window it may + consume - lower them on small local models. + + + + + + + Enter/Esc + + + ); +} diff --git a/source/app/components/settings-tabs.tsx b/source/app/components/settings-tabs.tsx index f97a7818b..46a0b1d53 100644 --- a/source/app/components/settings-tabs.tsx +++ b/source/app/components/settings-tabs.tsx @@ -11,6 +11,7 @@ import { getPasteThreshold, getPrivacyPreference, getProfessionalTone, + getProjectContextPreferences, getReasoningExpanded, updateAlternateScreen, updateProfessionalTone, @@ -38,6 +39,7 @@ import { SettingsNotificationsPanel, SettingsPasteThresholdPanel, SettingsPrivacyPanel, + SettingsSemanticMemoryPanel, SettingsThemePanel, SettingsTitleShapePanel, } from './settings-selector'; @@ -259,6 +261,15 @@ function buildRowsForTab( ]; case 'advanced': { const rows: SettingRow[] = [ + { + kind: 'managed', + id: 'semantic-memory', + label: 'Semantic Memory', + value: getProjectContextPreferences().semanticMemoryEnabled + ? 'on' + : 'off', + panel: 'semantic-memory', + }, { kind: 'managed', id: 'privacy', @@ -408,6 +419,8 @@ function renderManagedPanel( return ; case 'display-settings': return ; + case 'semantic-memory': + return ; case 'privacy': return ; case 'json-config': diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts index ab9af0568..b9e0ea689 100644 --- a/source/app/utils/app-util.ts +++ b/source/app/utils/app-util.ts @@ -10,6 +10,7 @@ import {CopilotLogin} from '@/commands/copilot-login'; import BashProgress from '@/components/bash-progress'; import CommandProgress from '@/components/command-progress'; import {DELAY_COMMAND_COMPLETE_MS, MAX_SESSION_NAME_LENGTH} from '@/constants'; +import {sharedProposalStore} from '@/memory/proposal-store'; import {CheckpointManager} from '@/services/checkpoint-manager'; import {generateKey} from '@/session/key-generator'; import {executeBashCommand, formatBashResultForLLM} from '@/tools/execute-bash'; @@ -312,7 +313,7 @@ async function handleSpecialCommand( } case SPECIAL_COMMANDS.CLEAR: await onClearMessages(); - // Increment clear counter to force re-render of static components + sharedProposalStore.clear(); options.onClearCounterIncrement?.(); setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); return true; @@ -562,6 +563,7 @@ async function handleBuiltInCommand( developmentMode: options.developmentMode, lastApiUsage, apiCallHistory, + sessionId: options.sessionId, }); } finally { if (progressLabel) { diff --git a/source/commands.ts b/source/commands.ts index a9b76d388..3671fffcb 100644 --- a/source/commands.ts +++ b/source/commands.ts @@ -105,6 +105,7 @@ class CommandRegistry { developmentMode?: import('@/types/core').DevelopmentMode; lastApiUsage?: import('@/types/core').ApiUsageSnapshot | null; apiCallHistory?: import('@/types/core').ApiCallRecord[]; + sessionId?: string; }, ): Promise { const parts = input.trim().split(/\s+/); diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts index 8b44ccb66..e701874b9 100644 --- a/source/commands/lazy-registry.ts +++ b/source/commands/lazy-registry.ts @@ -177,6 +177,16 @@ export const lazyCommands: LazyCommand[] = [ 'Re-run the last user turn (use --model to switch models first)', load: () => import('@/commands/retry').then(m => m.retryCommand), }, + { + name: 'remember', + description: 'Save a durable project memory', + load: () => import('@/commands/remember').then(m => m.rememberCommand), + }, + { + name: 'memory', + description: 'Manage project memories', + load: () => import('@/commands/memory').then(m => m.memoryCommand), + }, { name: 'tasks', description: 'Manage your task list', diff --git a/source/commands/memory.spec.tsx b/source/commands/memory.spec.tsx new file mode 100644 index 000000000..5b4a27646 --- /dev/null +++ b/source/commands/memory.spec.tsx @@ -0,0 +1,473 @@ +import test from 'ava'; +import React from 'react'; +import {ProposalStore} from '@/memory/proposal-store'; +import type {SemanticMemory} from '@/memory/semantic-memory-manager'; +import type {MemoryProposal} from '@/memory/summarizer-service'; +import {renderWithTheme} from '@/test-utils/render-with-theme'; +import type {Message} from '@/types/core'; +import {lazyCommands} from './lazy-registry.js'; +import {createMemoryCommand, memoryCommand} from './memory.js'; + +const testMetadata = { + provider: 'test-provider', + model: 'test-model', + tokens: 0, + getMessageTokens: (message: Message) => message.content.length, +}; + +class FakeMemoryManager { + memories: SemanticMemory[] = []; + cleared = false; + + async listMemories(): Promise { + return this.memories; + } + + async deleteMemory(id: string): Promise { + const before = this.memories.length; + this.memories = this.memories.filter(memory => memory.id !== id); + return this.memories.length !== before; + } + + async clearMemories(): Promise { + this.cleared = true; + this.memories = []; + } +} + +class FakeSummarizerService { + accepted: Array> = []; + sessionIds: Array = []; + + constructor(private readonly proposals: MemoryProposal[]) {} + + proposeMemoriesFromMessages(messages: Message[]): MemoryProposal[] { + return messages.length === 0 ? [] : this.proposals; + } + + async acceptProposal( + proposal: Pick, + sourceSessionId?: string, + ): Promise { + this.accepted.push({content: proposal.content, category: proposal.category}); + this.sessionIds.push(sourceSessionId); + return { + id: `accepted-${this.accepted.length}`, + content: proposal.content, + category: proposal.category, + timestamp: '2026-08-05T00:00:00.000Z', + }; + } +} + +test('memoryCommand has correct name and description', t => { + t.is(memoryCommand.name, 'memory'); + t.is(memoryCommand.description, 'Manage project memories'); +}); + +test('memory command lists empty state', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['list'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('No project memories saved.')); +}); + +test('memory command lists saved memories', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + { + id: 'memory-1', + content: 'Auth uses Clerk.', + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }, + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['list'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + // Listed by short id rather than the raw UUID. + t.true(output.includes('memory1')); + t.true(output.includes('architecture')); + t.true(output.includes('Auth uses Clerk.')); +}); + +test('memory command deletes a memory', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + { + id: 'memory-1', + content: 'Auth uses Clerk.', + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }, + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', 'memory-1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Deleted memory: memory1')); + t.deepEqual(manager.memories, []); +}); + +test('memory command reports missing memory delete', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', 'missing'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Memory not found: missing')); +}); + +test('memory command clears memories', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + { + id: 'memory-1', + content: 'Auth uses Clerk.', + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }, + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['clear'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true(manager.cleared); + t.true((lastFrame() ?? '').includes('Cleared project memories.')); +}); + +test('memory command shows usage for unknown subcommand', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['unknown'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Usage: /memory')); +}); + +test('memory command proposes durable memories from current messages', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({ + memoryManager: manager, + summarizerService: new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: { + userMessages: ['Refactor auth.'], + assistantMessages: [], + }, + warnings: [], + }, + ]), + }); + + const result = await command.handler( + ['propose'], + [ + { + role: 'user', + content: 'Refactor auth.', + }, + ], + testMetadata, + ); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + t.true(output.includes('[architecture]')); + t.true(output.includes('Auth uses Clerk.')); +}); + +test('memory command reports when no proposals are found', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({ + memoryManager: manager, + summarizerService: new FakeSummarizerService([]), + }); + + const result = await command.handler(['propose'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('No durable memory proposals found.')); +}); + +test('memory command accepts a proposal by index after propose', async t => { + const manager = new FakeMemoryManager(); + const summarizerService = new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: {userMessages: ['Refactor auth.'], assistantMessages: []}, + warnings: [], + }, + ]); + const command = createMemoryCommand({memoryManager: manager, summarizerService}); + + await command.handler(['propose'], [{role: 'user', content: 'Refactor auth.'}], testMetadata); + const result = await command.handler(['accept', '1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Saved architecture memory: Auth uses Clerk.')); + t.deepEqual(summarizerService.accepted, [ + {content: 'Auth uses Clerk.', category: 'architecture'}, + ]); + t.deepEqual(summarizerService.sessionIds, [undefined]); +}); + +test('memory command passes the current session id when accepting a proposal', async t => { + const manager = new FakeMemoryManager(); + const summarizerService = new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: {userMessages: ['Refactor auth.'], assistantMessages: []}, + warnings: [], + }, + ]); + const command = createMemoryCommand({memoryManager: manager, summarizerService}); + + await command.handler(['propose'], [{role: 'user', content: 'Refactor auth.'}], testMetadata); + await command.handler(['accept', '1'], [], { + ...testMetadata, + sessionId: 'session-1', + }); + + t.deepEqual(summarizerService.sessionIds, ['session-1']); +}); + +test('memory command rejects accept with no prior proposals', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({ + memoryManager: manager, + summarizerService: new FakeSummarizerService([]), + }); + + const result = await command.handler(['accept', '1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true( + (lastFrame() ?? '').includes('No proposals to accept. Run /memory propose first.'), + ); +}); + +test('memory command rejects accept with an out-of-range index', async t => { + const manager = new FakeMemoryManager(); + const summarizerService = new FakeSummarizerService([ + { + content: 'Auth uses Clerk.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: {userMessages: ['Refactor auth.'], assistantMessages: []}, + warnings: [], + }, + ]); + const command = createMemoryCommand({memoryManager: manager, summarizerService}); + + await command.handler(['propose'], [{role: 'user', content: 'Refactor auth.'}], testMetadata); + const result = await command.handler(['accept', '5'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Usage: /memory accept <1-1>')); + t.deepEqual(summarizerService.accepted, []); +}); + +test('lazy registry exposes /memory', t => { + const memory = lazyCommands.find(command => command.name === 'memory'); + + t.truthy(memory); + t.is(memory?.description, 'Manage project memories'); +}); + +// --- Accept indexing: the round-3 review's merge blocker. Accepting a proposal +// must not renumber the list the user is still reading off screen. --- + +function proposal(content: string, category = 'architecture'): MemoryProposal { + return { + content, + category, + sourceType: 'explicit-user', + evidence: {userMessages: [content], assistantMessages: []}, + warnings: [], + }; +} + +const FOUR_PROPOSALS = [ + proposal('Proposal one.'), + proposal('Proposal two.'), + proposal('Proposal three.'), + proposal('Proposal four.'), +]; + +test('memory accept keeps indices stable across successive accepts', async t => { + const summarizerService = new FakeSummarizerService(FOUR_PROPOSALS); + const command = createMemoryCommand({ + memoryManager: new FakeMemoryManager(), + summarizerService, + }); + + await command.handler( + ['propose'], + [{role: 'user', content: 'seed'}], + testMetadata, + ); + await command.handler(['accept', '2'], [], testMetadata); + await command.handler(['accept', '3'], [], testMetadata); + + // Before the fix the second accept saved "Proposal four." because the list + // was re-indexed after the first accept. + t.deepEqual(summarizerService.accepted, [ + {content: 'Proposal two.', category: 'architecture'}, + {content: 'Proposal three.', category: 'architecture'}, + ]); +}); + +test('memory accept refuses to save the same proposal twice', async t => { + const summarizerService = new FakeSummarizerService(FOUR_PROPOSALS); + const command = createMemoryCommand({ + memoryManager: new FakeMemoryManager(), + summarizerService, + }); + + await command.handler( + ['propose'], + [{role: 'user', content: 'seed'}], + testMetadata, + ); + await command.handler(['accept', '2'], [], testMetadata); + const result = await command.handler(['accept', '2'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Proposal 2 was already saved.')); + t.is(summarizerService.accepted.length, 1); +}); + +test('memory accept is reset when the proposal store is cleared', async t => { + const store = new ProposalStore(); + const summarizerService = new FakeSummarizerService(FOUR_PROPOSALS); + const command = createMemoryCommand({ + memoryManager: new FakeMemoryManager(), + summarizerService, + proposalStore: store, + }); + + await command.handler( + ['propose'], + [{role: 'user', content: 'seed'}], + testMetadata, + ); + // What /clear does. + store.clear(); + + const result = await command.handler(['accept', '1'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true( + (lastFrame() ?? '').includes('No proposals to accept. Run /memory propose first.'), + ); + t.deepEqual(summarizerService.accepted, []); +}); + +// --- Short ids --- + +const UUID_A = '18d51c0d-becb-4efc-8d0d-b8c1f3b61802'; +const UUID_B = '18d51c0d-0000-4efc-8d0d-b8c1f3b61802'; +const UUID_C = 'ff000000-1111-4efc-8d0d-b8c1f3b61802'; + +function storedMemory(id: string, content: string): SemanticMemory { + return { + id, + content, + category: 'architecture', + timestamp: '2026-07-21T00:00:00.000Z', + }; +} + +test('memory list shows a short id instead of the raw UUID', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_A, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['list'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + t.true(output.includes('18d51c0d')); + t.false(output.includes(UUID_A)); +}); + +test('memory delete accepts a short id', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_C, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', 'ff000000'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Deleted memory: ff000000')); + t.deepEqual(manager.memories, []); +}); + +test('memory delete still accepts a full UUID', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_C, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + await command.handler(['delete', UUID_C], [], testMetadata); + + t.deepEqual(manager.memories, []); +}); + +test('memory delete reports an ambiguous short id instead of guessing', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [ + storedMemory(UUID_A, 'Auth uses Clerk.'), + storedMemory(UUID_B, 'Storage uses SQLite.'), + ]; + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler(['delete', '18d51c0d'], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Ambiguous memory id')); + t.is(manager.memories.length, 2); +}); + +test('bare /memory defaults to list', async t => { + const manager = new FakeMemoryManager(); + const command = createMemoryCommand({memoryManager: manager}); + + const result = await command.handler([], [], testMetadata); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('No project memories saved.')); +}); + +test('memory ls and rm aliases behave like list and delete', async t => { + const manager = new FakeMemoryManager(); + manager.memories = [storedMemory(UUID_C, 'Auth uses Clerk.')]; + const command = createMemoryCommand({memoryManager: manager}); + + const listed = await command.handler(['ls'], [], testMetadata); + t.true( + (renderWithTheme(listed as React.ReactElement).lastFrame() ?? '').includes( + 'Auth uses Clerk.', + ), + ); + + await command.handler(['rm', 'ff000000'], [], testMetadata); + t.deepEqual(manager.memories, []); +}); diff --git a/source/commands/memory.tsx b/source/commands/memory.tsx new file mode 100644 index 000000000..528cb7e8a --- /dev/null +++ b/source/commands/memory.tsx @@ -0,0 +1,327 @@ +import {Box, Text} from 'ink'; +import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; +import {useTerminalWidth} from '@/hooks/useTerminalWidth'; +import {useTheme} from '@/hooks/useTheme'; +import {ProposalStore, sharedProposalStore} from '@/memory/proposal-store'; +import type {SemanticMemory} from '@/memory/semantic-memory-manager'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; +import type {MemoryProposal} from '@/memory/summarizer-service'; +import {SummarizerService} from '@/memory/summarizer-service'; +import type {Command} from '@/types/commands'; +import {formatError} from '@/utils/error-formatter'; +import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory'; + +interface MemoryCommandOptions { + memoryManager?: Pick< + SemanticMemoryManager, + 'listMemories' | 'deleteMemory' | 'clearMemories' + >; + summarizerService?: Pick< + SummarizerService, + 'proposeMemoriesFromMessages' | 'acceptProposal' + >; + proposalStore?: ProposalStore; +} + +const USAGE = + 'Usage: /memory list | /memory delete | /memory clear | /memory propose | /memory accept '; + +/** Length of the display id. Long enough to stay unique in a realistic pool, + * short enough to retype without copying out of wrapped terminal output. */ +const SHORT_ID_LENGTH = 8; + +export function shortMemoryId(id: string): string { + return id.replaceAll('-', '').slice(0, SHORT_ID_LENGTH); +} + +type IdLookup = + | {kind: 'found'; memory: SemanticMemory} + | {kind: 'missing'} + | {kind: 'ambiguous'; matches: SemanticMemory[]}; + +/** Accepts a short id, a full UUID, or any unambiguous prefix of either. */ +export function resolveMemoryId( + memories: SemanticMemory[], + input: string, +): IdLookup { + const needle = input.trim().toLowerCase(); + if (!needle) return {kind: 'missing'}; + + const exact = memories.find(memory => memory.id.toLowerCase() === needle); + if (exact) return {kind: 'found', memory: exact}; + + const matches = memories.filter(memory => { + const compact = memory.id.replaceAll('-', '').toLowerCase(); + return ( + compact.startsWith(needle.replaceAll('-', '')) || + memory.id.toLowerCase().startsWith(needle) + ); + }); + + if (matches.length === 0) return {kind: 'missing'}; + if (matches.length > 1) return {kind: 'ambiguous', matches}; + return {kind: 'found', memory: matches[0] as SemanticMemory}; +} + +function MemoryList({memories}: {memories: SemanticMemory[]}) { + const {colors} = useTheme(); + const width = useTerminalWidth(); + + return ( + + {memories.map((memory, index) => ( + + + + {shortMemoryId(memory.id)} + + · {memory.category} + + + {memory.content} + + + ))} + + + + {memories.length} memor{memories.length === 1 ? 'y' : 'ies'} · delete + one with /memory delete <id> + + + + ); +} + +function ProposalEvidence({proposal}: {proposal: MemoryProposal}) { + const {colors} = useTheme(); + const rows = [ + ...proposal.evidence.userMessages.map(text => ({label: 'User', text})), + ...proposal.evidence.assistantMessages.map(text => ({ + label: 'Assistant', + text, + })), + ]; + + if (rows.length === 0) return null; + + return ( + + {rows.map(row => ( + + {row.label}: "{row.text}" + + ))} + + ); +} + +function MemoryProposals({proposals}: {proposals: readonly MemoryProposal[]}) { + const {colors} = useTheme(); + const width = useTerminalWidth(); + + return ( + + {proposals.map((proposal, index) => { + const hasWarnings = proposal.warnings.length > 0; + return ( + + + + {index + 1}. + + [{proposal.category}] + {proposal.sourceType} + {hasWarnings && ( + · review carefully + )} + + + {proposal.content} + + + {proposal.warnings.map(warning => ( + + ⚠ {warning} + + ))} + + ); + })} + + + + Save one with /memory accept <1-{proposals.length}> + + + + ); +} + +export function createMemoryCommand( + options: MemoryCommandOptions = {}, +): Command { + let memoryManager = options.memoryManager; + let summarizerService = options.summarizerService; + if (!memoryManager) { + const manager = new SemanticMemoryManager(); + memoryManager = manager; + summarizerService ??= new SummarizerService(manager); + } else { + summarizerService ??= new SummarizerService(); + } + // Defaults to a private store; only the exported singleton binds the shared + // one, so tests and any ad-hoc instance can't clobber each other's state. + const proposalStore = options.proposalStore ?? new ProposalStore(); + + return { + name: 'memory', + description: 'Manage project memories', + handler: async (args, messages, metadata) => { + const subcommand = args[0]?.toLowerCase() ?? 'list'; + + try { + if (subcommand === 'list' || subcommand === 'ls') { + const memories = await memoryManager.listMemories(); + if (memories.length === 0) { + return infoMsg('No project memories saved.', 'memory-list'); + } + + return ; + } + + if (subcommand === 'delete' || subcommand === 'rm') { + const id = args[1]?.trim(); + if (!id) return errorMsg(USAGE, 'memory-error'); + + const lookup = resolveMemoryId( + await memoryManager.listMemories(), + id, + ); + if (lookup.kind === 'missing') { + return errorMsg(`Memory not found: ${id}`, 'memory-error'); + } + if (lookup.kind === 'ambiguous') { + const ids = lookup.matches + .map(memory => shortMemoryId(memory.id)) + .join(', '); + return errorMsg( + `Ambiguous memory id "${id}" matches: ${ids}`, + 'memory-error', + ); + } + + const deleted = await memoryManager.deleteMemory(lookup.memory.id); + if (!deleted) { + return errorMsg(`Memory not found: ${id}`, 'memory-error'); + } + + return successMsg( + `Deleted memory: ${shortMemoryId(lookup.memory.id)}`, + 'memory-deleted', + ); + } + + if (subcommand === 'clear') { + await memoryManager.clearMemories(); + proposalStore.clear(); + return successMsg('Cleared project memories.', 'memory-cleared'); + } + + if (subcommand === 'propose') { + const proposals = + summarizerService.proposeMemoriesFromMessages(messages); + if (proposals.length === 0) { + proposalStore.clear(); + return infoMsg( + 'No durable memory proposals found.', + 'memory-propose', + ); + } + + // Warning-free proposals first, so the safest choices carry the + // lowest numbers. Order is fixed here and never changes again - + // `/memory accept` indexes into exactly this list. + proposals.sort( + (a, b) => + (a.warnings.length === 0 ? 0 : 1) - + (b.warnings.length === 0 ? 0 : 1), + ); + proposalStore.set(proposals); + + return ; + } + + if (subcommand === 'accept') { + if (proposalStore.size === 0) { + return errorMsg( + 'No proposals to accept. Run /memory propose first.', + 'memory-error', + ); + } + + const index = Number.parseInt(args[1] ?? '', 10); + const proposal = proposalStore.at(index); + if (!proposal) { + return errorMsg( + `Usage: /memory accept <1-${proposalStore.size}>`, + 'memory-error', + ); + } + if (proposalStore.isAccepted(index)) { + return errorMsg( + `Proposal ${index} was already saved.`, + 'memory-error', + ); + } + + const memory = await summarizerService.acceptProposal( + proposal, + metadata.sessionId, + ); + proposalStore.markAccepted(index); + + return successMsg( + `Saved ${memory.category} memory: ${memory.content}`, + 'memory-accept', + ); + } + + return errorMsg(USAGE, 'memory-error'); + } catch (error) { + return errorMsg( + `Failed to manage memory: ${formatError(error)}`, + 'memory-error', + ); + } + }, + }; +} + +export const memoryCommand: Command = createMemoryCommand({ + proposalStore: sharedProposalStore, +}); diff --git a/source/commands/remember.spec.tsx b/source/commands/remember.spec.tsx new file mode 100644 index 000000000..4f07106e9 --- /dev/null +++ b/source/commands/remember.spec.tsx @@ -0,0 +1,137 @@ +import test from 'ava'; +import React from 'react'; +import type {SemanticMemory} from '@/memory/semantic-memory-manager'; +import {SummarizerService} from '@/memory/summarizer-service'; +import {renderWithTheme} from '@/test-utils/render-with-theme'; +import type {Message} from '@/types/core'; +import {lazyCommands} from './lazy-registry.js'; +import {createRememberCommand, rememberCommand} from './remember.js'; + +const testMetadata = { + provider: 'test-provider', + model: 'test-model', + tokens: 0, + getMessageTokens: (message: Message) => message.content.length, +}; + +class FakeSummarizerService extends SummarizerService { + rememberedInput?: { + content: string; + category?: string; + sourceSessionId?: string; + }; + + constructor( + private readonly memory: SemanticMemory, + private readonly error?: Error, + ) { + super(); + } + + override async remember(input: { + content: string; + category?: string; + sourceSessionId?: string; + }): Promise { + this.rememberedInput = input; + if (this.error) throw this.error; + return this.memory; + } +} + +test('rememberCommand has correct name and description', t => { + t.is(rememberCommand.name, 'remember'); + t.is(rememberCommand.description, 'Save a durable project memory'); +}); + +test('remember command returns usage when content is missing', async t => { + const result = await rememberCommand.handler([], [], testMetadata); + t.truthy(React.isValidElement(result)); + + const {lastFrame} = renderWithTheme(result as React.ReactElement); + const output = lastFrame() ?? ''; + + t.true(output.includes('Usage: /remember')); +}); + +test('remember command saves a manual memory', async t => { + const service = new FakeSummarizerService({ + id: 'memory-1', + content: 'Use the existing auth adapter.', + category: 'architecture', + timestamp: '2026-07-15T00:00:00.000Z', + }); + const command = createRememberCommand({summarizerService: service}); + + const result = await command.handler( + ['Use', 'the', 'existing', 'auth', 'adapter.'], + [], + testMetadata, + ); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.deepEqual(service.rememberedInput, { + content: 'Use the existing auth adapter.', + category: undefined, + }); + t.true((lastFrame() ?? '').includes('Remembered architecture memory.')); +}); + +test('remember command forwards explicit category', async t => { + const service = new FakeSummarizerService({ + id: 'memory-1', + content: 'Keep generated files out of review.', + category: 'codingStyle', + timestamp: '2026-07-15T00:00:00.000Z', + }); + const command = createRememberCommand({summarizerService: service}); + + await command.handler( + [ + '--category', + 'coding-style', + 'Keep', + 'generated', + 'files', + 'out', + 'of', + 'review.', + ], + [], + testMetadata, + ); + + t.deepEqual(service.rememberedInput, { + content: 'Keep generated files out of review.', + category: 'coding-style', + }); +}); + +test('remember command reports save failures', async t => { + const service = new FakeSummarizerService( + { + id: 'memory-1', + content: 'Use the existing auth adapter.', + category: 'architecture', + timestamp: '2026-07-15T00:00:00.000Z', + }, + new Error('disk full'), + ); + const command = createRememberCommand({summarizerService: service}); + + const result = await command.handler( + ['Use', 'the', 'existing', 'auth', 'adapter.'], + [], + testMetadata, + ); + const {lastFrame} = renderWithTheme(result as React.ReactElement); + + t.true((lastFrame() ?? '').includes('Failed to save memory: disk full')); +}); + +test('lazy registry exposes /remember', t => { + const remember = lazyCommands.find(command => command.name === 'remember'); + + t.truthy(remember); + t.is(remember?.description, 'Save a durable project memory'); +}); diff --git a/source/commands/remember.ts b/source/commands/remember.ts new file mode 100644 index 000000000..0d7cef6c2 --- /dev/null +++ b/source/commands/remember.ts @@ -0,0 +1,79 @@ +import {SummarizerService} from '@/memory/summarizer-service'; +import type {Command} from '@/types/commands'; +import {formatError} from '@/utils/error-formatter'; +import {errorMsg, successMsg} from '@/utils/message-factory'; + +interface RememberCommandOptions { + summarizerService?: SummarizerService; +} + +interface ParsedRememberArgs { + content: string; + category?: string; + error?: string; +} + +const USAGE = 'Usage: /remember [--category ] '; + +export function createRememberCommand( + options: RememberCommandOptions = {}, +): Command { + const summarizerService = + options.summarizerService ?? new SummarizerService(); + + return { + name: 'remember', + description: 'Save a durable project memory', + handler: async (args: string[]) => { + const parsed = parseRememberArgs(args); + if (parsed.error) { + return errorMsg(parsed.error, 'remember-error'); + } + + try { + const memory = await summarizerService.remember({ + content: parsed.content, + category: parsed.category, + }); + + return successMsg( + `Remembered ${memory.category} memory.`, + 'remember-success', + ); + } catch (error) { + return errorMsg( + `Failed to save memory: ${formatError(error)}`, + 'remember-error', + ); + } + }, + }; +} + +export const rememberCommand: Command = createRememberCommand(); + +function parseRememberArgs(args: string[]): ParsedRememberArgs { + let category: string | undefined; + const contentParts: string[] = []; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--category' || arg === '-c') { + const value = args[index + 1]; + if (!value) { + return {content: '', error: USAGE}; + } + + category = value; + index++; + continue; + } + + contentParts.push(arg); + } + + const content = contentParts.join(' ').trim(); + if (!content) return {content: '', error: USAGE}; + + return {content, category}; +} diff --git a/source/config/preferences.spec.ts b/source/config/preferences.spec.ts index 1e7c0da4e..2bf98753c 100644 --- a/source/config/preferences.spec.ts +++ b/source/config/preferences.spec.ts @@ -2,6 +2,14 @@ import {existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:f import {tmpdir} from 'node:os'; import {join} from 'node:path'; import test from 'ava'; +import { + DEFAULT_MEMORY_LIMIT, + DEFAULT_TOKEN_BUDGET, + MAX_MEMORY_LIMIT, + MAX_TOKEN_BUDGET, + MIN_MEMORY_LIMIT, + MIN_TOKEN_BUDGET, +} from '@/memory/project-context'; import { getCompactToolDisplay, getLastUsedModel, @@ -9,8 +17,11 @@ import { getNotificationsPreference, getPasteThreshold, getProfessionalTone, + getProjectContextPreferences, getReasoningExpanded, + getSemanticMemoryEnabled, loadPreferences, + resolveProjectContextPreferences, resetPreferencesCache, savePreferences, getShowUsageFooter, @@ -21,6 +32,9 @@ import { updatePasteThreshold, updateProfessionalTone, updateReasoningExpanded, + updateSemanticMemoryEnabled, + updateSemanticMemoryLimit, + updateSemanticMemoryTokenBudget, getPrivacyPreference, updatePrivacyPreference, updateShowUsageFooter, @@ -1711,3 +1725,177 @@ test.serial('updateProfessionalTone preserves other preferences', t => { } } }); + +test.serial('getSemanticMemoryEnabled returns true when not set', t => { + const preferencesPath = getTestPreferencesPath(); + const preferences: UserPreferences = {}; + writeFileSync(preferencesPath, JSON.stringify(preferences), 'utf-8'); + + try { + const result = getSemanticMemoryEnabled(); + t.is(result, true); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('getSemanticMemoryEnabled returns false when disabled', t => { + const preferencesPath = getTestPreferencesPath(); + const preferences: UserPreferences = {semanticMemoryEnabled: false}; + writeFileSync(preferencesPath, JSON.stringify(preferences), 'utf-8'); + + try { + const result = getSemanticMemoryEnabled(); + t.is(result, false); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('updateSemanticMemoryEnabled saves the preference correctly', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryEnabled(false); + + t.true(existsSync(preferencesPath)); + const content = readFileSync(preferencesPath, 'utf-8'); + const parsed = JSON.parse(content) as UserPreferences; + + t.is(parsed.semanticMemoryEnabled, false); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +// ============================================================================ +// Project Context Preferences Tests (token budget + memory limit, round-4 review) +// ============================================================================ + +test('resolveProjectContextPreferences falls back to the shipped defaults', t => { + t.deepEqual(resolveProjectContextPreferences({} as UserPreferences), { + semanticMemoryEnabled: true, + memoryLimit: DEFAULT_MEMORY_LIMIT, + tokenBudget: DEFAULT_TOKEN_BUDGET, + }); +}); + +test('resolveProjectContextPreferences honours configured values', t => { + t.deepEqual( + resolveProjectContextPreferences({ + semanticMemoryEnabled: false, + semanticMemoryLimit: 3, + semanticMemoryTokenBudget: 120, + } as UserPreferences), + {semanticMemoryEnabled: false, memoryLimit: 3, tokenBudget: 120}, + ); +}); + +test('resolveProjectContextPreferences clamps out-of-range values', t => { + const tooLow = resolveProjectContextPreferences({ + semanticMemoryLimit: 0, + semanticMemoryTokenBudget: 1, + } as UserPreferences); + t.is(tooLow.memoryLimit, MIN_MEMORY_LIMIT); + t.is(tooLow.tokenBudget, MIN_TOKEN_BUDGET); + + const tooHigh = resolveProjectContextPreferences({ + semanticMemoryLimit: 10_000, + semanticMemoryTokenBudget: 10_000, + } as UserPreferences); + t.is(tooHigh.memoryLimit, MAX_MEMORY_LIMIT); + t.is(tooHigh.tokenBudget, MAX_TOKEN_BUDGET); +}); + +test.serial('getProjectContextPreferences reads token budget and memory limit from disk', t => { + const preferencesPath = getTestPreferencesPath(); + const data: UserPreferences = { + semanticMemoryEnabled: true, + semanticMemoryLimit: 12, + semanticMemoryTokenBudget: 480, + }; + writeFileSync(preferencesPath, JSON.stringify(data, null, 2), 'utf-8'); + + try { + t.deepEqual(getProjectContextPreferences(), { + semanticMemoryEnabled: true, + memoryLimit: 12, + tokenBudget: 480, + }); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('updateSemanticMemoryLimit saves a clamped value', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryLimit(500); + + const content = readFileSync(preferencesPath, 'utf-8'); + const parsed = JSON.parse(content) as UserPreferences; + + t.is(parsed.semanticMemoryLimit, MAX_MEMORY_LIMIT); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('updateSemanticMemoryTokenBudget saves a clamped value', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryTokenBudget(1); + + const content = readFileSync(preferencesPath, 'utf-8'); + const parsed = JSON.parse(content) as UserPreferences; + + t.is(parsed.semanticMemoryTokenBudget, MIN_TOKEN_BUDGET); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); + +test.serial('full workflow: update and retrieve project context preferences', t => { + const preferencesPath = getTestPreferencesPath(); + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + + try { + updateSemanticMemoryLimit(5); + updateSemanticMemoryTokenBudget(960); + + t.deepEqual(getProjectContextPreferences(), { + semanticMemoryEnabled: true, + memoryLimit: 5, + tokenBudget: 960, + }); + } finally { + if (existsSync(preferencesPath)) { + rmSync(preferencesPath, {force: true}); + } + } +}); diff --git a/source/config/preferences.ts b/source/config/preferences.ts index 6c134bc75..d7b944c21 100644 --- a/source/config/preferences.ts +++ b/source/config/preferences.ts @@ -1,6 +1,15 @@ import {readFileSync, writeFileSync} from 'fs'; import type {TitleShape} from '@/components/ui/styled-title'; import {getClosestConfigFile} from '@/config/index'; +import { + DEFAULT_MEMORY_LIMIT, + DEFAULT_TOKEN_BUDGET, + MAX_MEMORY_LIMIT, + MAX_TOKEN_BUDGET, + MIN_MEMORY_LIMIT, + MIN_TOKEN_BUDGET, + type ProjectContextOptions, +} from '@/memory/project-context'; import type {TuneConfig} from '@/types/config'; import type {UserPreferences} from '@/types/index'; import type {NanocoderShape, ThemePreset} from '@/types/ui'; @@ -242,6 +251,89 @@ export function updatePrivacyPreference(value: boolean): void { savePreferences(preferences); } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.round(value))); +} + +/** + * Resolve the project-context knobs from an already-loaded preferences object. + * + * The single place the semantic-memory defaults live. Callers that inject + * `loadPreferences` (the plain shell) pass their own object in; everything else + * goes through {@link getProjectContextPreferences}. + */ +export function resolveProjectContextPreferences( + preferences: UserPreferences, +): Required< + Pick< + ProjectContextOptions, + 'semanticMemoryEnabled' | 'memoryLimit' | 'tokenBudget' + > +> { + return { + semanticMemoryEnabled: preferences.semanticMemoryEnabled ?? true, + memoryLimit: clamp( + preferences.semanticMemoryLimit ?? DEFAULT_MEMORY_LIMIT, + MIN_MEMORY_LIMIT, + MAX_MEMORY_LIMIT, + ), + tokenBudget: clamp( + preferences.semanticMemoryTokenBudget ?? DEFAULT_TOKEN_BUDGET, + MIN_TOKEN_BUDGET, + MAX_TOKEN_BUDGET, + ), + }; +} + +/** Project-context knobs for the current user. */ +export function getProjectContextPreferences(): ReturnType< + typeof resolveProjectContextPreferences +> { + return resolveProjectContextPreferences(loadPreferences()); +} + +/** + * Get the semantic memory preference from preferences + */ +export function getSemanticMemoryEnabled(): boolean { + return getProjectContextPreferences().semanticMemoryEnabled; +} + +/** + * Save the semantic memory preference + */ +export function updateSemanticMemoryEnabled(value: boolean): void { + const preferences = loadPreferences(); + preferences.semanticMemoryEnabled = value; + savePreferences(preferences); +} + +/** + * Save how many memories may be recalled into a single prompt. + */ +export function updateSemanticMemoryLimit(value: number): void { + const preferences = loadPreferences(); + preferences.semanticMemoryLimit = clamp( + value, + MIN_MEMORY_LIMIT, + MAX_MEMORY_LIMIT, + ); + savePreferences(preferences); +} + +/** + * Save the token budget project context may consume in the system prompt. + */ +export function updateSemanticMemoryTokenBudget(value: number): void { + const preferences = loadPreferences(); + preferences.semanticMemoryTokenBudget = clamp( + value, + MIN_TOKEN_BUDGET, + MAX_TOKEN_BUDGET, + ); + savePreferences(preferences); +} + /** * Get the alternate-screen (fullscreen) preference. Also settable via * --alt-screen/--no-alt-screen at launch; this is the persisted default. diff --git a/source/hooks/chat-handler/types.ts b/source/hooks/chat-handler/types.ts index 05c84bf7f..4143d5df4 100644 --- a/source/hooks/chat-handler/types.ts +++ b/source/hooks/chat-handler/types.ts @@ -1,5 +1,9 @@ import type React from 'react'; import type {CustomCommandLoader} from '@/custom-commands/loader'; +import type { + MemoryFinder, + ProjectContextOptions, +} from '@/memory/project-context'; import type {Task} from '@/tools/tasks/types'; import type {ToolManager} from '@/tools/tool-manager'; import type {TuneConfig} from '@/types/config'; @@ -55,6 +59,8 @@ export interface UseChatHandlerProps { subagentsReady?: boolean; privacySessionMapRef?: React.MutableRefObject>; privacyEnabled?: boolean; + memoryFinder?: MemoryFinder; + projectContextOptions?: ProjectContextOptions; /** Ensure tool calls in this turn share the persisted conversation ID. */ ensureCurrentSessionId?: () => string; } diff --git a/source/hooks/chat-handler/useChatHandler.spec.tsx b/source/hooks/chat-handler/useChatHandler.spec.tsx index b14e03269..c20a74e3d 100644 --- a/source/hooks/chat-handler/useChatHandler.spec.tsx +++ b/source/hooks/chat-handler/useChatHandler.spec.tsx @@ -771,3 +771,134 @@ test.serial( } }, ); + +test('useChatHandler - injects project context from memory finder', async t => { + let hookResult: ChatHandlerReturn | null = null; + let sentMessages: Message[] = []; + const queuedComponents: React.ReactNode[] = []; + const client: LLMClient = { + ...createMockClient(), + chat: async (messages, _tools, callbacks) => { + sentMessages = messages; + callbacks.onFinish?.(); + return { + choices: [ + { + message: { + role: 'assistant', + content: 'ok', + }, + }, + ], + }; + }, + }; + + const props = createMockProps({ + client, + toolManager: createMockToolManager(), + addToChatQueue: component => { + queuedComponents.push(component); + }, + memoryFinder: { + findRelevantMemories: async (query, limit) => { + t.is(query, 'refactor auth'); + t.is(limit, 8); + return [ + { + id: 'memory-1', + content: 'Auth uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-07-17T00:00:00.000Z', + }, + ]; + }, + }, + }); + + const rendered = render( + { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('refactor auth'); + + t.true(sentMessages[0].content.includes('## Project Context')); + t.true( + sentMessages[0].content.includes( + '- Auth uses Clerk and avoids middleware.', + ), + ); + t.true( + queuedComponents.some( + component => + React.isValidElement(component) && + component.props.message === 'Recalling 1 project memory...', + ), + ); + rendered.unmount(); +}); + +test('useChatHandler - does not accumulate project context across turns', async t => { + let hookResult: ChatHandlerReturn | null = null; + const sentSystemPrompts: string[] = []; + const client: LLMClient = { + ...createMockClient(), + chat: async (messages, _tools, callbacks) => { + sentSystemPrompts.push(String(messages[0]?.content ?? '')); + callbacks.onFinish?.(); + return { + choices: [ + { + message: { + role: 'assistant', + content: 'ok', + }, + }, + ], + }; + }, + }; + + const props = createMockProps({ + client, + toolManager: createMockToolManager(), + memoryFinder: { + findRelevantMemories: async query => { + if (query === 'refactor auth') { + return [ + { + id: 'memory-1', + content: 'Auth uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-07-17T00:00:00.000Z', + }, + ]; + } + return []; + }, + }, + }); + + const rendered = render( + { + hookResult = result; + }} + />, + ); + + await waitForCondition(() => hookResult !== null); + await hookResult!.handleChatMessage('refactor auth'); + await hookResult!.handleChatMessage('unrelated question about docs'); + + t.true(sentSystemPrompts[0]?.includes('## Project Context')); + t.false(sentSystemPrompts[1]?.includes('## Project Context')); + rendered.unmount(); +}); diff --git a/source/hooks/chat-handler/useChatHandler.tsx b/source/hooks/chat-handler/useChatHandler.tsx index 1a9010a26..2a295d45a 100644 --- a/source/hooks/chat-handler/useChatHandler.tsx +++ b/source/hooks/chat-handler/useChatHandler.tsx @@ -6,9 +6,12 @@ import {getAppConfig} from '@/config/index'; import { getPreferencesVersion, getProfessionalTone, + getProjectContextPreferences, subscribeToPreferences, } from '@/config/preferences'; import {CommandIntegration} from '@/custom-commands/command-integration'; +import {appendRelevantProjectContextWithCount} from '@/memory/project-context'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import {processToolUse} from '@/message-handler'; import {generateKey} from '@/session/key-generator'; import {getTuneToolMode} from '@/types/config'; @@ -97,11 +100,18 @@ export function useChatHandler({ subagentsReady, privacySessionMapRef, privacyEnabled, + memoryFinder, + projectContextOptions, ensureCurrentSessionId, }: UseChatHandlerProps): ChatHandlerReturn { // Conversation state manager for enhanced context const conversationStateManager = React.useRef(new ConversationStateManager()); + const projectMemoryFinder = React.useMemo( + () => memoryFinder ?? new SemanticMemoryManager(), + [memoryFinder], + ); + // Resolve the active fallback format when native tools are disabled. When // native is on, this value is unused. The tune override takes priority over // provider-level disables so users can pick the JSON path explicitly even @@ -391,6 +401,25 @@ export function useChatHandler({ ); } + const projectContext = await appendRelevantProjectContextWithCount( + systemPrompt, + message, + projectMemoryFinder, + // Preferences supply the defaults; an explicit prop still wins so + // callers (and tests) can override per session. + {...getProjectContextPreferences(), ...projectContextOptions}, + ); + systemPrompt = projectContext.systemPrompt; + setLastBuiltPrompt(systemPrompt); + if (projectContext.memoryCount > 0) { + addToChatQueue( + infoMsg( + `Recalling ${projectContext.memoryCount} project memor${projectContext.memoryCount === 1 ? 'y' : 'ies'}...`, + 'memory-recall', + ), + ); + } + // Create stream request const systemMessage: Message = { role: 'system', diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index eef7c9a46..60ba1b487 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -710,6 +710,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { developmentMode: props.developmentMode, lastApiUsage: props.lastApiUsage, apiCallHistory: props.apiCallHistory, + sessionId: props.ensureCurrentSessionId(), }, displayValue, images, @@ -742,6 +743,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.developmentMode, props.lastApiUsage, props.apiCallHistory, + props.ensureCurrentSessionId, clearMessages, enterCheckpointLoadMode, handleShowStatus, diff --git a/source/memory/project-context.spec.ts b/source/memory/project-context.spec.ts new file mode 100644 index 000000000..c3fe2477c --- /dev/null +++ b/source/memory/project-context.spec.ts @@ -0,0 +1,178 @@ +import test from 'ava'; +import { + appendRelevantProjectContextWithCount, + type ProjectContextOptions, +} from './project-context.js'; +import type {SemanticMemory} from './semantic-memory-manager.js'; + +const memory = (content: string): SemanticMemory => ({ + id: content, + content, + category: 'project', + timestamp: '2026-07-17T00:00:00.000Z', +}); + +async function inject( + memories: SemanticMemory[], + options: ProjectContextOptions = {}, + query = 'auth', +) { + return appendRelevantProjectContextWithCount( + 'base prompt', + query, + {findRelevantMemories: async () => memories}, + options, + ); +} + +test('appendRelevantProjectContextWithCount returns original prompt for no memories', async t => { + const result = await inject([]); + t.is(result.systemPrompt, 'base prompt'); + t.is(result.memoryCount, 0); +}); + +test('appendRelevantProjectContextWithCount formats memories as project context', async t => { + const result = await inject([ + memory('Auth uses Clerk.'), + memory('Avoid middleware.\nUse adapters.'), + ]); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Auth uses Clerk.\n- Avoid middleware. Use adapters.\n```', + ); + t.is(result.memoryCount, 2); +}); + +test('appendRelevantProjectContextWithCount strips a leading list marker so bullets are not doubled', async t => { + const result = await inject([ + memory('- Added a regression test for the 40-column case.'), + ]); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Added a regression test for the 40-column case.\n```', + ); +}); + +test('appendRelevantProjectContextWithCount respects token budget', async t => { + const result = await inject( + [ + memory('Use existing hooks.'), + memory( + 'This second memory is intentionally long enough to exceed the tiny test budget.', + ), + ], + {tokenBudget: 14}, + ); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Use existing hooks.\n```', + ); +}); + +test('appendRelevantProjectContextWithCount returns original prompt when budget is too small', async t => { + const result = await inject([memory('Use existing hooks.')], { + tokenBudget: 1, + }); + t.is(result.systemPrompt, 'base prompt'); + t.is(result.memoryCount, 0); +}); + +test('appendRelevantProjectContextWithCount skips an oversized memory and still injects later ones', async t => { + const result = await inject( + [ + memory('This first memory is intentionally too long for the small budget.'), + memory('Use adapters.'), + ], + {tokenBudget: 12}, + ); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Use adapters.\n```', + ); + t.is(result.memoryCount, 1); +}); + +test('appendRelevantProjectContextWithCount reports injected memory count', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async () => [ + memory('Auth uses Clerk.'), + memory('Use adapters.'), + ], + }, + ); + + t.is(result.memoryCount, 2); + t.true(result.systemPrompt.includes('## Project Context')); +}); + +test('appendRelevantProjectContextWithCount skips memory lookup when disabled', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async () => { + throw new Error('should not look up memories when disabled'); + }, + }, + {semanticMemoryEnabled: false}, + ); + + t.is(result.memoryCount, 0); + t.is(result.systemPrompt, 'base prompt'); +}); + +test('appendRelevantProjectContextWithCount passes configured memory limit', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async (query, limit) => { + t.is(query, 'auth'); + t.is(limit, 2); + return [memory('Auth uses Clerk.')]; + }, + }, + {memoryLimit: 2}, + ); + + t.true(result.systemPrompt.includes('Auth uses Clerk.')); +}); + +test('appendRelevantProjectContextWithCount returns original prompt when lookup fails', async t => { + const result = await appendRelevantProjectContextWithCount( + 'base prompt', + 'auth', + { + findRelevantMemories: async () => { + throw new Error('memory unavailable'); + }, + }, + ); + + t.is(result.systemPrompt, 'base prompt'); + t.is(result.memoryCount, 0); +}); + +test('appendRelevantProjectContextWithCount widens the fence so memory content cannot escape it', async t => { + const result = await inject([ + memory('Use ``` fenced blocks ``` carefully.'), + ]); + + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n````\n- Use ``` fenced blocks ``` carefully.\n````', + ); + const [, body] = result.systemPrompt.split('````'); + t.true(body?.includes('fenced blocks') ?? false); +}); + +test('appendRelevantProjectContextWithCount keeps the standard fence when content has no backticks', async t => { + const result = await inject([memory('Auth uses Clerk.')]); + t.is( + result.systemPrompt, + 'base prompt\n\n## Project Context\n\n```\n- Auth uses Clerk.\n```', + ); +}); diff --git a/source/memory/project-context.ts b/source/memory/project-context.ts new file mode 100644 index 000000000..db4854e02 --- /dev/null +++ b/source/memory/project-context.ts @@ -0,0 +1,108 @@ +import {getLogger} from '@/utils/logging'; +import type {SemanticMemory} from './semantic-memory-manager'; +import {SemanticMemoryManager} from './semantic-memory-manager'; + +export type MemoryFinder = Pick; + +export interface ProjectContextOptions { + memoryLimit?: number; + tokenBudget?: number; + semanticMemoryEnabled?: boolean; +} + +export interface ProjectContextResult { + systemPrompt: string; + memoryCount: number; +} + +export const DEFAULT_MEMORY_LIMIT = 8; +export const DEFAULT_TOKEN_BUDGET = 240; + +/** Bounds for the user-configurable values, applied when preferences are read. */ +export const MIN_MEMORY_LIMIT = 1; +export const MAX_MEMORY_LIMIT = 50; +export const MIN_TOKEN_BUDGET = 40; +export const MAX_TOKEN_BUDGET = 4000; + +function estimateTokens(value: string): number { + return Math.ceil(value.length / 4); +} + +/** + * Picks a fence longer than the longest backtick run in the body, the way + * Markdown itself does. Memory content is interpolated verbatim, so a fixed + * three-backtick fence could be escaped by a memory containing backticks. + */ +function fenceFor(body: string): string { + let longest = 0; + for (const match of body.matchAll(/`+/gu)) { + longest = Math.max(longest, match[0].length); + } + return '`'.repeat(Math.max(3, longest + 1)); +} + +function formatProjectContextWithCount( + memories: SemanticMemory[], + options: ProjectContextOptions = {}, +): {content: string; memoryCount: number} { + if (memories.length === 0) return {content: '', memoryCount: 0}; + + const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET; + const bullets: string[] = []; + let usedTokens = + estimateTokens('## Project Context\n\n') + estimateTokens('```\n\n```'); + + for (const memory of memories) { + const text = memory.content + .replaceAll(/\s+/gu, ' ') + .trim() + .replace(/^[-*]\s+/u, ''); + const bullet = `- ${text}`; + const bulletTokens = estimateTokens(`${bullet}\n`); + if (usedTokens + bulletTokens > tokenBudget) continue; + + bullets.push(bullet); + usedTokens += bulletTokens; + } + + if (bullets.length === 0) return {content: '', memoryCount: 0}; + + const body = bullets.join('\n'); + const fence = fenceFor(body); + + return { + content: `## Project Context\n\n${fence}\n${body}\n${fence}`, + memoryCount: bullets.length, + }; +} + +export async function appendRelevantProjectContextWithCount( + systemPrompt: string, + query: string, + memoryFinder: MemoryFinder = new SemanticMemoryManager(), + options: ProjectContextOptions = {}, +): Promise { + if (options.semanticMemoryEnabled === false) { + return {systemPrompt, memoryCount: 0}; + } + + try { + const projectContext = formatProjectContextWithCount( + await memoryFinder.findRelevantMemories( + query, + options.memoryLimit ?? DEFAULT_MEMORY_LIMIT, + ), + options, + ); + + if (!projectContext.content) return {systemPrompt, memoryCount: 0}; + + return { + systemPrompt: `${systemPrompt}\n\n${projectContext.content}`, + memoryCount: projectContext.memoryCount, + }; + } catch (error) { + getLogger().warn({error}, 'Failed to recall project memories'); + return {systemPrompt, memoryCount: 0}; + } +} diff --git a/source/memory/proposal-store.ts b/source/memory/proposal-store.ts new file mode 100644 index 000000000..819983045 --- /dev/null +++ b/source/memory/proposal-store.ts @@ -0,0 +1,58 @@ +import type {MemoryProposal} from './summarizer-service'; + +/** + * Holds the proposal list printed by the last `/memory propose`. + * + * The list is never mutated once printed. `/memory accept ` addresses it by + * the same 1-based index the user is reading off screen, so accepted entries are + * tracked in a separate set rather than removed - dropping an entry would shift + * every later number against the printout and silently save the wrong memory. + */ +export class ProposalStore { + private proposals: MemoryProposal[] = []; + private readonly accepted = new Set(); + + set(proposals: MemoryProposal[]): void { + this.proposals = proposals; + this.accepted.clear(); + } + + list(): readonly MemoryProposal[] { + return this.proposals; + } + + get size(): number { + return this.proposals.length; + } + + /** `index` is 1-based, matching the printed list. */ + at(index: number): MemoryProposal | undefined { + if ( + !Number.isInteger(index) || + index < 1 || + index > this.proposals.length + ) { + return undefined; + } + return this.proposals[index - 1]; + } + + isAccepted(index: number): boolean { + return this.accepted.has(index); + } + + markAccepted(index: number): void { + this.accepted.add(index); + } + + clear(): void { + this.proposals = []; + this.accepted.clear(); + } +} + +/** + * Shared store backing the lazily-loaded `/memory` command. Cleared by `/clear` + * so a proposal derived from a discarded conversation can't still be accepted. + */ +export const sharedProposalStore = new ProposalStore(); diff --git a/source/memory/semantic-memory-manager.spec.ts b/source/memory/semantic-memory-manager.spec.ts new file mode 100644 index 000000000..1345a9c5a --- /dev/null +++ b/source/memory/semantic-memory-manager.spec.ts @@ -0,0 +1,274 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'ava'; +import {SemanticMemoryManager} from './semantic-memory-manager.js'; + +async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'nanocoder-memory-')); +} + +test('SemanticMemoryManager stores and reloads repo-scoped memories', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const memory = await manager.addMemory({ + content: ' Use the existing auth adapter pattern for Clerk changes. ', + sourceSessionId: 'session-1', + }); + + t.is(memory.content, 'Use the existing auth adapter pattern for Clerk changes.'); + t.is(memory.category, 'project'); + t.regex(memory.timestamp, /^\d{4}-\d{2}-\d{2}T/); + t.is(memory.sourceSessionId, 'session-1'); + + const reloaded = new SemanticMemoryManager({memoryDir: dir, cwd}); + t.deepEqual(await reloaded.listMemories(), [memory]); +}); + +test('SemanticMemoryManager keeps different repositories isolated', async t => { + const dir = await createTempDir(); + const repoA = path.join(dir, 'repo-a'); + const repoB = path.join(dir, 'repo-b'); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await new SemanticMemoryManager({memoryDir: dir, cwd: repoA}).addMemory({ + content: 'Repo A uses route handlers.', + }); + + const repoBManager = new SemanticMemoryManager({memoryDir: dir, cwd: repoB}); + t.deepEqual(await repoBManager.listMemories(), []); +}); + +test('SemanticMemoryManager stores memory category', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const memory = await manager.addMemory({ + content: 'Follow the existing provider abstraction.', + category: 'architecture', + }); + + t.is(memory.category, 'architecture'); + t.deepEqual(await manager.listMemories(), [memory]); +}); + +test('SemanticMemoryManager deletes and clears memories', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const first = await manager.addMemory({content: 'Keep components small.'}); + const second = await manager.addMemory({content: 'Prefer existing hooks.'}); + + t.true(await manager.deleteMemory(first.id)); + t.false(await manager.deleteMemory(first.id)); + t.deepEqual(await manager.listMemories(), [second]); + + await manager.clearMemories(); + t.deepEqual(await manager.listMemories(), []); +}); + +test('SemanticMemoryManager returns relevant memories before unrelated ones', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const auth = await manager.addMemory({ + content: 'Auth flow uses Clerk and avoids middleware.', + }); + await manager.addMemory({ + content: 'Release notes are generated from contributor history.', + }); + + t.deepEqual(await manager.findRelevantMemories('refactor clerk auth', 3), [ + auth, + ]); +}); + +test('SemanticMemoryManager includes category matches in relevance ranking', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const architecture = await manager.addMemory({ + content: 'Use the service layer for persistence changes.', + category: 'architecture', + }); + await manager.addMemory({ + content: 'Release notes are generated from contributor history.', + category: 'workflow', + }); + + t.deepEqual(await manager.findRelevantMemories('architecture', 3), [ + architecture, + ]); +}); + +test('SemanticMemoryManager filters out stopword-only matches on an unrelated query', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await manager.addMemory({ + content: 'The auth module uses Clerk and we avoid middleware in the edge runtime.', + }); + await manager.addMemory({ + content: + 'The flaky test in the payments suite is a known failure and we should fix it later.', + }); + const style = await manager.addMemory({ + content: 'Use tabs not spaces in the settings form styling.', + }); + + const results = await manager.findRelevantMemories( + 'can you add a new field to the user profile page in the settings form', + 5, + ); + + t.deepEqual(results, [style]); +}); + +test('SemanticMemoryManager ranks by query coverage, not memory length', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await manager.addMemory({ + content: 'Always add tests.', + }); + const worker = await manager.addMemory({ + content: + 'We decided against introducing a separate background worker process for indexing, because the daemon already owns scheduling and a second long-lived process would complicate the lockfile story.', + }); + + t.deepEqual( + await manager.findRelevantMemories( + 'should I add a background worker for this', + 5, + ), + [worker], + ); +}); + +test('SemanticMemoryManager recalls a memory on a single keyword when it covers half the query', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + const auth = await manager.addMemory({ + content: + 'The auth module uses Clerk and avoids middleware in the edge runtime.', + }); + + t.deepEqual(await manager.findRelevantMemories('auth', 3), [auth]); + t.deepEqual(await manager.findRelevantMemories('fix auth', 3), [auth]); + t.deepEqual( + await manager.findRelevantMemories('refactor the auth middleware', 3), + [auth], + ); + t.deepEqual(await manager.findRelevantMemories('update clerk auth flow', 3), [ + auth, + ]); +}); + +test('SemanticMemoryManager serializes concurrent writes so none are lost', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await Promise.all( + Array.from({length: 10}, (_, i) => + manager.addMemory({content: `Memory number ${i}.`}), + ), + ); + + const memories = await manager.listMemories(); + t.is(memories.length, 10); +}); + +test('SemanticMemoryManager serializes concurrent writes across manager instances', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const first = new SemanticMemoryManager({memoryDir: dir, cwd}); + const second = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await Promise.all([ + ...Array.from({length: 10}, (_, i) => + first.addMemory({content: `First instance memory ${i}.`}), + ), + ...Array.from({length: 10}, (_, i) => + second.addMemory({content: `Second instance memory ${i}.`}), + ), + ]); + + t.is((await first.listMemories()).length, 20); +}); + +test('SemanticMemoryManager drops oldest memories when the store cap is exceeded', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({ + memoryDir: dir, + cwd, + maxStoredMemories: 3, + }); + + for (const index of [1, 2, 3, 4, 5]) { + await manager.addMemory({ + content: `Auth adapter numbered convention ${index}.`, + }); + await new Promise(resolve => setTimeout(resolve, 5)); + } + + const memories = await manager.listMemories(); + t.deepEqual( + memories.map(memory => memory.content), + [ + 'Auth adapter numbered convention 3.', + 'Auth adapter numbered convention 4.', + 'Auth adapter numbered convention 5.', + ], + ); +}); + +test('SemanticMemoryManager rejects empty memory content', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await t.throwsAsync(manager.addMemory({content: ' '}), { + message: 'Memory content cannot be empty', + }); +}); + +test('SemanticMemoryManager rewrites a corrupt store on the next write', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + + await manager.addMemory({content: 'Auth uses Clerk.'}); + const files = await fs.readdir(dir); + const store = files.find(name => name.endsWith('.json')); + t.truthy(store); + await fs.writeFile(path.join(dir, store!), '{not json', 'utf8'); + + const repaired = await manager.addMemory({content: 'Use adapters.'}); + t.deepEqual(await manager.listMemories(), [repaired]); +}); diff --git a/source/memory/semantic-memory-manager.ts b/source/memory/semantic-memory-manager.ts new file mode 100644 index 000000000..10e4667f7 --- /dev/null +++ b/source/memory/semantic-memory-manager.ts @@ -0,0 +1,433 @@ +import {execFile} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {promisify} from 'node:util'; +import {getAppDataPath} from '@/config/paths'; + +const execFileAsync = promisify(execFile); + +export interface SemanticMemory { + id: string; + content: string; + category: string; + timestamp: string; + sourceSessionId?: string; +} + +export interface CreateMemoryInput { + content: string; + category?: string; + sourceSessionId?: string; +} + +export interface SemanticMemoryManagerOptions { + memoryDir?: string; + cwd?: string; + maxStoredMemories?: number; +} + +const DEFAULT_MAX_STORED_MEMORIES = 500; + +const writeQueues = new Map>(); + +function enqueueByKey(key: string, operation: () => Promise): Promise { + const previous = writeQueues.get(key) ?? Promise.resolve(); + const result = previous.then(operation, operation); + writeQueues.set( + key, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +const LOCK_STALE_MS = 10_000; +const LOCK_WAIT_MS = 15_000; + +async function withExclusiveLock( + lockPath: string, + operation: () => Promise, +): Promise { + const deadline = Date.now() + LOCK_WAIT_MS; + while (true) { + try { + const handle = await fs.open(lockPath, 'wx', 0o600); + try { + await handle.writeFile(String(process.pid), 'utf8'); + return await operation(); + } finally { + await handle.close(); + try { + const owner = (await fs.readFile(lockPath, 'utf8')).trim(); + if (owner === String(process.pid)) { + await fs.unlink(lockPath); + } + } catch { + // Lock already gone or stolen. + } + } + } catch (error) { + const code = + error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code !== 'EEXIST') throw error; + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for memory file lock: ${lockPath}`); + } + try { + const stat = await fs.stat(lockPath); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + await fs.unlink(lockPath); + continue; + } + } catch { + // Lock gone; retry create. + } + await new Promise(resolve => setTimeout(resolve, 20)); + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSemanticMemory(value: unknown): value is SemanticMemory { + if (!isRecord(value)) return false; + return ( + typeof value.id === 'string' && + typeof value.content === 'string' && + typeof value.category === 'string' && + typeof value.timestamp === 'string' && + (value.sourceSessionId === undefined || + typeof value.sourceSessionId === 'string') + ); +} + +async function atomicWriteFile(filePath: string, data: string): Promise { + const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; + try { + await fs.writeFile(tmpPath, data, {mode: 0o600}); + await fs.rename(tmpPath, filePath); + } catch (error) { + try { + await fs.unlink(tmpPath); + } catch (_cleanupError) { + // Ignore cleanup errors. + } + throw error; + } +} + +function hashScope(scope: string): string { + return crypto.createHash('sha256').update(scope).digest('hex').slice(0, 32); +} + +const STOPWORDS = new Set([ + 'a', + 'about', + 'after', + 'again', + 'all', + 'am', + 'an', + 'and', + 'any', + 'are', + 'as', + 'at', + 'be', + 'been', + 'being', + 'but', + 'by', + 'can', + 'could', + 'did', + 'do', + 'does', + 'doing', + 'down', + 'during', + 'each', + 'few', + 'for', + 'from', + 'further', + 'had', + 'has', + 'have', + 'having', + 'he', + 'her', + 'here', + 'hers', + 'herself', + 'him', + 'himself', + 'his', + 'how', + 'if', + 'in', + 'into', + 'is', + 'it', + 'its', + 'itself', + 'just', + 'me', + 'more', + 'most', + 'my', + 'myself', + 'no', + 'nor', + 'not', + 'now', + 'of', + 'off', + 'on', + 'once', + 'only', + 'or', + 'other', + 'our', + 'ours', + 'ourselves', + 'out', + 'over', + 'own', + 'same', + 'she', + 'should', + 'so', + 'some', + 'such', + 'than', + 'that', + 'the', + 'their', + 'theirs', + 'them', + 'themselves', + 'then', + 'there', + 'these', + 'they', + 'this', + 'those', + 'through', + 'to', + 'too', + 'under', + 'until', + 'up', + 'very', + 'was', + 'we', + 'were', + 'what', + 'when', + 'where', + 'which', + 'while', + 'who', + 'whom', + 'why', + 'will', + 'with', + 'would', + 'you', + 'your', + 'yours', + 'yourself', + 'yourselves', +]); + +const MIN_RELEVANCE_RATIO = 0.1; +const SINGLE_HIT_MIN_RATIO = 0.5; + +function tokenize(value: string): Set { + return new Set( + value + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(part => part.length > 1 && !STOPWORDS.has(part)), + ); +} + +export class SemanticMemoryManager { + private readonly memoryDir: string; + private readonly cwd: string; + private readonly maxStoredMemories: number; + private memoryFilePath?: string; + + constructor(options: SemanticMemoryManagerOptions = {}) { + this.memoryDir = options.memoryDir ?? path.join(getAppDataPath(), 'memory'); + this.cwd = options.cwd ?? process.cwd(); + this.maxStoredMemories = Math.max( + 1, + options.maxStoredMemories ?? DEFAULT_MAX_STORED_MEMORIES, + ); + } + + private mutate(operation: () => Promise): Promise { + return this.getMemoryFilePath().then(filePath => + enqueueByKey(filePath, () => + withExclusiveLock(`${filePath}.lock`, operation), + ), + ); + } + + async addMemory(input: CreateMemoryInput): Promise { + const content = input.content.trim(); + if (!content) { + throw new Error('Memory content cannot be empty'); + } + + const category = input.category?.trim() || 'project'; + const memory: SemanticMemory = { + id: crypto.randomUUID(), + content, + category, + timestamp: new Date().toISOString(), + ...(input.sourceSessionId + ? {sourceSessionId: input.sourceSessionId} + : {}), + }; + + return this.mutate(async () => { + const memories = await this.listMemories(); + memories.push(memory); + await this.writeMemories(memories); + return memory; + }); + } + + async listMemories(): Promise { + const filePath = await this.getMemoryFilePath(); + try { + const data = await fs.readFile(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(data); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isSemanticMemory); + } catch (error) { + if ( + error instanceof SyntaxError || + (error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + return []; + } + throw error; + } + } + + async deleteMemory(id: string): Promise { + return this.mutate(async () => { + const memories = await this.listMemories(); + const filtered = memories.filter(memory => memory.id !== id); + if (filtered.length === memories.length) { + return false; + } + + await this.writeMemories(filtered); + return true; + }); + } + + async clearMemories(): Promise { + await this.mutate(() => this.writeMemories([])); + } + + async findRelevantMemories( + query: string, + limit = 5, + ): Promise { + const queryTerms = tokenize(query); + if (queryTerms.size === 0 || limit <= 0) return []; + + return (await this.listMemories()) + .map(memory => { + const memoryTerms = tokenize(memory.content); + const categoryTerms = tokenize(memory.category); + let matchedQueryTerms = 0; + let categoryHit = false; + for (const term of queryTerms) { + if (categoryTerms.has(term)) categoryHit = true; + if (memoryTerms.has(term) || categoryTerms.has(term)) { + matchedQueryTerms++; + } + } + const relevanceRatio = matchedQueryTerms / queryTerms.size; + return {memory, matchedQueryTerms, categoryHit, relevanceRatio}; + }) + .filter( + result => + result.relevanceRatio >= MIN_RELEVANCE_RATIO && + (result.categoryHit || + result.matchedQueryTerms >= 2 || + result.relevanceRatio >= SINGLE_HIT_MIN_RATIO), + ) + .sort((a, b) => { + if (a.matchedQueryTerms !== b.matchedQueryTerms) { + return b.matchedQueryTerms - a.matchedQueryTerms; + } + return b.memory.timestamp.localeCompare(a.memory.timestamp); + }) + .slice(0, limit) + .map(result => result.memory); + } + + private async getMemoryFilePath(): Promise { + if (this.memoryFilePath) return this.memoryFilePath; + + await fs.mkdir(this.memoryDir, {recursive: true, mode: 0o700}); + const scope = await this.getRepositoryScope(); + this.memoryFilePath = path.join(this.memoryDir, `${hashScope(scope)}.json`); + return this.memoryFilePath; + } + + private async getRepositoryScope(): Promise { + try { + const {stdout} = await execFileAsync( + 'git', + ['config', '--get', 'remote.origin.url'], + {cwd: this.cwd}, + ); + const remote = stdout.trim(); + if (remote) return remote; + } catch { + // Non-git directories fall back to their absolute path. + } + + return path.resolve(this.cwd); + } + + private capMemories(memories: SemanticMemory[]): SemanticMemory[] { + if (memories.length <= this.maxStoredMemories) return memories; + + const keep = new Set( + [...memories] + .sort((a, b) => { + const byTime = b.timestamp.localeCompare(a.timestamp); + return byTime !== 0 ? byTime : a.id.localeCompare(b.id); + }) + .slice(0, this.maxStoredMemories) + .map(memory => memory.id), + ); + + return memories.filter(memory => keep.has(memory.id)); + } + + private async writeMemories(memories: SemanticMemory[]): Promise { + const filePath = await this.getMemoryFilePath(); + await atomicWriteFile( + filePath, + JSON.stringify(this.capMemories(memories), null, 2), + ); + } +} diff --git a/source/memory/summarizer-service.spec.ts b/source/memory/summarizer-service.spec.ts new file mode 100644 index 000000000..5d5279311 --- /dev/null +++ b/source/memory/summarizer-service.spec.ts @@ -0,0 +1,619 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'ava'; +import type {Message} from '@/types/core'; +import {SemanticMemoryManager} from './semantic-memory-manager.js'; +import { + inferMemoryCategory, + MAX_PROPOSALS, + MAX_SCANNED_MESSAGES, + type MemoryProposal, + REVERSAL_WARNING, + SummarizerService, + toCamelCaseCategory, +} from './summarizer-service.js'; + +async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'nanocoder-memory-')); +} + +test('SummarizerService stores a manual memory', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager, () => true); + + const memory = await service.remember({ + content: ' Use the existing provider abstraction for model changes. ', + sourceSessionId: 'session-1', + }); + + t.is(memory.content, 'Use the existing provider abstraction for model changes.'); + t.is(memory.category, 'architecture'); + t.is(memory.sourceSessionId, 'session-1'); + t.deepEqual(await manager.listMemories(), [memory]); +}); + +test('SummarizerService rejects empty manual memory content', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const service = new SummarizerService( + new SemanticMemoryManager({memoryDir: dir, cwd}), + () => true, + ); + + await t.throwsAsync(service.remember({content: ' '}), { + message: 'Memory content cannot be empty', + }); +}); + +test('SummarizerService uses explicit camelCase category', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const service = new SummarizerService( + new SemanticMemoryManager({memoryDir: dir, cwd}), + () => true, + ); + + const memory = await service.remember({ + content: 'Keep generated files out of review unless needed.', + category: 'coding style', + }); + + t.is(memory.category, 'codingStyle'); +}); + +test('inferMemoryCategory maps durable facts to stable categories', t => { + t.is( + inferMemoryCategory('Avoid middleware in the auth architecture.'), + 'architecture', + ); + t.is( + inferMemoryCategory('Use camel case for new command variables.'), + 'codingStyle', + ); + t.is( + inferMemoryCategory('Use camelCase for all variable names.'), + 'codingStyle', + ); + t.is(inferMemoryCategory('This fixes the queued input regression.'), 'bugFix'); + t.is(inferMemoryCategory('Refactor the old storage path later.'), 'refactor'); + t.is(inferMemoryCategory('TODO delete obsolete project memory.'), 'todo'); + t.is(inferMemoryCategory('The project name is Nanocoder.'), 'project'); +}); + +test('toCamelCaseCategory normalizes category names', t => { + t.is(toCamelCaseCategory('coding style'), 'codingStyle'); + t.is(toCamelCaseCategory('BUG-FIX'), 'bugFix'); + t.is(toCamelCaseCategory(''), 'project'); +}); + +test('SummarizerService proposes durable memories from messages', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'system', + content: 'You are Nanocoder.', + }, + { + role: 'user', + content: 'Use the existing provider abstraction for model changes.', + }, + { + role: 'assistant', + content: 'Fixed the queued input regression by restoring drafts.', + }, + { + role: 'tool', + content: 'command output', + tool_call_id: 'tool-1', + name: 'execute_bash', + }, + ]), + [ + { + content: 'Use the existing provider abstraction for model changes.', + category: 'architecture', + sourceType: 'explicit-user', + evidence: { + userMessages: [ + 'Use the existing provider abstraction for model changes.', + ], + assistantMessages: [], + }, + warnings: [], + }, + { + content: 'Fixed the queued input regression by restoring drafts.', + category: 'bugFix', + sourceType: 'conversation-inferred', + evidence: { + userMessages: [], + assistantMessages: [ + 'Fixed the queued input regression by restoring drafts.', + ], + }, + warnings: ['Inferred from conversation, no explicit user statement.'], + }, + ] satisfies MemoryProposal[], + ); +}); + +test('SummarizerService dedupes proposed memories and user takes precedence', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'Refactor the storage path later.', + }, + { + role: 'assistant', + content: 'Refactor the storage path later.', + }, + ]), + [ + { + content: 'Refactor the storage path later.', + category: 'refactor', + sourceType: 'explicit-user', + evidence: { + userMessages: ['Refactor the storage path later.'], + assistantMessages: ['Refactor the storage path later.'], + }, + warnings: [], + }, + ], + ); +}); + +test('SummarizerService detects assistant position reversal', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'user', + content: "Actually, generic exceptions look cleaner, you'd agree right?", + }, + { + role: 'assistant', + content: "You're right. I will use generic exceptions formatting.", + }, + ]), + [ + { + content: "You're right. I will use generic exceptions formatting.", + category: 'codingStyle', + sourceType: 'conversation-inferred', + evidence: { + userMessages: [], + assistantMessages: ["You're right. I will use generic exceptions formatting."], + }, + warnings: [ + 'Possible assistant position reversal.', + 'Inferred from conversation, no explicit user statement.', + ], + }, + ], + ); +}); + +test('SummarizerService clears the reversal warning once the user later explicitly restates the same line', t => { + const service = new SummarizerService(); + + const proposals = service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'Actually, generic exceptions look cleaner, do not you think.', + }, + { + role: 'assistant', + content: "You're right. I will use generic exceptions formatting.", + }, + { + role: 'user', + content: "You're right. I will use generic exceptions formatting.", + }, + ]); + + const restated = proposals.find( + p => p.content === "You're right. I will use generic exceptions formatting.", + ); + t.truthy(restated); + t.is(restated?.sourceType, 'explicit-user'); + t.deepEqual(restated?.warnings, []); +}); + +test('SummarizerService guards false positive on reversal when user provides path/code/error', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'That path is wrong, it should be /src/auth/style.ts.', + }, + { + role: 'assistant', + content: "You're right. The style convention is updated.", + }, + ]), + [ + { + content: 'That path is wrong, it should be /src/auth/style.ts.', + category: 'codingStyle', + sourceType: 'explicit-user', + evidence: { + userMessages: ['That path is wrong, it should be /src/auth/style.ts.'], + assistantMessages: [], + }, + warnings: [], + }, + { + content: "You're right. The style convention is updated.", + category: 'codingStyle', + sourceType: 'conversation-inferred', + evidence: { + userMessages: [], + assistantMessages: ["You're right. The style convention is updated."], + }, + warnings: [ + 'Inferred from conversation, no explicit user statement.', + ], + }, + ], + ); +}); + +test('SummarizerService drops uncategorized lines from both user and assistant', t => { + const service = new SummarizerService(); + + t.deepEqual( + service.proposeMemoriesFromMessages([ + { + role: 'assistant', + content: 'The project name shows up in the welcome banner.', + }, + { + role: 'user', + content: 'The project name is Nanocoder, not nano-coder.', + }, + { + role: 'user', + content: 'run the tests', + }, + { + role: 'user', + content: + 'One thing I noticed while in there: the provider list component does the same', + }, + ]), + [], + ); +}); + +test('SummarizerService caps candidates per message and truncates evidence snippets', t => { + const service = new SummarizerService(); + const longSuffix = 'x'.repeat(200); + const longMessage = [ + `Fix the provider retry storage schema bug one. ${longSuffix}.`, + 'Fix the provider retry storage schema bug two.', + 'Fix the provider retry storage schema bug three.', + 'Fix the provider retry storage schema bug four.', + 'Fix the provider retry storage schema bug five.', + ].join('\n'); + + const proposals = service.proposeMemoriesFromMessages([ + {role: 'assistant', content: longMessage}, + ]); + + t.is(proposals.length, 3); + for (const proposal of proposals) { + for (const evidence of proposal.evidence.assistantMessages) { + t.true(evidence.length <= 161); + } + } + t.true(proposals[0]!.evidence.assistantMessages[0]!.endsWith('…')); +}); + +test('SummarizerService proposals do not save memories automatically', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager); + + const proposals = service.proposeMemoriesFromMessages([ + { + role: 'user', + content: 'TODO delete obsolete project memory later.', + }, + ]); + + t.deepEqual(proposals, [ + { + content: 'TODO delete obsolete project memory later.', + category: 'todo', + sourceType: 'explicit-user', + evidence: { + userMessages: ['TODO delete obsolete project memory later.'], + assistantMessages: [], + }, + warnings: [], + }, + ]); + t.deepEqual(await manager.listMemories(), []); +}); + +test('SummarizerService blocks writes when semantic memory is disabled', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager, () => false); + + await t.throwsAsync(service.remember({content: 'Use tabs not spaces.'}), { + message: 'Semantic memory is turned off. Enable it in /settings to save memories.', + }); + await t.throwsAsync( + service.acceptProposal({content: 'Use tabs not spaces.', category: 'codingStyle'}), + { + message: 'Semantic memory is turned off. Enable it in /settings to save memories.', + }, + ); + t.deepEqual(await manager.listMemories(), []); +}); + +test('SummarizerService acceptProposal saves a proposal without re-deriving its category', async t => { + const dir = await createTempDir(); + const cwd = path.join(dir, 'repo'); + await fs.mkdir(cwd); + const manager = new SemanticMemoryManager({memoryDir: dir, cwd}); + const service = new SummarizerService(manager, () => true); + + const memory = await service.acceptProposal( + {content: 'Fixed the queued input regression by restoring drafts.', category: 'bugFix'}, + 'session-1', + ); + + t.is(memory.category, 'bugFix'); + t.is(memory.sourceSessionId, 'session-1'); + t.deepEqual(await manager.listMemories(), [memory]); +}); + +// --- Reversal detector: the four variants the round-3 review found defeated, +// plus the contradiction case the original report actually asked for. --- + +const CONCESSION = + "You're right. The provider config should load lazily, not eagerly."; +const USER_PREFERENCE_ONLY = + 'Honestly, lazy provider config just feels cleaner to me.'; + +function reversalWarnings(messages: Message[], content: string): string[] { + const proposal = new SummarizerService() + .proposeMemoriesFromMessages(messages) + .find(p => p.content === content); + if (!proposal) throw new Error(`no proposal produced for: ${content}`); + return proposal.warnings; +} + +test('reversal is flagged on a clean two-turn concession', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged when the user message contains a bare slash', t => { + // "auth/login" is prose, not a file path; it must not count as evidence. + t.true( + reversalWarnings( + [ + { + role: 'user', + content: + 'Honestly, for auth/login lazy provider config just feels cleaner to me.', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged across intervening tool-call turns', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + { + role: 'assistant', + content: '', + tool_calls: [ + {id: 't1', function: {name: 'read_file', arguments: {}}}, + ], + }, + { + role: 'tool', + content: 'file contents', + tool_call_id: 't1', + name: 'read_file', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is flagged for agreement openers outside the original six phrases', t => { + const conceded = + 'Agreed on reflection. The provider config should load lazily, not eagerly.'; + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: conceded}, + ], + conceded, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is flagged when a turn contradicts an earlier assistant turn without any opener', t => { + const reversed = + 'The provider config should load lazily, not eagerly, for this project.'; + t.true( + reversalWarnings( + [ + {role: 'user', content: 'How should provider config load?'}, + { + role: 'assistant', + content: 'The provider config should load eagerly, not lazily.', + }, + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: reversed}, + ], + reversed, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is not flagged when a substantive assistant reply sits in between', t => { + const conceded = "You're right. Use eager provider config."; + t.false( + reversalWarnings( + [ + {role: 'user', content: 'Honestly lazy just feels cleaner.'}, + {role: 'assistant', content: 'Here is a summary of the current setup.'}, + {role: 'assistant', content: conceded}, + ], + conceded, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is not flagged for a routine factual assistant turn', t => { + const fact = 'The storage schema keeps one provider row per workspace.'; + t.false( + reversalWarnings( + [ + {role: 'user', content: 'How is the storage laid out here?'}, + {role: 'assistant', content: fact}, + ], + fact, + ).includes(REVERSAL_WARNING), + ); +}); + +test('SummarizerService bounds the scan window and total proposal count', t => { + const service = new SummarizerService(); + const messages: Message[] = []; + // Well past both limits, and old enough that the earliest fall outside the window. + const total = MAX_SCANNED_MESSAGES + 20; + for (let i = 0; i < total; i++) { + messages.push({ + role: 'user', + content: `Fix the provider retry storage schema bug number ${i}.`, + }); + } + + const proposals = service.proposeMemoriesFromMessages(messages); + + t.is(proposals.length, MAX_PROPOSALS); + // The window keeps the newest turns, so the oldest message is not proposed. + t.false(proposals.some(p => p.content.endsWith('number 0.'))); + t.true(proposals.some(p => p.content.endsWith(`number ${total - 1}.`))); +}); + +test('reversal is still flagged when an intervening tool call has narration', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + { + role: 'assistant', + content: 'Let me re-read the config loader.', + tool_calls: [ + {id: 't1', function: {name: 'read_file', arguments: {}}}, + ], + }, + { + role: 'tool', + content: 'file contents', + tool_call_id: 't1', + name: 'read_file', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged across a look-ahead narration turn with no tool call', t => { + t.true( + reversalWarnings( + [ + {role: 'user', content: USER_PREFERENCE_ONLY}, + {role: 'assistant', content: 'Let me look at the file first.'}, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('reversal is still flagged when the user message contains camelCase or the word error', t => { + t.true( + reversalWarnings( + [ + { + role: 'user', + content: + 'the two exceptClauses are over-engineered, simplify it.', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); + t.true( + reversalWarnings( + [ + { + role: 'user', + content: 'is that not over-engineered? one error handler reads better.', + }, + {role: 'assistant', content: CONCESSION}, + ], + CONCESSION, + ).includes(REVERSAL_WARNING), + ); +}); + +test('SummarizerService strips leading list markers from proposed content', t => { + const service = new SummarizerService(); + const proposals = service.proposeMemoriesFromMessages([ + { + role: 'user', + content: '- Added a regression test for the 40-column case.', + }, + ]); + + t.is(proposals.length, 1); + t.is(proposals[0]?.content, 'Added a regression test for the 40-column case.'); +}); diff --git a/source/memory/summarizer-service.ts b/source/memory/summarizer-service.ts new file mode 100644 index 000000000..8b9edd558 --- /dev/null +++ b/source/memory/summarizer-service.ts @@ -0,0 +1,510 @@ +import {getSemanticMemoryEnabled} from '@/config/preferences'; +import type {Message} from '@/types/core'; +import { + type SemanticMemory, + SemanticMemoryManager, +} from './semantic-memory-manager'; + +export interface RememberMemoryInput { + content: string; + category?: string; + sourceSessionId?: string; +} + +export type MemorySourceType = 'explicit-user' | 'conversation-inferred'; + +export interface MemoryProposal { + content: string; + category: string; + sourceType: MemorySourceType; + evidence: { + userMessages: string[]; + assistantMessages: string[]; + }; + warnings: string[]; +} + +const MAX_CANDIDATES_PER_MESSAGE = 3; +const MAX_EVIDENCE_LENGTH = 160; + +/** + * How far back `/memory propose` scans. Proposals are reviewed by eye against a + * printed, numbered list, so an unbounded scan over a long session produces a + * list nobody reads. Both limits keep the newest turns. + */ +export const MAX_SCANNED_MESSAGES = 40; +export const MAX_PROPOSALS = 20; + +export const REVERSAL_WARNING = 'Possible assistant position reversal.'; +const INFERRED_WARNING = + 'Inferred from conversation, no explicit user statement.'; + +function truncateEvidence(content: string): string { + const collapsed = content.replaceAll(/\s+/gu, ' ').trim(); + if (collapsed.length <= MAX_EVIDENCE_LENGTH) return collapsed; + return `${collapsed.slice(0, MAX_EVIDENCE_LENGTH)}…`; +} + +const CATEGORY_RULES: Array<{category: string; pattern: RegExp}> = [ + { + category: 'bugFix', + pattern: /\b(bug|fix|fixed|regression|failure|failed|failing|flake)\b/i, + }, + { + category: 'refactor', + pattern: /\b(refactor|migration|migrate|migrated|rewrite)\b/i, + }, + { + category: 'todo', + pattern: /\b(todo|follow up|later|defer|deferred|unresolved)\b/i, + }, + { + category: 'architecture', + pattern: + /\b(architecture|architectural|adapter|middleware|provider|database|storage|schema|abstraction)\b/i, + }, + { + category: 'codingStyle', + pattern: + /\b(style|convention|format|formatting|naming|camel ?case|lint)\b/i, + }, +]; + +/** + * Openers a model reaches for when conceding. Deliberately broader than a + * handful of stock phrases: a concession phrased "Agreed on reflection" is the + * same event as one phrased "You're right", and only one of them was previously + * detectable. + */ +const AGREEMENT_OPENER_PATTERN = + /^\s*[^a-z0-9]*(you(?:'|’)?re\s+(?:right|correct)|you\s+are\s+(?:right|correct)|good\s+point|fair\s+(?:enough|point)|that\s+makes\s+sense|agreed|i\s+agree|on\s+reflection|point\s+taken|my\s+mistake|i\s+was\s+wrong|apologies|sorry,\s+you)/i; + +/** + * Signals that a user turn carried real evidence rather than bare preference. + * + * A path needs a genuine path shape - a leading `/`, `./` or `~/`, two or more + * segments, or a known file extension. A single bare slash does not count, so + * ordinary prose like "auth/login" no longer suppresses detection. + */ +const CODE_FENCE_PATTERN = /```|~~~/; +const INLINE_CODE_PATTERN = /`[^`]+`/; +const PATH_PATTERN = + /(?:^|[\s('"])(?:~\/|\.{1,2}\/|\/)[\w.-]+|[\w.-]+\/[\w.-]+\/[\w.-]+|\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|ya?ml|md|py|rb|go|rs|java|html|css|scss|toml|sh|sql)\b/; +const ERROR_OUTPUT_PATTERN = + /\b(error:|exception|traceback|stack\s?trace|failed\s+with|exit\s+code|ENOENT|undefined is not|cannot read)\b/i; +const CODE_IDENTIFIER_PATTERN = + /\b[A-Za-z_$][\w$]*\([^)]*\)|\b[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]+\b/; + +function hasTechnicalEvidence(content: string): boolean { + return ( + CODE_FENCE_PATTERN.test(content) || + INLINE_CODE_PATTERN.test(content) || + PATH_PATTERN.test(content) || + ERROR_OUTPUT_PATTERN.test(content) || + CODE_IDENTIFIER_PATTERN.test(content) + ); +} + +const LOOKAHEAD_NARRATION = + /^(?:let me |i(?:'m going to |'ll | will )?)(?:re-)?(?:read|look at|check|inspect|open|examine)\b/i; + +/** Tool calls (with or without narration) and "I'll look at the file" turns. */ +function isPlumbingTurn(message: Message): boolean { + if ((message.tool_calls?.length ?? 0) > 0) return true; + return LOOKAHEAD_NARRATION.test(message.content.trim()); +} + +const NEGATION_PATTERN = + /\b(not|never|no|avoid|isn'?t|aren'?t|won'?t|shouldn'?t|doesn'?t|don'?t|instead\s+of|rather\s+than|no\s+longer)\b/i; + +/** + * Opposed term pairs used to spot a stance flip between two assistant turns. + * Each entry is one axis; which side a turn *asserts* is decided by whether the + * term is negated, so "lazily, not eagerly" asserts lazy rather than both. + */ +const OPPOSED_TERM_PAIRS: Array<[RegExp, RegExp]> = [ + [/\blazil?y?\b|\blazy\b/i, /\beager(?:ly)?\b/i], + [/\bsynchronous(?:ly)?\b|\bsync\b/i, /\basynchronous(?:ly)?\b|\basync\b/i], + [/\benabled?\b/i, /\bdisabled?\b/i], + [/\bincluded?\b/i, /\bexcluded?\b/i], + [/\badded?\b/i, /\bremoved?\b/i], + [/\bmutable\b/i, /\bimmutable\b/i], + [/\bexplicit(?:ly)?\b/i, /\bimplicit(?:ly)?\b/i], + [/\bstatic(?:ally)?\b/i, /\bdynamic(?:ally)?\b/i], + [/\bbefore\b/i, /\bafter\b/i], + [/\bsingle\b/i, /\bmultiple\b/i], + [/\bshould\b/i, /\bshould\s?n[o']?t\b/i], +]; + +const NEGATION_LOOKBEHIND = 24; + +/** True when `pattern` matches `text` at a position not preceded by a negation. */ +function assertsTerm(text: string, pattern: RegExp): boolean { + let from = 0; + while (from < text.length) { + const match = text.slice(from).match(pattern); + if (!match || match.index === undefined) return false; + const start = from + match.index; + const preceding = text.slice( + Math.max(0, start - NEGATION_LOOKBEHIND), + start, + ); + if (!NEGATION_PATTERN.test(preceding)) return true; + from = start + Math.max(match[0].length, 1); + } + return false; +} + +/** Whole-word match for a token already split out of the text (no regex). */ +function assertsTermWord(text: string, term: string): boolean { + const haystack = text.toLowerCase(); + let from = 0; + while (from <= haystack.length - term.length) { + const start = haystack.indexOf(term, from); + if (start < 0) return false; + const before = start === 0 ? '' : haystack[start - 1]; + const after = haystack[start + term.length] ?? ''; + const bounded = + (start === 0 || /[^a-z0-9]/i.test(before ?? '')) && + (after === '' || /[^a-z0-9]/i.test(after)); + if (bounded) { + const preceding = text.slice( + Math.max(0, start - NEGATION_LOOKBEHIND), + start, + ); + if (!NEGATION_PATTERN.test(preceding)) return true; + } + from = start + 1; + } + return false; +} + +function contentTerms(value: string): Set { + return new Set( + value + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(part => part.length > 2 && !TOPIC_STOPWORDS.has(part)), + ); +} + +const TOPIC_STOPWORDS = new Set([ + 'the', + 'and', + 'but', + 'for', + 'not', + 'you', + 'are', + 'was', + 'were', + 'this', + 'that', + 'with', + 'have', + 'has', + 'had', + 'will', + 'would', + 'should', + 'could', + 'can', + 'its', + 'your', + 'our', + 'their', + 'them', + 'they', + 'from', + 'into', + 'been', + 'right', + 'correct', + 'agreed', + 'agree', + 'point', + 'sense', + 'makes', + 'reflection', +]); + +const MIN_SHARED_TOPIC_TERMS = 2; + +/** + * True when `later` reverses a stance `earlier` took on the same subject. + * + * Requires topical overlap first, then either a flip along one of the opposed + * term axes or a negation asymmetry on a shared term. This is a heuristic + * feeding a *warning* on a proposal the user is already reviewing by hand, so it + * is tuned to tolerate false positives rather than miss real concessions. + */ +function isContradiction(earlier: string, later: string): boolean { + const earlierTerms = contentTerms(earlier); + const laterTerms = contentTerms(later); + const shared = [...laterTerms].filter(term => earlierTerms.has(term)); + if (shared.length < MIN_SHARED_TOPIC_TERMS) return false; + + for (const [sideA, sideB] of OPPOSED_TERM_PAIRS) { + const earlierA = assertsTerm(earlier, sideA); + const earlierB = assertsTerm(earlier, sideB); + const laterA = assertsTerm(later, sideA); + const laterB = assertsTerm(later, sideB); + if ( + (earlierA && !earlierB && laterB && !laterA) || + (earlierB && !earlierA && laterA && !laterB) + ) { + return true; + } + } + + return shared.some( + term => assertsTermWord(earlier, term) !== assertsTermWord(later, term), + ); +} + +export class SummarizerService { + constructor( + private readonly memoryManager = new SemanticMemoryManager(), + private readonly isMemoryEnabled: () => boolean = getSemanticMemoryEnabled, + ) {} + + async remember(input: RememberMemoryInput): Promise { + this.assertMemoryWritesEnabled(); + + const content = input.content.trim(); + if (!content) { + throw new Error('Memory content cannot be empty'); + } + + return this.memoryManager.addMemory({ + content, + category: input.category + ? toCamelCaseCategory(input.category) + : inferMemoryCategory(content), + sourceSessionId: input.sourceSessionId, + }); + } + + async acceptProposal( + proposal: Pick, + sourceSessionId?: string, + ): Promise { + this.assertMemoryWritesEnabled(); + + return this.memoryManager.addMemory({ + content: proposal.content, + category: proposal.category, + sourceSessionId, + }); + } + + private assertMemoryWritesEnabled(): void { + if (!this.isMemoryEnabled()) { + throw new Error( + 'Semantic memory is turned off. Enable it in /settings to save memories.', + ); + } + } + + proposeMemoriesFromMessages(messages: Message[]): MemoryProposal[] { + const proposals = new Map< + string, + { + content: string; + category: string; + sourceRole: 'user' | 'assistant'; + userTurns: string[]; + assistantTurns: string[]; + warnings: string[]; + } + >(); + + // Only the most recent turns are scanned; older ones would swell the + // printed list past what anyone reviews by eye. + const firstScanned = Math.max(0, messages.length - MAX_SCANNED_MESSAGES); + + for (let i = firstScanned; i < messages.length; i++) { + const message = messages[i]; + if (!message || (message.role !== 'user' && message.role !== 'assistant')) + continue; + + const candidates = splitMemoryCandidates(message.content).slice( + 0, + MAX_CANDIDATES_PER_MESSAGE, + ); + + for (const candidate of candidates) { + const category = inferMemoryCategory(candidate); + if (category === 'project') continue; + + const key = candidate.toLowerCase(); + let entry = proposals.get(key); + if (!entry) { + entry = { + content: candidate, + category, + sourceRole: message.role, + userTurns: [], + assistantTurns: [], + warnings: [], + }; + proposals.set(key, entry); + } + + const snippet = truncateEvidence(message.content); + if (message.role === 'user') { + entry.userTurns.push(snippet); + entry.sourceRole = 'user'; + entry.warnings = entry.warnings.filter( + warning => warning !== REVERSAL_WARNING, + ); + } else { + entry.assistantTurns.push(snippet); + } + + if ( + message.role === 'assistant' && + entry.sourceRole !== 'user' && + !entry.warnings.includes(REVERSAL_WARNING) && + this.isAssistantReversal(messages, i) + ) { + entry.warnings.push(REVERSAL_WARNING); + } + } + } + + return [...proposals.values()].slice(-MAX_PROPOSALS).map(entry => { + const sourceType: MemorySourceType = + entry.sourceRole === 'user' ? 'explicit-user' : 'conversation-inferred'; + const warnings = [...entry.warnings]; + + if ( + sourceType === 'conversation-inferred' && + !warnings.includes(INFERRED_WARNING) + ) { + warnings.push(INFERRED_WARNING); + } + + return { + content: entry.content, + category: entry.category, + sourceType, + evidence: { + userMessages: entry.userTurns, + assistantMessages: entry.assistantTurns, + }, + warnings, + }; + }); + } + + /** + * Flags an assistant turn that reads as a concession to social pressure + * rather than to evidence. + * + * Shape, following the original report: + * 1. the turn is preceded by a user turn carrying no code, path or error + * output - i.e. pushback with no new information, and + * 2. the turn either contradicts an earlier assistant turn on the same + * subject, or opens with an agreement phrase. + * + * Tool-call turns and tool results are stepped over in (1); in a real + * agentic session the assistant reads files between almost every pair of + * user turns, and bailing on those made the check near-unreachable. + */ + private isAssistantReversal( + messages: Message[], + assistantIndex: number, + ): boolean { + const assistantMsg = messages[assistantIndex]; + if (!assistantMsg) return false; + + let userIndex = -1; + for (let i = assistantIndex - 1; i >= 0; i--) { + const m = messages[i]; + if (!m) continue; + if (m.role === 'tool') continue; + if (m.role === 'assistant') { + if (isPlumbingTurn(m)) continue; + return false; + } + if (m.role === 'user') { + userIndex = i; + break; + } + } + + const userMsg = userIndex >= 0 ? messages[userIndex] : undefined; + if (!userMsg) return false; + if (hasTechnicalEvidence(userMsg.content)) return false; + + if (AGREEMENT_OPENER_PATTERN.test(assistantMsg.content)) return true; + + return this.contradictsEarlierAssistantTurn( + messages, + assistantIndex, + userIndex, + ); + } + + /** True when any assistant turn before `userIndex` took the opposite stance. */ + private contradictsEarlierAssistantTurn( + messages: Message[], + assistantIndex: number, + userIndex: number, + ): boolean { + const later = messages[assistantIndex]; + if (!later) return false; + + for (let i = userIndex - 1; i >= 0; i--) { + const earlier = messages[i]; + if (!earlier || earlier.role !== 'assistant') continue; + if (isPlumbingTurn(earlier)) continue; + if (isContradiction(earlier.content, later.content)) return true; + } + + return false; + } +} + +export function inferMemoryCategory(content: string): string { + for (const rule of CATEGORY_RULES) { + if (rule.pattern.test(content)) return rule.category; + } + + return 'project'; +} + +export function toCamelCaseCategory(value: string): string { + const parts = value + .trim() + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(Boolean); + + if (parts.length === 0) return 'project'; + + return parts + .map((part, index) => + index === 0 ? part : `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`, + ) + .join(''); +} + +function splitMemoryCandidates(content: string): string[] { + return content + .split(/\n+/u) + .map(part => + part + .trim() + .replace(/^[-*]\s+/u, '') + .replace(/^\d+\.\s+/u, ''), + ) + .filter( + part => + part.length >= 12 && + part.length <= 300 && + !part.endsWith('?') && + /[.!:]$/u.test(part), + ); +} diff --git a/source/plain/shell.spec.ts b/source/plain/shell.spec.ts index df10dfcf2..16d0699c8 100644 --- a/source/plain/shell.spec.ts +++ b/source/plain/shell.spec.ts @@ -652,6 +652,86 @@ test.serial( }, ); +test.serial( + "text mode recalls relevant project memories and surfaces the count on stderr", + async (t) => { + const shutdown: CapturedShutdown = { code: null }; + const stdout = capturingStdout(); + const stderr = capturingStderr(); + const calls: Array<{ systemPrompt: string; query: string }> = []; + try { + await runPlainShell({ + prompt: "refactor the auth module", + developmentMode: "auto-accept", + trustDirectory: true, + outputFormat: "text", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: makeFakeRunPlainConversation({ + kind: "success", + finalText: "all done", + reasoning: null, + toolCalls: [], + }), + getShutdownManager: makeFakeShutdownManager(shutdown), + appendRelevantProjectContextWithCount: async (systemPrompt, query) => { + calls.push({ systemPrompt, query }); + return { + systemPrompt: `${systemPrompt}\n\n## Project Context\n\n- Auth uses Clerk.`, + memoryCount: 2, + }; + }, + }), + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + t.is(calls.length, 1); + t.is(calls[0]?.query, "refactor the auth module"); + t.regex(stderr.get(), /Recalling 2 project memories\.\.\./); + t.is(shutdown.code, 0); + }, +); + +test.serial( + "text mode stays silent when no relevant memories are recalled", + async (t) => { + const shutdown: CapturedShutdown = { code: null }; + const stdout = capturingStdout(); + const stderr = capturingStderr(); + try { + await runPlainShell({ + prompt: "do the thing", + developmentMode: "auto-accept", + trustDirectory: true, + outputFormat: "text", + deps: baseDeps({ + initializePlain: makeFakeInitializePlain(), + runPlainConversation: makeFakeRunPlainConversation({ + kind: "success", + finalText: "all done", + reasoning: null, + toolCalls: [], + }), + getShutdownManager: makeFakeShutdownManager(shutdown), + appendRelevantProjectContextWithCount: async (systemPrompt) => ({ + systemPrompt, + memoryCount: 0, + }), + }), + }); + } finally { + stdout.restore(); + stderr.restore(); + } + + t.false(stderr.get().includes("Recalling")); + t.is(shutdown.code, 0); + }, +); + test.serial( "text error outcome writes the error message to stderr with exit code 1", async (t) => { diff --git a/source/plain/shell.ts b/source/plain/shell.ts index 188cbce21..6c57281d0 100644 --- a/source/plain/shell.ts +++ b/source/plain/shell.ts @@ -6,12 +6,18 @@ import { artifactManager, } from '@/artifacts/artifact-manager'; import {getAppConfig} from '@/config/index'; -import {loadPreferences, savePreferences} from '@/config/preferences'; +import { + loadPreferences, + resolveProjectContextPreferences, + savePreferences, +} from '@/config/preferences'; import {resolveTune} from '@/config/tune'; import { TOOL_APPROVAL_REQUIRED_KIND, TOOL_APPROVAL_REQUIRED_PREFIX, } from '@/constants'; +import {appendRelevantProjectContextWithCount} from '@/memory/project-context'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import {runPlainConversation} from '@/plain/conversation'; import {initializePlain} from '@/plain/initialize'; import { @@ -51,6 +57,7 @@ export interface RunPlainShellDeps { getShutdownManager: typeof getShutdownManager; loadPreferences: typeof loadPreferences; savePreferences: typeof savePreferences; + appendRelevantProjectContextWithCount: typeof appendRelevantProjectContextWithCount; artifacts: Pick< ArtifactManager, | 'cleanupStaleEphemeralSessions' @@ -65,6 +72,7 @@ const defaultDeps: RunPlainShellDeps = { getShutdownManager, loadPreferences, savePreferences, + appendRelevantProjectContextWithCount, artifacts: artifactManager, }; @@ -168,12 +176,25 @@ export async function runPlainShell( const toolsForPrompt = toolsDisabled ? toolManager.getFilteredTools(availableNames) : {}; - const systemContent = appendToolDefinitionsToPrompt( + const toolPrompt = appendToolDefinitionsToPrompt( basePrompt, toolsDisabled, fallbackToolFormat, toolsForPrompt, ); + + const projectContext = await deps.appendRelevantProjectContextWithCount( + toolPrompt, + prompt, + new SemanticMemoryManager(), + resolveProjectContextPreferences(deps.loadPreferences()), + ); + const systemContent = projectContext.systemPrompt; + if (projectContext.memoryCount > 0) { + writeStatus( + `Recalling ${projectContext.memoryCount} project memor${projectContext.memoryCount === 1 ? 'y' : 'ies'}...`, + ); + } setLastBuiltPrompt(systemContent); const systemMessage: Message = {role: 'system', content: systemContent}; diff --git a/source/subagents/subagent-executor.spec.ts b/source/subagents/subagent-executor.spec.ts index d6cc76bc3..22fee0edc 100644 --- a/source/subagents/subagent-executor.spec.ts +++ b/source/subagents/subagent-executor.spec.ts @@ -7,6 +7,7 @@ import { setSessionContextLimit, } from '@/models/index'; import {SubagentLoader, getSubagentLoader} from './subagent-loader.js'; +import type {MemoryFinder} from '@/memory/project-context'; import type {ToolManager} from '@/tools/tool-manager'; import type { LLMClient, @@ -1137,6 +1138,82 @@ test.serial('a subagent cannot execute a tool outside its allow-list', async t = t.regex(toolResult, /not available to this subagent/); }); +test.serial('injects relevant project memories into the subagent system prompt', async t => { + const toolManager = createMockToolManager(); + const client = createMockClient([{content: 'Here are the results'}]); + let systemPrompt = ''; + const originalChat = client.chat.bind(client); + client.chat = async (messages: Message[], tools, callbacks, signal, modeOverrides) => { + systemPrompt = String(messages[0]?.content ?? ''); + return originalChat(messages, tools, callbacks, signal, modeOverrides); + }; + + const memoryFinder: MemoryFinder = { + findRelevantMemories: async () => [ + { + id: 'mem-1', + content: 'Auth flow uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ], + }; + + const executor = new SubagentExecutor(toolManager, client, process.cwd(), 'normal', { + memoryFinder, + projectContextOptions: {semanticMemoryEnabled: true}, + }); + + const result = await executor.execute({ + subagent_type: 'explore', + description: 'Refactor Clerk auth', + }); + + t.true(result.success); + t.true(systemPrompt.includes('## Project Context')); + t.true(systemPrompt.includes('Auth flow uses Clerk and avoids middleware.')); +}); + +test.serial('skips subagent memory recall when semantic memory is disabled', async t => { + const toolManager = createMockToolManager(); + const client = createMockClient([{content: 'Here are the results'}]); + let systemPrompt = ''; + const originalChat = client.chat.bind(client); + client.chat = async (messages: Message[], tools, callbacks, signal, modeOverrides) => { + systemPrompt = String(messages[0]?.content ?? ''); + return originalChat(messages, tools, callbacks, signal, modeOverrides); + }; + + let finderCalls = 0; + const memoryFinder: MemoryFinder = { + findRelevantMemories: async () => { + finderCalls++; + return [ + { + id: 'mem-1', + content: 'Auth flow uses Clerk and avoids middleware.', + category: 'architecture', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ]; + }, + }; + + const executor = new SubagentExecutor(toolManager, client, process.cwd(), 'normal', { + memoryFinder, + projectContextOptions: {semanticMemoryEnabled: false}, + }); + + const result = await executor.execute({ + subagent_type: 'explore', + description: 'Refactor Clerk auth', + }); + + t.true(result.success); + t.is(finderCalls, 0); + t.false(systemPrompt.includes('## Project Context')); +}); + test.serial( 'caps subagent history before client.chat without starting on a tool row', async t => { @@ -1283,4 +1360,3 @@ test.serial('compacts subagent history after a tool turn', async t => { resetSessionContextLimit(); } }); - diff --git a/source/subagents/subagent-executor.ts b/source/subagents/subagent-executor.ts index 775a2b977..b01ddeb1f 100644 --- a/source/subagents/subagent-executor.ts +++ b/source/subagents/subagent-executor.ts @@ -7,7 +7,14 @@ import {createLLMClient} from '@/client-factory'; import {getAppConfig, getRetryLimits} from '@/config/index'; +import {getProjectContextPreferences} from '@/config/preferences'; import {computeToolCallSignature} from '@/hooks/chat-handler/utils/tool-signature'; +import { + appendRelevantProjectContextWithCount, + type MemoryFinder, + type ProjectContextOptions, +} from '@/memory/project-context'; +import {SemanticMemoryManager} from '@/memory/semantic-memory-manager'; import { appendSubagentTool, getSubagentProgress, @@ -85,17 +92,27 @@ export class SubagentExecutor { * that don't supply a resolver (plain shell, tests). */ private modeResolver?: () => DevelopmentMode; + private memoryFinder: MemoryFinder; + private projectContextOptions?: ProjectContextOptions; constructor( toolManager: ToolManager, parentClient: LLMClient, projectRoot: string = process.cwd(), parentMode: DevelopmentMode = 'normal', + options: { + memoryFinder?: MemoryFinder; + projectContextOptions?: ProjectContextOptions; + } = {}, ) { this.toolManager = toolManager; this.parentClient = parentClient; this.projectRoot = projectRoot; this.parentMode = parentMode; + this.memoryFinder = + options.memoryFinder ?? + new SemanticMemoryManager({cwd: this.projectRoot}); + this.projectContextOptions = options.projectContextOptions; } /** @@ -164,9 +181,18 @@ export class SubagentExecutor { const context = this.createSubagentContext(config, task); const filteredTools = this.filterTools(config); + const recalled = await appendRelevantProjectContextWithCount( + context.systemMessage, + this.buildTaskPrompt(task), + this.memoryFinder, + { + ...getProjectContextPreferences(), + ...this.projectContextOptions, + }, + ); const messages: Message[] = [ - {role: 'system', content: context.systemMessage}, + {role: 'system', content: recalled.systemPrompt}, ...context.initialMessages, ]; diff --git a/source/types/app.ts b/source/types/app.ts index 20a81c687..6c3086acf 100644 --- a/source/types/app.ts +++ b/source/types/app.ts @@ -59,4 +59,5 @@ export interface MessageSubmissionOptions { developmentMode?: DevelopmentMode; lastApiUsage?: ApiUsageSnapshot | null; apiCallHistory?: ApiCallRecord[]; + sessionId?: string; } diff --git a/source/types/commands.ts b/source/types/commands.ts index 6fecf234e..8b4e6988a 100644 --- a/source/types/commands.ts +++ b/source/types/commands.ts @@ -22,6 +22,7 @@ export interface Command { developmentMode?: import('@/types/core').DevelopmentMode; lastApiUsage?: ApiUsageSnapshot | null; apiCallHistory?: ApiCallRecord[]; + sessionId?: string; }, ) => Promise; } diff --git a/source/types/config.ts b/source/types/config.ts index 3af794a5d..cda914987 100644 --- a/source/types/config.ts +++ b/source/types/config.ts @@ -464,6 +464,12 @@ export interface UserPreferences { */ showUsageFooter?: boolean; enablePromptScrubbing?: boolean; + /** Whether semantic memory is active. Default true to preserve existing behavior. */ + semanticMemoryEnabled?: boolean; + /** Max memories recalled into one prompt. Defaults and bounds live in project-context.ts. */ + semanticMemoryLimit?: number; + /** Approximate token ceiling for the injected Project Context block. */ + semanticMemoryTokenBudget?: number; /** * Interactive TUI screen mode. true (default): fullscreen on the * alternate screen buffer with in-app scrolling (wheel / PgUp / PgDn). From 0f2d93b9ed094babb56b852f40c0325bf1d09863 Mon Sep 17 00:00:00 2001 From: Tisankan Jeyakumar <61219211+rascal-sl@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:56:54 +0530 Subject: [PATCH 19/25] fix(acp): route sub-agent tool approvals to the client (#1080) A tool call made inside a dispatched sub-agent goes through the global approval slot in tool-approval-queue. Only the Ink TUI installs a handler there, so under ACP the slot fell back to denying, and the sub-agent got "Tool execution was denied by the user." without the client ever being asked. A client that gates writes saw and decided every top-level call and nothing a sub-agent did, so delegated work could only write by bypassing approval. Install a handler for the turn that forwards these to the same session/request_permission channel the top-level calls use. The call is announced first, because a permission request naming a tool call the client has not seen is rejected as invalid params, and the title carries the sub-agent name so the client can tell the two sources apart. A denied or cancelled decision marks the announced call failed. The terminal status after an approval is not emitted, because the sub-agent layer does not report its tool results back to the ACP conversation. That gap predates this change. Closes #1019 --- .changeset/acp-subagent-tool-permission.md | 5 + source/acp/acp-conversation.spec.ts | 136 +++++++++++++++++++++ source/acp/acp-conversation.ts | 93 ++++++++++++++ source/utils/global-handler-slot.ts | 17 ++- 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 .changeset/acp-subagent-tool-permission.md diff --git a/.changeset/acp-subagent-tool-permission.md b/.changeset/acp-subagent-tool-permission.md new file mode 100644 index 000000000..d7d7f44a4 --- /dev/null +++ b/.changeset/acp-subagent-tool-permission.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed sub-agent tool calls being denied silently under ACP. A tool call made inside a dispatched sub-agent went through the global approval slot, which no ACP code path installed a handler for, so its safe fallback denied every one without the client seeing a `session/request_permission`. Delegated work could only write by bypassing approval entirely, which was invisible to a client that gates writes. Sub-agent calls now use the same permission channel as top-level ones, announced first so the request names a known tool call, prefixed so their cards cannot collide with a top-level id, and titled with the sub-agent so the client can tell them apart. The handler is scoped to the turn that installs it, so concurrent sessions cannot answer each other's approvals. Note that sub-agent approvals still ignore the ACP session's mode and the configured `alwaysAllow` list, so a `yolo` or `auto-accept` session is prompted inside a sub-agent for a tool it would not be prompted for at top level. Closes #1019. diff --git a/source/acp/acp-conversation.spec.ts b/source/acp/acp-conversation.spec.ts index e12bccf1a..28859dd73 100644 --- a/source/acp/acp-conversation.spec.ts +++ b/source/acp/acp-conversation.spec.ts @@ -6,6 +6,7 @@ import test from 'ava'; import type {AgentSideConnection} from '@agentclientprotocol/sdk'; import {AcpSession} from '@/acp/acp-session'; import {runAcpConversation} from '@/acp/acp-conversation'; +import {signalToolApproval} from '@/utils/tool-approval-queue'; import { setToolRegistryGetter, setToolManagerGetter, @@ -2183,3 +2184,138 @@ test.serial( }, ); + +// ============================================================================ +// Sub-agent tool approval (#1019) +// ============================================================================ + +/** + * Drives signalToolApproval from inside the turn, which is the only way the + * sub-agent executor ever reaches it. Firing it after runAcpConversation + * returned would only pass while the handler leaked past the turn. + */ +const approveFromInsideTurn = async ( + requestPermission: (p: any) => Promise, + subagentName = 'docs', +) => { + const updates: any[] = []; + const permissionRequests: any[] = []; + let approved: boolean | undefined; + let signalled = false; + + const conn = { + sessionUpdate: async (u: any) => { + updates.push(u.update); + }, + requestPermission: async (p: any) => { + permissionRequests.push(p); + return requestPermission(p); + }, + } as unknown as AgentSideConnection; + + const session = createMockSession(conn); + // chat() is awaited by the turn, so this is a point where the handler is + // installed and the turn has not returned - the state the sub-agent + // executor signals from. + const client = { + chat: async () => { + if (!signalled) { + signalled = true; + approved = await signalToolApproval({ + toolCall: createMockToolCall('write_file', {path: 'a.txt'}, 'call-1'), + subagentName, + }); + } + return { + choices: [{message: {content: 'done', tool_calls: []}}], + toolsDisabled: false, + }; + }, + } as unknown as LLMClient; + const toolManager = { + getAvailableToolNames: () => [], + getFilteredTools: () => ({}), + hasTool: () => false, + getToolEntry: () => undefined, + isReadOnly: () => true, + }; + + await runAcpConversation({ + session, + client, + toolManager: toolManager as any, + conn, + nonInteractiveAlwaysAllow: [], + }); + + return {approved, updates, permissionRequests, session}; +}; + +test('runAcpConversation - a sub-agent tool call reaches the client for permission', async t => { + const {approved, updates, permissionRequests} = await approveFromInsideTurn( + async () => ({outcome: {outcome: 'selected', optionId: 'allow'}}), + ); + + t.true(approved); + t.is(permissionRequests.length, 1); + + // Prefixed: sub-agent ids come from the sub-agent's own model and share an + // id space with top-level calls, so an unprefixed id could merge two cards. + const announcedId = permissionRequests[0].toolCall.toolCallId; + t.is(announcedId, 'subagent:call-1'); + t.true(String(permissionRequests[0].toolCall.title).includes('docs')); + + // Announced before the request, or the client rejects the id. + const announced = updates.filter( + (u: any) => u.sessionUpdate === 'tool_call' && u.toolCallId === announcedId, + ); + t.is(announced.length, 1); + + // Settled rather than left spinning: the sub-agent layer reports no result. + const terminal = updates.filter( + (u: any) => u.toolCallId === announcedId && u.status === 'completed', + ); + t.is(terminal.length, 1); +}); + +test('runAcpConversation - a denied sub-agent tool call is reported as failed', async t => { + const {approved, updates, permissionRequests} = await approveFromInsideTurn( + async () => ({outcome: {outcome: 'selected', optionId: 'deny'}}), + ); + + t.false(approved); + t.is(permissionRequests.length, 1); + const failed = updates.filter( + (u: any) => u.toolCallId === 'subagent:call-1' && u.status === 'failed', + ); + t.is(failed.length, 1); +}); + +test('runAcpConversation - a transport failure denies the tool rather than aborting the sub-agent', async t => { + const {approved, permissionRequests} = await approveFromInsideTurn(async () => { + throw new Error('connection closed'); + }); + + // signalToolApproval is awaited outside the sub-agent executor's own try, so + // a throw here would abort the whole run instead of denying one tool. + t.false(approved); + t.is(permissionRequests.length, 1); +}); + +test('runAcpConversation - the approval handler does not outlive the turn', async t => { + const {permissionRequests} = await approveFromInsideTurn(async () => ({ + outcome: {outcome: 'selected', optionId: 'allow'}, + })); + t.is(permissionRequests.length, 1); + + // The slot is a module singleton shared across sessions, so a handler left + // installed would answer a later session's approvals with this turn's + // session id and abort controller. + const afterTurn = await signalToolApproval({ + toolCall: createMockToolCall('write_file', {path: 'b.txt'}, 'call-2'), + subagentName: 'docs', + }); + + t.false(afterTurn); + t.is(permissionRequests.length, 1); +}); diff --git a/source/acp/acp-conversation.ts b/source/acp/acp-conversation.ts index a7034faa9..afbf06d8d 100644 --- a/source/acp/acp-conversation.ts +++ b/source/acp/acp-conversation.ts @@ -44,6 +44,10 @@ import type { import {buildResponseUsage} from '@/usage/response-usage'; import {maybeAutoCompact} from '@/utils/auto-compact'; import {capMessagesForModel} from '@/utils/message-capping'; +import { + type PendingToolApproval, + setGlobalToolApprovalHandler, +} from '@/utils/tool-approval-queue'; import {createCancellationResults} from '@/utils/tool-cancellation'; import {toOptionString} from '@/utils/type-helpers'; @@ -74,8 +78,97 @@ export interface RunAcpConversationOptions { nonInteractiveAlwaysAllow: string[]; } +/** + * Sub-agent tool calls reach the approval slot in tool-approval-queue, which + * only the Ink UI installs a handler for. Under ACP the slot fell back to + * denying, so a dispatched sub-agent was refused without the client seeing a + * request. Route those to the same session/request_permission channel the + * top-level calls use. + */ +function createSubagentApprovalHandler( + session: AcpSession, + conn: AgentSideConnection, +): (approval: PendingToolApproval) => Promise { + return async ({toolCall, subagentName}) => { + // The sub-agent's own model names its tool calls and shares an id space + // with the top-level ones, so prefix to keep the two cards distinct. + const announced: ToolCall = { + ...toolCall, + id: `${SUBAGENT_TOOL_CALL_PREFIX}${toolCall.id}`, + }; + + try { + const meta = await buildToolCallMeta(announced); + const subagentMeta: AcpToolCallMeta = { + ...meta, + title: `${meta.title} (${subagentName})`, + }; + + // Announced first: a permission request naming a tool call the + // client has not seen is rejected as invalid params. + await emitToolCall(session, conn, announced, 'pending', subagentMeta); + + const permission = await requestToolPermission( + session, + announced, + conn, + subagentMeta, + session.abortController.signal, + ); + + if (permission === 'approved') { + // The sub-agent layer does not report its results back here, so + // settle the card now rather than leave it spinning forever. + await emitToolCall( + session, + conn, + announced, + 'completed', + subagentMeta, + 'tool_call_update', + ); + return true; + } + + await emitToolCallUpdate( + session, + conn, + announced, + 'failed', + permission === 'cancelled' ? 'Cancelled by user' : 'Denied by user', + ); + return false; + } catch { + // signalToolApproval is awaited outside the sub-agent executor's own + // try, so a transport failure here would abort the whole sub-agent + // run. Deny the one tool instead and keep the safe-fallback posture. + return false; + } + }; +} + export async function runAcpConversation( options: RunAcpConversationOptions, +): Promise { + // The slot is a module singleton with last-writer-wins semantics, and two + // sessions can have turns in flight at once, so the handler is scoped to + // this turn. Without the teardown a second session's closure would answer + // the first session's approvals against the wrong session id and abort + // controller, and a finished turn's session would stay reachable. + const restoreApprovalHandler = setGlobalToolApprovalHandler( + createSubagentApprovalHandler(options.session, options.conn), + ); + try { + return await runTurn(options); + } finally { + restoreApprovalHandler(); + } +} + +const SUBAGENT_TOOL_CALL_PREFIX = 'subagent:'; + +async function runTurn( + options: RunAcpConversationOptions, ): Promise { const {session, client, toolManager, conn, nonInteractiveAlwaysAllow} = options; diff --git a/source/utils/global-handler-slot.ts b/source/utils/global-handler-slot.ts index 4970c47f8..d1ea37bd6 100644 --- a/source/utils/global-handler-slot.ts +++ b/source/utils/global-handler-slot.ts @@ -5,8 +5,13 @@ * handler is registered, `signal()` resolves to a caller-supplied fallback. */ export interface GlobalHandlerSlot { - /** Called once from App.tsx to wire up the UI handler. */ - set(handler: (input: TInput) => Promise): void; + /** + * Wire up the handler. Returns a disposer that restores whatever handler + * was installed before, for callers whose handler is only valid for a + * bounded scope. Callers that own the slot for the process lifetime, such + * as the Ink UI, can ignore it. + */ + set(handler: (input: TInput) => Promise): () => void; /** Called from the tool/executor; resolves with the user's response. */ signal(input: TInput): Promise; } @@ -18,7 +23,15 @@ export function createGlobalHandlerSlot( return { set(next) { + const previous = handler; handler = next; + return () => { + // Only step back if nobody replaced us in the meantime, so a + // later owner is not clobbered by an earlier one's teardown. + if (handler === next) { + handler = previous; + } + }; }, async signal(input) { if (!handler) { From 3f474ddd0334288c8e5c5b0ee232d5683f05a5f3 Mon Sep 17 00:00:00 2001 From: Will Lamerton Date: Mon, 31 Aug 2026 18:29:42 +0100 Subject: [PATCH 20/25] fix(acp): stop claiming the sub-agent approval handler is session-safe The changeset shipped with #1080 said "the handler is scoped to the turn that installs it, so concurrent sessions cannot answer each other's approvals". They still can. turnActive is per session (acp-session.ts, guarded in acp-agent.ts), so two sessions can be mid-turn at once, and the approval slot is a process-wide singleton with last-writer-wins semantics. For the overlap the later installer answers the earlier session's approvals against the wrong session id and abort controller - the exact mis-routing the disposer was added to address. What the disposer does fix is the leak past the turn: a finished turn's session, connection and aborted controller no longer stay reachable, and the restore is LIFO-correct, so routing rights hand back when the later turn ends. Say only that, in both the release note and the comment, and name what closing the window would take: keying the slot by session id, or threading an approval channel through SubagentExecutor. Also record the other half-truth. An approved sub-agent call is marked completed at approval time rather than when it runs, because the sub-agent layer does not report results back, so a client sees completed for a tool that may still fail. That was a deliberate trade against leaving the card spinning forever, but it was undocumented. Restore the cancelled-permission test dropped when the sub-agent tests were rewritten to signal from inside the turn. Nothing covered the permission === 'cancelled' branch, so the distinct "Cancelled by user" output was free to regress into the deny path; both messages are now asserted. --- .changeset/acp-subagent-tool-permission.md | 2 +- source/acp/acp-conversation.spec.ts | 20 ++++++++++++++++++++ source/acp/acp-conversation.ts | 22 ++++++++++++++++------ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.changeset/acp-subagent-tool-permission.md b/.changeset/acp-subagent-tool-permission.md index d7d7f44a4..a3eb4396d 100644 --- a/.changeset/acp-subagent-tool-permission.md +++ b/.changeset/acp-subagent-tool-permission.md @@ -2,4 +2,4 @@ "@nanocollective/nanocoder": patch --- -Fixed sub-agent tool calls being denied silently under ACP. A tool call made inside a dispatched sub-agent went through the global approval slot, which no ACP code path installed a handler for, so its safe fallback denied every one without the client seeing a `session/request_permission`. Delegated work could only write by bypassing approval entirely, which was invisible to a client that gates writes. Sub-agent calls now use the same permission channel as top-level ones, announced first so the request names a known tool call, prefixed so their cards cannot collide with a top-level id, and titled with the sub-agent so the client can tell them apart. The handler is scoped to the turn that installs it, so concurrent sessions cannot answer each other's approvals. Note that sub-agent approvals still ignore the ACP session's mode and the configured `alwaysAllow` list, so a `yolo` or `auto-accept` session is prompted inside a sub-agent for a tool it would not be prompted for at top level. Closes #1019. +Fixed sub-agent tool calls being denied silently under ACP. A tool call made inside a dispatched sub-agent went through the global approval slot, which no ACP code path installed a handler for, so its safe fallback denied every one without the client seeing a `session/request_permission`. Delegated work could only write by bypassing approval entirely, which was invisible to a client that gates writes. Sub-agent calls now use the same permission channel as top-level ones, announced first so the request names a known tool call, prefixed so their cards cannot collide with a top-level id, and titled with the sub-agent so the client can tell them apart. The handler is scoped to the turn that installs it, so it no longer outlives that turn holding a finished session. Two known limits remain. The approval slot is still a process-wide singleton, so while two sessions have turns in flight at once the later one's handler answers the earlier one's approvals, against the wrong session id and abort controller; closing that needs the slot keyed by session or an approval channel threaded through the sub-agent executor. And an approved sub-agent call is marked `completed` as soon as it is approved rather than when it runs, because the sub-agent layer does not report results back, so a client sees `completed` for a tool that may still fail. Note also that sub-agent approvals ignore the ACP session's mode and the configured `alwaysAllow` list, so a `yolo` or `auto-accept` session is prompted inside a sub-agent for a tool it would not be prompted for at top level. Closes #1019. diff --git a/source/acp/acp-conversation.spec.ts b/source/acp/acp-conversation.spec.ts index 28859dd73..a18508bda 100644 --- a/source/acp/acp-conversation.spec.ts +++ b/source/acp/acp-conversation.spec.ts @@ -2289,6 +2289,26 @@ test('runAcpConversation - a denied sub-agent tool call is reported as failed', (u: any) => u.toolCallId === 'subagent:call-1' && u.status === 'failed', ); t.is(failed.length, 1); + t.is(failed[0].rawOutput, 'Denied by user'); +}); + +test('runAcpConversation - a cancelled sub-agent permission denies the call', async t => { + const {approved, updates, permissionRequests} = await approveFromInsideTurn( + async () => ({outcome: {outcome: 'cancelled'}}), + ); + + // t.false(approved) alone would pass with no handler installed at all, + // since the slot's fallback already denies. The request reaching the + // connection and the call being settled are the parts that need one. + t.false(approved); + t.is(permissionRequests.length, 1); + const failed = updates.filter( + (u: any) => u.toolCallId === 'subagent:call-1' && u.status === 'failed', + ); + t.is(failed.length, 1); + // Distinct from the deny path, so a client can tell a user's refusal from + // a turn that was torn down under it. + t.is(failed[0].rawOutput, 'Cancelled by user'); }); test('runAcpConversation - a transport failure denies the tool rather than aborting the sub-agent', async t => { diff --git a/source/acp/acp-conversation.ts b/source/acp/acp-conversation.ts index afbf06d8d..b95652053 100644 --- a/source/acp/acp-conversation.ts +++ b/source/acp/acp-conversation.ts @@ -118,7 +118,10 @@ function createSubagentApprovalHandler( if (permission === 'approved') { // The sub-agent layer does not report its results back here, so - // settle the card now rather than leave it spinning forever. + // settle the card on approval rather than leave it spinning + // forever. This does claim success before the tool has run: a + // call that then fails still shows completed. Reporting the + // real outcome means threading results out of the executor. await emitToolCall( session, conn, @@ -150,11 +153,18 @@ function createSubagentApprovalHandler( export async function runAcpConversation( options: RunAcpConversationOptions, ): Promise { - // The slot is a module singleton with last-writer-wins semantics, and two - // sessions can have turns in flight at once, so the handler is scoped to - // this turn. Without the teardown a second session's closure would answer - // the first session's approvals against the wrong session id and abort - // controller, and a finished turn's session would stay reachable. + // The slot is a module singleton with last-writer-wins semantics, so the + // handler is scoped to this turn: without the teardown a finished turn's + // session, connection and aborted controller stay reachable, and the last + // turn to run would keep answering approvals for every later one. + // + // This does not make overlapping turns safe. turnActive is per session + // (acp-session.ts), so two sessions can be mid-turn at once, and for that + // window the later installer answers the earlier session's approvals + // against the wrong session id and abort controller. The restore is + // LIFO-correct, so routing rights hand back once the later turn ends. + // Closing the window itself needs the slot keyed by session id, or an + // approval channel threaded through SubagentExecutor. const restoreApprovalHandler = setGlobalToolApprovalHandler( createSubagentApprovalHandler(options.session, options.conn), ); From 7899745735cfc815393722f919664d4bd4ee76b5 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 1 Sep 2026 01:35:01 +0800 Subject: [PATCH 21/25] fix(subagents): preserve object tool output (#1074) * fix(subagents): preserve object tool output * chore: add changeset for structured subagent output --- .changeset/curvy-tools-smile.md | 5 +++ source/subagents/subagent-executor.spec.ts | 38 +++++++++++++++++++++- source/subagents/subagent-executor.ts | 5 ++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/curvy-tools-smile.md diff --git a/.changeset/curvy-tools-smile.md b/.changeset/curvy-tools-smile.md new file mode 100644 index 000000000..6c8e88f19 --- /dev/null +++ b/.changeset/curvy-tools-smile.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed subagent tool results that return structured data without `llmContent` so the complete output is preserved for the model instead of being passed as `undefined`. Closes #1033. diff --git a/source/subagents/subagent-executor.spec.ts b/source/subagents/subagent-executor.spec.ts index 22fee0edc..53ebb2491 100644 --- a/source/subagents/subagent-executor.spec.ts +++ b/source/subagents/subagent-executor.spec.ts @@ -34,7 +34,7 @@ function createMockToolManager( handler: ( args: unknown, options?: ToolExecutionContext, - ) => Promise; + ) => Promise; readOnly: boolean; needsApproval?: boolean; } @@ -186,6 +186,42 @@ test.serial('executes tool calls and returns final response', async t => { t.is(result.output, 'Found the file with 100 lines'); }); +test.serial('stringifies structured tool output without llmContent', async t => { + const toolManager = createMockToolManager({ + read_file: { + handler: async () => ({someField: 'value'}), + readOnly: true, + }, + }); + const toolResults: Message[] = []; + const client = createMockClient( + [ + { + content: '', + tool_calls: [{ + id: 'tc-structured', + function: {name: 'read_file', arguments: '{}'}, + }], + }, + {content: 'The tool returned structured data.'}, + ], + messages => { + const toolMessage = messages.find(message => message.role === 'tool'); + if (toolMessage) toolResults.push(toolMessage); + }, + ); + + const executor = new SubagentExecutor(toolManager, client); + const result = await executor.execute({ + subagent_type: 'explore', + description: 'Read structured data', + }); + + t.true(result.success); + t.is(result.output, 'The tool returned structured data.'); + t.is(toolResults[0]?.content, '{"someField":"value"}'); +}); + test.serial('forwards the parent execution context to subagent tools', async t => { let receivedContext: ToolExecutionContext | undefined; const toolManager = createMockToolManager({ diff --git a/source/subagents/subagent-executor.ts b/source/subagents/subagent-executor.ts index b01ddeb1f..2d867d10d 100644 --- a/source/subagents/subagent-executor.ts +++ b/source/subagents/subagent-executor.ts @@ -765,7 +765,10 @@ export class SubagentExecutor { }); // Subagents converse in text, so collapse structured output to its // text representation. - const content = typeof result === 'string' ? result : result.llmContent; + const content = + typeof result === 'string' + ? result + : (result.llmContent ?? JSON.stringify(result)); return truncateToolResult(content); } catch (error) { // Handler validation failures surface here too (the handler is From a1e895388c0941c7139fc17ec0c1c6ee47159ec8 Mon Sep 17 00:00:00 2001 From: Aniket Rawat Date: Mon, 31 Aug 2026 23:08:15 +0530 Subject: [PATCH 22/25] add fix(lsp): resolve verification promise on early process exit to prevent hang (#1091) --- source/lsp/server-discovery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/source/lsp/server-discovery.ts b/source/lsp/server-discovery.ts index b3a8d8616..46cb9cd77 100644 --- a/source/lsp/server-discovery.ts +++ b/source/lsp/server-discovery.ts @@ -328,6 +328,7 @@ function verifyLSPServerWithCommunication( // A clean exit can also indicate success for some servers // However, for LSP servers waiting for input, an immediate exit is often a failure // The 'spawn' event is a more reliable indicator for our purpose + resolve(false); }); }); } From 4143e0ef55d1c92fe6ceffb0c3b99528b1401efb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 1 Sep 2026 02:41:23 +0000 Subject: [PATCH 23/25] Update status badges [skip ci] --- badges/coverage.svg | 2 +- badges/forks.svg | 2 +- badges/repo-size.svg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/badges/coverage.svg b/badges/coverage.svg index 67a2edb70..99b5986cd 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -COVERAGE: 92.02%COVERAGE92.02% \ No newline at end of file +COVERAGE: 92.17%COVERAGE92.17% \ No newline at end of file diff --git a/badges/forks.svg b/badges/forks.svg index e1ce2b1ff..0da1a63fb 100644 --- a/badges/forks.svg +++ b/badges/forks.svg @@ -1 +1 @@ -FORKS294 \ No newline at end of file +FORKS300 \ No newline at end of file diff --git a/badges/repo-size.svg b/badges/repo-size.svg index 1c98ceb20..d94a4047d 100644 --- a/badges/repo-size.svg +++ b/badges/repo-size.svg @@ -1 +1 @@ -REPO SIZE: 35.2 MIBREPO SIZE35.2 MIB \ No newline at end of file +REPO SIZE: 35.3 MIBREPO SIZE35.3 MIB \ No newline at end of file From 85f6c95a783b857f4dc10e5e65bfdfccd235126e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 2 Sep 2026 06:14:24 +0800 Subject: [PATCH 24/25] fix: guard queued prompt draining after commands --- source/app/sections/interactive-app.spec.tsx | 112 ++++++++++++++++++ source/app/sections/interactive-app.tsx | 36 +++++- .../app/utils/handlers/retry-handler.spec.ts | 24 ++++ source/app/utils/handlers/retry-handler.ts | 3 +- source/hooks/useAppHandlers.spec.tsx | 8 +- source/hooks/useAppHandlers.tsx | 8 +- 6 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 source/app/utils/handlers/retry-handler.spec.ts diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index 4b96b5467..7e930bce6 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -1,6 +1,8 @@ import test from 'ava'; import {Text} from 'ink'; import React from 'react'; +import {DELAY_COMMAND_COMPLETE_MS} from '@/constants'; +import {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import type {Message} from '@/types'; import {renderWithTheme} from '../../test-utils/render-with-theme.js'; import {InteractiveApp} from './interactive-app.js'; @@ -193,6 +195,116 @@ test('does not drain queued prompts while a turn is generating', async t => { unmount(); }); +test('does not drain queued prompts while a modal mode is active', async t => { + let submitted = false; + const {unmount} = renderWithTheme( + { + submitted = true; + }, + })} + />, + ); + + await new Promise(resolve => setTimeout(resolve, 25)); + t.false(submitted); + unmount(); +}); + +test('drains every queued prompt after each dispatched turn returns to idle', async t => { + const submitted: string[] = []; + + const QueueDrainHarness = () => { + const userMessageQueue = useUserMessageQueue(); + const [isConversationComplete, setIsConversationComplete] = + React.useState(true); + + React.useEffect(() => { + userMessageQueue.enqueueMessage({message: 'first', displayValue: 'first'}); + userMessageQueue.enqueueMessage({message: 'second', displayValue: 'second'}); + }, [userMessageQueue.enqueueMessage]); + + return ( + { + submitted.push(message); + setIsConversationComplete(false); + await new Promise(resolve => setTimeout(resolve, 10)); + setIsConversationComplete(true); + }, + })} + userMessageQueue={userMessageQueue} + /> + ); + }; + + const {unmount} = renderWithTheme(); + await new Promise(resolve => setTimeout(resolve, 100)); + t.deepEqual(submitted, ['first', 'second']); + unmount(); +}); + +test('drains a prompt after delayed command completion when the app is idle', async t => { + const submitted: string[] = []; + + const DelayedCommandHarness = () => { + const userMessageQueue = useUserMessageQueue(); + const [isToolExecuting, setIsToolExecuting] = React.useState(true); + const [isConversationComplete, setIsConversationComplete] = + React.useState(false); + + React.useEffect(() => { + userMessageQueue.enqueueMessage({ + message: 'after compact', + displayValue: 'after compact', + }); + const timeout = setTimeout(() => { + setIsToolExecuting(false); + setIsConversationComplete(true); + }, DELAY_COMMAND_COMPLETE_MS); + + return () => clearTimeout(timeout); + }, [userMessageQueue.enqueueMessage]); + + return ( + { + submitted.push(message); + }, + })} + userMessageQueue={userMessageQueue} + /> + ); + }; + + const {unmount} = renderWithTheme(); + await new Promise(resolve => + setTimeout(resolve, DELAY_COMMAND_COMPLETE_MS + 40), + ); + t.deepEqual(submitted, ['after compact']); + unmount(); +}); + test('renders the static-component marker through ChatHistory', t => { const {lastFrame} = renderWithTheme( , diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index a079d8aac..7033b905e 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -100,6 +100,7 @@ export function InteractiveApp({ const [restoredDraft, setRestoredDraft] = React.useState(null); const drainInProgressRef = React.useRef(false); + const [drainAttempt, setDrainAttempt] = React.useState(0); const handleToggleCompactDisplay = () => { const expanding = appState.compactToolDisplay; @@ -185,9 +186,22 @@ export function InteractiveApp({ // modal modes have closed. Command handlers and conversation completion can // both signal completion, so keeping the drain here makes it idempotent and // prevents nested or duplicate turns. + const queueDrainBlocked = + appState.isCancelling || + chatHandler.isGenerating || + appState.isToolExecuting || + appState.abortController !== null || + appState.isToolConfirmationMode || + appState.isQuestionMode || + pendingSubagentApproval !== null || + pendingToolConfirmation !== null; + React.useEffect(() => { + // Re-run after a successful dispatch settles, once its queue update has + // rendered and the next item can be considered. + void drainAttempt; if ( - cancellable || + queueDrainBlocked || appState.activeMode !== null || appState.isSettingsMode || !appState.isConversationComplete || @@ -211,9 +225,20 @@ export function InteractiveApp({ ); return true; }) - .finally(() => { - drainInProgressRef.current = false; - }); + .then( + dispatched => { + drainInProgressRef.current = false; + // The queue state update happens before the dispatch resolves. A + // separate render is needed to notice and drain the next item after + // the dispatched turn returns to idle. + if (dispatched) { + setDrainAttempt(attempt => attempt + 1); + } + }, + () => { + drainInProgressRef.current = false; + }, + ); }, 0); return () => { @@ -226,10 +251,11 @@ export function InteractiveApp({ appState.isConversationComplete, appState.isSettingsMode, appState.toolManager, - cancellable, + queueDrainBlocked, handleUserSubmit, userMessageQueue.drainNextMessage, userMessageQueue.queuedMessages.length, + drainAttempt, ]); const recallableSubmittedDraft = diff --git a/source/app/utils/handlers/retry-handler.spec.ts b/source/app/utils/handlers/retry-handler.spec.ts new file mode 100644 index 000000000..66895dda2 --- /dev/null +++ b/source/app/utils/handlers/retry-handler.spec.ts @@ -0,0 +1,24 @@ +import test from 'ava'; +import type {MessageSubmissionOptions} from '@/types'; +import {handleRetryCommand} from './retry-handler.js'; + +test('does not signal command completion after the retried turn returns', async t => { + let chatCalls = 0; + let completionCalls = 0; + + const options = { + messages: [{role: 'user', content: 'retry me'}], + provider: 'mock', + onAddToChatQueue: () => {}, + onHandleChatMessage: async () => { + chatCalls++; + }, + onCommandComplete: () => { + completionCalls++; + }, + } as unknown as MessageSubmissionOptions; + + t.true(await handleRetryCommand(['retry'], options)); + t.is(chatCalls, 1); + t.is(completionCalls, 0); +}); diff --git a/source/app/utils/handlers/retry-handler.ts b/source/app/utils/handlers/retry-handler.ts index 6bf654f33..328d75607 100644 --- a/source/app/utils/handlers/retry-handler.ts +++ b/source/app/utils/handlers/retry-handler.ts @@ -86,6 +86,7 @@ export async function handleRetryCommand( lastUserMessage.content, lastUserMessage.content, ); - options.onCommandComplete?.(); + // The retried chat turn owns its completion signal. Emitting another one + // here can start the next queued prompt while that turn is still unwinding. return true; } diff --git a/source/hooks/useAppHandlers.spec.tsx b/source/hooks/useAppHandlers.spec.tsx index 5475132cc..09ee334c2 100644 --- a/source/hooks/useAppHandlers.spec.tsx +++ b/source/hooks/useAppHandlers.spec.tsx @@ -35,7 +35,6 @@ interface ProbeOverrides { developmentMode?: DevelopmentMode; client?: LLMClient | null; messages?: Message[]; - onCommandComplete?: () => void; } let captured: AppHandlers | null = null; @@ -103,7 +102,6 @@ function makeProps(overrides: ProbeOverrides) { setIsCancelling, setDevelopmentMode, setIsConversationComplete, - onCommandComplete: overrides.onCommandComplete, setIsToolExecuting, setActiveMode, setCheckpointLoadData, @@ -192,14 +190,12 @@ test('returns the expected handler surface', t => { t.is(typeof handlers.handleMessageSubmit, 'function'); }); -test('forwards slash-command completion so queued work can resume', async t => { - const onCommandComplete = spy<[]>(); - const {handlers, spies} = setup({onCommandComplete}); +test('signals slash-command completion so queued work can resume', async t => { + const {handlers, spies} = setup(); await handlers.handleMessageSubmit('/compact'); t.deepEqual(spies.setIsConversationComplete.calls, [[false], [true]]); - t.is(onCommandComplete.calls.length, 1); }); test('handleCancel without an abort controller is a no-op', t => { diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index d9540e114..eef7c9a46 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -73,8 +73,6 @@ interface UseAppHandlersProps { // Callbacks onClearCounterIncrement?: () => void; - /** Called after a slash command finishes, including delayed completions. */ - onCommandComplete?: () => void; // State setters updateMessages: (newMessages: Message[]) => void; @@ -698,10 +696,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { onAddToChatQueue: props.addToChatQueue, setLiveComponent: props.setLiveComponent, setIsToolExecuting: props.setIsToolExecuting, - onCommandComplete: () => { - props.setIsConversationComplete(true); - props.onCommandComplete?.(); - }, + onCommandComplete: () => props.setIsConversationComplete(true), setMessages: props.updateMessages, messages: props.messages, provider: props.currentProvider, @@ -747,7 +742,6 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.developmentMode, props.lastApiUsage, props.apiCallHistory, - props.onCommandComplete, clearMessages, enterCheckpointLoadMode, handleShowStatus, From 6c5d556dc2ccaad27656cdf283c22d188a0ccf5e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 4 Sep 2026 01:17:31 +0800 Subject: [PATCH 25/25] fix(ci): read PR label config from head revision --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 94842a8bd..b75fec903 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -32,7 +32,7 @@ jobs: owner, repo, path: '.github/labeler.yml', - ref: context.payload.pull_request.base.sha, + ref: context.payload.pull_request.head.sha, }); if (data.type !== 'file' || typeof data.content !== 'string') { throw new Error('labeler.yml is not a file');