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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|