dr-claw 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +286 -177
  2. package/README.zh-CN.md +286 -174
  3. package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
  4. package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
  5. package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
  9. package/dist/assets/channel-BJdHHkhR.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
  18. package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
  20. package/dist/assets/clone-CwXTbwxl.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
  30. package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
  31. package/dist/assets/index-C-sL-ag0.css +1 -0
  32. package/dist/assets/index-VdqxIFPL.js +670 -0
  33. package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
  36. package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
  37. package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
  38. package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
  50. package/dist/icons/nanoclaude.png +0 -0
  51. package/dist/index.html +2 -2
  52. package/dist/screenshots/screenshot_v1_2.png +0 -0
  53. package/package.json +61 -4
  54. package/public/icons/nanoclaude.png +0 -0
  55. package/public/screenshots/screenshot_v1_2.png +0 -0
  56. package/scripts/build-electron-icons.mjs +40 -0
  57. package/scripts/check-deps.js +28 -0
  58. package/scripts/native-runtime.mjs +128 -0
  59. package/server/__tests__/gemini-api.test.mjs +699 -0
  60. package/server/__tests__/gemini-session-index.test.mjs +95 -0
  61. package/server/__tests__/project-sync-dedup.test.mjs +173 -0
  62. package/server/__tests__/session-delete.test.mjs +152 -0
  63. package/server/claude-sdk.js +175 -14
  64. package/server/cli-chat.js +28 -17
  65. package/server/cli.js +48 -11
  66. package/server/data/news-log-arxiv.json +1 -0
  67. package/server/data/news-log-huggingface.json +1 -0
  68. package/server/data/news-results-arxiv.json +460 -0
  69. package/server/data/news-results-huggingface.json +817 -0
  70. package/server/data/research_interests_arxiv.json +50 -0
  71. package/server/data/research_interests_huggingface.json +4 -0
  72. package/server/database/db.js +101 -5
  73. package/server/database/init.sql +17 -1
  74. package/server/gemini-api.js +1838 -0
  75. package/server/gemini-cli.js +13 -2
  76. package/server/index.js +94 -16
  77. package/server/load-env.js +13 -1
  78. package/server/local-gpu.js +166 -24
  79. package/server/mcp-compute.js +223 -0
  80. package/server/middleware/auth.js +25 -6
  81. package/server/nano-claude-code.js +340 -0
  82. package/server/nanoSessionPaths.js +41 -0
  83. package/server/openai-codex.js +6 -2
  84. package/server/openrouter.js +37 -22
  85. package/server/projects.js +516 -110
  86. package/server/routes/agent.js +32 -7
  87. package/server/routes/auto-research.js +1 -1
  88. package/server/routes/claude-btw.js +55 -0
  89. package/server/routes/cli-auth.js +422 -58
  90. package/server/routes/commands.js +25 -1
  91. package/server/routes/community-tools.js +398 -0
  92. package/server/routes/compute.js +155 -0
  93. package/server/routes/git.js +9 -11
  94. package/server/routes/memory.js +143 -0
  95. package/server/routes/news.js +3 -0
  96. package/server/routes/skills.js +35 -0
  97. package/server/routes/user.js +3 -5
  98. package/server/utils/__tests__/safePath.test.js +93 -0
  99. package/server/utils/cliResolution.js +13 -1
  100. package/server/utils/codexSessionMessages.js +285 -0
  101. package/server/utils/geminiOAuth.js +333 -0
  102. package/server/utils/memoryPrompt.js +78 -0
  103. package/server/utils/safePath.js +51 -0
  104. package/server/utils/sessionIndex.js +2 -0
  105. package/server/utils/skillExpander.js +142 -0
  106. package/server/utils/spawnAsync.js +78 -0
  107. package/shared/modelConstants.js +19 -1
  108. package/dist/assets/channel-BFOE7OyC.js +0 -1
  109. package/dist/assets/clone-BulQSblH.js +0 -1
  110. package/dist/assets/index-B8Yutsnm.css +0 -1
  111. package/dist/assets/index-Cf6gyhc3.js +0 -654
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
  113. package/scripts/install-python-tools.js +0 -64
@@ -0,0 +1,95 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ const originalHome = process.env.HOME;
7
+ const originalUserProfile = process.env.USERPROFILE;
8
+ const originalDatabasePath = process.env.DATABASE_PATH;
9
+
10
+ let tempRoot = null;
11
+
12
+ async function loadTestModules() {
13
+ vi.resetModules();
14
+ const projects = await import('../projects.js');
15
+ const database = await import('../database/db.js');
16
+ await database.initializeDatabase();
17
+ return { projects, database };
18
+ }
19
+
20
+ describe('Gemini API session indexing', () => {
21
+ beforeEach(async () => {
22
+ tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-gemini-index-'));
23
+ process.env.HOME = tempRoot;
24
+ process.env.USERPROFILE = tempRoot;
25
+ process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db');
26
+ });
27
+
28
+ afterEach(async () => {
29
+ vi.resetModules();
30
+
31
+ if (originalHome === undefined) delete process.env.HOME;
32
+ else process.env.HOME = originalHome;
33
+
34
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
35
+ else process.env.USERPROFILE = originalUserProfile;
36
+
37
+ if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH;
38
+ else process.env.DATABASE_PATH = originalDatabasePath;
39
+
40
+ if (tempRoot) {
41
+ await rm(tempRoot, { recursive: true, force: true });
42
+ tempRoot = null;
43
+ }
44
+ });
45
+
46
+ it('discovers API-written Gemini sessions from ~/.gemini/sessions', async () => {
47
+ const { projects } = await loadTestModules();
48
+ const projectPath = path.join(tempRoot, 'workspace', 'gemini-project');
49
+ const sessionId = 'gemini-api-session-1';
50
+ const sessionsDir = path.join(tempRoot, '.gemini', 'sessions');
51
+ const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
52
+
53
+ await mkdir(projectPath, { recursive: true });
54
+ await mkdir(sessionsDir, { recursive: true });
55
+ await writeFile(sessionFile, [
56
+ JSON.stringify({
57
+ type: 'session_meta',
58
+ payload: {
59
+ id: sessionId,
60
+ cwd: projectPath,
61
+ timestamp: '2026-04-05T12:00:00.000Z',
62
+ sessionMode: 'research',
63
+ title: 'Explore Gemini API flow',
64
+ },
65
+ cwd: projectPath,
66
+ timestamp: '2026-04-05T12:00:00.000Z',
67
+ title: 'Explore Gemini API flow',
68
+ }),
69
+ JSON.stringify({
70
+ type: 'message',
71
+ role: 'user',
72
+ content: 'Explore Gemini API flow',
73
+ timestamp: '2026-04-05T12:00:01.000Z',
74
+ }),
75
+ JSON.stringify({
76
+ type: 'message',
77
+ role: 'assistant',
78
+ content: 'I inspected the repository.',
79
+ timestamp: '2026-04-05T12:00:02.000Z',
80
+ }),
81
+ '',
82
+ ].join('\n'), 'utf-8');
83
+
84
+ const sessions = await projects.getGeminiSessions(projectPath, { limit: 5 });
85
+
86
+ expect(sessions).toHaveLength(1);
87
+ expect(sessions[0]).toEqual(expect.objectContaining({
88
+ id: sessionId,
89
+ name: 'Explore Gemini API flow',
90
+ mode: 'research',
91
+ projectPath,
92
+ messageCount: 2,
93
+ }));
94
+ });
95
+ });
@@ -0,0 +1,173 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtemp, mkdir, rm } from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ const originalHome = process.env.HOME;
7
+ const originalUserProfile = process.env.USERPROFILE;
8
+ const originalDatabasePath = process.env.DATABASE_PATH;
9
+
10
+ let tempRoot = null;
11
+
12
+ async function loadTestModules() {
13
+ vi.resetModules();
14
+ const database = await import('../database/db.js');
15
+ await database.initializeDatabase();
16
+ const projects = await import('../projects.js');
17
+ return { projects, database };
18
+ }
19
+
20
+ /** Create a test user and return its numeric id */
21
+ function createTestUser(database, username = 'testuser') {
22
+ const user = database.userDb.createUser(username, 'hash');
23
+ return user.id;
24
+ }
25
+
26
+ describe('project sync and dedup (PR #89)', () => {
27
+ beforeEach(async () => {
28
+ tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-project-dedup-'));
29
+ process.env.HOME = tempRoot;
30
+ process.env.USERPROFILE = tempRoot;
31
+ process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db');
32
+ });
33
+
34
+ afterEach(async () => {
35
+ vi.resetModules();
36
+
37
+ if (originalHome === undefined) delete process.env.HOME;
38
+ else process.env.HOME = originalHome;
39
+
40
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
41
+ else process.env.USERPROFILE = originalUserProfile;
42
+
43
+ if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH;
44
+ else process.env.DATABASE_PATH = originalDatabasePath;
45
+
46
+ if (tempRoot) {
47
+ await rm(tempRoot, { recursive: true, force: true });
48
+ tempRoot = null;
49
+ }
50
+ });
51
+
52
+ describe('getProjectByPath', () => {
53
+ it('returns unowned project when queried with a userId', async () => {
54
+ const { database } = await loadTestModules();
55
+ const testPath = '/tmp/test-project-alpha';
56
+
57
+ database.projectDb.upsertProject('proj-alpha', null, 'Alpha', testPath, 0, null, null);
58
+
59
+ const result = database.projectDb.getProjectByPath(testPath, 1);
60
+ expect(result).not.toBeNull();
61
+ expect(result.id).toBe('proj-alpha');
62
+ expect(result.user_id).toBeNull();
63
+ });
64
+
65
+ it('prefers same-user record over unowned when both exist', async () => {
66
+ const { database } = await loadTestModules();
67
+ const userId = createTestUser(database);
68
+ const testPath = '/tmp/test-project-beta';
69
+
70
+ // Insert unowned record first
71
+ database.projectDb.upsertProject('proj-beta-unowned', null, 'Beta Unowned', testPath, 0, null, null);
72
+ // Insert user-owned record (different ID to allow both rows)
73
+ database.projectDb.upsertProject('proj-beta-user1', userId, 'Beta User1', testPath, 0, null, null);
74
+
75
+ const result = database.projectDb.getProjectByPath(testPath, userId);
76
+ expect(result).not.toBeNull();
77
+ expect(result.id).toBe('proj-beta-user1');
78
+ expect(result.user_id).toBe(userId);
79
+ });
80
+
81
+ it('returns user-owned project when queried with userId=null', async () => {
82
+ const { database } = await loadTestModules();
83
+ const userId = createTestUser(database);
84
+ const testPath = '/tmp/test-project-gamma';
85
+
86
+ database.projectDb.upsertProject('proj-gamma', userId, 'Gamma', testPath, 0, null, null);
87
+
88
+ const result = database.projectDb.getProjectByPath(testPath, null);
89
+ expect(result).not.toBeNull();
90
+ expect(result.id).toBe('proj-gamma');
91
+ expect(result.user_id).toBe(userId);
92
+ });
93
+ });
94
+
95
+ describe('addProjectManually — preserves existing values', () => {
96
+ it('preserves is_starred and metadata when project already exists', async () => {
97
+ const { database, projects } = await loadTestModules();
98
+
99
+ // Create a real directory for addProjectManually's fs.access check
100
+ const projectDir = path.join(tempRoot, 'my-project');
101
+ await mkdir(projectDir, { recursive: true });
102
+
103
+ const encodedId = projects.encodeProjectPath(projectDir);
104
+ const customMetadata = { customKey: 'customValue', provider: 'claude' };
105
+
106
+ // Seed the DB with a starred project that has metadata
107
+ database.projectDb.upsertProject(
108
+ encodedId, null, 'My Project', projectDir,
109
+ 1, '2026-01-01T00:00:00.000Z', customMetadata,
110
+ );
111
+
112
+ // Now call addProjectManually — should NOT wipe is_starred or metadata
113
+ const result = await projects.addProjectManually(projectDir, null, null);
114
+
115
+ expect(result.alreadyExists).toBe(true);
116
+
117
+ // Verify DB state
118
+ const dbRow = database.projectDb.getProjectByPath(projectDir, null);
119
+ expect(dbRow.is_starred).toBe(1);
120
+ expect(dbRow.metadata.customKey).toBe('customValue');
121
+ expect(dbRow.metadata.provider).toBe('claude');
122
+ expect(dbRow.metadata.manuallyAdded).toBe(true);
123
+ });
124
+
125
+ it('does not create duplicate when same path exists with different ID', async () => {
126
+ const { database, projects } = await loadTestModules();
127
+
128
+ const projectDir = path.join(tempRoot, 'dup-project');
129
+ await mkdir(projectDir, { recursive: true });
130
+
131
+ // Insert with a "legacy" ID that differs from encodeProjectPath output
132
+ const legacyId = 'legacy-encoded-dup-project';
133
+ database.projectDb.upsertProject(
134
+ legacyId, null, 'Dup Project', projectDir,
135
+ 0, null, null,
136
+ );
137
+
138
+ // addProjectManually will compute a different ID via encodeProjectPath
139
+ const result = await projects.addProjectManually(projectDir, null, null);
140
+ expect(result.alreadyExists).toBe(true);
141
+
142
+ // Should still be only one record for this path
143
+ const all = database.projectDb.getAllProjects(null);
144
+ const matching = all.filter(p => p.path === projectDir);
145
+ expect(matching.length).toBe(1);
146
+ });
147
+ });
148
+
149
+ describe('bootstrap throttle', () => {
150
+ it('does not re-scan legacy sources within 60 seconds', async () => {
151
+ const { projects, database } = await loadTestModules();
152
+
153
+ // Set up minimal directory structure so getProjects doesn't error
154
+ await mkdir(path.join(tempRoot, 'dr-claw'), { recursive: true });
155
+ await mkdir(path.join(tempRoot, '.claude', 'projects'), { recursive: true });
156
+
157
+ // Spy on bootstrapProjectsIndexFromLegacySources via upsertProject call count
158
+ const upsertSpy = vi.spyOn(database.projectDb, 'upsertProject');
159
+
160
+ await projects.getProjects(null);
161
+ const callsAfterFirst = upsertSpy.mock.calls.length;
162
+
163
+ // Second call within 60s — bootstrap should be skipped
164
+ await projects.getProjects(null);
165
+ const callsAfterSecond = upsertSpy.mock.calls.length;
166
+
167
+ // No additional upsertProject calls from bootstrap on second run
168
+ expect(callsAfterSecond).toBe(callsAfterFirst);
169
+
170
+ upsertSpy.mockRestore();
171
+ });
172
+ });
173
+ });
@@ -163,4 +163,156 @@ describe('session deletion fallbacks', () => {
163
163
  expect(sessions[0].provider).toBe('codex');
164
164
  expect(sessions[0].summary).toContain('Inspect the project state');
165
165
  });
166
+
167
+
168
+ it('parses Codex history into session context inputs that survive reload', async () => {
169
+ const sessionId = 'codex-session-context';
170
+ const projectRoot = path.join(tempRoot, 'workspace', 'demo');
171
+ const sessionDir = path.join(tempRoot, '.codex', 'sessions', '2026', '03', '30');
172
+ const sessionFile = path.join(sessionDir, `rollout-2026-03-30T05-00-00-${sessionId}.jsonl`);
173
+
174
+ await mkdir(sessionDir, { recursive: true });
175
+ await writeFile(sessionFile, [
176
+ JSON.stringify({
177
+ timestamp: '2026-03-30T05:18:20.000Z',
178
+ type: 'response_item',
179
+ payload: {
180
+ type: 'function_call',
181
+ name: 'exec_command',
182
+ arguments: JSON.stringify({
183
+ cmd: "sed -n '1,200p' src/components/chat/utils/sessionContextSummary.ts",
184
+ workdir: projectRoot,
185
+ }),
186
+ call_id: 'call-read',
187
+ },
188
+ }),
189
+ JSON.stringify({
190
+ timestamp: '2026-03-30T05:18:21.000Z',
191
+ type: 'response_item',
192
+ payload: {
193
+ type: 'function_call_output',
194
+ call_id: 'call-read',
195
+ output: 'export const example = true;',
196
+ },
197
+ }),
198
+ JSON.stringify({
199
+ timestamp: '2026-03-30T05:18:22.000Z',
200
+ type: 'event_msg',
201
+ payload: {
202
+ type: 'exec_command_end',
203
+ call_id: 'call-read',
204
+ cwd: projectRoot,
205
+ parsed_cmd: [
206
+ {
207
+ type: 'read',
208
+ cmd: "sed -n '1,200p' src/components/chat/utils/sessionContextSummary.ts",
209
+ path: 'src/components/chat/utils/sessionContextSummary.ts',
210
+ },
211
+ ],
212
+ aggregated_output: 'export const example = true;',
213
+ exit_code: 0,
214
+ },
215
+ }),
216
+ JSON.stringify({
217
+ timestamp: '2026-03-30T05:19:00.000Z',
218
+ type: 'response_item',
219
+ payload: {
220
+ type: 'function_call',
221
+ name: 'update_plan',
222
+ arguments: JSON.stringify({
223
+ plan: [
224
+ { step: 'Normalize Codex history', status: 'in_progress' },
225
+ { step: 'Expand session summary', status: 'pending' },
226
+ ],
227
+ }),
228
+ call_id: 'call-plan',
229
+ },
230
+ }),
231
+ JSON.stringify({
232
+ timestamp: '2026-03-30T05:20:00.000Z',
233
+ type: 'response_item',
234
+ payload: {
235
+ type: 'custom_tool_call',
236
+ name: 'apply_patch',
237
+ input: [
238
+ '*** Begin Patch',
239
+ '*** Update File: src/components/chat/utils/sessionContextSummary.ts',
240
+ '*** Add File: docs/plan.md',
241
+ '*** End Patch',
242
+ ].join('\n'),
243
+ call_id: 'call-patch',
244
+ },
245
+ }),
246
+ JSON.stringify({
247
+ timestamp: '2026-03-30T05:20:01.000Z',
248
+ type: 'event_msg',
249
+ payload: {
250
+ type: 'patch_apply_end',
251
+ call_id: 'call-patch',
252
+ stdout: 'Success. Updated the following files:\nM src/components/chat/utils/sessionContextSummary.ts\nA docs/plan.md\n',
253
+ stderr: '',
254
+ success: true,
255
+ status: 'completed',
256
+ changes: {
257
+ [path.join(projectRoot, 'src/components/chat/utils/sessionContextSummary.ts')]: { type: 'update' },
258
+ [path.join(projectRoot, 'docs/plan.md')]: { type: 'add' },
259
+ },
260
+ },
261
+ }),
262
+ JSON.stringify({
263
+ timestamp: '2026-03-30T05:21:00.000Z',
264
+ type: 'response_item',
265
+ payload: {
266
+ type: 'web_search_call',
267
+ status: 'completed',
268
+ action: {
269
+ type: 'search',
270
+ query: 'Codex session context panel',
271
+ queries: ['Codex session context panel'],
272
+ },
273
+ },
274
+ }),
275
+ JSON.stringify({
276
+ timestamp: '2026-03-30T05:21:30.000Z',
277
+ type: 'response_item',
278
+ payload: {
279
+ type: 'web_search_call',
280
+ status: 'completed',
281
+ action: {
282
+ type: 'open_page',
283
+ url: 'https://developers.openai.com/api/docs',
284
+ },
285
+ },
286
+ }),
287
+ JSON.stringify({
288
+ timestamp: '2026-03-30T05:22:00.000Z',
289
+ type: 'response_item',
290
+ payload: {
291
+ type: 'web_search_call',
292
+ status: 'completed',
293
+ action: {
294
+ type: 'find_in_page',
295
+ url: 'https://developers.openai.com/api/docs',
296
+ pattern: 'session',
297
+ },
298
+ },
299
+ }),
300
+ ].join('\n'));
301
+
302
+ const { projects } = await loadTestModules();
303
+ const { convertSessionMessages } = await import('../../src/components/chat/utils/messageTransforms.ts');
304
+ const { deriveSessionContextSummary } = await import('../../src/components/chat/utils/sessionContextSummary.ts');
305
+ const result = await projects.getCodexSessionMessages(sessionId, null, 0);
306
+ const summary = deriveSessionContextSummary(convertSessionMessages(result.messages), projectRoot);
307
+
308
+ expect(summary.contextFiles.some((item) => item.relativePath === 'src/components/chat/utils/sessionContextSummary.ts')).toBe(true);
309
+ expect(summary.outputFiles.map((item) => item.relativePath).sort()).toEqual([
310
+ 'docs/plan.md',
311
+ 'src/components/chat/utils/sessionContextSummary.ts',
312
+ ]);
313
+ expect(summary.tasks.some((item) => item.label === 'Normalize Codex history')).toBe(true);
314
+ expect(summary.tasks.some((item) => item.label === 'Codex session context panel')).toBe(true);
315
+ expect(summary.tasks.some((item) => item.label === 'https://developers.openai.com/api/docs')).toBe(true);
316
+ expect(summary.tasks.some((item) => item.label === 'session')).toBe(true);
317
+ });
166
318
  });
@@ -25,6 +25,7 @@ import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIn
25
25
  import { buildTempAttachmentFilename } from './utils/imageAttachmentFiles.js';
26
26
 
27
27
  import { createRequestId, waitForToolApproval, resolveToolApproval as resolvePermApproval, matchesToolPermission } from './utils/permissions.js';
28
+ import { buildMemoryBlock } from './utils/memoryPrompt.js';
28
29
 
29
30
  const activeSessions = new Map();
30
31
  const pendingClaudeSessionIndexReconciles = new Map();
@@ -143,10 +144,12 @@ function mapCliOptionsToSDK(options = {}) {
143
144
  sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
144
145
  console.log(`Using model: ${sdkOptions.model}`);
145
146
 
146
- // Map system prompt configuration
147
+ // Map system prompt configuration with optional user memory injection
148
+ const memoryBlock = options.userId ? buildMemoryBlock(options.userId) : '';
147
149
  sdkOptions.systemPrompt = {
148
150
  type: 'preset',
149
- preset: 'claude_code' // Required to use CLAUDE.md
151
+ preset: 'claude_code', // Required to use CLAUDE.md
152
+ ...(memoryBlock ? { append: memoryBlock } : {}),
150
153
  };
151
154
 
152
155
  // Map setting sources for CLAUDE.md loading
@@ -220,28 +223,78 @@ function transformMessage(sdkMessage) {
220
223
  }
221
224
 
222
225
  /**
223
- * Extracts token budget from the last assistant message's usage data.
224
- * This gives us per-API-call input tokens, which represents the actual
225
- * context window fill level (not cumulative across the agentic turn).
226
+ * Returns the context window size for a given model name.
227
+ * Supports both SDK format ('sonnet', 'opus') and API format ('claude-opus-4-6').
228
+ * Falls back to CONTEXT_WINDOW env var, then 200000.
229
+ * @param {string|null} modelName
230
+ * @returns {number}
231
+ */
232
+ function getContextWindowForModel(modelName) {
233
+ const MODEL_CONTEXT_WINDOWS = {
234
+ // SDK format names
235
+ 'sonnet': 200000,
236
+ 'opus': 200000,
237
+ 'haiku': 200000,
238
+ 'opusplan': 200000,
239
+ 'sonnet[1m]': 1000000,
240
+ // API format names
241
+ 'claude-opus-4-6': 200000,
242
+ 'claude-opus-4-20250918': 200000,
243
+ 'claude-sonnet-4-6': 200000,
244
+ 'claude-sonnet-4-20250514': 200000,
245
+ 'claude-haiku-4-5': 200000,
246
+ 'claude-haiku-4-5-20251001': 200000,
247
+ 'claude-3-5-sonnet': 200000,
248
+ 'claude-3-5-sonnet-20241022': 200000,
249
+ 'claude-3-5-haiku': 200000,
250
+ 'claude-3-5-haiku-20241022': 200000,
251
+ 'claude-3-opus': 200000,
252
+ 'claude-3-opus-20240229': 200000,
253
+ 'claude-3-sonnet': 200000,
254
+ 'claude-3-haiku': 200000,
255
+ };
256
+
257
+ if (modelName) {
258
+ // Exact match first
259
+ const exact = MODEL_CONTEXT_WINDOWS[modelName];
260
+ if (exact) return exact;
261
+ // Prefix match (e.g. 'claude-opus-4-6-20260301' matches 'claude-opus-4-6')
262
+ const prefix = Object.keys(MODEL_CONTEXT_WINDOWS).find(k => modelName.startsWith(k));
263
+ if (prefix) return MODEL_CONTEXT_WINDOWS[prefix];
264
+ }
265
+
266
+ // Fallback: env var override, then default
267
+ const envVal = parseInt(process.env.CONTEXT_WINDOW, 10);
268
+ return Number.isFinite(envVal) ? envVal : 200000;
269
+ }
270
+
271
+ /**
272
+ * Extracts token budget from the latest assistant message's usage data.
273
+ * Returns a snapshot of context window usage for a single API call,
274
+ * including both input tokens (with cache) and output tokens.
275
+ * Not cumulative across agentic turns.
226
276
  * @param {Object|null} usage - usage object from assistant message (message.usage)
227
- * @returns {Object|null} Token budget object or null
277
+ * @param {string|null} modelName - model name for context window lookup
278
+ * @returns {Object|null} Token budget object { used, total } or null
228
279
  */
229
- function extractTokenBudgetFromUsage(usage) {
280
+ function extractTokenBudgetFromUsage(usage, modelName) {
230
281
  if (!usage) {
231
282
  return null;
232
283
  }
233
284
 
234
285
  // In Claude API: input_tokens is the non-cached portion.
235
- // Total context = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
286
+ // Total context = input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens
287
+ // This is a per-call snapshot — output_tokens here are for THIS response only,
288
+ // not double-counted with future turns' input_tokens.
236
289
  const inputTokens = usage.input_tokens || 0;
237
290
  const cacheReadTokens = usage.cache_read_input_tokens || 0;
238
291
  const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
239
- const totalUsed = inputTokens + cacheReadTokens + cacheCreationTokens;
292
+ const outputTokens = usage.output_tokens || 0;
293
+ const totalUsed = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
240
294
 
241
- // Context window is the model's input limit
242
- const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 200000;
295
+ const contextWindow = getContextWindowForModel(modelName);
243
296
 
244
- console.log(`Token calculation: input=${inputTokens}, cacheRead=${cacheReadTokens}, cacheCreation=${cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
297
+ console.log(`Token calculation: model=${modelName}, input=${inputTokens}, cacheRead=${cacheReadTokens}, cacheCreation=${cacheCreationTokens}, output=${outputTokens}, total=${totalUsed}/${contextWindow}`);
245
298
 
246
299
  return {
247
300
  used: totalUsed,
@@ -469,6 +522,37 @@ async function queryClaudeSDK(command, options = {}, ws) {
469
522
  sdkOptions.mcpServers = mcpServers;
470
523
  }
471
524
 
525
+ // Inject compute-tools MCP server if an active compute node is configured
526
+ try {
527
+ const { getActiveNode } = await import('./compute-node.js');
528
+ const activeNode = await getActiveNode();
529
+ if (activeNode) {
530
+ const computeMcpPath = new URL('./mcp-compute.js', import.meta.url).pathname;
531
+ sdkOptions.mcpServers = {
532
+ ...(sdkOptions.mcpServers || {}),
533
+ 'compute-tools': {
534
+ command: 'node',
535
+ args: [computeMcpPath],
536
+ },
537
+ };
538
+ // Append compute node context to system prompt
539
+ sdkOptions.appendSystemPrompt = [
540
+ sdkOptions.appendSystemPrompt || '',
541
+ '\n\n# Active Compute Node',
542
+ `A remote compute node "${activeNode.name}" (${activeNode.user}@${activeNode.host}) is configured and available.`,
543
+ `Type: ${activeNode.type === 'slurm' ? 'Slurm HPC cluster' : 'Direct GPU server'}.`,
544
+ `Work directory: ${activeNode.workDir || '~'}.`,
545
+ 'When the user asks to run training, GPU tasks, or heavy computation, use the compute_* tools (compute_run, compute_sync, compute_slurm_submit, etc.) instead of local Bash.',
546
+ 'Use compute_sync direction="up" to upload project code before running, and compute_run to execute commands remotely.',
547
+ 'Use compute_info first if you need to check what GPU resources are available.',
548
+ ].join('\n');
549
+ console.log(`[claude-sdk] Compute MCP server injected for node "${activeNode.name}" (${activeNode.host})`);
550
+ }
551
+ } catch (err) {
552
+ // Compute node not configured or error loading — skip silently
553
+ console.log('[claude-sdk] No active compute node:', err.message);
554
+ }
555
+
472
556
  // Handle images - save to temp files and modify prompt
473
557
  const imageResult = await handleImages(command, options.images, options.cwd);
474
558
  const finalCommand = imageResult.modifiedCommand;
@@ -645,7 +729,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
645
729
 
646
730
  // Send token budget update when the turn completes
647
731
  if (message.type === 'result') {
648
- const tokenBudget = extractTokenBudgetFromUsage(lastAssistantUsage);
732
+ const tokenBudget = extractTokenBudgetFromUsage(lastAssistantUsage, options.model);
649
733
  if (tokenBudget) {
650
734
  console.log('Token budget from last assistant usage:', tokenBudget);
651
735
  ws.send({
@@ -788,6 +872,81 @@ function getActiveClaudeSDKSessions() {
788
872
  return getAllSessions();
789
873
  }
790
874
 
875
+ const BTW_SYSTEM_PROMPT = `You answer a short side question for someone in the middle of a coding session.
876
+
877
+ Rules:
878
+ - You have NO tools. Do not claim to read files, run commands, or fetch URLs unless that information already appears in the conversation context below.
879
+ - Use the "Conversation context" section plus general programming knowledge. If something is not in the context, say you do not see it there.
880
+ - Be concise.`;
881
+
882
+ /**
883
+ * One-shot, tool-free side question (Claude Code /btw-style). Does not resume the main SDK session.
884
+ */
885
+ async function runClaudeBtw({ question, transcript, cwd, model, signal }) {
886
+ const safeTranscript =
887
+ typeof transcript === 'string' && transcript.trim()
888
+ ? transcript
889
+ : '(No prior conversation in this session.)';
890
+ const userBlock = `## Conversation context\n\n${safeTranscript}\n\n---\n\n## Side question\n\n${question}`;
891
+
892
+ const sdkOptions = {
893
+ cwd: cwd || process.cwd(),
894
+ model: model || CLAUDE_MODELS.DEFAULT,
895
+ tools: [],
896
+ allowedTools: [],
897
+ settingSources: [],
898
+ systemPrompt: BTW_SYSTEM_PROMPT,
899
+ permissionMode: 'default',
900
+ };
901
+
902
+ const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
903
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '120000';
904
+
905
+ let answer = '';
906
+ let lastError = null;
907
+
908
+ try {
909
+ const queryInstance = query({
910
+ prompt: userBlock,
911
+ options: sdkOptions,
912
+ });
913
+
914
+ for await (const message of queryInstance) {
915
+ if (signal?.aborted) {
916
+ break;
917
+ }
918
+ if (message.type === 'result') {
919
+ if (message.subtype === 'success') {
920
+ answer = message.result || answer;
921
+ } else {
922
+ lastError = Array.isArray(message.errors) ? message.errors.join('\n') : 'Side question failed';
923
+ }
924
+ }
925
+ }
926
+ } catch (err) {
927
+ if (signal?.aborted) {
928
+ return { answer: '' };
929
+ }
930
+ throw new Error(err?.message || String(err));
931
+ } finally {
932
+ if (prevStreamTimeout !== undefined) {
933
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
934
+ } else {
935
+ delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
936
+ }
937
+ }
938
+
939
+ if (signal?.aborted) {
940
+ return { answer: '' };
941
+ }
942
+
943
+ if (lastError) {
944
+ throw new Error(lastError);
945
+ }
946
+
947
+ return { answer: answer || '' };
948
+ }
949
+
791
950
  // Export public API
792
951
  export {
793
952
  queryClaudeSDK,
@@ -795,5 +954,7 @@ export {
795
954
  isClaudeSDKSessionActive,
796
955
  getClaudeSDKSessionStartTime,
797
956
  getActiveClaudeSDKSessions,
798
- resolveToolApproval
957
+ resolveToolApproval,
958
+ getContextWindowForModel,
959
+ runClaudeBtw,
799
960
  };