mouaif 0.3.0

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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
package/src/tags.js ADDED
@@ -0,0 +1,414 @@
1
+ 'use strict';
2
+
3
+ // File tagging — annotate project files and inject them into a chat.
4
+ //
5
+ // Implements docs/decisions.md §15 and docs/features/file-tagging.md.
6
+ // Tags live in `<projectDir>/.mouaif.json` under a top-level `tags` key so
7
+ // they can be committed with the project and edited by hand.
8
+ //
9
+ // Schema (inside project.tags):
10
+ // {
11
+ // "<relPath>": {
12
+ // tags: ['api', 'auth'], // free-form chip list
13
+ // excerpt: { start, end } | null, // 1-indexed inclusive; null = whole file
14
+ // includeInChat: true, // auto-inject on send (default true)
15
+ // note?: 'exceeds fileTagMaxBytes' // set by the size-cap guard
16
+ // },
17
+ // ...
18
+ // }
19
+ //
20
+ // Public surface:
21
+ // getTags(projectDir) -> { "<relPath>": entry, ... }
22
+ // setTags(projectDir, map) -> normalized map (persisted)
23
+ // removeTag(projectDir, relPath) -> boolean
24
+ // scanFiles(projectDir, exts?) -> [{ path, size, ext, binary }]
25
+ // resolveForInjection(projectDir, opts?) -> [{ role, content, relPath }]
26
+ //
27
+ // Path normalization: every relPath is stored POSIX-relative to the
28
+ // project root (`src/api/users.js`). The injection loader resolves with
29
+ // path.join and refuses anything that escapes the root (`..`, absolute,
30
+ // or a symlink pointing outside) with EOUTSIDE_PROJECT.
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { err } = require('./util.js');
35
+ const settings = require('./settings.js');
36
+
37
+ // ---- Constants ----------------------------------------------------------
38
+
39
+ // Default per-file soft cap. Files larger than this are not auto-injected
40
+ // unless the user pins a smaller excerpt. Overridable via app.fileTagMaxBytes.
41
+ const DEFAULT_MAX_BYTES = 256 * 1024;
42
+
43
+ // Text-friendly extension allowlist (docs/decisions.md §15). Anything
44
+ // outside this list is reported by the scan as `binary: true` and cannot
45
+ // be tagged through the UI.
46
+ const DEFAULT_EXTS = [
47
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json', '.json5', '.md',
48
+ '.txt', '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.h',
49
+ '.cpp', '.hpp', '.css', '.scss', '.less', '.html', '.vue', '.svelte',
50
+ '.astro', '.yml', '.yaml', '.toml', '.sh', '.sql', '.graphql', '.gql',
51
+ '.proto', '.ini', '.conf', '.cfg', '.env', '.lock', '.map', '.log',
52
+ '.lua', '.pl', '.pm', '.r', '.jl', '.dart', '.zig', '.ex', '.exs',
53
+ '.erl', '.hrl', '.fs', '.fsx', '.ml', '.clj', '.cljs', '.scala',
54
+ '.groovy', '.gradle', '.tf', '.hcl', '.pug', '.jade', '.ejs', '.hbs',
55
+ '.mustache', '.tpl', '.gitignore', '.gitattributes', '.editorconfig',
56
+ '.eslintrc', '.prettierrc', '.babelrc', '.npmrc', '.nvmrc', '.yarnrc',
57
+ '.dockerignore', '.gitmodules', '.htaccess', '.dockerfile', '.makefile'
58
+ ];
59
+
60
+ // Directories the scan never descends into. `.mouaif` holds the tag map
61
+ // itself (and traces) — scanning it would just show `.mouaif.json`.
62
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.mouaif', 'dist', 'build', '.next', '.cache']);
63
+
64
+ // ---- Path safety --------------------------------------------------------
65
+
66
+ // err() is shared from src/util.js.
67
+
68
+ // Normalize any incoming path to a POSIX-relative path under projectDir.
69
+ // Throws EOUTSIDE_PROJECT if it escapes the root. Does NOT touch disk.
70
+ function toRelPath(projectDir, p) {
71
+ if (typeof p !== 'string' || !p.trim()) {
72
+ throw err('EBADINPUT', 'path is required');
73
+ }
74
+ const rootResolved = path.resolve(projectDir);
75
+ // Accept both an already-relative path and an absolute path inside root.
76
+ const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(rootResolved, p);
77
+ const rel = path.relative(rootResolved, abs);
78
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
79
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: p });
80
+ }
81
+ // Store POSIX-style so the file round-trips across OSes.
82
+ return rel.split(path.sep).join('/');
83
+ }
84
+
85
+ // Resolve a stored relPath back to an absolute path, refusing escapes.
86
+ // Also checks that the realpath (following symlinks) stays inside root.
87
+ function toAbsInside(projectDir, relPath) {
88
+ const rootResolved = path.resolve(projectDir);
89
+ const abs = path.resolve(rootResolved, relPath);
90
+ const rel = path.relative(rootResolved, abs);
91
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
92
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: relPath });
93
+ }
94
+ // Symlink guard: if the file exists and its realpath leaves root, refuse.
95
+ try {
96
+ const real = fs.realpathSync(abs);
97
+ const realRel = path.relative(fs.realpathSync(rootResolved), real);
98
+ if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
99
+ throw err('EOUTSIDE_PROJECT', 'Symlink target escapes the project root', { path: relPath });
100
+ }
101
+ } catch (e) {
102
+ if (e.code === 'EOUTSIDE_PROJECT') throw e;
103
+ // ENOENT (stale path) is fine here — the caller handles missing files.
104
+ }
105
+ return abs;
106
+ }
107
+
108
+ // ---- Entry normalization ------------------------------------------------
109
+
110
+ function normalizeExcerpt(raw) {
111
+ if (!raw || typeof raw !== 'object') return null;
112
+ const start = Number(raw.start);
113
+ const end = Number(raw.end);
114
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
115
+ if (start < 1 || end < 1 || end < start) return null;
116
+ return { start, end };
117
+ }
118
+
119
+ function normalizeEntry(raw) {
120
+ if (!raw || typeof raw !== 'object') {
121
+ // A bare key with no value (e.g. a hand-edited `"src/a.js": true`) is
122
+ // not a meaningful entry — an empty tags list with includeInChat=true
123
+ // would silently tag nothing while telling the UI the file is tagged.
124
+ return { tags: [], excerpt: null, includeInChat: false };
125
+ }
126
+ const tags = Array.isArray(raw.tags)
127
+ ? raw.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim())
128
+ : [];
129
+ const entry = {
130
+ tags,
131
+ excerpt: normalizeExcerpt(raw.excerpt),
132
+ includeInChat: raw.includeInChat !== false
133
+ };
134
+ if (typeof raw.note === 'string' && raw.note) entry.note = raw.note;
135
+ return entry;
136
+ }
137
+
138
+ function normalizeMap(projectDir, raw) {
139
+ const out = {};
140
+ if (!raw || typeof raw !== 'object') return out;
141
+ for (const key of Object.keys(raw)) {
142
+ let rel;
143
+ try { rel = toRelPath(projectDir, key); }
144
+ catch { continue; } // drop entries that escape the root
145
+ out[rel] = normalizeEntry(raw[key]);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // ---- CRUD ---------------------------------------------------------------
151
+
152
+ function getTags(projectDir) {
153
+ const project = settings.getProject(projectDir);
154
+ return normalizeMap(projectDir, project && project.tags);
155
+ }
156
+
157
+ function setTags(projectDir, map) {
158
+ const normalized = normalizeMap(projectDir, map);
159
+ settings.setProject(projectDir, { tags: normalized });
160
+ return normalized;
161
+ }
162
+
163
+ function removeTag(projectDir, relPathOrAbs) {
164
+ const rel = toRelPath(projectDir, relPathOrAbs);
165
+ const project = settings.getProject(projectDir);
166
+ const current = normalizeMap(projectDir, project && project.tags);
167
+ if (!Object.prototype.hasOwnProperty.call(current, rel)) return false;
168
+ delete current[rel];
169
+ settings.setProject(projectDir, { tags: current });
170
+ return true;
171
+ }
172
+
173
+ // ---- Scan ---------------------------------------------------------------
174
+
175
+ // One-pass directory walk. Returns text-ish files with size + ext and a
176
+ // `binary` flag for out-of-allowlist extensions. Skips heavy build dirs
177
+ // and nested tooling dirs (node_modules, .git, dist, build, .next, .cache,
178
+ // .mouaif). Root-level dotfiles and dot-dirs (.env, .github) are included —
179
+ // they are config the user tags. Bounded by `limit` (default 5000) so a
180
+ // huge project does not hang the request.
181
+ function scanFiles(projectDir, exts, limit) {
182
+ const rootResolved = path.resolve(projectDir);
183
+ const allow = new Set((Array.isArray(exts) && exts.length ? exts : DEFAULT_EXTS)
184
+ .map(e => (e.startsWith('.') ? e : '.' + e).toLowerCase()));
185
+ const cap = Number.isInteger(limit) && limit > 0 ? limit : 5000;
186
+ const out = [];
187
+ const stack = [rootResolved];
188
+ while (stack.length && out.length < cap) {
189
+ const dir = stack.pop();
190
+ let names;
191
+ try { names = fs.readdirSync(dir, { withFileTypes: true }); }
192
+ catch { continue; }
193
+ for (const dirent of names) {
194
+ // Hidden entries at the root (`.github`, `.env`, `.config`) are
195
+ // usually config the user wants to tag, so they are NOT skipped
196
+ // here. Nested hidden dirs are almost always tooling cache
197
+ // (`.cache`, `.vite`); hidden files nested deeper are editor/
198
+ // tool noise. The one exception: the tag map itself,
199
+ // `.mouaif.json`, is skipped outright.
200
+ const isHidden = dirent.name.startsWith('.');
201
+ const full = path.join(dir, dirent.name);
202
+ if (dirent.isDirectory()) {
203
+ // Nested hidden dirs (deeper than the project root) are almost
204
+ // always tooling cache (`.cache`, `.vite`); the root-level ones
205
+ // (`.github`, `.config`) are config the user tags.
206
+ if (isHidden && dir !== rootResolved) continue;
207
+ if (SKIP_DIRS.has(dirent.name)) continue;
208
+ stack.push(full);
209
+ continue;
210
+ }
211
+ if (!dirent.isFile()) continue;
212
+ // The tag map itself must never appear as a taggable file.
213
+ if (dirent.name === '.mouaif.json') continue;
214
+ // Hidden files nested deeper than the root are tooling noise.
215
+ if (isHidden && dir !== rootResolved) continue;
216
+ const ext = path.extname(dirent.name).toLowerCase();
217
+ let size = 0;
218
+ try { size = fs.statSync(full).size; } catch { continue; }
219
+ // Binary filter. The allowlist check is against the extension
220
+ // (`app.vue` has ext `.vue`, `my.config.js` ext `.js`) — not the
221
+ // whole name, so a dot inside the base name never hides a file.
222
+ // A file is text when:
223
+ // - its extension is on the allowlist, OR
224
+ // - it has no extension and its name is either extensionless-but-
225
+ // -allowlisted (`Makefile`) or a leading-dot file whose suffix
226
+ // is allowlisted (`.env` -> `.env`, `.gitignore` -> `.gitignore`).
227
+ // Leading-dot names keep the dot in the extension key so the
228
+ // same allowlist covers `Dockerfile` and `.dockerignore`.
229
+ const isText = allow.has(ext)
230
+ || (ext === '' && allow.has('.' + dirent.name.toLowerCase()))
231
+ || (ext === '' && /^\.[A-Za-z0-9_-]+$/.test(dirent.name) && allow.has(dirent.name.toLowerCase()));
232
+ out.push({
233
+ path: path.relative(rootResolved, full).split(path.sep).join('/'),
234
+ size,
235
+ ext,
236
+ binary: !isText
237
+ });
238
+ if (out.length >= cap) break;
239
+ }
240
+ }
241
+ out.sort((a, b) => a.path.toLowerCase().localeCompare(b.path.toLowerCase()));
242
+ return out;
243
+ }
244
+
245
+ // ---- Injection ----------------------------------------------------------
246
+
247
+ function readExcerpt(body, excerpt) {
248
+ if (!excerpt) return body;
249
+ const lines = body.split(/\r?\n/);
250
+ // 1-indexed inclusive: [2,2] contains line 2.
251
+ const start = Math.max(1, excerpt.start);
252
+ const end = Math.min(lines.length, excerpt.end);
253
+ return lines.slice(start - 1, end).join('\n');
254
+ }
255
+
256
+ function excerptHeader(excerpt) {
257
+ if (!excerpt) return '';
258
+ return '# Excerpt: ' + excerpt.start + '-' + excerpt.end + '\n';
259
+ }
260
+
261
+ // Build one synthetic message for a tagged file. Returns null when the
262
+ // file is missing, oversized (and not excerpted), or excluded. The role
263
+ // defaults to 'system'; explicit @-referenced files are promoted to
264
+ // 'user' by the caller passing forceUserPaths.
265
+ function buildInjectedMessage(projectDir, relPath, entry, opts) {
266
+ const maxBytes = (opts && Number.isInteger(opts.maxBytes) && opts.maxBytes > 0)
267
+ ? opts.maxBytes : DEFAULT_MAX_BYTES;
268
+ const forced = opts && opts.forceUserPaths instanceof Set && opts.forceUserPaths.has(relPath);
269
+
270
+ let abs;
271
+ try { abs = toAbsInside(projectDir, relPath); }
272
+ catch { return null; } // escapes root — skip silently
273
+
274
+ let stat;
275
+ try { stat = fs.statSync(abs); }
276
+ catch { return null; } // stale / missing — skip at injection time
277
+
278
+ if (!stat.isFile()) return null;
279
+
280
+ // Size cap: oversized files are skipped unless the user pinned an
281
+ // excerpt (a smaller window bypasses the cap).
282
+ if (!entry.excerpt && stat.size > maxBytes) return null;
283
+
284
+ let body;
285
+ try { body = fs.readFileSync(abs, 'utf8'); }
286
+ catch { return null; }
287
+
288
+ const content =
289
+ '# File: ' + relPath + '\n' +
290
+ '# Tags: ' + (entry.tags.length ? entry.tags.join(', ') : '(none)') + '\n' +
291
+ excerptHeader(entry.excerpt) +
292
+ '\n' +
293
+ readExcerpt(body, entry.excerpt);
294
+
295
+ return { role: forced ? 'user' : 'system', content, relPath };
296
+ }
297
+
298
+ // Resolve every tagged file that should be injected for a chat send.
299
+ // - includeInChat === true entries are injected as `system`.
300
+ // - Any relPath listed in opts.referencedPaths is injected as `user`
301
+ // (the composer @-reference), regardless of includeInChat, so an
302
+ // explicit mention always wins.
303
+ // Returns an array of { role, content, relPath } in stable path order.
304
+ function resolveForInjection(projectDir, opts) {
305
+ const map = getTags(projectDir);
306
+ let maxBytes = DEFAULT_MAX_BYTES;
307
+ try {
308
+ const app = settings.getApp();
309
+ if (app && Number.isInteger(app.fileTagMaxBytes) && app.fileTagMaxBytes > 0) {
310
+ maxBytes = app.fileTagMaxBytes;
311
+ }
312
+ } catch { /* keep default */ }
313
+
314
+ const forceUserPaths = new Set();
315
+ if (opts && Array.isArray(opts.referencedPaths)) {
316
+ for (const p of opts.referencedPaths) {
317
+ try { forceUserPaths.add(toRelPath(projectDir, p)); }
318
+ catch { /* ignore bad @-reference */ }
319
+ }
320
+ }
321
+
322
+ const out = [];
323
+ const paths = Object.keys(map).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
324
+ for (const rel of paths) {
325
+ const entry = map[rel];
326
+ const referenced = forceUserPaths.has(rel);
327
+ if (!entry.includeInChat && !referenced) continue;
328
+ const msg = buildInjectedMessage(projectDir, rel, entry, { maxBytes, forceUserPaths });
329
+ if (msg) out.push(msg);
330
+ }
331
+ return out;
332
+ }
333
+
334
+ // Parse @<relPath> tokens out of a composer message. Returns the list of
335
+ // referenced relative paths (POSIX-normalized where possible; unparseable
336
+ // tokens are dropped). Used to promote a tagged file to a `user` message.
337
+ //
338
+ // Matching is relaxed on purpose: an exact `@src/api/users.js` works, and
339
+ // so does a bare basename `@users.js` when it uniquely identifies one
340
+ // file in the project. The basename is resolved against the tagged map
341
+ // (which is all the composer needs — an `@` mention is about files the
342
+ // user already tagged) and, as a fallback, the on-disk scan. Ambiguous
343
+ // basenames are resolved by the shortest path — the user attaches the
344
+ // file they can type — and dropped only when nothing at all matches.
345
+ function parseReferences(projectDir, text) {
346
+ if (typeof text !== 'string' || !text) return [];
347
+ const out = [];
348
+ // @ followed by a path-ish token (no whitespace). Stops at whitespace.
349
+ const re = /(?:^|\s)@([^\s]+)/g;
350
+ let bareBases = [];
351
+ let m;
352
+ while ((m = re.exec(text)) !== null) {
353
+ const raw = m[1];
354
+ // A slash means the user typed a path (`@src/api/users.js`) — resolve
355
+ // it exactly. A bare token (`@users.js`) is a basename hint; leave it
356
+ // for the resolution pass below so `@a.js` resolves to src/a.js
357
+ // instead of being rejected as an ambiguous literal path.
358
+ if (raw.indexOf('/') === -1) { bareBases.push(raw); continue; }
359
+ try { out.push(toRelPath(projectDir, raw)); }
360
+ catch { /* try basename resolution */ }
361
+ }
362
+ // Short-circuit: if there are no @-tokens at all (the common case), do
363
+ // not touch the disk — parseReferences runs on every chat send.
364
+ if (!out.length && !bareBases.length) return [];
365
+ // Second pass: bare basename references. `@users.js` resolves the file
366
+ // `src/api/users.js` when that is unambiguous.
367
+ const byBase = new Map(); // basename -> [relPath]
368
+ const tagged = (() => { try { return getTags(projectDir); } catch { return {}; } })();
369
+ // Union of the on-disk scan and the tagged map. The scan is the primary
370
+ // source (a mention can target any file in the project, tagged or not);
371
+ // the tagged map is merged in so a stale/missing tagged path still
372
+ // resolves even though the scan can no longer see it. scanFiles returns
373
+ // objects ({path,size,ext,binary}); map them to their path strings here
374
+ // so the byBase loop below sees the relPaths it expects.
375
+ const candidates = []
376
+ .concat((bareBases.length ? scanFiles(projectDir, null, 5000) : []).map(f => f.path))
377
+ .concat(Object.keys(tagged))
378
+ .filter(Boolean);
379
+ // Only resolve bare basenames that were actually mentioned; scanning
380
+ // is avoidable when every mention was already an exact path.
381
+ for (const rel of candidates) {
382
+ if (typeof rel !== 'string') continue;
383
+ const name = String(rel).split('/').pop() || '';
384
+ if (!name) continue;
385
+ if (!byBase.has(name)) byBase.set(name, []);
386
+ const arr = byBase.get(name);
387
+ if (!arr.includes(rel)) arr.push(rel);
388
+ }
389
+ for (const raw of bareBases) {
390
+ const base = String(raw).split('/').pop();
391
+ if (out.some((p) => p === base)) continue; // exact path already resolved
392
+ const hits = byBase.get(base) || [];
393
+ if (hits.length === 1) {
394
+ out.push(hits[0]);
395
+ } else if (hits.length > 1) {
396
+ // Shortest path wins for an ambiguous basename.
397
+ hits.sort((a, b) => a.length - b.length);
398
+ out.push(hits[0]);
399
+ }
400
+ }
401
+ return Array.from(new Set(out));
402
+ }
403
+
404
+ module.exports = {
405
+ DEFAULT_MAX_BYTES,
406
+ DEFAULT_EXTS,
407
+ toRelPath,
408
+ getTags,
409
+ setTags,
410
+ removeTag,
411
+ scanFiles,
412
+ resolveForInjection,
413
+ parseReferences
414
+ };
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ // Model-facing tool feedback compaction.
4
+ //
5
+ // Tool results have two consumers with different needs:
6
+ // - the chat UI / transcript needs the complete structured result;
7
+ // - the next model request needs a useful, bounded summary.
8
+ //
9
+ // This module only produces the second form. Callers keep the original result
10
+ // for SSE, persistence, traces, and UI rendering.
11
+
12
+ const DEFAULT_MAX_BYTES = 64 * 1024;
13
+ const MIN_MAX_BYTES = 4 * 1024;
14
+ const MAX_MAX_BYTES = 1024 * 1024;
15
+
16
+ // Tool output profile (per-project `toolOutput`). `size` picks how much of
17
+ // a result the model sees before the truncation marker; `structure` picks
18
+ // how the serialized body is laid out. Only defined values are honored —
19
+ // anything else falls back to the safe default (`average` / `full`).
20
+ const SIZE_MULTIPLIER = Object.freeze({
21
+ 'very-small': 0.25, // a quarter of the cap: head/tail only
22
+ average: 1, // the cap itself (default, 64 KiB)
23
+ full: 4, // four times the cap: bigger results pass through
24
+ extensive: Infinity // never truncate
25
+ });
26
+ // `structure` values:
27
+ // - `full` — raw body (kept for back-compat); generic tools use it.
28
+ // - `concise` — whitespace/JSON minification for generic tools.
29
+ // - `json` — file-tool results serialized as their structured JSON.
30
+ // - `tree` — file listings as an indented hierarchical tree (the
31
+ // default file-tool layout: every shared path prefix is
32
+ // printed once, files nested two spaces per depth level).
33
+ // The file-listing values only change how the native file tools render;
34
+ // for generic (shell / MCP) output they behave like `full`.
35
+ const STRUCTURES = Object.freeze(['full', 'concise', 'json', 'tree']);
36
+ const FILE_STRUCTURES = Object.freeze(['json', 'tree']);
37
+ const DEFAULT_SIZE = 'average';
38
+ const DEFAULT_STRUCTURE = 'tree';
39
+
40
+ function resolveToolOutput(raw) {
41
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
42
+ const size = Object.prototype.hasOwnProperty.call(SIZE_MULTIPLIER, o.size) ? o.size : DEFAULT_SIZE;
43
+ const structure = STRUCTURES.includes(o.structure) ? o.structure : DEFAULT_STRUCTURE;
44
+ return { size, structure };
45
+ }
46
+
47
+ // Effective byte cap for a size profile, against the configured base cap.
48
+ // `null` / `undefined` base uses the module default. `Infinity` (extensive)
49
+ // returns Infinity so the truncator passes the result through unchanged.
50
+ function effectiveMaxForSize(size, baseMaxBytes) {
51
+ const mult = SIZE_MULTIPLIER[size] === undefined ? 1 : SIZE_MULTIPLIER[size];
52
+ if (mult === Infinity) return Infinity;
53
+ const base = resolveMaxBytes(baseMaxBytes);
54
+ return Math.max(MIN_MAX_BYTES, Math.floor(base * mult));
55
+ }
56
+
57
+ function resolveMaxBytes(value) {
58
+ const n = Number(value);
59
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_MAX_BYTES;
60
+ return Math.max(MIN_MAX_BYTES, Math.min(MAX_MAX_BYTES, Math.floor(n)));
61
+ }
62
+
63
+ // Compact a JSON-ish string to its minimal single-line form, so the model
64
+ // pays once for keys/whitespace instead of re-reading an indented blob.
65
+ // Plain (non-JSON) text is returned with runs of blank lines collapsed.
66
+ function conciseLayout(text) {
67
+ const s = String(text == null ? '' : text);
68
+ if (!s.trim()) return s;
69
+ try {
70
+ const parsed = JSON.parse(s);
71
+ return JSON.stringify(parsed);
72
+ } catch { /* not JSON — fall through */ }
73
+ return s.replace(/\n[ \t]*\n+/g, '\n').replace(/^[ \t]+/gm, '').replace(/\s+$/gm, '');
74
+ }
75
+
76
+ function utf8Bytes(value) {
77
+ return Buffer.byteLength(String(value == null ? '' : value), 'utf8');
78
+ }
79
+
80
+ function utf8Prefix(buffer, bytes) {
81
+ if (bytes <= 0) return '';
82
+ let end = Math.min(buffer.length, bytes);
83
+ while (end > 0 && end < buffer.length && (buffer[end] & 0xC0) === 0x80) end--;
84
+ return buffer.subarray(0, end).toString('utf8');
85
+ }
86
+
87
+ function utf8Suffix(buffer, bytes) {
88
+ if (bytes <= 0) return '';
89
+ let start = Math.max(0, buffer.length - bytes);
90
+ while (start < buffer.length && (buffer[start] & 0xC0) === 0x80) start++;
91
+ return buffer.subarray(start).toString('utf8');
92
+ }
93
+
94
+ function truncateUtf8HeadTail(value, requestedMaxBytes) {
95
+ const text = String(value == null ? '' : value);
96
+ // Explicit `Infinity` means "no cap" (the `extensive` output size).
97
+ // resolveMaxBytes would clamp it to MAX_MAX_BYTES, losing the intent.
98
+ if (requestedMaxBytes === Infinity) return text;
99
+ const maxBytes = resolveMaxBytes(requestedMaxBytes);
100
+ const originalBytes = utf8Bytes(text);
101
+ if (originalBytes <= maxBytes) return text;
102
+
103
+ const raw = Buffer.from(text, 'utf8');
104
+ let marker = '\n\n...[tool feedback truncated; original ' + originalBytes + ' bytes]...\n\n';
105
+ let payloadBudget = Math.max(0, maxBytes - utf8Bytes(marker));
106
+ let headBudget = Math.floor(payloadBudget * 0.75);
107
+ let tailBudget = payloadBudget - headBudget;
108
+ let head = utf8Prefix(raw, headBudget);
109
+ let tail = utf8Suffix(raw, tailBudget);
110
+ let output = head + marker + tail;
111
+
112
+ // UTF-8 boundary cleanup and decimal metadata can shift the final size by a
113
+ // few bytes. Tighten the payload until the complete string fits the cap.
114
+ while (utf8Bytes(output) > maxBytes && (headBudget > 0 || tailBudget > 0)) {
115
+ if (headBudget >= tailBudget && headBudget > 0) headBudget--;
116
+ else if (tailBudget > 0) tailBudget--;
117
+ head = utf8Prefix(raw, headBudget);
118
+ tail = utf8Suffix(raw, tailBudget);
119
+ output = head + marker + tail;
120
+ }
121
+ if (utf8Bytes(output) <= maxBytes) return output;
122
+ marker = utf8Prefix(Buffer.from(marker, 'utf8'), maxBytes);
123
+ return marker;
124
+ }
125
+
126
+ function safeJson(value, fallback) {
127
+ try { return JSON.stringify(value); }
128
+ catch { return fallback == null ? '{}' : String(fallback); }
129
+ }
130
+
131
+ function omitImagePayloads(value, seen) {
132
+ if (!value || typeof value !== 'object') return value;
133
+ const visited = seen || new WeakSet();
134
+ if (visited.has(value)) return '[circular]';
135
+ visited.add(value);
136
+ if (Array.isArray(value)) return value.map((item) => omitImagePayloads(item, visited));
137
+
138
+ const out = {};
139
+ const isImage = value.type === 'image' || value.type === 'image_url';
140
+ for (const [key, item] of Object.entries(value)) {
141
+ if (isImage && (key === 'data' || key === 'base64' || key === 'dataUrl')) {
142
+ out[key] = '[image payload omitted; attached separately]';
143
+ } else if (isImage && key === 'url' && typeof item === 'string' && item.startsWith('data:image/')) {
144
+ out[key] = '[image payload omitted; attached separately]';
145
+ } else if (key === 'thumbnail' && typeof item === 'string' && item.startsWith('data:image/')) {
146
+ // webpreview keeps its screenshot on `result.thumbnail` for the UI rather
147
+ // than in a generic image block. Historical tool feedback must strip it
148
+ // too, otherwise the next model turn receives the user-only preview.
149
+ out[key] = '[user preview image omitted]';
150
+ } else {
151
+ out[key] = omitImagePayloads(item, visited);
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function subagentModelResult(result, content) {
158
+ let source = result;
159
+ if (!source || typeof source !== 'object') {
160
+ try { source = JSON.parse(typeof content === 'string' ? content : ''); }
161
+ catch { source = null; }
162
+ }
163
+ if (!source || typeof source !== 'object') return null;
164
+ const out = { ok: source.ok !== false, text: typeof source.text === 'string' ? source.text : '' };
165
+ if (source.error) {
166
+ out.ok = false;
167
+ out.error = {
168
+ code: source.error.code || 'ESUBAGENT',
169
+ message: source.error.message || String(source.error)
170
+ };
171
+ }
172
+ return out;
173
+ }
174
+
175
+ function compactToolFeedback(options) {
176
+ const opts = options || {};
177
+ const name = String(opts.name || 'tool');
178
+ let content = typeof opts.content === 'string' ? opts.content : safeJson(opts.content, '');
179
+
180
+ if (name === 'subagent') {
181
+ // A delegated run can carry the subagent's pictures on `result.content`
182
+ // (the subagent branch in src/ai-stream.js sets r.content =
183
+ // nestedImageParts). Those pixels are attached to the following vision
184
+ // message, so the model-facing tool text must never re-serialize them as
185
+ // base64. subagentModelResult already reduces the result to
186
+ // { ok, text, error } — dropping `content` entirely — and
187
+ // omitImagePayloads is applied defensively for any shape that slips
188
+ // through with an image block still attached.
189
+ const compact = subagentModelResult(opts.result, content);
190
+ if (compact) {
191
+ content = safeJson(compact, content);
192
+ } else if (opts.result && typeof opts.result === 'object' && Array.isArray(opts.result.content)) {
193
+ content = safeJson(omitImagePayloads(opts.result), content);
194
+ }
195
+ } else if (opts.result && typeof opts.result === 'object') {
196
+ // Images are already attached to the following vision message by ai.js.
197
+ // Avoid paying again for their base64 representation in role=tool text.
198
+ const sanitized = omitImagePayloads(opts.result);
199
+ const sanitizedJson = safeJson(sanitized, '');
200
+ if (sanitizedJson.includes('[image payload omitted; attached separately]')
201
+ || sanitizedJson.includes('[user preview image omitted]')) {
202
+ content = sanitizedJson;
203
+ }
204
+ }
205
+
206
+ // Apply the per-project tool output profile: `structure` before `size`,
207
+ // so a concise layout is compacted first and then bounded by the size
208
+ // budget. Defaults keep legacy behavior (full body, `average` cap).
209
+ const profile = resolveToolOutput(opts.toolOutput);
210
+ const layout = profile.structure === 'concise' ? conciseLayout(content) : content;
211
+ const maxBytes = effectiveMaxForSize(profile.size, opts.maxBytes);
212
+ return truncateUtf8HeadTail(layout, maxBytes);
213
+ }
214
+
215
+ module.exports = {
216
+ compactToolFeedback,
217
+ truncateUtf8HeadTail,
218
+ resolveMaxBytes,
219
+ effectiveMaxForSize,
220
+ resolveToolOutput,
221
+ FILE_STRUCTURES,
222
+ DEFAULT_MAX_BYTES,
223
+ MIN_MAX_BYTES,
224
+ MAX_MAX_BYTES
225
+ };