@noetic-tools/context 1.0.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 +201 -0
- package/README.md +28 -0
- package/dist/context/budget.d.ts +39 -0
- package/dist/context/budget.js +183 -0
- package/dist/context/exec-context-factory.d.ts +24 -0
- package/dist/context/exec-context-factory.js +30 -0
- package/dist/context/function-call-utils.d.ts +6 -0
- package/dist/context/function-call-utils.js +27 -0
- package/dist/context/layer-api.d.ts +11 -0
- package/dist/context/layer-api.js +91 -0
- package/dist/context/layer-lifecycle.d.ts +239 -0
- package/dist/context/layer-lifecycle.js +800 -0
- package/dist/context/layer-provides.d.ts +32 -0
- package/dist/context/layer-provides.js +33 -0
- package/dist/context/layer-usage.d.ts +13 -0
- package/dist/context/layer-usage.js +54 -0
- package/dist/context/layers/durable-task-state.d.ts +107 -0
- package/dist/context/layers/durable-task-state.js +250 -0
- package/dist/context/layers/file-reference.d.ts +55 -0
- package/dist/context/layers/file-reference.js +729 -0
- package/dist/context/layers/history-window.d.ts +31 -0
- package/dist/context/layers/history-window.js +150 -0
- package/dist/context/layers/observational-context.d.ts +56 -0
- package/dist/context/layers/observational-context.js +152 -0
- package/dist/context/layers/plan-context.d.ts +73 -0
- package/dist/context/layers/plan-context.js +749 -0
- package/dist/context/layers/static-content.d.ts +27 -0
- package/dist/context/layers/static-content.js +46 -0
- package/dist/context/layers/steering.d.ts +50 -0
- package/dist/context/layers/steering.js +291 -0
- package/dist/context/layers/temporal-context.d.ts +91 -0
- package/dist/context/layers/temporal-context.js +327 -0
- package/dist/context/layers/tool-context-layer.d.ts +15 -0
- package/dist/context/layers/tool-context-layer.js +43 -0
- package/dist/context/layers/working-memory-context.d.ts +46 -0
- package/dist/context/layers/working-memory-context.js +155 -0
- package/dist/context/projector.d.ts +19 -0
- package/dist/context/projector.js +89 -0
- package/dist/context/scope.d.ts +3 -0
- package/dist/context/scope.js +42 -0
- package/dist/context/storage-batch.d.ts +18 -0
- package/dist/context/storage-batch.js +35 -0
- package/dist/context/strip-unresolved.d.ts +13 -0
- package/dist/context/strip-unresolved.js +35 -0
- package/dist/deprecated.d.ts +43 -0
- package/dist/deprecated.js +28 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +31 -0
- package/package.json +63 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { createMessage, estimateTokens, Slot } from '@noetic-tools/types';
|
|
2
|
+
/** Default max file size: 1MB */
|
|
3
|
+
const DEFAULT_MAX_FILE_SIZE = 1024 * 1024;
|
|
4
|
+
/** Default allowed extensions */
|
|
5
|
+
const DEFAULT_ALLOWED_EXTENSIONS = [
|
|
6
|
+
'.ts',
|
|
7
|
+
'.tsx',
|
|
8
|
+
'.js',
|
|
9
|
+
'.jsx',
|
|
10
|
+
'.mjs',
|
|
11
|
+
'.cjs',
|
|
12
|
+
'.json',
|
|
13
|
+
'.yaml',
|
|
14
|
+
'.yml',
|
|
15
|
+
'.toml',
|
|
16
|
+
'.md',
|
|
17
|
+
'.mdx',
|
|
18
|
+
'.txt',
|
|
19
|
+
'.csv',
|
|
20
|
+
'.html',
|
|
21
|
+
'.css',
|
|
22
|
+
'.scss',
|
|
23
|
+
'.less',
|
|
24
|
+
'.py',
|
|
25
|
+
'.rb',
|
|
26
|
+
'.go',
|
|
27
|
+
'.rs',
|
|
28
|
+
'.java',
|
|
29
|
+
'.kt',
|
|
30
|
+
'.swift',
|
|
31
|
+
'.c',
|
|
32
|
+
'.cpp',
|
|
33
|
+
'.h',
|
|
34
|
+
'.hpp',
|
|
35
|
+
'.sh',
|
|
36
|
+
'.bash',
|
|
37
|
+
'.zsh',
|
|
38
|
+
'.sql',
|
|
39
|
+
'.graphql',
|
|
40
|
+
'.xml',
|
|
41
|
+
'.svg',
|
|
42
|
+
'.env',
|
|
43
|
+
'.env.local',
|
|
44
|
+
'.env.example',
|
|
45
|
+
'.gitignore',
|
|
46
|
+
'.dockerignore',
|
|
47
|
+
'Dockerfile',
|
|
48
|
+
'Makefile',
|
|
49
|
+
'Taskfile',
|
|
50
|
+
];
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region Helpers
|
|
53
|
+
// Pattern requires file-like structure: path with extension or explicit path separator
|
|
54
|
+
// Avoids matching #hashtag, #123, #region, etc.
|
|
55
|
+
const FILE_REF_PATTERN = /#((?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)/g;
|
|
56
|
+
function sanitizeAnchor(refPath) {
|
|
57
|
+
return refPath.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
function transformReferencesToAnchors(text) {
|
|
60
|
+
return text.replace(FILE_REF_PATTERN, (_match, filePath) => {
|
|
61
|
+
const anchor = sanitizeAnchor(filePath);
|
|
62
|
+
return `[#${filePath}](#${anchor})`;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function extractFileReferences(text) {
|
|
66
|
+
const refs = [];
|
|
67
|
+
const pattern = new RegExp(FILE_REF_PATTERN);
|
|
68
|
+
let match = pattern.exec(text);
|
|
69
|
+
while (match !== null) {
|
|
70
|
+
refs.push(match[1]);
|
|
71
|
+
match = pattern.exec(text);
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
...new Set(refs),
|
|
75
|
+
]; // Dedupe
|
|
76
|
+
}
|
|
77
|
+
function normalizePath(input) {
|
|
78
|
+
const raw = input.length > 0 ? input : '.';
|
|
79
|
+
const absolute = raw.startsWith('/') ? raw : `/${raw}`;
|
|
80
|
+
const parts = [];
|
|
81
|
+
for (const part of absolute.split('/')) {
|
|
82
|
+
if (part.length === 0 || part === '.') {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (part === '..') {
|
|
86
|
+
parts.pop();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
parts.push(part);
|
|
90
|
+
}
|
|
91
|
+
return `/${parts.join('/')}`;
|
|
92
|
+
}
|
|
93
|
+
function resolvePath(base, ref = '') {
|
|
94
|
+
return normalizePath(ref.startsWith('/') ? ref : `${base}/${ref}`);
|
|
95
|
+
}
|
|
96
|
+
/** Resolves a raw reference to the absolute path used for dedup/reads. */
|
|
97
|
+
function resolveRefPath(baseDir, ref) {
|
|
98
|
+
return isAbsolutePath(ref) ? ref : resolvePath(baseDir, ref);
|
|
99
|
+
}
|
|
100
|
+
function pathBasename(input) {
|
|
101
|
+
const normalized = normalizePath(input);
|
|
102
|
+
if (normalized === '/') {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
return normalized.slice(normalized.lastIndexOf('/') + 1);
|
|
106
|
+
}
|
|
107
|
+
function pathExtname(input) {
|
|
108
|
+
const name = pathBasename(input);
|
|
109
|
+
const idx = name.lastIndexOf('.');
|
|
110
|
+
return idx > 0 ? name.slice(idx) : '';
|
|
111
|
+
}
|
|
112
|
+
function isAbsolutePath(input) {
|
|
113
|
+
return input.startsWith('/');
|
|
114
|
+
}
|
|
115
|
+
function currentWorkingDirectory() {
|
|
116
|
+
return typeof process !== 'undefined' ? process.cwd() : '/';
|
|
117
|
+
}
|
|
118
|
+
function simpleHash(content) {
|
|
119
|
+
let hash = 0;
|
|
120
|
+
for (let i = 0; i < content.length; i++) {
|
|
121
|
+
const char = content.charCodeAt(i);
|
|
122
|
+
hash = (hash << 5) - hash + char;
|
|
123
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
124
|
+
}
|
|
125
|
+
// Include content length to reduce collision probability
|
|
126
|
+
return `${content.length}:${hash.toString(16)}`;
|
|
127
|
+
}
|
|
128
|
+
function isInputTextPart(value) {
|
|
129
|
+
return (typeof value === 'object' &&
|
|
130
|
+
value !== null &&
|
|
131
|
+
'type' in value &&
|
|
132
|
+
value.type === 'input_text' &&
|
|
133
|
+
'text' in value &&
|
|
134
|
+
typeof value.text === 'string');
|
|
135
|
+
}
|
|
136
|
+
function isInputMessage(item) {
|
|
137
|
+
return (item.type === 'message' &&
|
|
138
|
+
'role' in item &&
|
|
139
|
+
'content' in item &&
|
|
140
|
+
Array.isArray(item.content) &&
|
|
141
|
+
item.content.every(isInputTextPart));
|
|
142
|
+
}
|
|
143
|
+
function extractTextFromItem(item) {
|
|
144
|
+
if (!isInputMessage(item)) {
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
return item.content.map((c) => c.text).join('\n');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* lstat every path component of `target` strictly BELOW `base`; any symlink
|
|
151
|
+
* component (directory or leaf) is rejected. Lexical containment alone is not
|
|
152
|
+
* enough — `base/link → /etc` keeps the unresolved string inside `base` while
|
|
153
|
+
* the OS resolves the intermediate symlink, escaping the sandbox. `base`
|
|
154
|
+
* itself and its ancestors are deliberately never lstat'ed (macOS `/tmp` is a
|
|
155
|
+
* symlink). Uses the existing `FsAdapter.lstat` (no realpath — adding it
|
|
156
|
+
* would fan out to every adapter). The leaf's stats are returned so the
|
|
157
|
+
* caller can reuse them for the size check.
|
|
158
|
+
*/
|
|
159
|
+
async function walkRejectingSymlinks(base, target, fsAdapter) {
|
|
160
|
+
const relative = target === base ? '' : target.slice(base.length + 1);
|
|
161
|
+
const segments = relative.length > 0 ? relative.split('/') : [];
|
|
162
|
+
if (segments.length === 0) {
|
|
163
|
+
// Degenerate case (target === base): the base is trusted and never
|
|
164
|
+
// symlink-checked, so resolve through it with stat for the size check.
|
|
165
|
+
return {
|
|
166
|
+
leafStats: await fsAdapter.stat(target),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
let current = base;
|
|
170
|
+
let leafStats;
|
|
171
|
+
for (const segment of segments) {
|
|
172
|
+
current = `${current}/${segment}`;
|
|
173
|
+
const stats = await fsAdapter.lstat(current);
|
|
174
|
+
if (stats.isSymbolicLink()) {
|
|
175
|
+
return {
|
|
176
|
+
error: 'SYMLINK: Symlinks not allowed',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
leafStats = stats;
|
|
180
|
+
}
|
|
181
|
+
// `segments.length > 0` guarantees the loop assigned leafStats.
|
|
182
|
+
if (!leafStats) {
|
|
183
|
+
return {
|
|
184
|
+
error: 'SYMLINK: Symlinks not allowed',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
leafStats,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function readFileContent(absolutePath, opts, fsAdapter) {
|
|
192
|
+
try {
|
|
193
|
+
// Security: Validate path stays within baseDir (prevent path traversal)
|
|
194
|
+
const normalizedBase = resolvePath(opts.baseDir);
|
|
195
|
+
const normalizedPath = resolvePath(absolutePath);
|
|
196
|
+
if (!normalizedPath.startsWith(`${normalizedBase}/`) && normalizedPath !== normalizedBase) {
|
|
197
|
+
return {
|
|
198
|
+
content: null,
|
|
199
|
+
deleted: false,
|
|
200
|
+
error: 'PATH_TRAVERSAL: File outside allowed directory',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// Security: Check file extension
|
|
204
|
+
const ext = pathExtname(normalizedPath).toLowerCase();
|
|
205
|
+
const basename = pathBasename(normalizedPath);
|
|
206
|
+
const isAllowedExt = opts.allowedExtensions.some((allowed) => ext === allowed.toLowerCase() || basename === allowed);
|
|
207
|
+
if (!isAllowedExt && opts.allowedExtensions.length > 0) {
|
|
208
|
+
return {
|
|
209
|
+
content: null,
|
|
210
|
+
deleted: false,
|
|
211
|
+
error: `DISALLOWED_EXTENSION: ${ext || 'no extension'}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// Security: Reject symlinked path components (directory or leaf) unless
|
|
215
|
+
// the followSymlinks opt-out is enabled. The lexical containment check
|
|
216
|
+
// above is not enough on its own — `baseDir/link → /etc` keeps the
|
|
217
|
+
// unresolved string inside baseDir while the OS resolves the intermediate
|
|
218
|
+
// symlink, escaping the sandbox. The walk's leaf stats double as the
|
|
219
|
+
// size-check stats.
|
|
220
|
+
let fileStats;
|
|
221
|
+
if (opts.followSymlinks) {
|
|
222
|
+
fileStats = await fsAdapter.stat(absolutePath);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const walk = await walkRejectingSymlinks(normalizedBase, normalizedPath, fsAdapter);
|
|
226
|
+
if (walk.error !== undefined) {
|
|
227
|
+
return {
|
|
228
|
+
content: null,
|
|
229
|
+
deleted: false,
|
|
230
|
+
error: walk.error,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
fileStats = walk.leafStats;
|
|
234
|
+
}
|
|
235
|
+
// Security: Check file size
|
|
236
|
+
if (fileStats.size > opts.maxFileSize) {
|
|
237
|
+
return {
|
|
238
|
+
content: null,
|
|
239
|
+
deleted: false,
|
|
240
|
+
error: `FILE_TOO_LARGE: ${fileStats.size} bytes exceeds limit of ${opts.maxFileSize}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const content = await fsAdapter.readFileText(absolutePath);
|
|
244
|
+
return {
|
|
245
|
+
content,
|
|
246
|
+
deleted: false,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
if (isNodeError(e) && e.code === 'ENOENT') {
|
|
251
|
+
return {
|
|
252
|
+
content: null,
|
|
253
|
+
deleted: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (isNodeError(e) && e.code === 'EACCES') {
|
|
257
|
+
return {
|
|
258
|
+
content: null,
|
|
259
|
+
deleted: false,
|
|
260
|
+
error: 'PERMISSION_DENIED: Cannot read file',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function isNodeError(e) {
|
|
267
|
+
return e instanceof Error && 'code' in e;
|
|
268
|
+
}
|
|
269
|
+
function extractResponseText(items) {
|
|
270
|
+
const texts = [];
|
|
271
|
+
for (const item of items) {
|
|
272
|
+
if (item.type !== 'message') {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!('content' in item) || !Array.isArray(item.content)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
for (const part of item.content) {
|
|
279
|
+
if ('text' in part && typeof part.text === 'string') {
|
|
280
|
+
texts.push(part.text);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return texts.join('').trim();
|
|
285
|
+
}
|
|
286
|
+
async function scoreFileRelevance(params) {
|
|
287
|
+
const { filePath, fileContent, userQuery, ctx, model } = params;
|
|
288
|
+
if (!ctx.callModel) {
|
|
289
|
+
const queryLower = userQuery.toLowerCase();
|
|
290
|
+
const pathLower = filePath.toLowerCase();
|
|
291
|
+
if (pathLower.includes(queryLower) || queryLower.includes(pathBasename(pathLower))) {
|
|
292
|
+
return 80;
|
|
293
|
+
}
|
|
294
|
+
return 50;
|
|
295
|
+
}
|
|
296
|
+
const scoringPrompt = `Rate the relevance of this file to the user's query.
|
|
297
|
+
|
|
298
|
+
User Query: ${userQuery}
|
|
299
|
+
|
|
300
|
+
File Path: ${filePath}
|
|
301
|
+
File Content (first 2000 chars):
|
|
302
|
+
${fileContent.slice(0, 2e3)}
|
|
303
|
+
|
|
304
|
+
Respond with ONLY a number from 0-100 where:
|
|
305
|
+
- 0-20: Not relevant at all
|
|
306
|
+
- 21-40: Slightly relevant
|
|
307
|
+
- 41-60: Moderately relevant
|
|
308
|
+
- 61-80: Highly relevant
|
|
309
|
+
- 81-100: Critical/essential
|
|
310
|
+
|
|
311
|
+
Score:`;
|
|
312
|
+
try {
|
|
313
|
+
const response = await ctx.callModel({
|
|
314
|
+
model,
|
|
315
|
+
items: [
|
|
316
|
+
createMessage(scoringPrompt, 'user'),
|
|
317
|
+
],
|
|
318
|
+
instructions: 'You are a relevance scorer. Respond with only a number 0-100.',
|
|
319
|
+
});
|
|
320
|
+
const responseText = extractResponseText(response.items);
|
|
321
|
+
const score = Number.parseInt(responseText, 10);
|
|
322
|
+
if (Number.isNaN(score) || score < 0 || score > 100) {
|
|
323
|
+
return 50;
|
|
324
|
+
}
|
|
325
|
+
return score;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return 50;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function createFileReferenceRuntime(opts) {
|
|
332
|
+
const baseDir = opts?.baseDir ?? currentWorkingDirectory();
|
|
333
|
+
const maxFileSize = opts?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
334
|
+
const followSymlinks = opts?.followSymlinks ?? false;
|
|
335
|
+
const allowedExtensions = opts?.allowedExtensions ?? DEFAULT_ALLOWED_EXTENSIONS;
|
|
336
|
+
return {
|
|
337
|
+
baseDir,
|
|
338
|
+
slot: opts?.slot ?? Slot.RAG,
|
|
339
|
+
scoringModel: opts?.scoringModel ?? 'anthropic/claude-haiku-4-5-20251001',
|
|
340
|
+
readOpts: {
|
|
341
|
+
maxFileSize,
|
|
342
|
+
followSymlinks,
|
|
343
|
+
baseDir,
|
|
344
|
+
allowedExtensions,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async function initFileReferenceState(baseDir) {
|
|
349
|
+
return {
|
|
350
|
+
state: {
|
|
351
|
+
files: new Map(),
|
|
352
|
+
baseDir,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function absolutePathReference(ref) {
|
|
357
|
+
return {
|
|
358
|
+
absolutePath: ref,
|
|
359
|
+
referencePath: ref,
|
|
360
|
+
content: null,
|
|
361
|
+
priority: 0,
|
|
362
|
+
contentHash: '',
|
|
363
|
+
deleted: false,
|
|
364
|
+
tokenCount: 0,
|
|
365
|
+
error: 'ABSOLUTE_PATH: Only relative paths are allowed',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async function trackNewReference(args) {
|
|
369
|
+
if (isAbsolutePath(args.ref)) {
|
|
370
|
+
return absolutePathReference(args.ref);
|
|
371
|
+
}
|
|
372
|
+
const absolutePath = resolvePath(args.state.baseDir, args.ref);
|
|
373
|
+
const result = await readFileContent(absolutePath, args.readOpts, args.ctx.fs);
|
|
374
|
+
const priority = result.content
|
|
375
|
+
? await scoreFileRelevance({
|
|
376
|
+
filePath: args.ref,
|
|
377
|
+
fileContent: result.content,
|
|
378
|
+
userQuery: args.userQuery,
|
|
379
|
+
ctx: args.ctx,
|
|
380
|
+
model: args.scoringModel,
|
|
381
|
+
})
|
|
382
|
+
: 0;
|
|
383
|
+
return {
|
|
384
|
+
absolutePath,
|
|
385
|
+
referencePath: args.ref,
|
|
386
|
+
content: result.content,
|
|
387
|
+
priority,
|
|
388
|
+
contentHash: result.content ? simpleHash(result.content) : '',
|
|
389
|
+
deleted: result.deleted,
|
|
390
|
+
tokenCount: result.content ? estimateTokens(result.content) : 0,
|
|
391
|
+
error: result.error,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async function addNewReferences(args) {
|
|
395
|
+
// Dedupe by RESOLVED absolute path so `#config.json` and `#./config.json`
|
|
396
|
+
// (which point at the same file) only get tracked once.
|
|
397
|
+
const seenPaths = new Set();
|
|
398
|
+
for (const tracked of args.files.values()) {
|
|
399
|
+
seenPaths.add(tracked.absolutePath);
|
|
400
|
+
}
|
|
401
|
+
const newRefs = [];
|
|
402
|
+
for (const ref of args.refs) {
|
|
403
|
+
if (args.files.has(ref)) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const absolutePath = resolveRefPath(args.state.baseDir, ref);
|
|
407
|
+
if (seenPaths.has(absolutePath)) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
seenPaths.add(absolutePath);
|
|
411
|
+
newRefs.push(ref);
|
|
412
|
+
}
|
|
413
|
+
if (newRefs.length === 0) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
// Read + score every new reference in PARALLEL — k sequential LLM scoring
|
|
417
|
+
// calls would multiply wall time and blow the onItemAppend timeout. Results
|
|
418
|
+
// are inserted in deterministic ref order.
|
|
419
|
+
const tracked = await Promise.all(newRefs.map((ref) => trackNewReference({
|
|
420
|
+
...args,
|
|
421
|
+
ref,
|
|
422
|
+
})));
|
|
423
|
+
for (let i = 0; i < newRefs.length; i++) {
|
|
424
|
+
args.files.set(newRefs[i], tracked[i]);
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
function transformItemReferences(item, refs) {
|
|
429
|
+
if (refs.length === 0 || !isInputMessage(item)) {
|
|
430
|
+
return item;
|
|
431
|
+
}
|
|
432
|
+
const transformedContent = item.content.map((c) => ({
|
|
433
|
+
type: 'input_text',
|
|
434
|
+
text: transformReferencesToAnchors(c.text),
|
|
435
|
+
}));
|
|
436
|
+
return {
|
|
437
|
+
...item,
|
|
438
|
+
content: transformedContent,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
async function processAppendedItems(args) {
|
|
442
|
+
const transformedItems = [];
|
|
443
|
+
let hasChanges = false;
|
|
444
|
+
let userQuery = '';
|
|
445
|
+
for (const item of args.items) {
|
|
446
|
+
const text = extractTextFromItem(item);
|
|
447
|
+
if (!text) {
|
|
448
|
+
transformedItems.push(item);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (item.type === 'message' && 'role' in item && item.role === 'user') {
|
|
452
|
+
userQuery = text;
|
|
453
|
+
}
|
|
454
|
+
const refs = extractFileReferences(text);
|
|
455
|
+
const added = await addNewReferences({
|
|
456
|
+
refs,
|
|
457
|
+
files: args.files,
|
|
458
|
+
state: args.state,
|
|
459
|
+
readOpts: args.readOpts,
|
|
460
|
+
ctx: args.ctx,
|
|
461
|
+
userQuery,
|
|
462
|
+
scoringModel: args.scoringModel,
|
|
463
|
+
});
|
|
464
|
+
hasChanges = hasChanges || added;
|
|
465
|
+
transformedItems.push(transformItemReferences(item, refs));
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
transformedItems,
|
|
469
|
+
hasChanges,
|
|
470
|
+
userQuery,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
async function refreshTrackedFile(args) {
|
|
474
|
+
// NB: a previously-errored file is retried (no early bail) so it can recover
|
|
475
|
+
// once the underlying condition (too large / permission) is resolved.
|
|
476
|
+
const result = await readFileContent(args.tracked.absolutePath, args.readOpts, args.ctx.fs);
|
|
477
|
+
if (result.deleted && !args.tracked.deleted) {
|
|
478
|
+
return {
|
|
479
|
+
...args.tracked,
|
|
480
|
+
content: null,
|
|
481
|
+
deleted: true,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
if (result.error && !args.tracked.error) {
|
|
485
|
+
return {
|
|
486
|
+
...args.tracked,
|
|
487
|
+
content: null,
|
|
488
|
+
error: result.error,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (result.deleted || !result.content) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const newHash = simpleHash(result.content);
|
|
495
|
+
if (newHash === args.tracked.contentHash) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const priority = args.userQuery
|
|
499
|
+
? await scoreFileRelevance({
|
|
500
|
+
filePath: args.ref,
|
|
501
|
+
fileContent: result.content,
|
|
502
|
+
userQuery: args.userQuery,
|
|
503
|
+
ctx: args.ctx,
|
|
504
|
+
model: args.scoringModel,
|
|
505
|
+
})
|
|
506
|
+
: args.tracked.priority;
|
|
507
|
+
return {
|
|
508
|
+
...args.tracked,
|
|
509
|
+
content: result.content,
|
|
510
|
+
contentHash: newHash,
|
|
511
|
+
deleted: false,
|
|
512
|
+
tokenCount: estimateTokens(result.content),
|
|
513
|
+
priority,
|
|
514
|
+
error: undefined,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
async function refreshTrackedFiles(args) {
|
|
518
|
+
// Re-read (and possibly re-score) every tracked file in PARALLEL — see
|
|
519
|
+
// addNewReferences. Applied in deterministic map-iteration order.
|
|
520
|
+
const entries = [
|
|
521
|
+
...args.files.entries(),
|
|
522
|
+
];
|
|
523
|
+
const refreshed = await Promise.all(entries.map(([ref, tracked]) => refreshTrackedFile({
|
|
524
|
+
...args,
|
|
525
|
+
ref,
|
|
526
|
+
tracked,
|
|
527
|
+
})));
|
|
528
|
+
let hasChanges = false;
|
|
529
|
+
for (let i = 0; i < entries.length; i++) {
|
|
530
|
+
const next = refreshed[i];
|
|
531
|
+
if (next === null) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
args.files.set(entries[i][0], next);
|
|
535
|
+
hasChanges = true;
|
|
536
|
+
}
|
|
537
|
+
return hasChanges;
|
|
538
|
+
}
|
|
539
|
+
async function onFileReferenceItemAppend(args, runtime) {
|
|
540
|
+
const newFiles = new Map(args.state.files);
|
|
541
|
+
const currentReadOpts = {
|
|
542
|
+
...runtime.readOpts,
|
|
543
|
+
baseDir: args.state.baseDir,
|
|
544
|
+
};
|
|
545
|
+
const processed = await processAppendedItems({
|
|
546
|
+
items: args.items,
|
|
547
|
+
files: newFiles,
|
|
548
|
+
state: args.state,
|
|
549
|
+
readOpts: currentReadOpts,
|
|
550
|
+
ctx: args.ctx,
|
|
551
|
+
scoringModel: runtime.scoringModel,
|
|
552
|
+
});
|
|
553
|
+
const refreshed = await refreshTrackedFiles({
|
|
554
|
+
files: newFiles,
|
|
555
|
+
readOpts: currentReadOpts,
|
|
556
|
+
ctx: args.ctx,
|
|
557
|
+
userQuery: processed.userQuery,
|
|
558
|
+
scoringModel: runtime.scoringModel,
|
|
559
|
+
});
|
|
560
|
+
return {
|
|
561
|
+
items: processed.transformedItems,
|
|
562
|
+
state: {
|
|
563
|
+
...args.state,
|
|
564
|
+
files: newFiles,
|
|
565
|
+
},
|
|
566
|
+
rerender: processed.hasChanges || refreshed,
|
|
567
|
+
timing: 'immediate',
|
|
568
|
+
scope: 'self',
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function buildDeletedOrErrorBlock(file) {
|
|
572
|
+
if (file.deleted) {
|
|
573
|
+
return `## ${file.referencePath}\n\n[FILE DELETED: ${file.absolutePath}]`;
|
|
574
|
+
}
|
|
575
|
+
if (file.error) {
|
|
576
|
+
return `## ${file.referencePath}\n\n[ERROR: ${file.error}]`;
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
function truncationMarker(omittedLines) {
|
|
581
|
+
return `\n\n... [truncated ${omittedLines} lines] ...\n\n`;
|
|
582
|
+
}
|
|
583
|
+
/** Number of leading lines whose joined length fits within `charBudget`. */
|
|
584
|
+
function fitLeadingLines(lines, start, charBudget) {
|
|
585
|
+
let count = 0;
|
|
586
|
+
let used = 0;
|
|
587
|
+
for (let i = start; i < lines.length; i++) {
|
|
588
|
+
const next = used === 0 ? lines[i].length : lines[i].length + 1;
|
|
589
|
+
if (used + next > charBudget) {
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
used += next;
|
|
593
|
+
count++;
|
|
594
|
+
}
|
|
595
|
+
return count;
|
|
596
|
+
}
|
|
597
|
+
/** Number of trailing lines (not before `floor`) whose joined length fits. */
|
|
598
|
+
function fitTrailingLines(lines, floor, charBudget) {
|
|
599
|
+
let count = 0;
|
|
600
|
+
let used = 0;
|
|
601
|
+
for (let i = lines.length - 1; i >= floor; i--) {
|
|
602
|
+
const next = used === 0 ? lines[i].length : lines[i].length + 1;
|
|
603
|
+
if (used + next > charBudget) {
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
used += next;
|
|
607
|
+
count++;
|
|
608
|
+
}
|
|
609
|
+
return count;
|
|
610
|
+
}
|
|
611
|
+
// Truncates content so the rendered text honors `remainingBudget` tokens
|
|
612
|
+
// (estimateTokens ≈ chars / 4). The previous head(60%)+tail(30%) line split
|
|
613
|
+
// ignored the budget and kept ~90% of any file, blowing the budget wide open.
|
|
614
|
+
function truncateFileContent(file, remainingBudget) {
|
|
615
|
+
if (!file.content || file.tokenCount <= remainingBudget) {
|
|
616
|
+
return file.content ?? '';
|
|
617
|
+
}
|
|
618
|
+
const lines = file.content.split('\n');
|
|
619
|
+
const totalChars = Math.max(0, remainingBudget * 4);
|
|
620
|
+
// Reserve room for the marker (sized for the worst case of all lines omitted).
|
|
621
|
+
const contentChars = Math.max(0, totalChars - truncationMarker(lines.length).length);
|
|
622
|
+
const headBudget = Math.floor(contentChars * 0.6);
|
|
623
|
+
const headCount = fitLeadingLines(lines, 0, headBudget);
|
|
624
|
+
const tailBudget = contentChars - lines.slice(0, headCount).join('\n').length;
|
|
625
|
+
const tailCount = fitTrailingLines(lines, headCount, tailBudget);
|
|
626
|
+
const omitted = lines.length - headCount - tailCount;
|
|
627
|
+
const head = lines.slice(0, headCount).join('\n');
|
|
628
|
+
const tail = tailCount > 0 ? lines.slice(lines.length - tailCount).join('\n') : '';
|
|
629
|
+
if (omitted <= 0) {
|
|
630
|
+
return [
|
|
631
|
+
head,
|
|
632
|
+
tail,
|
|
633
|
+
]
|
|
634
|
+
.filter((s) => s.length > 0)
|
|
635
|
+
.join('\n');
|
|
636
|
+
}
|
|
637
|
+
return `${head}${truncationMarker(omitted)}${tail}`;
|
|
638
|
+
}
|
|
639
|
+
function buildContentBlock(file, totalTokens, budget) {
|
|
640
|
+
const statusBlock = buildDeletedOrErrorBlock(file);
|
|
641
|
+
if (statusBlock !== null) {
|
|
642
|
+
return totalTokens + estimateTokens(statusBlock) <= budget ? statusBlock : null;
|
|
643
|
+
}
|
|
644
|
+
if (!file.content) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const header = `## ${file.referencePath}`;
|
|
648
|
+
const headerTokens = estimateTokens(header);
|
|
649
|
+
if (totalTokens + headerTokens >= budget) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const remainingBudget = budget - totalTokens - headerTokens - 10;
|
|
653
|
+
const contentToInclude = truncateFileContent(file, remainingBudget);
|
|
654
|
+
return `${header}\n\n\`\`\`\n${contentToInclude}\n\`\`\``;
|
|
655
|
+
}
|
|
656
|
+
async function recallFileReferences({ state, budget, }) {
|
|
657
|
+
if (state.files.size === 0) {
|
|
658
|
+
return {
|
|
659
|
+
items: [],
|
|
660
|
+
tokenCount: 0,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const sortedFiles = [
|
|
664
|
+
...state.files.values(),
|
|
665
|
+
].sort((a, b) => b.priority - a.priority);
|
|
666
|
+
const blocks = [];
|
|
667
|
+
let totalTokens = 0;
|
|
668
|
+
for (const file of sortedFiles) {
|
|
669
|
+
const block = buildContentBlock(file, totalTokens, budget);
|
|
670
|
+
if (block === null) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const blockTokens = estimateTokens(block);
|
|
674
|
+
blocks.push(block);
|
|
675
|
+
totalTokens += blockTokens;
|
|
676
|
+
}
|
|
677
|
+
if (blocks.length === 0) {
|
|
678
|
+
return {
|
|
679
|
+
items: [],
|
|
680
|
+
tokenCount: 0,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const content = `# Referenced Files\n\n${blocks.join('\n\n')}`;
|
|
684
|
+
return {
|
|
685
|
+
items: [
|
|
686
|
+
createMessage(content, 'developer'),
|
|
687
|
+
],
|
|
688
|
+
tokenCount: estimateTokens(content),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region Layer Factory
|
|
693
|
+
/**
|
|
694
|
+
* Creates a context layer that tracks file references in user messages.
|
|
695
|
+
*
|
|
696
|
+
* Syntax: `#path/to/file` in user messages
|
|
697
|
+
*
|
|
698
|
+
* Behavior:
|
|
699
|
+
* - Transforms references to markdown anchor links `[#path/to/file](#path-to-file)`
|
|
700
|
+
* - Scores file relevance using LLM when first referenced
|
|
701
|
+
* - Injects file contents into context via recall(), ordered by priority
|
|
702
|
+
* - Detects file changes on each new message, triggers immediate re-render
|
|
703
|
+
* - Shows warning for deleted files
|
|
704
|
+
*
|
|
705
|
+
* @public
|
|
706
|
+
*/
|
|
707
|
+
export function fileReference(opts) {
|
|
708
|
+
const runtime = createFileReferenceRuntime(opts);
|
|
709
|
+
return {
|
|
710
|
+
id: 'file-reference',
|
|
711
|
+
name: 'File Reference',
|
|
712
|
+
slot: runtime.slot,
|
|
713
|
+
scope: 'thread',
|
|
714
|
+
budget: 'auto',
|
|
715
|
+
rerenderTiming: 'immediate',
|
|
716
|
+
timeouts: {
|
|
717
|
+
// onItemAppend reads files AND runs an LLM scoring call per new
|
|
718
|
+
// reference (parallelized) — the 5s pipeline default silently drops the
|
|
719
|
+
// transform + state update on timeout, killing the layer's feature.
|
|
720
|
+
onItemAppend: 30_000,
|
|
721
|
+
},
|
|
722
|
+
hooks: {
|
|
723
|
+
init: () => initFileReferenceState(runtime.baseDir),
|
|
724
|
+
onItemAppend: (args) => onFileReferenceItemAppend(args, runtime),
|
|
725
|
+
recall: recallFileReferences,
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
//#endregion
|