@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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/context/budget.d.ts +39 -0
  4. package/dist/context/budget.js +183 -0
  5. package/dist/context/exec-context-factory.d.ts +24 -0
  6. package/dist/context/exec-context-factory.js +30 -0
  7. package/dist/context/function-call-utils.d.ts +6 -0
  8. package/dist/context/function-call-utils.js +27 -0
  9. package/dist/context/layer-api.d.ts +11 -0
  10. package/dist/context/layer-api.js +91 -0
  11. package/dist/context/layer-lifecycle.d.ts +239 -0
  12. package/dist/context/layer-lifecycle.js +800 -0
  13. package/dist/context/layer-provides.d.ts +32 -0
  14. package/dist/context/layer-provides.js +33 -0
  15. package/dist/context/layer-usage.d.ts +13 -0
  16. package/dist/context/layer-usage.js +54 -0
  17. package/dist/context/layers/durable-task-state.d.ts +107 -0
  18. package/dist/context/layers/durable-task-state.js +250 -0
  19. package/dist/context/layers/file-reference.d.ts +55 -0
  20. package/dist/context/layers/file-reference.js +729 -0
  21. package/dist/context/layers/history-window.d.ts +31 -0
  22. package/dist/context/layers/history-window.js +150 -0
  23. package/dist/context/layers/observational-context.d.ts +56 -0
  24. package/dist/context/layers/observational-context.js +152 -0
  25. package/dist/context/layers/plan-context.d.ts +73 -0
  26. package/dist/context/layers/plan-context.js +749 -0
  27. package/dist/context/layers/static-content.d.ts +27 -0
  28. package/dist/context/layers/static-content.js +46 -0
  29. package/dist/context/layers/steering.d.ts +50 -0
  30. package/dist/context/layers/steering.js +291 -0
  31. package/dist/context/layers/temporal-context.d.ts +91 -0
  32. package/dist/context/layers/temporal-context.js +327 -0
  33. package/dist/context/layers/tool-context-layer.d.ts +15 -0
  34. package/dist/context/layers/tool-context-layer.js +43 -0
  35. package/dist/context/layers/working-memory-context.d.ts +46 -0
  36. package/dist/context/layers/working-memory-context.js +155 -0
  37. package/dist/context/projector.d.ts +19 -0
  38. package/dist/context/projector.js +89 -0
  39. package/dist/context/scope.d.ts +3 -0
  40. package/dist/context/scope.js +42 -0
  41. package/dist/context/storage-batch.d.ts +18 -0
  42. package/dist/context/storage-batch.js +35 -0
  43. package/dist/context/strip-unresolved.d.ts +13 -0
  44. package/dist/context/strip-unresolved.js +35 -0
  45. package/dist/deprecated.d.ts +43 -0
  46. package/dist/deprecated.js +28 -0
  47. package/dist/index.d.ts +29 -0
  48. package/dist/index.js +31 -0
  49. 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