diff --git a/CLAUDE.md b/CLAUDE.md index 34cddb4b6..3ad2536b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,14 @@ - No trailing whitespace. - Use `const` and `let` instead of `var`. +## Translations / i18n +- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. +- Use `const Strings = require("strings");` then `Strings.KEY_NAME`. +- For parameterized strings use `StringUtils.format(Strings.KEY, arg0, arg1)` with `{0}`, `{1}` placeholders. +- Keys use UPPER_SNAKE_CASE grouped by feature prefix (e.g. `AI_CHAT_*`). +- Only `src/nls/root/strings.js` (English) needs manual edits — other locales are auto-translated by GitHub Actions. +- Never compare `$(el).text()` against English strings for logic — use data attributes or CSS classes instead. + ## Phoenix MCP (Desktop App Testing) Use `exec_js` to run JS in the Phoenix browser runtime. jQuery `$()` is global. `brackets.test.*` exposes internal modules (DocumentManager, CommandManager, ProjectManager, FileSystem, EditorManager). Always `return` a value from `exec_js` to see results. Prefer reusing an already-running Phoenix instance (`get_phoenix_status`) over launching a new one. diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index acced693c..c58bb98cf 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -28,6 +28,7 @@ const { execSync } = require("child_process"); const path = require("path"); +const { createEditorMcpServer } = require("./mcp-editor-tools"); const CONNECTOR_ID = "ph_ai_claude"; @@ -40,6 +41,9 @@ let currentSessionId = null; // Active query state let currentAbortController = null; +// Lazily-initialized in-process MCP server for editor context +let editorMcpServer = null; + // Streaming throttle const TEXT_STREAM_THROTTLE_MS = 50; @@ -125,7 +129,7 @@ exports.checkAvailability = async function () { * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete */ exports.sendPrompt = async function (params) { - const { prompt, projectPath, sessionAction, model } = params; + const { prompt, projectPath, sessionAction, model, locale } = params; const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); // Handle session @@ -142,7 +146,7 @@ exports.sendPrompt = async function (params) { currentAbortController = new AbortController(); // Run the query asynchronously — don't await here so we return requestId immediately - _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal) + _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal, locale) .catch(err => { console.error("[Phoenix AI] Query error:", err); }); @@ -176,13 +180,16 @@ exports.destroySession = async function () { /** * Internal: run a Claude SDK query and stream results back to the browser. */ -async function _runQuery(requestId, prompt, projectPath, model, signal) { +async function _runQuery(requestId, prompt, projectPath, model, signal, locale) { let editCount = 0; let toolCounter = 0; let queryFn; try { queryFn = await getQueryFn(); + if (!editorMcpServer) { + editorMcpServer = createEditorMcpServer(queryModule, nodeConnector); + } } catch (err) { nodeConnector.triggerPeer("aiError", { requestId: requestId, @@ -201,7 +208,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { const queryOptions = { cwd: projectPath || process.cwd(), maxTurns: 10, - allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"], + allowedTools: [ + "Read", "Edit", "Write", "Glob", "Grep", + "mcp__phoenix-editor__getEditorState", + "mcp__phoenix-editor__takeScreenshot" + ], + mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: "acceptEdits", appendSystemPrompt: "When modifying an existing file, always prefer the Edit tool " + @@ -209,7 +221,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { "to create brand new files that do not exist yet. For existing files, always use " + "multiple Edit calls to make targeted changes rather than rewriting the entire " + "file with Write. This is critical because Write replaces the entire file content " + - "which is slow and loses undo history.", + "which is slow and loses undo history." + + (locale && !locale.startsWith("en") + ? "\n\nThe user's display language is " + locale + ". " + + "Respond in this language unless they write in a different language." + : ""), includePartialMessages: true, abortController: currentAbortController, hooks: { diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js new file mode 100644 index 000000000..a1c2eb097 --- /dev/null +++ b/src-node/mcp-editor-tools.js @@ -0,0 +1,93 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * MCP server factory for exposing Phoenix editor context to Claude Code. + * + * Provides two tools: + * - getEditorState: returns active file, working set, and live preview file + * - takeScreenshot: captures a screenshot of the Phoenix window as base64 PNG + * + * Uses the Claude Code SDK's in-process MCP server support (createSdkMcpServer / tool). + */ + +const { z } = require("zod"); + +/** + * Create an in-process MCP server exposing editor context tools. + * + * @param {Object} sdkModule - The imported @anthropic-ai/claude-code ESM module + * @param {Object} nodeConnector - The NodeConnector instance for communicating with the browser + * @returns {McpSdkServerConfigWithInstance} MCP server config ready for queryOptions.mcpServers + */ +function createEditorMcpServer(sdkModule, nodeConnector) { + const getEditorStateTool = sdkModule.tool( + "getEditorState", + "Get the current Phoenix editor state: active file, working set (open files), and live preview file.", + {}, + async function () { + try { + const state = await nodeConnector.execPeer("getEditorState", {}); + return { + content: [{ type: "text", text: JSON.stringify(state) }] + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error getting editor state: " + err.message }], + isError: true + }; + } + } + ); + + const takeScreenshotTool = sdkModule.tool( + "takeScreenshot", + "Take a screenshot of the Phoenix Code editor window. Returns a PNG image.", + { selector: z.string().optional().describe("Optional CSS selector to capture a specific element") }, + async function (args) { + try { + const result = await nodeConnector.execPeer("takeScreenshot", { + selector: args.selector || undefined + }); + if (result.base64) { + return { + content: [{ type: "image", data: result.base64, mimeType: "image/png" }] + }; + } + return { + content: [{ type: "text", text: result.error || "Screenshot failed" }], + isError: true + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error taking screenshot: " + err.message }], + isError: true + }; + } + } + ); + + return sdkModule.createSdkMcpServer({ + name: "phoenix-editor", + tools: [getEditorStateTool, takeScreenshotTool] + }); +} + +exports.createEditorMcpServer = createEditorMcpServer; diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 9ba207622..aa06a7edb 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -18,7 +18,8 @@ "npm": "11.8.0", "open": "^10.1.0", "which": "^2.0.1", - "ws": "^8.17.1" + "ws": "^8.17.1", + "zod": "^3.25.76" }, "engines": { "node": "24" @@ -2882,6 +2883,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/src-node/package.json b/src-node/package.json index 59eb1cf45..e1fb19f77 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -19,15 +19,16 @@ }, "IMPORTANT!!": "Adding things here will bloat up the package size", "dependencies": { + "@anthropic-ai/claude-code": "^1.0.0", + "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", - "open": "^10.1.0", - "npm": "11.8.0", - "ws": "^8.17.1", + "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", - "cross-spawn": "^7.0.6", + "npm": "11.8.0", + "open": "^10.1.0", "which": "^2.0.1", - "@expo/sudo-prompt": "^9.3.2", - "@anthropic-ai/claude-code": "^1.0.0" + "ws": "^8.17.1", + "zod": "^3.25.76" } -} \ No newline at end of file +} diff --git a/src/brackets.js b/src/brackets.js index b14f4e1b9..a4c486b0f 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -296,6 +296,7 @@ define(function (require, exports, module) { SidebarTabs: require("view/SidebarTabs"), SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), + AISnapshotStore: require("core-ai/AISnapshotStore"), doneLoading: false }; diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index af54050c2..7411047e7 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -24,14 +24,17 @@ */ define(function (require, exports, module) { - const SidebarTabs = require("view/SidebarTabs"), - DocumentManager = require("document/DocumentManager"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), - ProjectManager = require("project/ProjectManager"), - FileSystem = require("filesystem/FileSystem"), - SnapshotStore = require("core-ai/AISnapshotStore"), - marked = require("thirdparty/marked.min"); + const SidebarTabs = require("view/SidebarTabs"), + DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + ProjectManager = require("project/ProjectManager"), + FileSystem = require("filesystem/FileSystem"), + SnapshotStore = require("core-ai/AISnapshotStore"), + PhoenixConnectors = require("core-ai/aiPhoenixConnectors"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), + marked = require("thirdparty/marked.min"); let _nodeConnector = null; let _isStreaming = false; @@ -39,13 +42,14 @@ define(function (require, exports, module) { let _segmentText = ""; // text for the current segment only let _autoScroll = true; let _hasReceivedContent = false; // tracks if we've received any text/tool in current response - const _previousContentMap = {}; // filePath → previous content before edit, for undo support let _currentEdits = []; // edits in current response, for summary card let _firstEditInResponse = true; // tracks first edit per response for initial PUC let _undoApplied = false; // whether undo/restore has been clicked on any card // --- AI event trace logging (compact, non-flooding) --- let _traceTextChunks = 0; let _traceToolStreamCounts = {}; // toolId → count + let _toolStreamStaleTimer = null; // timer to start rotating activity text + let _toolStreamRotateTimer = null; // interval for cycling activity phrases // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; @@ -60,23 +64,23 @@ define(function (require, exports, module) { const PANEL_HTML = '
npm install -g @anthropic-ai/claude-codeclaude login to authenticate.",
+ "AI_CHAT_RETRY": "Retry",
+ "AI_CHAT_DESKTOP_ONLY": "AI features require the Phoenix desktop app.",
+ "AI_CHAT_TOOL_SEARCH_FILES": "Search files",
+ "AI_CHAT_TOOL_SEARCH_CODE": "Search code",
+ "AI_CHAT_TOOL_READ": "Read",
+ "AI_CHAT_TOOL_EDIT": "Edit",
+ "AI_CHAT_TOOL_WRITE": "Write",
+ "AI_CHAT_TOOL_RUN_CMD": "Run command",
+ "AI_CHAT_TOOL_SKILL": "Skill",
+ "AI_CHAT_TOOL_SEARCHED": "Searched: {0}",
+ "AI_CHAT_TOOL_GREP": "Grep: {0}",
+ "AI_CHAT_TOOL_READ_FILE": "Read {0}",
+ "AI_CHAT_TOOL_EDIT_FILE": "Edit {0}",
+ "AI_CHAT_TOOL_WRITE_FILE": "Write {0}",
+ "AI_CHAT_TOOL_RAN_CMD": "Ran command",
+ "AI_CHAT_TOOL_SKILL_NAME": "Skill: {0}",
+ "AI_CHAT_TOOL_IN_PATH": "in {0}",
+ "AI_CHAT_TOOL_INCLUDE": "include {0}",
+ "AI_CHAT_RECEIVING_BYTES": "receiving {0} bytes...",
+ "AI_CHAT_FILES_CHANGED": "{0} {1} changed",
+ "AI_CHAT_FILE_SINGULAR": "file",
+ "AI_CHAT_FILE_PLURAL": "files",
+ "AI_CHAT_RESTORE_POINT": "Restore to this point",
+ "AI_CHAT_UNDO": "Undo",
+ "AI_CHAT_UNDO_TITLE": "Undo changes from this response",
+ "AI_CHAT_RESTORE_TITLE": "Restore files to this point",
+ "AI_CHAT_RESTORED": "Restored",
+ "AI_CHAT_SHOW_DIFF": "Show diff",
+ "AI_CHAT_HIDE_DIFF": "Hide diff",
+ "AI_CHAT_LABEL_YOU": "You",
+ "AI_CHAT_LABEL_CLAUDE": "Claude",
+ "AI_CHAT_SEND_ERROR": "Failed to send message: {0}",
+ "AI_CHAT_EDIT_NOT_FOUND": "Text not found in file \u2014 it may have changed",
+ "AI_CHAT_WORKING": "Working...",
+ "AI_CHAT_WRITING": "Writing...",
+ "AI_CHAT_PROCESSING": "Processing...",
+
// demo start - Phoenix Code Playground - Interactive Onboarding
"DEMO_SECTION1_TITLE": "Edit in Live Preview",
"DEMO_SECTION1_SUBTITLE": "Edit your page visually - Your HTML updates instantly",
diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js
index 7c061cf05..da2d38eed 100644
--- a/test/UnitTestSuite.js
+++ b/test/UnitTestSuite.js
@@ -62,6 +62,7 @@ define(function (require, exports, module) {
require("spec/KeybindingManager-integ-test");
require("spec/LanguageManager-test");
require("spec/LanguageManager-integ-test");
+ require("spec/ai-snapshot-test");
require("spec/LowLevelFileIO-test");
require("spec/Metrics-test");
require("spec/MultiRangeInlineEditor-test");
diff --git a/test/spec/Document-test.js b/test/spec/Document-test.js
index 355d064c0..b6f8d0fb4 100644
--- a/test/spec/Document-test.js
+++ b/test/spec/Document-test.js
@@ -32,10 +32,10 @@ define(function (require, exports, module) {
describe("doMultipleEdits", function () {
// Even though these are Document unit tests, we need to create an editor in order to
// be able to test actual edit ops.
- var myEditor, myDocument, initialContentLines;
+ let myEditor, myDocument, initialContentLines;
function makeDummyLines(num) {
- var content = [], i;
+ let content = [], i;
for (i = 0; i < num; i++) {
content.push("this is line " + i);
}
@@ -45,7 +45,7 @@ define(function (require, exports, module) {
beforeEach(function () {
// Each line from 0-9 is 14 chars long, each line from 10-19 is 15 chars long
initialContentLines = makeDummyLines(20);
- var mocks = SpecRunnerUtils.createMockEditor(initialContentLines.join("\n"), "unknown");
+ const mocks = SpecRunnerUtils.createMockEditor(initialContentLines.join("\n"), "unknown");
myDocument = mocks.doc;
myEditor = mocks.editor;
});
@@ -59,7 +59,7 @@ define(function (require, exports, module) {
});
function _verifySingleEdit() {
- var result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}},
+ const result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}},
selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: true, isBeforeEdit: true}}]);
initialContentLines[2] = "new content";
expect(myDocument.getText()).toEqual(initialContentLines.join("\n"));
@@ -83,7 +83,7 @@ define(function (require, exports, module) {
});
it("should do a single edit, leaving a non-beforeEdit selection untouched and preserving reversed flag", function () {
- var result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}},
+ const result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}},
selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: true}}]);
initialContentLines[2] = "new content";
expect(myDocument.getText()).toEqual(initialContentLines.join("\n"));
@@ -96,7 +96,7 @@ define(function (require, exports, module) {
});
it("should do multiple edits, fixing up isBeforeEdit selections with respect to both edits and preserving other selection attributes", function () {
- var result = myDocument.doMultipleEdits([
+ const result = myDocument.doMultipleEdits([
{edit: {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}},
selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, isBeforeEdit: true, primary: true}},
{edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}},
@@ -121,7 +121,7 @@ define(function (require, exports, module) {
});
it("should do multiple edits, fixing up non-isBeforeEdit selections only with respect to other edits", function () {
- var result = myDocument.doMultipleEdits([
+ const result = myDocument.doMultipleEdits([
{edit: {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}},
selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, primary: true}},
{edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}},
@@ -146,7 +146,7 @@ define(function (require, exports, module) {
});
it("should perform multiple changes/track multiple selections within a single edit, selections specified as isBeforeEdit", function () {
- var result = myDocument.doMultipleEdits([
+ const result = myDocument.doMultipleEdits([
{edit: [{text: "modified line 1", start: {line: 1, ch: 0}, end: {line: 1, ch: 14}},
{text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}],
selection: [{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, isBeforeEdit: true},
@@ -179,7 +179,7 @@ define(function (require, exports, module) {
});
it("should perform multiple changes/track multiple selections within a single edit, selections not specified as isBeforeEdit", function () {
- var result = myDocument.doMultipleEdits([
+ const result = myDocument.doMultipleEdits([
{edit: [{text: "modified line 1", start: {line: 1, ch: 0}, end: {line: 1, ch: 14}},
{text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}],
selection: [{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}},
@@ -235,5 +235,83 @@ define(function (require, exports, module) {
});
});
+
+ describe("posFromIndex", function () {
+ const TEST_CONTENT = "line0\nline1\nline2\n";
+ let myEditor, myDocument;
+
+ beforeEach(function () {
+ const mocks = SpecRunnerUtils.createMockEditor(TEST_CONTENT, "unknown");
+ myDocument = mocks.doc;
+ myEditor = mocks.editor;
+ });
+
+ afterEach(function () {
+ if (myEditor) {
+ SpecRunnerUtils.destroyMockEditor(myDocument);
+ myEditor = null;
+ myDocument = null;
+ }
+ });
+
+ // Verify the character at a given index matches the char at the returned position
+ function expectCharAtIndex(index, expectedChar) {
+ const pos = myDocument.posFromIndex(index);
+ const actualChar = myDocument.getLine(pos.line).charAt(pos.ch);
+ expect(actualChar).toBe(expectedChar);
+ return pos;
+ }
+
+ it("should return {0,0} for index 0", function () {
+ const pos = expectCharAtIndex(0, "l");
+ expect(pos.line).toBe(0);
+ expect(pos.ch).toBe(0);
+ });
+
+ it("should return correct position within first line", function () {
+ const pos = expectCharAtIndex(3, "e");
+ expect(pos.line).toBe(0);
+ expect(pos.ch).toBe(3);
+ });
+
+ it("should return start of second line after newline", function () {
+ // "line0\n" is 6 chars, so index 6 is 'l' at start of "line1"
+ const pos = expectCharAtIndex(6, "l");
+ expect(pos.line).toBe(1);
+ expect(pos.ch).toBe(0);
+ });
+
+ it("should return correct position on third line", function () {
+ // "line0\nline1\n" is 12 chars, index 14 is 'n' in "line2"
+ const pos = expectCharAtIndex(14, "n");
+ expect(pos.line).toBe(2);
+ expect(pos.ch).toBe(2);
+ });
+
+ it("should work without a master editor (text-only fallback)", function () {
+ // Destroy the editor so _masterEditor becomes null and
+ // the document falls back to its internal _text string
+ SpecRunnerUtils.destroyMockEditor(myDocument);
+ myEditor = null;
+
+ expect(myDocument._masterEditor).toBe(null);
+
+ // Verify against raw string since getLine is unavailable without editor
+ let pos = myDocument.posFromIndex(0);
+ expect(pos.line).toBe(0);
+ expect(pos.ch).toBe(0);
+ expect(TEST_CONTENT[0]).toBe("l");
+
+ pos = myDocument.posFromIndex(6);
+ expect(pos.line).toBe(1);
+ expect(pos.ch).toBe(0);
+ expect(TEST_CONTENT[6]).toBe("l");
+
+ pos = myDocument.posFromIndex(14);
+ expect(pos.line).toBe(2);
+ expect(pos.ch).toBe(2);
+ expect(TEST_CONTENT[14]).toBe("n");
+ });
+ });
});
});
diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js
new file mode 100644
index 000000000..f83b5f685
--- /dev/null
+++ b/test/spec/ai-snapshot-test.js
@@ -0,0 +1,717 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/*global describe, beforeAll, afterAll, beforeEach, afterEach, it, expect, awaitsFor, awaitsForDone, jsPromise */
+
+define(function (require, exports, module) {
+
+ const SpecRunnerUtils = require("spec/SpecRunnerUtils");
+
+ const tempDir = SpecRunnerUtils.getTempDirectory();
+
+ let AISnapshotStore,
+ DocumentManager,
+ CommandManager,
+ Commands,
+ FileSystem,
+ testWindow;
+
+ describe("integration:AISnapshotStore", function () {
+
+ beforeAll(async function () {
+ testWindow = await SpecRunnerUtils.createTestWindowAndRun();
+ AISnapshotStore = testWindow.brackets.test.AISnapshotStore;
+ DocumentManager = testWindow.brackets.test.DocumentManager;
+ CommandManager = testWindow.brackets.test.CommandManager;
+ Commands = testWindow.brackets.test.Commands;
+ FileSystem = testWindow.brackets.test.FileSystem;
+ }, 30000);
+
+ afterAll(async function () {
+ AISnapshotStore = null;
+ DocumentManager = null;
+ CommandManager = null;
+ Commands = null;
+ FileSystem = null;
+ testWindow = null;
+ await SpecRunnerUtils.closeTestWindow();
+ }, 30000);
+
+ beforeEach(async function () {
+ await SpecRunnerUtils.createTempDirectory();
+ await SpecRunnerUtils.loadProjectInTestWindow(tempDir);
+ });
+
+ afterEach(async function () {
+ await testWindow.closeAllFiles();
+ AISnapshotStore.reset();
+ await SpecRunnerUtils.removeTempDirectory();
+ });
+
+ // --- helpers ---
+
+ // Convert a file name to a VFS path that matches what realToVfsPath produces.
+ // In native (Tauri) builds, realToVfsPath adds /tauri/ prefix to native paths.
+ // By opening docs with VFS paths, doc.file.fullPath matches what finalizeResponse
+ // will look up via realToVfsPath.
+ function toVfsPath(name) {
+ return AISnapshotStore.realToVfsPath(tempDir + "/" + name);
+ }
+
+ async function createFile(name, content) {
+ // Write through the test window's FileSystem (not the host's) so
+ // the document cache stays consistent across tests.
+ const path = toVfsPath(name);
+ return new Promise(function (resolve, reject) {
+ const file = FileSystem.getFileForPath(path);
+ file.write(content, {blind: true}, function (err) {
+ if (err) { reject(err); } else { resolve(); }
+ });
+ });
+ }
+
+ async function openDoc(name) {
+ const fullPath = toVfsPath(name);
+ await awaitsForDone(
+ CommandManager.execute(Commands.FILE_OPEN, { fullPath: fullPath }),
+ "open " + name
+ );
+ return DocumentManager.getOpenDocumentForPath(fullPath);
+ }
+
+ function simulateEdit(doc, newContent, isNewFile) {
+ AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, doc.getText(), isNewFile);
+ doc.setText(newContent);
+ }
+
+ async function simulateCreateFile(name, content) {
+ await createFile(name, "");
+ const doc = await openDoc(name);
+ AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true);
+ doc.setText(content);
+ return doc;
+ }
+
+ function restoreToSnapshot(index) {
+ return new Promise(function (resolve) {
+ AISnapshotStore.restoreToSnapshot(index, function (errorCount) {
+ resolve(errorCount);
+ });
+ });
+ }
+
+ async function readFile(name) {
+ // Read from the open Document to avoid FileSystem read-cache staleness.
+ // _createOrUpdateFile always updates the document text before resolving.
+ const path = toVfsPath(name);
+ const doc = DocumentManager.getOpenDocumentForPath(path);
+ if (doc) {
+ return doc.getText();
+ }
+ return new Promise(function (resolve, reject) {
+ DocumentManager.getDocumentForPath(path)
+ .done(function (d) { resolve(d.getText()); })
+ .fail(function (err) { reject(err); });
+ });
+ }
+
+ async function fileExists(name) {
+ // Use FileSystem.existsAsync which bypasses the cached _stat on
+ // File objects — file.exists() can return stale true when
+ // _handleDirectoryChange re-populates _stat from a racing readdir.
+ return FileSystem.existsAsync(toVfsPath(name));
+ }
+
+ async function expectFileDeleted(name) {
+ let gone = false;
+ let checking = false;
+ await awaitsFor(function () {
+ if (!checking && !gone) {
+ checking = true;
+ fileExists(name).then(function (e) {
+ gone = !e;
+ checking = false;
+ });
+ }
+ return gone;
+ }, name + " to be deleted", 5000);
+ }
+
+ function unlinkFile(name) {
+ return new Promise(function (resolve, reject) {
+ const file = FileSystem.getFileForPath(toVfsPath(name));
+ file.unlink(function (err) {
+ if (err) { reject(err); } else { resolve(); }
+ });
+ });
+ }
+
+ function beginResponse() {
+ if (AISnapshotStore.getSnapshotCount() === 0) {
+ AISnapshotStore.createInitialSnapshot();
+ }
+ }
+
+ // --- storeContent ---
+
+ describe("storeContent", function () {
+ it("should return same hash for identical content", function () {
+ const h1 = AISnapshotStore.storeContent("hello world");
+ const h2 = AISnapshotStore.storeContent("hello world");
+ expect(h1).toBe(h2);
+ });
+
+ it("should return different hashes for different content", function () {
+ const h1 = AISnapshotStore.storeContent("aaa");
+ const h2 = AISnapshotStore.storeContent("bbb");
+ expect(h1).not.toBe(h2);
+ });
+
+ it("should return a valid hash for empty string", function () {
+ const h = AISnapshotStore.storeContent("");
+ expect(typeof h).toBe("string");
+ expect(h.length).toBeGreaterThan(0);
+ });
+ });
+
+ // --- createInitialSnapshot and recordFileBeforeEdit ---
+
+ describe("createInitialSnapshot and recordFileBeforeEdit", function () {
+ it("should create initial snapshot at index 0 with count 1", function () {
+ const idx = AISnapshotStore.createInitialSnapshot();
+ expect(idx).toBe(0);
+ expect(AISnapshotStore.getSnapshotCount()).toBe(1);
+ });
+
+ it("should back-fill snapshot 0 when recording before-edit", async function () {
+ await createFile("a.txt", "original");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "original", false);
+ doc.setText("modified");
+
+ // Snapshot 0 should now contain a hash for "original"
+ const errorCount = await restoreToSnapshot(0);
+ expect(errorCount).toBe(0);
+ const content = await readFile("a.txt");
+ expect(content).toBe("original");
+ });
+
+ it("should store null for isNewFile=true", async function () {
+ await createFile("new.txt", "");
+ const doc = await openDoc("new.txt");
+
+ beginResponse();
+ AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true);
+ doc.setText("new content");
+ await AISnapshotStore.finalizeResponse();
+
+ // Snapshot 0 has null → restore deletes file
+ const errorCount = await restoreToSnapshot(0);
+ expect(errorCount).toBe(0);
+ await expectFileDeleted("new.txt");
+ });
+
+ it("should ignore duplicate recordFileBeforeEdit for same file", async function () {
+ await createFile("a.txt", "v0");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v0", false);
+ doc.setText("v1");
+ // Second call with different content should be ignored (first-edit-wins)
+ AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // Restore to snapshot 0 should give v0, not v1
+ const errorCount = await restoreToSnapshot(0);
+ expect(errorCount).toBe(0);
+ const content = await readFile("a.txt");
+ expect(content).toBe("v0");
+ });
+ });
+
+ // --- finalizeResponse ---
+
+ describe("finalizeResponse", function () {
+ it("should return -1 when no pending edits", async function () {
+ beginResponse();
+ const idx = await AISnapshotStore.finalizeResponse();
+ expect(idx).toBe(-1);
+ });
+
+ it("should build after-snapshot from open doc content", async function () {
+ await createFile("a.txt", "before");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ simulateEdit(doc, "after", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // Snapshot 1 should have "after"
+ const errorCount = await restoreToSnapshot(1);
+ expect(errorCount).toBe(0);
+ const content = await readFile("a.txt");
+ expect(content).toBe("after");
+ });
+
+ it("should increment snapshot count", async function () {
+ await createFile("a.txt", "v0");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ simulateEdit(doc, "v1", false);
+ expect(AISnapshotStore.getSnapshotCount()).toBe(1);
+ await AISnapshotStore.finalizeResponse();
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+ });
+
+ it("should clear pending state (second finalize returns -1)", async function () {
+ await createFile("a.txt", "v0");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ simulateEdit(doc, "v1", false);
+ const idx = await AISnapshotStore.finalizeResponse();
+ expect(idx).toBe(1);
+ const idx2 = await AISnapshotStore.finalizeResponse();
+ expect(idx2).toBe(-1);
+ });
+
+ it("should capture closed doc content from disk", async function () {
+ await createFile("a.txt", "on-disk-content");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ simulateEdit(doc, "edited", false);
+
+ // Save to disk then close the tab
+ const file = doc.file;
+ await new Promise(function (resolve) {
+ file.write("edited", function () { resolve(); });
+ });
+ await awaitsForDone(
+ CommandManager.execute(Commands.FILE_CLOSE,
+ { file: file, _forceClose: true }),
+ "close a.txt"
+ );
+
+ await AISnapshotStore.finalizeResponse();
+
+ // After-snapshot should have captured "edited" from disk fallback
+ const err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("edited");
+ });
+
+ it("should capture deleted file as null in after-snapshot", async function () {
+ await createFile("a.txt", "content");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ simulateEdit(doc, "modified", false);
+
+ // Close tab and delete the file
+ await awaitsForDone(
+ CommandManager.execute(Commands.FILE_CLOSE,
+ { file: doc.file, _forceClose: true }),
+ "close a.txt"
+ );
+ await unlinkFile("a.txt");
+
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ // snap 0 has original content; restore recreates the file
+ let err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("content");
+
+ // snap 1 was captured as null (disk read failed → null fallback)
+ // Explicitly open to ensure doc is in working set (avoids CMD_OPEN race)
+ await openDoc("a.txt");
+ err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ await expectFileDeleted("a.txt");
+ });
+ });
+
+ // --- snapshot consistency (editApplyVerification cases) ---
+
+ describe("snapshot consistency", function () {
+
+ // Case 1: Single response, 2 files
+ it("should handle single response editing 2 files", async function () {
+ await createFile("a.txt", "a0");
+ await createFile("b.txt", "b0");
+ const docA = await openDoc("a.txt");
+ const docB = await openDoc("b.txt");
+
+ // R1
+ beginResponse();
+ simulateEdit(docA, "a1", false);
+ simulateEdit(docB, "b1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ // restore(0) → a0, b0
+ let err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a0");
+ expect(await readFile("b.txt")).toBe("b0");
+
+ // restore(1) → a1, b1
+ err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a1");
+ expect(await readFile("b.txt")).toBe("b1");
+ });
+
+ // Case 2: Two responses, same file
+ it("should handle two responses editing same file", async function () {
+ await createFile("a.txt", "v0");
+ const doc = await openDoc("a.txt");
+
+ // R1
+ beginResponse();
+ simulateEdit(doc, "v1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // R2
+ simulateEdit(doc, "v2", false);
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(3);
+
+ let err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("v0");
+
+ err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("v1");
+
+ err = await restoreToSnapshot(2);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("v2");
+ });
+
+ // Case 4: Three responses, restore middle
+ it("should restore to middle snapshot", async function () {
+ await createFile("a.txt", "v0");
+ const doc = await openDoc("a.txt");
+
+ // R1
+ beginResponse();
+ simulateEdit(doc, "v1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // R2
+ simulateEdit(doc, "v2", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // R3
+ simulateEdit(doc, "v3", false);
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(4);
+
+ let err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("v1");
+
+ err = await restoreToSnapshot(2);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("v2");
+ });
+
+ // Case 5: Different files, back-fill
+ it("should back-fill when different files edited in different responses", async function () {
+ await createFile("a.txt", "a0");
+ await createFile("b.txt", "b0");
+ const docA = await openDoc("a.txt");
+
+ // R1: edit A only
+ beginResponse();
+ simulateEdit(docA, "a1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // R2: edit B only
+ const docB = await openDoc("b.txt");
+ simulateEdit(docB, "b1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(3);
+
+ // snap 0 & 1 should have been back-filled with B:b0
+ let err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a0");
+ expect(await readFile("b.txt")).toBe("b0");
+
+ err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a1");
+ expect(await readFile("b.txt")).toBe("b0");
+
+ err = await restoreToSnapshot(2);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a1");
+ expect(await readFile("b.txt")).toBe("b1");
+ });
+
+ // Case 6: File created in R1, edited in R2
+ it("should handle file creation and subsequent edit", async function () {
+ // R1: create file A
+ beginResponse();
+ const docA = await simulateCreateFile("a.txt", "new");
+ await AISnapshotStore.finalizeResponse();
+
+ // R2: edit A
+ simulateEdit(docA, "edited", false);
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(3);
+
+ // snap 2 → A="edited"
+ let err = await restoreToSnapshot(2);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("edited");
+
+ // snap 1 → A="new"
+ err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("new");
+
+ // snap 0 has A:null → file deleted
+ err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ await expectFileDeleted("a.txt");
+ });
+
+ // Case 7: File created in R2
+ it("should handle file created in second response", async function () {
+ await createFile("a.txt", "a0");
+ const docA = await openDoc("a.txt");
+
+ // R1: edit A
+ beginResponse();
+ simulateEdit(docA, "a1", false);
+ await AISnapshotStore.finalizeResponse();
+
+ // R2: create B
+ const docB = await simulateCreateFile("b.txt", "new");
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(3);
+
+ // snap 0 → A=a0, B deleted (back-filled null)
+ let err = await restoreToSnapshot(0);
+ expect(await readFile("a.txt")).toBe("a0");
+ await expectFileDeleted("b.txt");
+
+ // snap 1 → A=a1, B deleted (back-filled null)
+ err = await restoreToSnapshot(1);
+ expect(await readFile("a.txt")).toBe("a1");
+ await expectFileDeleted("b.txt");
+
+ // snap 2 → A=a1, B="new"
+ err = await restoreToSnapshot(2);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a1");
+ expect(await readFile("b.txt")).toBe("new");
+ });
+
+ // File created and document closed in same turn — disk fallback reads empty content
+ it("should handle file created and closed in same turn", async function () {
+ await createFile("a.txt", "a0");
+ const docA = await openDoc("a.txt");
+
+ // R1: edit A, create B then close B's document
+ beginResponse();
+ simulateEdit(docA, "a1", false);
+ const docB = await simulateCreateFile("b.txt", "created");
+ // Close B — simulates file created then removed in same turn
+ await awaitsForDone(
+ CommandManager.execute(Commands.FILE_CLOSE,
+ { file: docB.file, _forceClose: true }),
+ "close b.txt"
+ );
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ // snap 0: A="a0", B=null (isNewFile). B still on disk from simulateCreateFile.
+ let err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a0");
+ await expectFileDeleted("b.txt");
+
+ // snap 1 (after): A="a1", B read from disk fallback (createFile wrote "")
+ err = await restoreToSnapshot(1);
+ expect(await readFile("a.txt")).toBe("a1");
+ // Disk fallback reads the empty string that createFile wrote
+ expect(await readFile("b.txt")).toBe("");
+ });
+
+ // Delete → recreate → delete round-trip
+ it("should handle delete-restore-delete round-trip", async function () {
+ // R1: create file A
+ beginResponse();
+ await simulateCreateFile("a.txt", "content");
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ // snap 0 → A=null → file deleted
+ let err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ await expectFileDeleted("a.txt");
+
+ // snap 1 → A="content" → file recreated from deleted state
+ err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("content");
+ });
+
+ // Case 9: Response with no edits
+ it("should return -1 for response with no edits", async function () {
+ beginResponse();
+ const idx = await AISnapshotStore.finalizeResponse();
+ expect(idx).toBe(-1);
+ expect(AISnapshotStore.getSnapshotCount()).toBe(1);
+ });
+ });
+
+ // --- recordFileDeletion ---
+
+ describe("recordFileDeletion", function () {
+ it("should track explicit deletion with before-content and null after", async function () {
+ await createFile("a.txt", "original");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ // Record deletion with known previous content
+ AISnapshotStore.recordFileDeletion(doc.file.fullPath, "original");
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ // snap 1 has null — doc still open from openDoc(), close+delete works
+ let err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ await expectFileDeleted("a.txt");
+
+ // snap 0 has "original" (back-filled before content) — recreates file
+ err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("original");
+ });
+ });
+
+ // --- recordFileRead ---
+
+ describe("recordFileRead", function () {
+ it("should enable restore when read-tracked file is later deleted", async function () {
+ await createFile("a.txt", "a0");
+ await createFile("b.txt", "b-content");
+ const docA = await openDoc("a.txt");
+
+ // Record that AI has read b.txt
+ AISnapshotStore.recordFileRead(toVfsPath("b.txt"), "b-content");
+
+ beginResponse();
+ // Edit a.txt (so we have at least one pending edit)
+ simulateEdit(docA, "a1", false);
+
+ // Simulate deletion of the read file by calling recordFileDeletion
+ // (mirrors what _onProjectFileChanged would do after promoting from _readFiles)
+ AISnapshotStore.recordFileDeletion(toVfsPath("b.txt"), "b-content");
+
+ await AISnapshotStore.finalizeResponse();
+
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ // snap 1: A="a1", B=null (deleted) — b.txt still on disk, delete first
+ let err = await restoreToSnapshot(1);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a1");
+ await expectFileDeleted("b.txt");
+
+ // snap 0: A="a0", B="b-content" — recreates b.txt
+ err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("a0");
+ expect(await readFile("b.txt")).toBe("b-content");
+ });
+ });
+
+ // --- reset ---
+
+ describe("reset", function () {
+ it("should clear snapshot count to 0", function () {
+ AISnapshotStore.createInitialSnapshot();
+ expect(AISnapshotStore.getSnapshotCount()).toBe(1);
+ AISnapshotStore.reset();
+ expect(AISnapshotStore.getSnapshotCount()).toBe(0);
+ });
+
+ it("should allow fresh start after operations", async function () {
+ await createFile("a.txt", "v0");
+ const doc = await openDoc("a.txt");
+
+ beginResponse();
+ simulateEdit(doc, "v1", false);
+ await AISnapshotStore.finalizeResponse();
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ AISnapshotStore.reset();
+ expect(AISnapshotStore.getSnapshotCount()).toBe(0);
+
+ // Start fresh
+ beginResponse();
+ simulateEdit(doc, "v2", false);
+ await AISnapshotStore.finalizeResponse();
+ expect(AISnapshotStore.getSnapshotCount()).toBe(2);
+
+ const err = await restoreToSnapshot(0);
+ expect(err).toBe(0);
+ expect(await readFile("a.txt")).toBe("v1");
+ });
+ });
+
+ // --- realToVfsPath ---
+
+ describe("realToVfsPath", function () {
+ it("should pass through /tauri/ paths unchanged", function () {
+ const p = "/tauri/some/path/file.txt";
+ expect(AISnapshotStore.realToVfsPath(p)).toBe(p);
+ });
+
+ it("should pass through /mnt/ paths unchanged", function () {
+ const p = "/mnt/some/path/file.txt";
+ expect(AISnapshotStore.realToVfsPath(p)).toBe(p);
+ });
+ });
+ });
+});
diff --git a/src/core-ai/editApplyVerification.md b/test/spec/ai-snapshot-test.md
similarity index 93%
rename from src/core-ai/editApplyVerification.md
rename to test/spec/ai-snapshot-test.md
index e30c5e3c2..edc50135e 100644
--- a/src/core-ai/editApplyVerification.md
+++ b/test/spec/ai-snapshot-test.md
@@ -23,8 +23,14 @@ _snapshots[2] = after R2 edits
## State Variables
### AISnapshotStore (pure data layer)
+- `_memoryBuffer`: `hash → content` map holding content in memory during an AI turn; flushed to disk at `finalizeResponse()` time
+- `_writtenHashes`: `Set` of hashes confirmed written to disk (reads skip disk check for these)
- `_snapshots[]`: flat array of `{ filePath: hash|null }` snapshots. `getSnapshotCount() > 0` replaces the old `_initialSnapshotCreated` flag.
- `_pendingBeforeSnap`: per-file pre-edit tracking during current response (dedup guard for first-edit-per-file + file list for `finalizeResponse`)
+- `_instanceDir` / `_aiSnapDir`: per-instance disk paths under `