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
@@ -67,6 +67,16 @@ import sqlite3 from 'sqlite3';
67
67
  import { open } from 'sqlite';
68
68
  import os from 'os';
69
69
  import { stripInternalContextPrefix } from './utils/sessionFormatting.js';
70
+ import {
71
+ buildToolResultMessage,
72
+ buildToolUseMessage,
73
+ extractTextContent,
74
+ normalizeCodexCustomToolCall,
75
+ normalizeCodexFunctionCall,
76
+ normalizeExecCommandEvent,
77
+ normalizePatchApplyEvent,
78
+ normalizeWebSearchCall,
79
+ } from './utils/codexSessionMessages.js';
70
80
  import {
71
81
  extractSessionModeFromMetadata,
72
82
  extractSessionModeFromText,
@@ -74,6 +84,7 @@ import {
74
84
  normalizeSessionMode,
75
85
  readExplicitSessionModeFromMetadata,
76
86
  } from './utils/sessionMode.js';
87
+ import { resolveNanoSessionAbsPath, safeNanoSessionFilename } from './nanoSessionPaths.js';
77
88
 
78
89
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
79
90
  const DRCLAW_SKILLS_DIR = path.join(__dirname, '..', 'skills');
@@ -84,6 +95,8 @@ const CURRENT_DEFAULT_WORKSPACES_ROOT = path.join(os.homedir(), 'dr-claw');
84
95
  const DELETED_PROJECTS_CONFIG_KEY = '_deletedProjects';
85
96
 
86
97
  let projectConfigMutationQueue = Promise.resolve();
98
+ const _lastBootstrapByUser = new Map(); // userId -> timestamp
99
+ const BOOTSTRAP_STALENESS_MS = 60_000; // Only re-scan legacy sources every 60 seconds
87
100
 
88
101
  function isProjectTrashed(projectInfo = null, dbEntry = null) {
89
102
  return Boolean(projectInfo?.trash?.trashedAt || dbEntry?.metadata?.trash?.trashedAt);
@@ -235,6 +248,120 @@ async function bootstrapProjectsIndexFromLegacySources(config, projectDb, userId
235
248
  return seededCount;
236
249
  }
237
250
 
251
+ function collectCodexProjectCandidates(sessionsByProject = new Map()) {
252
+ const candidatesByProject = new Map();
253
+
254
+ for (const sessions of sessionsByProject.values()) {
255
+ if (!Array.isArray(sessions) || sessions.length === 0) {
256
+ continue;
257
+ }
258
+
259
+ const projectPath = sessions.find((session) => typeof session?.cwd === 'string' && session.cwd.trim())?.cwd?.trim();
260
+ if (!projectPath) {
261
+ continue;
262
+ }
263
+
264
+ const projectName = encodeProjectPath(projectPath);
265
+ if (!projectName) {
266
+ continue;
267
+ }
268
+
269
+ if (!candidatesByProject.has(projectName)) {
270
+ candidatesByProject.set(projectName, {
271
+ projectName,
272
+ projectPath,
273
+ sessions: [],
274
+ });
275
+ }
276
+
277
+ const candidate = candidatesByProject.get(projectName);
278
+ const sessionIds = new Set(candidate.sessions.map((session) => session.id));
279
+
280
+ for (const session of sessions) {
281
+ if (!session?.id || sessionIds.has(session.id)) {
282
+ continue;
283
+ }
284
+
285
+ candidate.sessions.push(session);
286
+ sessionIds.add(session.id);
287
+ }
288
+ }
289
+
290
+ return Array.from(candidatesByProject.values());
291
+ }
292
+
293
+ const CODEX_SYNC_COOLDOWN_MS = 30_000;
294
+ let lastCodexSyncTimestamp = 0;
295
+
296
+ async function syncDiscoveredProjectsFromCodexSessions(config, projectDb, userId = null, visibleWorkspaceRoots = []) {
297
+ const now = Date.now();
298
+ if (now - lastCodexSyncTimestamp < CODEX_SYNC_COOLDOWN_MS) {
299
+ return 0;
300
+ }
301
+
302
+ const { sessionDb } = await import('./database/db.js');
303
+ const discoveredSessions = await buildCodexSessionsIndex();
304
+ const candidates = collectCodexProjectCandidates(discoveredSessions);
305
+ let syncedProjects = 0;
306
+
307
+ for (const candidate of candidates) {
308
+ const { projectName, projectPath, sessions } = candidate;
309
+
310
+ if (!projectPath || !await pathExists(projectPath)) {
311
+ continue;
312
+ }
313
+
314
+ if (visibleWorkspaceRoots.length > 0 && !await isPathWithinWorkspaceRoots(projectPath, visibleWorkspaceRoots)) {
315
+ continue;
316
+ }
317
+
318
+ const projectInfo = config[projectName];
319
+ const existing = projectDb.getProjectById(projectName);
320
+ if (isProjectSuppressed(projectName, config, projectInfo) || isProjectTrashed(projectInfo, existing)) {
321
+ continue;
322
+ }
323
+
324
+ const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null;
325
+ const metadata = { ...(existing?.metadata || {}) };
326
+
327
+ if (projectInfo?.trash?.trashedAt) {
328
+ metadata.trash = {
329
+ ...projectInfo.trash,
330
+ ownerUserId: projectInfo.trash.ownerUserId ?? ownerUserId,
331
+ };
332
+ }
333
+
334
+ projectDb.upsertProject(
335
+ projectName,
336
+ ownerUserId,
337
+ existing?.display_name || projectInfo?.displayName || null,
338
+ projectPath,
339
+ existing?.is_starred || 0,
340
+ existing?.last_accessed || null,
341
+ Object.keys(metadata).length > 0 ? metadata : null,
342
+ );
343
+
344
+ for (const session of sessions) {
345
+ sessionDb.upsertSessionFromSource(session.id, projectName, 'codex', {
346
+ displayName: session.summary || session.name || 'Codex Session',
347
+ lastActivity: session.lastActivity || new Date(),
348
+ messageCount: session.messageCount || 0,
349
+ createdAt: session.createdAt || session.lastActivity || new Date(),
350
+ metadata: {
351
+ projectPath,
352
+ sessionMode: normalizeSessionMode(session.mode),
353
+ indexState: 'synced',
354
+ },
355
+ });
356
+ }
357
+
358
+ syncedProjects += 1;
359
+ }
360
+
361
+ lastCodexSyncTimestamp = Date.now();
362
+ return syncedProjects;
363
+ }
364
+
238
365
  function buildTrashEntry(projectName, projectInfo = null, dbEntry = null) {
239
366
  const trashMeta = dbEntry?.metadata?.trash || projectInfo?.trash;
240
367
  if (!trashMeta?.trashedAt) {
@@ -914,6 +1041,20 @@ function mapIndexedSessionToProjectSession(session, provider) {
914
1041
  };
915
1042
  }
916
1043
 
1044
+ if (provider === 'nano') {
1045
+ return {
1046
+ id: session.id,
1047
+ summary: baseName || 'Nano Claude Code Session',
1048
+ name: baseName || 'Nano Claude Code Session',
1049
+ createdAt,
1050
+ lastActivity,
1051
+ messageCount,
1052
+ mode,
1053
+ tags,
1054
+ __provider: 'nano',
1055
+ };
1056
+ }
1057
+
917
1058
  return {
918
1059
  id: session.id,
919
1060
  summary: baseName || 'New Session',
@@ -936,6 +1077,8 @@ function getSessionPlaceholderName(provider) {
936
1077
  return 'Gemini Session';
937
1078
  case 'openrouter':
938
1079
  return 'OpenRouter Session';
1080
+ case 'nano':
1081
+ return 'Nano Claude Code Session';
939
1082
  default:
940
1083
  return 'New Session';
941
1084
  }
@@ -1165,18 +1308,22 @@ async function getProjects(userId, progressCallback = null) {
1165
1308
  let totalProjects = 0;
1166
1309
  let processedProjects = 0;
1167
1310
 
1168
- let dbProjects = projectDb.getAllProjects(userId || null);
1169
- if (dbProjects.length === 0) {
1170
- const seededCount = await bootstrapProjectsIndexFromLegacySources(
1311
+ // Sync from legacy sources so CLI-created projects appear in the web UI.
1312
+ // Only re-scan if the last bootstrap was more than 60 seconds ago to avoid
1313
+ // O(N) filesystem walks on every getProjects() call. See #86.
1314
+ const now = Date.now();
1315
+ const userKey = userId || '__anonymous__';
1316
+ if (now - (_lastBootstrapByUser.get(userKey) || 0) > BOOTSTRAP_STALENESS_MS) {
1317
+ await bootstrapProjectsIndexFromLegacySources(
1171
1318
  config,
1172
1319
  projectDb,
1173
1320
  userId || null,
1174
1321
  visibleWorkspaceRoots,
1175
1322
  );
1176
- if (seededCount > 0) {
1177
- dbProjects = projectDb.getAllProjects(userId || null);
1178
- }
1323
+ _lastBootstrapByUser.set(userKey, now);
1179
1324
  }
1325
+ await syncDiscoveredProjectsFromCodexSessions(config, projectDb, userId || null, visibleWorkspaceRoots);
1326
+ const dbProjects = projectDb.getAllProjects(userId || null);
1180
1327
 
1181
1328
  try {
1182
1329
  const visibleProjects = [];
@@ -1254,6 +1401,7 @@ async function getProjects(userId, progressCallback = null) {
1254
1401
  const geminiSessions = projectSessions.filter((session) => session.provider === 'gemini');
1255
1402
  const openrouterSessions = projectSessions.filter((session) => session.provider === 'openrouter');
1256
1403
  const localSessions = projectSessions.filter((session) => session.provider === 'local');
1404
+ const nanoSessions = projectSessions.filter((session) => session.provider === 'nano');
1257
1405
 
1258
1406
  project.sessions = claudeSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'claude'));
1259
1407
  project.sessionMeta = {
@@ -1265,6 +1413,7 @@ async function getProjects(userId, progressCallback = null) {
1265
1413
  project.geminiSessions = geminiSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'gemini'));
1266
1414
  project.openrouterSessions = openrouterSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'openrouter'));
1267
1415
  project.localSessions = localSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'local'));
1416
+ project.nanoSessions = nanoSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'nano'));
1268
1417
 
1269
1418
  const taskmasterResult = await detectTaskMasterFolder(actualProjectDir).catch(() => null);
1270
1419
 
@@ -1749,6 +1898,119 @@ async function parseAgentTools(filePath) {
1749
1898
  return tools;
1750
1899
  }
1751
1900
 
1901
+ function extractNanoMessageText(content) {
1902
+ if (typeof content === 'string') {
1903
+ return content;
1904
+ }
1905
+ if (!Array.isArray(content)) {
1906
+ return '';
1907
+ }
1908
+ const parts = [];
1909
+ for (const block of content) {
1910
+ if (typeof block === 'string') {
1911
+ parts.push(block);
1912
+ } else if (block && typeof block === 'object') {
1913
+ if (block.type === 'text' && block.text) {
1914
+ parts.push(String(block.text));
1915
+ }
1916
+ }
1917
+ }
1918
+ return parts.join('\n');
1919
+ }
1920
+
1921
+ /**
1922
+ * Real cwd for Nano session files. encodeProjectPath ↔ simple decode in extractProjectDirectory
1923
+ * is lossy; session metadata and projects.path store the authoritative path.
1924
+ */
1925
+ async function resolveNanoProjectDirectory(projectName, sessionId) {
1926
+ const { sessionDb, projectDb } = await import('./database/db.js');
1927
+ const session = sessionDb.getSessionById(sessionId);
1928
+ const metaPath = session?.metadata?.projectPath;
1929
+ if (typeof metaPath === 'string' && metaPath.trim()) {
1930
+ return metaPath.trim();
1931
+ }
1932
+ const projectRow = projectDb.getProjectById(projectName);
1933
+ if (projectRow?.path && typeof projectRow.path === 'string' && projectRow.path.trim()) {
1934
+ return projectRow.path.trim();
1935
+ }
1936
+ return extractProjectDirectory(projectName);
1937
+ }
1938
+
1939
+ /** Prefer ~/.dr-claw/nano-sessions; fall back to legacy <projectCwd>/drclaw-nano-*.json */
1940
+ async function findNanoSessionStoragePath(projectName, sessionId) {
1941
+ const central = resolveNanoSessionAbsPath(sessionId);
1942
+ if (central) {
1943
+ try {
1944
+ await fs.access(central);
1945
+ return central;
1946
+ } catch (_) {
1947
+ // legacy below
1948
+ }
1949
+ }
1950
+ const basename = safeNanoSessionFilename(sessionId);
1951
+ if (!basename) {
1952
+ return null;
1953
+ }
1954
+ try {
1955
+ const projectPath = await resolveNanoProjectDirectory(projectName, sessionId);
1956
+ const legacy = path.join(projectPath, basename);
1957
+ await fs.access(legacy);
1958
+ return legacy;
1959
+ } catch (_) {
1960
+ return null;
1961
+ }
1962
+ }
1963
+
1964
+ async function unlinkNanoSessionFilesEverywhere(projectName, sessionId) {
1965
+ let deleted = false;
1966
+ const central = resolveNanoSessionAbsPath(sessionId);
1967
+ if (central) {
1968
+ try {
1969
+ await fs.unlink(central);
1970
+ deleted = true;
1971
+ } catch (e) {
1972
+ if (e?.code !== 'ENOENT') {
1973
+ throw e;
1974
+ }
1975
+ }
1976
+ }
1977
+ const basename = safeNanoSessionFilename(sessionId);
1978
+ if (basename) {
1979
+ try {
1980
+ const projectPath = await resolveNanoProjectDirectory(projectName, sessionId);
1981
+ const legacy = path.join(projectPath, basename);
1982
+ await fs.unlink(legacy);
1983
+ deleted = true;
1984
+ } catch (e) {
1985
+ if (e?.code !== 'ENOENT') {
1986
+ throw e;
1987
+ }
1988
+ }
1989
+ }
1990
+ return deleted;
1991
+ }
1992
+
1993
+ /** Maps nano-claude-code session.json (title, messages[]) to Dr. Claw chat message shape. */
1994
+ function nanoSessionJsonToMessages(data) {
1995
+ const rawMessages = Array.isArray(data.messages) ? data.messages : [];
1996
+ const savedAt = data.saved_at || '';
1997
+ const out = [];
1998
+ for (const m of rawMessages) {
1999
+ const role = m.role;
2000
+ if (role !== 'user' && role !== 'assistant') {
2001
+ continue;
2002
+ }
2003
+ const text = extractNanoMessageText(m.content);
2004
+ out.push({
2005
+ type: 'message',
2006
+ role,
2007
+ content: text || '',
2008
+ timestamp: savedAt,
2009
+ });
2010
+ }
2011
+ return out;
2012
+ }
2013
+
1752
2014
  // Get messages for a specific session with pagination support
1753
2015
  async function getSessionMessages(projectName, sessionId, limit = null, offset = 0, provider = 'claude', userId = null) {
1754
2016
  console.log(`[DEBUG] getSessionMessages - project: ${projectName}, session: ${sessionId}, provider: ${provider}`);
@@ -1987,6 +2249,36 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
1987
2249
  }
1988
2250
  }
1989
2251
 
2252
+ if (String(provider || '').toLowerCase() === 'nano') {
2253
+ console.log('[DEBUG] Reading Nano Claude Code session file');
2254
+ try {
2255
+ const nanoPath = await findNanoSessionStoragePath(projectName, sessionId);
2256
+ if (!nanoPath) {
2257
+ return limit === null ? [] : { messages: [], total: 0, hasMore: false };
2258
+ }
2259
+ const raw = await fs.readFile(nanoPath, 'utf-8');
2260
+ const data = JSON.parse(raw);
2261
+ const messages = nanoSessionJsonToMessages(data);
2262
+ console.log(`[DEBUG] Found ${messages.length} messages in Nano session file at ${nanoPath}`);
2263
+ const total = messages.length;
2264
+ if (limit === null) {
2265
+ return messages;
2266
+ }
2267
+ const startIndex = Math.max(0, total - offset - limit);
2268
+ const endIndex = total - offset;
2269
+ return {
2270
+ messages: messages.slice(startIndex, endIndex),
2271
+ total,
2272
+ hasMore: startIndex > 0,
2273
+ offset,
2274
+ limit,
2275
+ };
2276
+ } catch (e) {
2277
+ console.warn(`Could not read Nano session ${sessionId}:`, e.message);
2278
+ return limit === null ? [] : { messages: [], total: 0, hasMore: false };
2279
+ }
2280
+ }
2281
+
1990
2282
  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
1991
2283
 
1992
2284
  try {
@@ -2206,6 +2498,31 @@ async function deleteSession(projectName, sessionId, provider = 'claude') {
2206
2498
  throw new Error(`Local GPU session ${sessionId} not found in file system or index`);
2207
2499
  }
2208
2500
 
2501
+ if (String(provider || '').toLowerCase() === 'nano') {
2502
+ if (!safeNanoSessionFilename(sessionId)) {
2503
+ throw new Error('Invalid Nano session id');
2504
+ }
2505
+ let deletedFile = false;
2506
+ try {
2507
+ deletedFile = await unlinkNanoSessionFilesEverywhere(projectName, sessionId);
2508
+ } catch (e) {
2509
+ console.error(`[Nano] Failed to delete session ${sessionId}:`, e.message);
2510
+ throw new Error(`Failed to delete Nano session: ${e.message}`);
2511
+ }
2512
+
2513
+ const deletedIndex = indexedSession?.provider === 'nano' || deletedFile;
2514
+ if (deletedIndex) {
2515
+ sessionDb.deleteSession(sessionId);
2516
+ }
2517
+
2518
+ if (deletedFile || deletedIndex) {
2519
+ console.log(`[Nano] Deleted session ${sessionId}${deletedFile ? ' (removed session file if present)' : ' from index only'}`);
2520
+ return true;
2521
+ }
2522
+
2523
+ throw new Error(`Nano session ${sessionId} not found in file system or index`);
2524
+ }
2525
+
2209
2526
  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
2210
2527
 
2211
2528
  try {
@@ -2831,38 +3148,63 @@ async function addProjectManually(projectPath, displayName = null, userId = null
2831
3148
 
2832
3149
  const projectName = encodeProjectPath(absolutePath);
2833
3150
 
2834
- // Check for existing project with the same path (may have legacy encoded ID)
2835
- const existingByPath = projectDb.getProjectByPath(absolutePath, userId);
2836
- if (existingByPath) {
2837
- if (existingByPath.id !== projectName) {
2838
- // Legacy ID detected — migrate to new encoding
2839
- projectDb.migrateProjectIdentity(existingByPath.id, projectName, absolutePath);
3151
+ // Check for existing project with the same path (any user or matching user).
3152
+ // A single lookup handles both same-user exact match and cross-user dedup.
3153
+ const existing = projectDb.getProjectByPath(absolutePath, userId);
3154
+
3155
+ if (existing) {
3156
+ // Migrate legacy encoded IDs if needed
3157
+ if (existing.id !== projectName) {
3158
+ projectDb.migrateProjectIdentity(existing.id, projectName, absolutePath);
3159
+ }
3160
+
3161
+ // If the existing record already belongs to this user, mark as manually
3162
+ // added and return without creating a duplicate.
3163
+ if (existing.user_id === userId || existing.user_id == null) {
3164
+ // Preserve existing values while marking as manually added
3165
+ const mergedMetadata = { ...(existing.metadata || {}), manuallyAdded: true };
3166
+ projectDb.upsertProject(
3167
+ projectName, userId,
3168
+ displayName || existing.display_name,
3169
+ absolutePath,
3170
+ existing.is_starred || 0,
3171
+ existing.last_accessed || new Date().toISOString(),
3172
+ mergedMetadata,
3173
+ );
3174
+
3175
+ return {
3176
+ name: projectName,
3177
+ path: absolutePath,
3178
+ fullPath: absolutePath,
3179
+ displayName: displayName || existing.display_name || await generateDisplayName(projectName, absolutePath),
3180
+ isManuallyAdded: true,
3181
+ createdAt: existing.created_at,
3182
+ sessions: [],
3183
+ cursorSessions: [],
3184
+ alreadyExists: true,
3185
+ };
2840
3186
  }
2841
- return {
2842
- name: projectName,
2843
- path: absolutePath,
2844
- fullPath: absolutePath,
2845
- displayName: displayName || existingByPath.display_name || await generateDisplayName(projectName, absolutePath),
2846
- isManuallyAdded: Boolean(existingByPath.metadata?.manuallyAdded),
2847
- createdAt: existingByPath.created_at,
2848
- sessions: [],
2849
- cursorSessions: [],
2850
- alreadyExists: true,
2851
- };
2852
3187
  }
2853
3188
 
2854
- projectDb.upsertProject(projectName, userId, displayName, absolutePath, 0, new Date().toISOString(), { manuallyAdded: true });
3189
+ const effectiveId = existing ? existing.id : projectName;
3190
+
3191
+ // Preserve existing values so the upsert doesn't silently clear them
3192
+ const preservedStarred = existing?.is_starred || 0;
3193
+ const mergedMetadata = { ...(existing?.metadata || {}), manuallyAdded: true };
3194
+ const preservedLastAccessed = existing?.last_accessed || new Date().toISOString();
3195
+
3196
+ projectDb.upsertProject(effectiveId, userId, displayName, absolutePath, preservedStarred, preservedLastAccessed, mergedMetadata);
2855
3197
 
2856
3198
  await mutateProjectConfig((config) => {
2857
- config[projectName] = {
2858
- ...(config[projectName] || {}),
3199
+ config[effectiveId] = {
3200
+ ...(config[effectiveId] || {}),
2859
3201
  manuallyAdded: true,
2860
3202
  originalPath: absolutePath,
2861
- ownerUserId: config[projectName]?.ownerUserId ?? userId ?? null,
3203
+ ownerUserId: config[effectiveId]?.ownerUserId ?? userId ?? null,
2862
3204
  };
2863
3205
 
2864
3206
  if (displayName) {
2865
- config[projectName].displayName = displayName;
3207
+ config[effectiveId].displayName = displayName;
2866
3208
  }
2867
3209
  });
2868
3210
 
@@ -2875,10 +3217,10 @@ async function addProjectManually(projectPath, displayName = null, userId = null
2875
3217
  } catch (_) {}
2876
3218
 
2877
3219
  return {
2878
- name: projectName,
3220
+ name: effectiveId,
2879
3221
  path: absolutePath,
2880
3222
  fullPath: absolutePath,
2881
- displayName: displayName || await generateDisplayName(projectName, absolutePath),
3223
+ displayName: displayName || await generateDisplayName(effectiveId, absolutePath),
2882
3224
  isManuallyAdded: true,
2883
3225
  createdAt: dirCreatedAt,
2884
3226
  sessions: [],
@@ -3538,27 +3880,46 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
3538
3880
 
3539
3881
  const messages = [];
3540
3882
  let tokenUsage = null;
3883
+ const toolUseIndexByCallId = new Map();
3884
+ const toolResultIndexByCallId = new Map();
3541
3885
  const fileStream = fsSync.createReadStream(sessionFilePath);
3542
3886
  const rl = readline.createInterface({
3543
3887
  input: fileStream,
3544
3888
  crlfDelay: Infinity
3545
3889
  });
3546
3890
 
3547
- // Helper to extract text from Codex content array
3548
- const extractText = (content) => {
3549
- if (!Array.isArray(content)) return content;
3550
- return content
3551
- .map(item => {
3552
- if (item.type === 'input_text' || item.type === 'output_text') {
3553
- return item.text;
3554
- }
3555
- if (item.type === 'text') {
3556
- return item.text;
3557
- }
3558
- return '';
3559
- })
3560
- .filter(Boolean)
3561
- .join('\n');
3891
+ const upsertToolUse = (nextMessage) => {
3892
+ const callId = nextMessage?.toolCallId;
3893
+ if (callId && toolUseIndexByCallId.has(callId)) {
3894
+ const existingIndex = toolUseIndexByCallId.get(callId);
3895
+ messages[existingIndex] = {
3896
+ ...messages[existingIndex],
3897
+ ...nextMessage,
3898
+ };
3899
+ return;
3900
+ }
3901
+
3902
+ const nextIndex = messages.push(nextMessage) - 1;
3903
+ if (callId) {
3904
+ toolUseIndexByCallId.set(callId, nextIndex);
3905
+ }
3906
+ };
3907
+
3908
+ const upsertToolResult = (nextMessage) => {
3909
+ const callId = nextMessage?.toolCallId;
3910
+ if (callId && toolResultIndexByCallId.has(callId)) {
3911
+ const existingIndex = toolResultIndexByCallId.get(callId);
3912
+ messages[existingIndex] = {
3913
+ ...messages[existingIndex],
3914
+ ...nextMessage,
3915
+ };
3916
+ return;
3917
+ }
3918
+
3919
+ const nextIndex = messages.push(nextMessage) - 1;
3920
+ if (callId) {
3921
+ toolResultIndexByCallId.set(callId, nextIndex);
3922
+ }
3562
3923
  };
3563
3924
 
3564
3925
  for await (const line of rl) {
@@ -3581,7 +3942,7 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
3581
3942
  if (entry.type === 'response_item' && entry.payload?.type === 'message') {
3582
3943
  const content = entry.payload.content;
3583
3944
  const role = entry.payload.role || 'assistant';
3584
- const textContent = extractText(content);
3945
+ const textContent = extractTextContent(content);
3585
3946
 
3586
3947
  // Skip system context messages (environment_context)
3587
3948
  if (textContent?.includes('<environment_context>')) {
@@ -3609,91 +3970,115 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
3609
3970
  // Skip Codex reasoning items - they are brief status notes, not useful to display
3610
3971
 
3611
3972
  if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
3612
- let toolName = entry.payload.name;
3613
- let toolInput = entry.payload.arguments;
3614
-
3615
- // Map Codex tool names to Claude equivalents
3616
- if (toolName === 'shell_command') {
3617
- toolName = 'Bash';
3618
- try {
3619
- const args = JSON.parse(entry.payload.arguments);
3620
- toolInput = JSON.stringify({ command: args.command });
3621
- } catch (e) {
3622
- // Keep original if parsing fails
3623
- }
3973
+ const normalized = normalizeCodexFunctionCall(entry.payload.name, entry.payload.arguments);
3974
+ if (!normalized.skip) {
3975
+ upsertToolUse(buildToolUseMessage(
3976
+ entry.timestamp,
3977
+ normalized.toolName,
3978
+ normalized.toolInput,
3979
+ entry.payload.call_id,
3980
+ ));
3624
3981
  }
3625
-
3626
- messages.push({
3627
- type: 'tool_use',
3628
- timestamp: entry.timestamp,
3629
- toolName: toolName,
3630
- toolInput: toolInput,
3631
- toolCallId: entry.payload.call_id
3632
- });
3633
3982
  }
3634
3983
 
3635
3984
  if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
3985
+ upsertToolResult(buildToolResultMessage(
3986
+ entry.timestamp,
3987
+ entry.payload.call_id,
3988
+ entry.payload.output,
3989
+ ));
3990
+ }
3991
+
3992
+ if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
3993
+ const normalized = normalizeCodexCustomToolCall(entry.payload.name, entry.payload.input);
3994
+ upsertToolUse(buildToolUseMessage(
3995
+ entry.timestamp,
3996
+ normalized.toolName,
3997
+ normalized.toolInput,
3998
+ entry.payload.call_id,
3999
+ ));
4000
+ }
4001
+
4002
+ if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
4003
+ upsertToolResult(buildToolResultMessage(
4004
+ entry.timestamp,
4005
+ entry.payload.call_id,
4006
+ entry.payload.output || '',
4007
+ ));
4008
+ }
4009
+
4010
+ // web_search_call events are self-contained display items — no separate result
4011
+ // event and no call_id in the payload. We push directly instead of upsertToolUse
4012
+ // because there is no toolCallId to match against a future tool_result.
4013
+ if (entry.type === 'response_item' && entry.payload?.type === 'web_search_call') {
4014
+ const normalized = normalizeWebSearchCall(entry.payload);
3636
4015
  messages.push({
3637
- type: 'tool_result',
4016
+ type: 'tool_use',
3638
4017
  timestamp: entry.timestamp,
3639
- toolCallId: entry.payload.call_id,
3640
- output: entry.payload.output
4018
+ toolName: normalized.toolName,
4019
+ toolInput: normalized.toolInput,
3641
4020
  });
3642
4021
  }
3643
4022
 
3644
- if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
3645
- const toolName = entry.payload.name || 'custom_tool';
3646
- const input = entry.payload.input || '';
3647
-
3648
- if (toolName === 'apply_patch') {
3649
- // Parse Codex patch format and convert to Claude Edit format
3650
- const fileMatch = input.match(/\*\*\* Update File: (.+)/);
3651
- const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
3652
-
3653
- // Extract old and new content from patch
3654
- const lines = input.split('\n');
3655
- const oldLines = [];
3656
- const newLines = [];
3657
-
3658
- for (const line of lines) {
3659
- if (line.startsWith('-') && !line.startsWith('---')) {
3660
- oldLines.push(line.substring(1));
3661
- } else if (line.startsWith('+') && !line.startsWith('+++')) {
3662
- newLines.push(line.substring(1));
3663
- }
4023
+ if (entry.type === 'event_msg' && entry.payload?.type === 'exec_command_end' && entry.payload?.call_id) {
4024
+ const toolUseIndex = toolUseIndexByCallId.get(entry.payload.call_id);
4025
+ const existingToolUse = toolUseIndex !== undefined ? messages[toolUseIndex] : null;
4026
+ let fallbackInput = null;
4027
+ if (existingToolUse?.toolInput) {
4028
+ try {
4029
+ fallbackInput = JSON.parse(existingToolUse.toolInput);
4030
+ } catch {
4031
+ fallbackInput = null;
3664
4032
  }
4033
+ }
4034
+ const normalized = normalizeExecCommandEvent(entry.payload, fallbackInput);
4035
+
4036
+ upsertToolUse(buildToolUseMessage(
4037
+ entry.timestamp,
4038
+ normalized.toolName,
4039
+ normalized.toolInput,
4040
+ entry.payload.call_id,
4041
+ ));
4042
+ upsertToolResult(buildToolResultMessage(
4043
+ entry.timestamp,
4044
+ entry.payload.call_id,
4045
+ normalized.toolResult?.content || '',
4046
+ {
4047
+ isError: normalized.toolResult?.isError === true,
4048
+ toolUseResult: normalized.toolResult?.toolUseResult || null,
4049
+ },
4050
+ ));
4051
+ }
3665
4052
 
4053
+ if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end' && entry.payload?.call_id) {
4054
+ const normalized = normalizePatchApplyEvent(entry.payload);
4055
+ upsertToolResult(buildToolResultMessage(
4056
+ entry.timestamp,
4057
+ entry.payload.call_id,
4058
+ normalized.toolResult.content,
4059
+ {
4060
+ isError: normalized.toolResult.isError === true,
4061
+ toolUseResult: normalized.toolResult.toolUseResult,
4062
+ },
4063
+ ));
4064
+
4065
+ if (normalized.fileChangesToolInput) {
3666
4066
  messages.push({
3667
4067
  type: 'tool_use',
3668
4068
  timestamp: entry.timestamp,
3669
- toolName: 'Edit',
3670
- toolInput: JSON.stringify({
3671
- file_path: filePath,
3672
- old_string: oldLines.join('\n'),
3673
- new_string: newLines.join('\n')
3674
- }),
3675
- toolCallId: entry.payload.call_id
4069
+ toolName: 'FileChanges',
4070
+ toolInput: normalized.fileChangesToolInput,
4071
+ toolCallId: `${entry.payload.call_id}:files`,
3676
4072
  });
3677
- } else {
3678
4073
  messages.push({
3679
- type: 'tool_use',
4074
+ type: 'tool_result',
3680
4075
  timestamp: entry.timestamp,
3681
- toolName: toolName,
3682
- toolInput: input,
3683
- toolCallId: entry.payload.call_id
4076
+ toolCallId: `${entry.payload.call_id}:files`,
4077
+ output: entry.payload.status || 'completed',
3684
4078
  });
3685
4079
  }
3686
4080
  }
3687
4081
 
3688
- if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
3689
- messages.push({
3690
- type: 'tool_result',
3691
- timestamp: entry.timestamp,
3692
- toolCallId: entry.payload.call_id,
3693
- output: entry.payload.output || ''
3694
- });
3695
- }
3696
-
3697
4082
  } catch (parseError) {
3698
4083
  // Skip malformed lines
3699
4084
  }
@@ -3845,6 +4230,26 @@ async function renameSession(projectName, sessionId, newSummary, provider = 'cla
3845
4230
  console.error(`[Gemini] Failed to rename session ${sessionId}:`, e.message);
3846
4231
  throw new Error(`Failed to rename Gemini session: ${e.message}`);
3847
4232
  }
4233
+ } else if (String(provider || '').toLowerCase() === 'nano') {
4234
+ if (!safeNanoSessionFilename(sessionId)) {
4235
+ throw new Error('Invalid Nano session id');
4236
+ }
4237
+ const nanoPath = await findNanoSessionStoragePath(projectName, sessionId);
4238
+ if (!nanoPath) {
4239
+ throw new Error(`Nano session ${sessionId} file not found`);
4240
+ }
4241
+ try {
4242
+ const raw = await fs.readFile(nanoPath, 'utf-8');
4243
+ const data = JSON.parse(raw);
4244
+ data.title = trimmedSummary;
4245
+ await fs.writeFile(nanoPath, JSON.stringify(data, null, 2), 'utf-8');
4246
+ sessionDb.updateSessionName(sessionId, trimmedSummary);
4247
+ console.log(`[Nano] Renamed session ${sessionId} to "${trimmedSummary}"`);
4248
+ return true;
4249
+ } catch (e) {
4250
+ console.error(`[Nano] Failed to rename session ${sessionId}:`, e.message);
4251
+ throw new Error(`Failed to rename Nano session: ${e.message}`);
4252
+ }
3848
4253
  }
3849
4254
  // 2. Handle Cursor sessions (SQLite)
3850
4255
  else if (provider === 'cursor') {
@@ -3945,6 +4350,7 @@ export {
3945
4350
  getTrashedProjects,
3946
4351
  getSessions,
3947
4352
  getSessionMessages,
4353
+ collectCodexProjectCandidates,
3948
4354
  parseJsonlSessions,
3949
4355
  renameProject,
3950
4356
  renameSession,