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,333 @@
1
+ /**
2
+ * Gemini OAuth Token Management
3
+ * ==============================
4
+ *
5
+ * Reads OAuth credentials from the Gemini CLI's token store (~/.gemini/oauth_creds.json),
6
+ * extracts client credentials from the Gemini CLI bundle for token refresh,
7
+ * and provides unified auth header resolution (OAuth → API key fallback).
8
+ *
9
+ * Modeled after OpenClaw's extensions/google/oauth.* modules.
10
+ */
11
+
12
+ import { promises as fs } from 'fs';
13
+ import { existsSync, readFileSync, realpathSync, readdirSync } from 'fs';
14
+ import path from 'path';
15
+ import os from 'os';
16
+ import { getGeminiApiKeyForUser } from './geminiApiKey.js';
17
+
18
+ const OAUTH_CREDS_PATH = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
19
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
20
+ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 minutes early
21
+
22
+ // Environment variable keys (same as OpenClaw)
23
+ const CLIENT_ID_KEYS = [
24
+ 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID',
25
+ 'GEMINI_CLI_OAUTH_CLIENT_ID',
26
+ 'VIBELAB_GEMINI_OAUTH_CLIENT_ID',
27
+ ];
28
+ const CLIENT_SECRET_KEYS = [
29
+ 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
30
+ 'GEMINI_CLI_OAUTH_CLIENT_SECRET',
31
+ 'VIBELAB_GEMINI_OAUTH_CLIENT_SECRET',
32
+ ];
33
+
34
+ let cachedClientCredentials = null;
35
+ let cachedOAuthTokens = null;
36
+ let cachedOAuthTokensMtime = 0;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // OAuth token loading & refresh
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Load raw OAuth credentials from ~/.gemini/oauth_creds.json
44
+ * Returns null if the file doesn't exist.
45
+ */
46
+ export async function loadGeminiOAuthTokens() {
47
+ try {
48
+ const stat = await fs.stat(OAUTH_CREDS_PATH);
49
+ const mtime = stat.mtimeMs;
50
+
51
+ // Use cache if file hasn't changed
52
+ if (cachedOAuthTokens && mtime === cachedOAuthTokensMtime) {
53
+ return cachedOAuthTokens;
54
+ }
55
+
56
+ const raw = await fs.readFile(OAUTH_CREDS_PATH, 'utf-8');
57
+ const tokens = JSON.parse(raw);
58
+ cachedOAuthTokens = tokens;
59
+ cachedOAuthTokensMtime = mtime;
60
+ return tokens;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Check if the OAuth access token is expired (with buffer).
68
+ */
69
+ export function isTokenExpired(tokens) {
70
+ if (!tokens?.expiry_date) return true;
71
+ return Date.now() >= (tokens.expiry_date - TOKEN_EXPIRY_BUFFER_MS);
72
+ }
73
+
74
+ /**
75
+ * Refresh the access token using the refresh_token.
76
+ * Writes updated credentials back to ~/.gemini/oauth_creds.json.
77
+ */
78
+ export async function refreshAccessToken(tokens) {
79
+ if (!tokens?.refresh_token) {
80
+ throw new Error('No refresh token available');
81
+ }
82
+
83
+ const clientConfig = resolveOAuthClientConfig();
84
+ if (!clientConfig) {
85
+ throw new Error('Cannot refresh token: Gemini CLI OAuth client credentials not found. Install Gemini CLI or set GEMINI_CLI_OAUTH_CLIENT_ID/SECRET.');
86
+ }
87
+
88
+ const body = new URLSearchParams({
89
+ client_id: clientConfig.clientId,
90
+ client_secret: clientConfig.clientSecret,
91
+ refresh_token: tokens.refresh_token,
92
+ grant_type: 'refresh_token',
93
+ });
94
+
95
+ const response = await fetch(TOKEN_URL, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
98
+ body: body.toString(),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ const errorText = await response.text();
103
+ throw new Error(`Token refresh failed (${response.status}): ${errorText}`);
104
+ }
105
+
106
+ const data = await response.json();
107
+
108
+ const updatedTokens = {
109
+ ...tokens,
110
+ access_token: data.access_token,
111
+ expiry_date: Date.now() + (data.expires_in * 1000),
112
+ ...(data.id_token ? { id_token: data.id_token } : {}),
113
+ ...(data.scope ? { scope: data.scope } : {}),
114
+ };
115
+
116
+ // Write back to file so Gemini CLI also benefits
117
+ try {
118
+ await fs.writeFile(OAUTH_CREDS_PATH, JSON.stringify(updatedTokens, null, 2), 'utf-8');
119
+ cachedOAuthTokens = updatedTokens;
120
+ cachedOAuthTokensMtime = Date.now();
121
+ console.log('[GeminiOAuth] Access token refreshed and saved.');
122
+ } catch (err) {
123
+ console.warn('[GeminiOAuth] Failed to persist refreshed token:', err.message);
124
+ // Still usable in-memory
125
+ cachedOAuthTokens = updatedTokens;
126
+ }
127
+
128
+ return updatedTokens;
129
+ }
130
+
131
+ /**
132
+ * Get a valid access token, refreshing if necessary.
133
+ * Returns null if no OAuth credentials are available.
134
+ */
135
+ export async function getValidAccessToken() {
136
+ let tokens = await loadGeminiOAuthTokens();
137
+ if (!tokens) return null;
138
+
139
+ if (!tokens.access_token || isTokenExpired(tokens)) {
140
+ if (!tokens.refresh_token) return null;
141
+ try {
142
+ tokens = await refreshAccessToken(tokens);
143
+ } catch (err) {
144
+ console.error('[GeminiOAuth] Token refresh failed:', err.message);
145
+ return null;
146
+ }
147
+ }
148
+
149
+ return tokens.access_token;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Auth header resolution (OAuth → API key fallback)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Returns { headers, authMethod } for authenticating Gemini API calls.
158
+ * Prefers API key (consistent with queryGeminiApi env-var check), falls back to OAuth.
159
+ */
160
+ export async function getGeminiAuthHeaders(userId) {
161
+ // Try API key first (matches queryGeminiApi precedence)
162
+ const apiKey = getGeminiApiKeyForUser(userId);
163
+ if (apiKey) {
164
+ return {
165
+ headers: { 'x-goog-api-key': apiKey },
166
+ authMethod: 'api-key',
167
+ };
168
+ }
169
+
170
+ // Fallback to OAuth
171
+ const accessToken = await getValidAccessToken();
172
+ if (accessToken) {
173
+ return {
174
+ headers: { 'Authorization': `Bearer ${accessToken}` },
175
+ authMethod: 'oauth',
176
+ };
177
+ }
178
+
179
+ return { headers: null, authMethod: null };
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Client credential extraction (from Gemini CLI bundle)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ function resolveEnvVar(keys) {
187
+ for (const key of keys) {
188
+ const value = process.env[key]?.trim();
189
+ if (value) return value;
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ /**
195
+ * Extract OAuth client_id and client_secret from the Gemini CLI installation.
196
+ * Same approach as OpenClaw: find the gemini binary, resolve it, search for oauth2.js.
197
+ */
198
+ export function extractGeminiCliCredentials() {
199
+ if (cachedClientCredentials) return cachedClientCredentials;
200
+
201
+ try {
202
+ const geminiPath = findInPath('gemini');
203
+ if (!geminiPath) return null;
204
+
205
+ const resolvedPath = realpathSync(geminiPath);
206
+ const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
207
+
208
+ for (const dir of geminiCliDirs) {
209
+ const creds = readFromKnownPaths(dir) || readFromBundle(dir) || findInTree(dir, 10);
210
+ if (creds) {
211
+ cachedClientCredentials = creds;
212
+ return creds;
213
+ }
214
+ }
215
+ } catch {
216
+ // Gemini CLI not installed
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /**
222
+ * Resolve client config: env vars → Gemini CLI extraction.
223
+ * Returns null if neither source provides credentials.
224
+ */
225
+ export function resolveOAuthClientConfig() {
226
+ const envClientId = resolveEnvVar(CLIENT_ID_KEYS);
227
+ const envClientSecret = resolveEnvVar(CLIENT_SECRET_KEYS);
228
+ if (envClientId && envClientSecret) {
229
+ return { clientId: envClientId, clientSecret: envClientSecret };
230
+ }
231
+
232
+ return extractGeminiCliCredentials();
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Helpers for finding credentials in Gemini CLI installation
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function findInPath(name) {
240
+ const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
241
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
242
+ for (const ext of exts) {
243
+ const p = path.join(dir, name + ext);
244
+ if (existsSync(p)) return p;
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ function resolveGeminiCliDirs(geminiPath, resolvedPath) {
251
+ const binDir = path.dirname(geminiPath);
252
+ const candidates = [
253
+ path.dirname(path.dirname(resolvedPath)),
254
+ path.join(path.dirname(resolvedPath), 'node_modules', '@google', 'gemini-cli'),
255
+ path.join(binDir, 'node_modules', '@google', 'gemini-cli'),
256
+ path.join(path.dirname(binDir), 'node_modules', '@google', 'gemini-cli'),
257
+ path.join(path.dirname(binDir), 'lib', 'node_modules', '@google', 'gemini-cli'),
258
+ ];
259
+
260
+ const seen = new Set();
261
+ return candidates.filter((dir) => {
262
+ const key = process.platform === 'win32' ? dir.replace(/\\/g, '/').toLowerCase() : dir;
263
+ if (seen.has(key)) return false;
264
+ seen.add(key);
265
+ return existsSync(path.join(dir, 'package.json')) ||
266
+ existsSync(path.join(dir, 'node_modules', '@google', 'gemini-cli-core'));
267
+ });
268
+ }
269
+
270
+ function parseCredentials(content) {
271
+ const clientId =
272
+ content.match(/OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/)?.[1] ??
273
+ content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/)?.[1];
274
+ const clientSecret =
275
+ content.match(/OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/)?.[1] ??
276
+ content.match(/(GOCSPX-[A-Za-z0-9_-]+)/)?.[1];
277
+ if (!clientId || !clientSecret) return null;
278
+ return { clientId, clientSecret };
279
+ }
280
+
281
+ function readCredFile(filePath) {
282
+ try {
283
+ return parseCredentials(readFileSync(filePath, 'utf8'));
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ function readFromKnownPaths(geminiCliDir) {
290
+ const searchPaths = [
291
+ path.join(geminiCliDir, 'node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'),
292
+ path.join(geminiCliDir, 'node_modules', '@google', 'gemini-cli-core', 'dist', 'code_assist', 'oauth2.js'),
293
+ ];
294
+ for (const p of searchPaths) {
295
+ if (existsSync(p)) {
296
+ const creds = readCredFile(p);
297
+ if (creds) return creds;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function readFromBundle(geminiCliDir) {
304
+ const bundleDir = path.join(geminiCliDir, 'bundle');
305
+ if (!existsSync(bundleDir)) return null;
306
+ try {
307
+ for (const entry of readdirSync(bundleDir, { withFileTypes: true })) {
308
+ if (entry.isFile() && entry.name.endsWith('.js')) {
309
+ const creds = readCredFile(path.join(bundleDir, entry.name));
310
+ if (creds) return creds;
311
+ }
312
+ }
313
+ } catch {}
314
+ return null;
315
+ }
316
+
317
+ function findInTree(dir, depth) {
318
+ if (depth <= 0) return null;
319
+ try {
320
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
321
+ const p = path.join(dir, entry.name);
322
+ if (entry.isFile() && entry.name === 'oauth2.js') {
323
+ const creds = readCredFile(p);
324
+ if (creds) return creds;
325
+ }
326
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
327
+ const found = findInTree(p, depth - 1);
328
+ if (found) return found;
329
+ }
330
+ }
331
+ } catch {}
332
+ return null;
333
+ }
@@ -0,0 +1,78 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { memoryDb } from '../database/db.js';
5
+
6
+ /**
7
+ * Sanitizes memory content before injecting into system prompts.
8
+ * Strips markdown headings that could confuse prompt structure.
9
+ */
10
+ function sanitizeContent(content) {
11
+ return content.replace(/^#+\s/gm, '').trim();
12
+ }
13
+
14
+ /**
15
+ * Builds a memory block string to inject into system prompts (Option A: web UI).
16
+ * Returns empty string if memory is disabled for this user or no enabled memories exist.
17
+ * @param {number} userId - The authenticated user's ID
18
+ * @returns {string} Formatted memory block or empty string
19
+ */
20
+ export function buildMemoryBlock(userId) {
21
+ if (!userId) return '';
22
+
23
+ if (!memoryDb.getMemoryEnabled(userId)) return '';
24
+
25
+ const memories = memoryDb.getEnabled(userId);
26
+ if (!memories || memories.length === 0) return '';
27
+
28
+ const lines = memories.map(m => `- ${sanitizeContent(m.content)}`).join('\n');
29
+ return `\n\n# User Memories\nThe following are things the user has asked you to remember. Incorporate them naturally into your responses where relevant:\n${lines}\n`;
30
+ }
31
+
32
+ /**
33
+ * Syncs enabled memories to a user-scoped reference file ~/.claude/MEMORY-{userId}.md.
34
+ * This file is for user reference only — Option A (web UI) injects memories via
35
+ * buildMemoryBlock() directly into system prompts and does not read this file.
36
+ * File is namespaced by userId to avoid multi-user conflicts on shared servers.
37
+ * @param {number} userId - The authenticated user's ID
38
+ */
39
+ export async function syncMemoryFiles(userId) {
40
+ if (!userId) return;
41
+
42
+ const claudeDir = path.join(os.homedir(), '.claude');
43
+
44
+ try {
45
+ await fs.mkdir(claudeDir, { recursive: true });
46
+ } catch {
47
+ // directory likely exists
48
+ }
49
+
50
+ const isEnabled = memoryDb.getMemoryEnabled(userId);
51
+ const memories = isEnabled ? memoryDb.getEnabled(userId) : [];
52
+
53
+ const lines = [
54
+ '# User Memories',
55
+ '',
56
+ '> This file is a read-only reference synced by Dr. Claw.',
57
+ '> To manage memories: use Dr. Claw Settings > Memory tab.',
58
+ '',
59
+ ];
60
+
61
+ if (!memories || memories.length === 0) {
62
+ lines.push('_No memories saved yet. Add them in Dr. Claw Settings > Memory._');
63
+ } else {
64
+ for (const m of memories) {
65
+ const tag = m.category && m.category !== 'general' ? ` [${m.category}]` : '';
66
+ lines.push(`- ${sanitizeContent(m.content)}${tag}`);
67
+ }
68
+ }
69
+
70
+ lines.push('');
71
+
72
+ const memoryMdPath = path.join(claudeDir, `MEMORY-${userId}.md`);
73
+ try {
74
+ await fs.writeFile(memoryMdPath, lines.join('\n'), 'utf-8');
75
+ } catch (err) {
76
+ console.warn('[memory-sync] Failed to write MEMORY file:', err.message);
77
+ }
78
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Safe path resolution that prevents directory traversal.
3
+ *
4
+ * Tool calls from LLM agents may contain crafted paths like "/etc/passwd"
5
+ * or "../../../etc/shadow". This module ensures every resolved path stays
6
+ * within the designated project root, blocking prompt-injection attacks
7
+ * that attempt to read or write files outside the workspace.
8
+ *
9
+ * Only the *logical* resolved path is checked (no `realpathSync`). This
10
+ * means legitimate symlinks inside the project that point outside the root
11
+ * (e.g. `data -> /mnt/storage/data`) still work, while `..` traversal and
12
+ * absolute-path escapes are caught.
13
+ */
14
+
15
+ import path from 'path';
16
+
17
+ /**
18
+ * Resolve `userPath` relative to `allowedRoot` and verify the result
19
+ * stays within `allowedRoot`.
20
+ *
21
+ * - Absolute paths that land outside the root are rejected.
22
+ * - Absolute paths inside the root are allowed (LLM may reference them).
23
+ * - `..` components that climb above the root are rejected.
24
+ * - Symlinks are NOT resolved — only the logical path is checked.
25
+ *
26
+ * @param {string} userPath Path supplied by the tool call.
27
+ * @param {string} allowedRoot Project root directory.
28
+ * @returns {string} The resolved, validated absolute path.
29
+ * @throws {Error} If the path escapes `allowedRoot`.
30
+ */
31
+ export function safePath(userPath, allowedRoot) {
32
+ const normalizedRoot = path.resolve(allowedRoot);
33
+
34
+ if (!userPath) return normalizedRoot;
35
+
36
+ // Resolve to absolute (works for both relative and absolute inputs).
37
+ // path.resolve normalises away any `.` and `..` segments.
38
+ const resolved = path.isAbsolute(userPath)
39
+ ? path.resolve(userPath)
40
+ : path.resolve(normalizedRoot, userPath);
41
+
42
+ // Ensure resolved path starts with root
43
+ if (resolved !== normalizedRoot && !resolved.startsWith(normalizedRoot + path.sep)) {
44
+ throw new Error(
45
+ `Path traversal blocked: "${userPath}" resolves to "${resolved}" ` +
46
+ `which is outside the project root "${normalizedRoot}".`
47
+ );
48
+ }
49
+
50
+ return resolved;
51
+ }
@@ -13,6 +13,8 @@ function defaultSessionName(provider) {
13
13
  return 'OpenRouter Session';
14
14
  case 'local':
15
15
  return 'Local GPU Session';
16
+ case 'nano':
17
+ return 'Nano Claude Code Session';
16
18
  default:
17
19
  return 'New Session';
18
20
  }
@@ -0,0 +1,142 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ /**
6
+ * Strip leading [Context: ...] prefixes the frontend injects for new sessions.
7
+ * Returns { prefix, body } so the prefix can be re-prepended after expansion.
8
+ */
9
+ function splitContextPrefix(text) {
10
+ const m = text.match(/^(\s*\[Context:[^\]]*\]\s*)+/i);
11
+ if (!m) return { prefix: '', body: text };
12
+ return { prefix: m[0], body: text.slice(m[0].length) };
13
+ }
14
+
15
+ /**
16
+ * Build the ordered list of directories to search for a skill SKILL.md.
17
+ */
18
+ function buildSkillSearchPaths(skillName, workingDir) {
19
+ return [
20
+ path.join(workingDir, '.agents', 'skills', skillName),
21
+ path.join(workingDir, '.claude', 'skills', skillName),
22
+ path.join(workingDir, '.gemini', 'skills', skillName),
23
+ path.join(process.cwd(), 'skills', skillName),
24
+ path.join(os.homedir(), '.claude', 'skills', skillName),
25
+ ];
26
+ }
27
+
28
+ /**
29
+ * Try to find the SKILL.md path for a given skill name.
30
+ * Returns the absolute path or null if not found.
31
+ */
32
+ async function findSkillMdPath(skillName, workingDir) {
33
+ const searchPaths = buildSkillSearchPaths(skillName, workingDir);
34
+ for (const skillDir of searchPaths) {
35
+ try {
36
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
37
+ const stat = await fs.stat(skillMdPath);
38
+ if (stat.isFile()) return skillMdPath;
39
+ } catch {
40
+ // not found, try next
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Read SKILL.md content for a given skill name.
48
+ */
49
+ async function readSkillMd(skillName, workingDir) {
50
+ const skillMdPath = await findSkillMdPath(skillName, workingDir);
51
+ if (!skillMdPath) return null;
52
+ try {
53
+ const content = await fs.readFile(skillMdPath, 'utf-8');
54
+ if (!content.trim()) return null;
55
+ return { content, path: skillMdPath };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Scan text for /skill-name references and return unique skill names found.
63
+ * Matches patterns like /aris-idea-discovery, /autoresearch:fix, etc.
64
+ * Skips matches that are clearly URLs (preceded by http:// or similar).
65
+ */
66
+ function findNestedSkillRefs(text) {
67
+ const refs = new Set();
68
+ const pattern = /(?<![a-zA-Z0-9:.])\/([a-zA-Z][a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)?)\b/g;
69
+ let m;
70
+ while ((m = pattern.exec(text)) !== null) {
71
+ refs.add(m[1]);
72
+ }
73
+ return [...refs];
74
+ }
75
+
76
+ /**
77
+ * Build a sub-skill lookup table: for each /skill-name referenced in the
78
+ * top-level SKILL.md, resolve its file path so the model can read it on demand.
79
+ * Only scans one level deep — the model reads sub-skills progressively.
80
+ */
81
+ async function buildSubSkillIndex(content, workingDir, topSkillName) {
82
+ const refs = findNestedSkillRefs(content);
83
+ const candidates = refs
84
+ .map((ref) => ({ ref, skillName: ref.includes(':') ? ref.split(':')[0] : ref }))
85
+ .filter(({ skillName }) => skillName !== topSkillName);
86
+
87
+ const results = await Promise.all(
88
+ candidates.map(async ({ ref, skillName }) => {
89
+ const skillMdPath = await findSkillMdPath(skillName, workingDir);
90
+ return skillMdPath ? { ref, path: skillMdPath } : null;
91
+ })
92
+ );
93
+
94
+ return results.filter(Boolean);
95
+ }
96
+
97
+ /**
98
+ * Resolve a `/skill-name` or `/skill-name:variant` slash command into the
99
+ * full SKILL.md content so non-Claude providers receive explicit instructions
100
+ * instead of an opaque slash command they cannot interpret.
101
+ *
102
+ * Sub-skill references within the expanded content are NOT inlined. Instead,
103
+ * a lookup table is appended so the model can read each sub-skill file on
104
+ * demand (progressive expansion), keeping the initial prompt small.
105
+ *
106
+ * Returns the expanded prompt string, or the original command unchanged if
107
+ * no matching skill is found.
108
+ */
109
+ export async function expandSkillCommand(command, workingDir) {
110
+ if (!command || typeof command !== 'string') return command;
111
+
112
+ const { prefix, body } = splitContextPrefix(command);
113
+
114
+ const match = body.match(/^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)?)\s*([\s\S]*)$/);
115
+ if (!match) return command;
116
+
117
+ const skillCommand = match[1];
118
+ const remainder = (match[2] || '').trim();
119
+
120
+ const skillName = skillCommand.includes(':') ? skillCommand.split(':')[0] : skillCommand;
121
+ const variant = skillCommand.includes(':') ? skillCommand.split(':').slice(1).join(':') : null;
122
+
123
+ const result = await readSkillMd(skillName, workingDir);
124
+ if (!result) return command;
125
+
126
+ console.log(`[SkillExpander] Expanded /${skillCommand} from ${result.path}`);
127
+ const variantNote = variant ? `\n\n**Variant requested:** \`${variant}\`\n` : '';
128
+ const userContext = remainder ? `\n\n**User context:**\n${remainder}` : '';
129
+
130
+ // Build a path index for sub-skills referenced in this SKILL.md
131
+ const subSkillIndex = await buildSubSkillIndex(result.content, workingDir, skillName);
132
+
133
+ let subSkillNote = '';
134
+ if (subSkillIndex.length > 0) {
135
+ const lines = subSkillIndex.map(({ ref, path: p }) => `- \`/${ref}\` → \`${p}\``).join('\n');
136
+ subSkillNote = `\n\n## Sub-Skill Loading\n\nThis procedure references other skills via \`/skill-name\`. You are NOT running inside Claude Code CLI, so slash commands are not available. Instead, when you reach a step that calls a sub-skill, **read its SKILL.md file** and follow those instructions inline.\n\nSub-skill file locations:\n${lines}\n\nExample: when the procedure says "run \`/aris-idea-discovery\`", do:\n1. Read the corresponding SKILL.md path listed above\n2. Follow the instructions in that file\n3. Continue with the next step in the parent procedure`;
137
+ }
138
+
139
+ return `${prefix}# Skill: ${skillCommand}\n\nFollow the procedure below exactly.\n${variantNote}\n${result.content}${userContext}${subSkillNote}`;
140
+ }
141
+
142
+ export { findSkillMdPath };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Safe child_process.spawn wrapper — shell: false, bounded output.
3
+ *
4
+ * When stdout exceeds maxBuffer the child is killed via SIGTERM. If the
5
+ * child is terminated by our SIGTERM (signal === 'SIGTERM'), the promise
6
+ * **resolves** with the truncated data and `truncated: true`. If the child
7
+ * exits non-zero for any other reason, the promise rejects normally.
8
+ */
9
+
10
+ import { spawn } from 'child_process';
11
+
12
+ /**
13
+ * @param {string} command
14
+ * @param {string[]} args
15
+ * @param {object} [options]
16
+ * @param {number} [options.maxBuffer=1048576] Max JS string length to keep (default ~1M chars)
17
+ * @returns {Promise<{stdout: string, stderr: string, truncated: boolean}>}
18
+ */
19
+ export default function spawnAsync(command, args, options = {}) {
20
+ const maxBuffer = options.maxBuffer || 1024 * 1024; // 1 MB default
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(command, args, {
23
+ ...options,
24
+ shell: false,
25
+ });
26
+
27
+ let stdout = '';
28
+ let stderr = '';
29
+ let truncated = false;
30
+
31
+ child.stdout.on('data', (data) => {
32
+ if (!truncated) {
33
+ stdout += data.toString();
34
+ if (stdout.length > maxBuffer) {
35
+ stdout = stdout.slice(0, maxBuffer);
36
+ // Trim the last partial line so callers never see a phantom entry
37
+ const lastNewline = stdout.lastIndexOf('\n');
38
+ if (lastNewline > 0) stdout = stdout.slice(0, lastNewline);
39
+ truncated = true;
40
+ child.kill('SIGTERM');
41
+ }
42
+ }
43
+ });
44
+
45
+ child.stderr.on('data', (data) => {
46
+ if (stderr.length < maxBuffer) {
47
+ stderr += data.toString();
48
+ if (stderr.length > maxBuffer) {
49
+ stderr = stderr.slice(0, maxBuffer);
50
+ }
51
+ }
52
+ });
53
+
54
+ child.on('error', (error) => {
55
+ reject(error);
56
+ });
57
+
58
+ child.on('close', (code, signal) => {
59
+ if (code === 0) {
60
+ resolve({ stdout, stderr, truncated });
61
+ return;
62
+ }
63
+
64
+ // We killed the child ourselves because stdout exceeded maxBuffer.
65
+ // Resolve with the truncated data so callers get partial results.
66
+ if (truncated && signal === 'SIGTERM') {
67
+ resolve({ stdout, stderr, truncated });
68
+ return;
69
+ }
70
+
71
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
72
+ error.code = code;
73
+ error.stdout = stdout;
74
+ error.stderr = stderr;
75
+ reject(error);
76
+ });
77
+ });
78
+ }