@nexrall/code-core 1.4.63 → 1.4.66

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 (59) hide show
  1. package/README.md +2 -2
  2. package/dist/agent/agentRegistry.d.ts +14 -3
  3. package/dist/agent/agentRegistry.d.ts.map +1 -1
  4. package/dist/agent/agentRegistry.js +136 -6
  5. package/dist/agent/agentTypes.d.ts +53 -3
  6. package/dist/agent/agentTypes.d.ts.map +1 -1
  7. package/dist/agent/agentTypes.js +200 -153
  8. package/dist/agent/backgroundAgents.d.ts +66 -0
  9. package/dist/agent/backgroundAgents.d.ts.map +1 -0
  10. package/dist/agent/backgroundAgents.js +145 -0
  11. package/dist/agent/loop.d.ts +44 -7
  12. package/dist/agent/loop.d.ts.map +1 -1
  13. package/dist/agent/loop.js +946 -263
  14. package/dist/agent/memory.d.ts +11 -0
  15. package/dist/agent/memory.d.ts.map +1 -1
  16. package/dist/agent/memory.js +23 -3
  17. package/dist/agent/modelCatalogue.d.ts +15 -0
  18. package/dist/agent/modelCatalogue.d.ts.map +1 -1
  19. package/dist/agent/modelCatalogue.js +46 -0
  20. package/dist/agent/planMode.d.ts +14 -0
  21. package/dist/agent/planMode.d.ts.map +1 -1
  22. package/dist/agent/planMode.js +145 -17
  23. package/dist/agent/readDedupe.d.ts +15 -0
  24. package/dist/agent/readDedupe.d.ts.map +1 -0
  25. package/dist/agent/readDedupe.js +146 -0
  26. package/dist/agent/securityLint.js +2 -2
  27. package/dist/agent/toolPrefetch.d.ts +44 -0
  28. package/dist/agent/toolPrefetch.d.ts.map +1 -0
  29. package/dist/agent/toolPrefetch.js +101 -0
  30. package/dist/agent/trust.d.ts +0 -5
  31. package/dist/agent/trust.d.ts.map +1 -1
  32. package/dist/agent/trust.js +41 -0
  33. package/dist/api/client.d.ts +15 -1
  34. package/dist/api/client.d.ts.map +1 -1
  35. package/dist/api/client.js +88 -8
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/permissions/destructive.d.ts +9 -7
  40. package/dist/permissions/destructive.d.ts.map +1 -1
  41. package/dist/permissions/destructive.js +55 -8
  42. package/dist/permissions/destructiveTokens.d.ts +29 -0
  43. package/dist/permissions/destructiveTokens.d.ts.map +1 -0
  44. package/dist/permissions/destructiveTokens.js +469 -0
  45. package/dist/permissions/modePolicy.d.ts +22 -8
  46. package/dist/permissions/modePolicy.d.ts.map +1 -1
  47. package/dist/permissions/modePolicy.js +25 -10
  48. package/dist/tools/executor.d.ts +36 -0
  49. package/dist/tools/executor.d.ts.map +1 -1
  50. package/dist/tools/executor.js +408 -85
  51. package/dist/tools/tsLangService.d.ts.map +1 -1
  52. package/dist/tools/tsLangService.js +107 -21
  53. package/dist/types.d.ts +101 -7
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +12 -1
  56. package/dist/util/miniYaml.d.ts +10 -0
  57. package/dist/util/miniYaml.d.ts.map +1 -0
  58. package/dist/util/miniYaml.js +149 -0
  59. package/package.json +2 -10
@@ -48,6 +48,17 @@ export declare function memoryStats(scope: MemoryScope, workDir?: string): {
48
48
  entries: number;
49
49
  archived: number;
50
50
  };
51
+ /**
52
+ * Keep only the lines of a consolidation reply that are real memory entries.
53
+ *
54
+ * The prompt says "Output ONLY the bullet lines", but models do not always comply: a reply
55
+ * that opens with "I'll read the full, untruncated memory text before consolidating…" (seen
56
+ * in a user's global.md and a project file) used to be saved verbatim as an ACTIVE fact, then
57
+ * injected into every later session as ground truth. An entry is a line of the exact shape
58
+ * memory_write produces — `- [YYYY-MM-DD] …` — so anything else (preamble, "Here are the
59
+ * merged bullets:", a code fence, a trailing remark) is dropped rather than persisted.
60
+ */
61
+ export declare function extractConsolidatedEntries(reply: string): string[];
51
62
  /**
52
63
  * Summarize the OLDEST half of a memory file's ACTIVE entries into a few consolidated
53
64
  * bullets via an LLM call, keeping the most recent entries verbatim — same "compact,
@@ -1 +1 @@
1
- {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/agent/memory.ts"],"names":[],"mappings":"AAiEA,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE/C;;;sDAGsD;AACtD,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3E;AAED,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,eAAO,MAAM,gBAAgB,QAAS,CAAC;AACvC,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAMnD,eAAO,MAAM,qBAAqB,QAAmC,CAAC;AAKtE,eAAO,MAAM,wBAAwB,QAAmC,CAAC;AAoFzE,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;kCAI8B;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;6DAEyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAsB,WAAW,CAC/B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,EAClB,OAAO,CAAC,EAAE,MAAM,EAChB,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CA2D5B;AAED,MAAM,WAAW,iBAAiB;IAChC;;;iDAG6C;IAC7C,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,IAAI,GAAE,iBAAsB,GAAG,MAAM,CAKrG;AAED;gFACgF;AAChF,wBAAgB,aAAa,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,IAAI,GAAE,iBAAsB,GAAG,MAAM,CASpF;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAGtE;AAED,wBAAgB,WAAW,CACzB,KAAK,EAAE,WAAW,EAClB,OAAO,CAAC,EAAE,MAAM,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAUpE;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC7C,OAAO,CAAC,OAAO,CAAC,CAsDlB;AAkBD,uEAAuE;AACvE,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAC7C,mFAAmF;AACnF,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAErD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,EACnC,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,GAAG,IAAI,CAqBf;AAED,iEAAiE;AACjE,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,EACnC,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,CAIR;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,EACnC,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAyB1D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,EACnC,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,CAYR"}
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/agent/memory.ts"],"names":[],"mappings":"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"}
@@ -40,6 +40,7 @@ exports.readMemory = readMemory;
40
40
  exports.readAllMemory = readAllMemory;
41
41
  exports.clearMemory = clearMemory;
42
42
  exports.memoryStats = memoryStats;
43
+ exports.extractConsolidatedEntries = extractConsolidatedEntries;
43
44
  exports.compactMemoryIfNeeded = compactMemoryIfNeeded;
44
45
  exports.isSafeAgentName = isSafeAgentName;
45
46
  exports.agentMemoryPath = agentMemoryPath;
@@ -318,6 +319,22 @@ function memoryStats(scope, workDir) {
318
319
  archived: archived.filter((l) => /^-\s\[\d{4}-\d{2}-\d{2}\]/.test(l)).length,
319
320
  };
320
321
  }
322
+ /**
323
+ * Keep only the lines of a consolidation reply that are real memory entries.
324
+ *
325
+ * The prompt says "Output ONLY the bullet lines", but models do not always comply: a reply
326
+ * that opens with "I'll read the full, untruncated memory text before consolidating…" (seen
327
+ * in a user's global.md and a project file) used to be saved verbatim as an ACTIVE fact, then
328
+ * injected into every later session as ground truth. An entry is a line of the exact shape
329
+ * memory_write produces — `- [YYYY-MM-DD] …` — so anything else (preamble, "Here are the
330
+ * merged bullets:", a code fence, a trailing remark) is dropped rather than persisted.
331
+ */
332
+ function extractConsolidatedEntries(reply) {
333
+ return reply
334
+ .split('\n')
335
+ .map((l) => l.trimEnd())
336
+ .filter((l) => /^-\s\[\d{4}-\d{2}-\d{2}\]\s*\S/.test(l));
337
+ }
321
338
  /**
322
339
  * Summarize the OLDEST half of a memory file's ACTIVE entries into a few consolidated
323
340
  * bullets via an LLM call, keeping the most recent entries verbatim — same "compact,
@@ -370,9 +387,12 @@ async function compactMemoryIfNeeded(scope, workDir, summarize) {
370
387
  `draws from, and preserve any leading "[src: ...]" provenance tag verbatim when present. ` +
371
388
  `Output ONLY the bullet lines, nothing else.\n\n${older.join('\n')}`;
372
389
  try {
373
- const summarized = (await summarize(prompt)).trim();
374
- if (summarized) {
375
- active = [...summarized.split('\n').filter((l) => l.trim().length > 0), ...recent];
390
+ const summarized = extractConsolidatedEntries(await summarize(prompt));
391
+ // Zero valid bullets means the model answered in prose (or refused): treat it
392
+ // exactly like a failed call and keep the original entries. Writing the reply
393
+ // anyway would REPLACE the older half with chatter.
394
+ if (summarized.length > 0) {
395
+ active = [...summarized, ...recent];
376
396
  changed = true;
377
397
  }
378
398
  }
@@ -64,4 +64,19 @@ export declare function liveCostMultiplierFor(id: string | undefined, fallback:
64
64
  export declare function liveSelectableModelIds(): string[] | null;
65
65
  /** Test-only: reset the module-level cache between test cases. */
66
66
  export declare function _resetModelCatalogueCacheForTests(): void;
67
+ /**
68
+ * `model: cheapest` — Claude Code's Explore-on-Haiku idea, without leaving the
69
+ * provider the user chose. Resolves to the lowest-costMultiplier AVAILABLE model
70
+ * of the parent session's vendor (Opus → Sonnet, GPT-6 Sol → GPT-6 Luna, …), never
71
+ * a pricier one. Returns the parent model unchanged when the catalogue is not
72
+ * loaded or does not know it — inheriting is always a safe fallback; guessing a
73
+ * model on another provider (the bug that made explorer bill Anthropic for
74
+ * DeepSeek sessions) is not.
75
+ */
76
+ export declare const CHEAPEST_MODEL_ALIAS = "cheapest";
77
+ export declare function cheapestSameVendorModel(parentModel: string | undefined): string | undefined;
78
+ /** Resolve a sub-agent's `model:` against its parent's: undefined/inherit → parent, cheapest → see above. */
79
+ export declare function resolveSubAgentModel(agentModel: string | undefined, parentModel: string | undefined): string | undefined;
80
+ /** Test hook: install a catalogue without a network fetch. */
81
+ export declare function _setModelCatalogueForTest(models: CodeModelInfo[] | null): void;
67
82
  //# sourceMappingURL=modelCatalogue.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"modelCatalogue.d.ts","sourceRoot":"","sources":["../../src/agent/modelCatalogue.ts"],"names":[],"mappings":"AAgDA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAiBlE;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7B,OAAO,CAAC;IAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAuBnE;AAED,iFAAiF;AACjF,wBAAgB,uBAAuB,IAAI;IAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAElG;AAOD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAErF;AAED,sDAAsD;AACtD,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAGzF;AAED,wEAAwE;AACxE,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAGvF;AAED,uFAAuF;AACvF,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAEnF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAExE;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,0EAA0E;AAC1E,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAG9G;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,EAAE,GAAG,IAAI,CAExD;AAED,kEAAkE;AAClE,wBAAgB,iCAAiC,IAAI,IAAI,CAGxD"}
1
+ {"version":3,"file":"modelCatalogue.d.ts","sourceRoot":"","sources":["../../src/agent/modelCatalogue.ts"],"names":[],"mappings":"AAgDA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAiBlE;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7B,OAAO,CAAC;IAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAuBnE;AAED,iFAAiF;AACjF,wBAAgB,uBAAuB,IAAI;IAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAElG;AAOD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAErF;AAED,sDAAsD;AACtD,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAGzF;AAED,wEAAwE;AACxE,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,OAAO,GAAG,OAAO,CAGvF;AAED,uFAAuF;AACvF,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAEnF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAExE;AAED,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,0EAA0E;AAC1E,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAG9G;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,EAAE,GAAG,IAAI,CAExD;AAED,kEAAkE;AAClE,wBAAgB,iCAAiC,IAAI,IAAI,CAGxD;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,aAAa,CAAC;AAO/C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW3F;AAED,6GAA6G;AAC7G,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAIxH;AAED,8DAA8D;AAC9D,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,IAAI,GAAG,IAAI,CAE9E"}
@@ -47,6 +47,7 @@
47
47
  // added mid-session is vanishingly rare and would need a restart to use
48
48
  // safely anyway, since the picker itself is also static per-render).
49
49
  Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.CHEAPEST_MODEL_ALIAS = void 0;
50
51
  exports.getModelCatalogue = getModelCatalogue;
51
52
  exports.getCachedModelCatalogue = getCachedModelCatalogue;
52
53
  exports.liveContextWindowFor = liveContextWindowFor;
@@ -59,6 +60,9 @@ exports.liveLabelFor = liveLabelFor;
59
60
  exports.liveCostMultiplierFor = liveCostMultiplierFor;
60
61
  exports.liveSelectableModelIds = liveSelectableModelIds;
61
62
  exports._resetModelCatalogueCacheForTests = _resetModelCatalogueCacheForTests;
63
+ exports.cheapestSameVendorModel = cheapestSameVendorModel;
64
+ exports.resolveSubAgentModel = resolveSubAgentModel;
65
+ exports._setModelCatalogueForTest = _setModelCatalogueForTest;
62
66
  const client_1 = require("../api/client");
63
67
  /** How long a successful fetch is trusted before a caller should re-fetch. */
64
68
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes — matches backend's own getPricing() TTL
@@ -180,4 +184,46 @@ function _resetModelCatalogueCacheForTests() {
180
184
  _state = null;
181
185
  _inFlight = null;
182
186
  }
187
+ /**
188
+ * `model: cheapest` — Claude Code's Explore-on-Haiku idea, without leaving the
189
+ * provider the user chose. Resolves to the lowest-costMultiplier AVAILABLE model
190
+ * of the parent session's vendor (Opus → Sonnet, GPT-6 Sol → GPT-6 Luna, …), never
191
+ * a pricier one. Returns the parent model unchanged when the catalogue is not
192
+ * loaded or does not know it — inheriting is always a safe fallback; guessing a
193
+ * model on another provider (the bug that made explorer bill Anthropic for
194
+ * DeepSeek sessions) is not.
195
+ */
196
+ exports.CHEAPEST_MODEL_ALIAS = 'cheapest';
197
+ // Legacy tier aliases the backend still resolves (see agentTypes.ts VALID_MODELS).
198
+ const LEGACY_ALIAS_IDS = {
199
+ turbo: 'claude-sonnet-5-5', fast: 'claude-sonnet-5-5', pro: 'claude-opus-5-5', ultra: 'claude-fable-5-1',
200
+ };
201
+ function cheapestSameVendorModel(parentModel) {
202
+ if (!_state || !parentModel)
203
+ return parentModel;
204
+ const parent = findModel(parentModel) ?? findModel(LEGACY_ALIAS_IDS[parentModel]);
205
+ if (!parent)
206
+ return parentModel;
207
+ const parentCost = parent.costMultiplier ?? Infinity;
208
+ let best = parent;
209
+ for (const m of _state.models) {
210
+ if (m.vendor !== parent.vendor || m.available === false || typeof m.costMultiplier !== 'number')
211
+ continue;
212
+ if (m.costMultiplier < (best.costMultiplier ?? Infinity) && m.costMultiplier <= parentCost)
213
+ best = m;
214
+ }
215
+ return best === parent ? parentModel : best.id;
216
+ }
217
+ /** Resolve a sub-agent's `model:` against its parent's: undefined/inherit → parent, cheapest → see above. */
218
+ function resolveSubAgentModel(agentModel, parentModel) {
219
+ if (!agentModel || agentModel === 'inherit')
220
+ return parentModel;
221
+ if (agentModel === exports.CHEAPEST_MODEL_ALIAS)
222
+ return cheapestSameVendorModel(parentModel);
223
+ return agentModel;
224
+ }
225
+ /** Test hook: install a catalogue without a network fetch. */
226
+ function _setModelCatalogueForTest(models) {
227
+ _state = models ? { models, defaultModel: models[0]?.id ?? '', fetchedAt: Date.now() } : null;
228
+ }
183
229
  //# sourceMappingURL=modelCatalogue.js.map
@@ -10,6 +10,20 @@ export interface PlanModeRefusal {
10
10
  * "Provably" is doing real work: unknown verbs are refused, not guessed at.
11
11
  */
12
12
  export declare function isReadOnlyCommand(command: string): boolean;
13
+ /**
14
+ * Is a (possibly piped / chained) command read-only on every segment?
15
+ *
16
+ * This is what the permission GATE uses to skip a prompt in ask/edit mode. Plan mode
17
+ * keeps the stricter whole-shape refusal above (`checkPlanMode`): a temporary lock can
18
+ * afford to say "no pipelines", an approval gate that says it about `git log | head`
19
+ * just trains people to click through prompts.
20
+ *
21
+ * Fail-closed throughout: anything that is not plainly a chain of read-only commands —
22
+ * a write redirect, `$(…)`, backticks, a lone `&`, an unknown verb, a segment split
23
+ * wrongly by a quote — returns false, which means "ask the user". The cost of a false
24
+ * false is one extra keypress; the cost of a false true is `rm` running unasked.
25
+ */
26
+ export declare function isReadOnlyPipeline(command: string): boolean;
13
27
  /**
14
28
  * Should this tool call be refused because the session is in plan mode?
15
29
  * Returns null when the call is allowed.
@@ -1 +1 @@
1
- {"version":3,"file":"planMode.d.ts","sourceRoot":"","sources":["../../src/agent/planMode.ts"],"names":[],"mappings":"AAwHA,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,MAAM,EAAE,eAAe,GAAG,oBAAoB,CAAC;IAC/C,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;CACjB;AAMD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAgE1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,eAAe,GAAG,IAAI,CAoCxB;AAED,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,QAqBvB,CAAC"}
1
+ {"version":3,"file":"planMode.d.ts","sourceRoot":"","sources":["../../src/agent/planMode.ts"],"names":[],"mappings":"AAgJA,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,MAAM,EAAE,eAAe,GAAG,oBAAoB,CAAC;IAC/C,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;CACjB;AAqFD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CA8D1D;AAQD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAO3D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,eAAe,GAAG,IAAI,CAoCxB;AAED,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,QAqBvB,CAAC"}
@@ -19,6 +19,7 @@
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.PLAN_MODE_INSTRUCTIONS = void 0;
21
21
  exports.isReadOnlyCommand = isReadOnlyCommand;
22
+ exports.isReadOnlyPipeline = isReadOnlyPipeline;
22
23
  exports.checkPlanMode = checkPlanMode;
23
24
  /**
24
25
  * Tools that cannot alter anything outside the conversation.
@@ -71,10 +72,31 @@ const READ_ONLY_BASH = new Set([
71
72
  'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'find', 'fd', 'locate',
72
73
  'diff', 'comm', 'cmp', 'sort', 'uniq', 'cut', 'tr', 'column', 'jq', 'yq',
73
74
  'date', 'whoami', 'hostname', 'uname', 'env', 'printenv', 'id', 'groups',
74
- 'ps', 'top', 'uptime', 'free', 'node', 'python', 'python3',
75
- 'tree', 'less', 'more', 'nl', 'tac', 'strings', 'md5sum', 'sha256sum',
75
+ 'ps', 'uptime', 'free', 'node', 'python', 'python3',
76
+ // `less`/`more`/`top` are deliberately NOT here: pagers run `!cmd` from inside and
77
+ // `top` never exits. `env` is listed but only bare — see toolFlagsOk.
78
+ 'tree', 'nl', 'tac', 'strings', 'md5sum', 'sha256sum',
76
79
  'true', 'false', 'test', 'sleep', 'seq', 'expr',
80
+ // Changes this shell's cwd only. Needed so `cd x && ls` is not refused.
81
+ 'cd',
77
82
  ]);
83
+ /**
84
+ * A verb written with a path is only trusted when it lives in a system bin dir.
85
+ * Stripping to the basename (the old behaviour) meant `./ls`, `/tmp/evil/cat` and
86
+ * `build/git` — attacker-written files — were classified by their NAME and waved
87
+ * through as read-only.
88
+ */
89
+ const SYSTEM_BIN_DIRS = new Set([
90
+ '/bin', '/usr/bin', '/usr/local/bin', '/sbin', '/usr/sbin', '/opt/homebrew/bin',
91
+ ]);
92
+ /**
93
+ * Environment assignments that change WHAT a read-only verb runs: the search path,
94
+ * loader, pager/editor/external-diff hooks, and the interpreters' option vars.
95
+ * `GIT_EXTERNAL_DIFF=./x git diff` and `PATH=/tmp ls` run the attacker's program
96
+ * while every word the classifier looks at is innocent.
97
+ */
98
+ const UNSAFE_ENV = /^(PATH|HOME|SHELL|IFS|ENV|BASH_ENV|PAGER|MANPAGER|EDITOR|VISUAL|BROWSER|LD_\w*|DYLD_\w*|GIT_\w*|NODE_\w*|NPM_\w*|npm_\w*|PYTHON\w*|BASH\w*|RUBY\w*|PERL\w*|LESS\w*|XDG_\w*|\w*OPTS?)=/i;
99
+ const GH_NOUNS = new Set(['pr', 'issue', 'run', 'repo', 'release', 'workflow', 'gist', 'label']);
78
100
  /**
79
101
  * Subcommands that are read-only for tools whose safety depends on the verb.
80
102
  *
@@ -117,6 +139,83 @@ const READ_ONLY_NESTED = {
117
139
  * `|` is included: `cat x | tee y` writes, and so does `... | sh`.
118
140
  */
119
141
  const SHELL_CONTROL = /[\n\r;&|`]|\$\(|>>|>|<\(/;
142
+ const isShort = (f) => f.startsWith('-') && !f.startsWith('--');
143
+ const flagName = (f) => f.split('=')[0];
144
+ /**
145
+ * Flags of an allowlisted VERB that turn it into a writer or an executor. The verb
146
+ * list says `sort` is read-only; `sort -o f` writes f. Each entry below is a real way
147
+ * to mutate through a verb the list trusts — the list alone was the hole.
148
+ */
149
+ function toolFlagsOk(verb, args) {
150
+ const flags = args.filter((a) => a.startsWith('-'));
151
+ const pos = args.filter((a) => !a.startsWith('-'));
152
+ switch (verb) {
153
+ case 'env': return args.length === 0; // `env CMD` RUNS CMD
154
+ case 'sort':
155
+ return !flags.some((f) => (isShort(f) && /^-[A-Za-z]*o/.test(f)) || f.startsWith('--output') || f.startsWith('--compress-program'));
156
+ case 'uniq': return pos.length <= 1; // second positional is an OUTPUT file
157
+ case 'find':
158
+ return !args.some((a) => ['-exec', '-execdir', '-delete', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls'].includes(a));
159
+ case 'fd':
160
+ return !flags.some((f) => f.startsWith('--exec') || (isShort(f) && /^-[A-Za-z]*[xX]/.test(f)));
161
+ case 'rg': return !flags.some((f) => flagName(f) === '--pre' || f.startsWith('--hostname-bin'));
162
+ case 'ag':
163
+ case 'ack': return !flags.some((f) => f.startsWith('--pager'));
164
+ case 'yq': return !flags.some((f) => (isShort(f) && /^-[A-Za-z]*i/.test(f)) || f.startsWith('--inplace'));
165
+ case 'tree': return !flags.some((f) => isShort(f) && /^-[A-Za-z]*o/.test(f));
166
+ case 'date': return !flags.some((f) => (isShort(f) && /^-[A-Za-z]*s/.test(f)) || f.startsWith('--set'));
167
+ case 'hostname': return pos.length === 0; // `hostname NAME` renames the machine
168
+ case 'file': return !flags.some((f) => (isShort(f) && /^-[A-Za-z]*C/.test(f)) || f.startsWith('--compile'));
169
+ default: return true;
170
+ }
171
+ }
172
+ /** Every flag must be a known read-only one; anything unfamiliar fails closed. */
173
+ function flagsWithin(flags, shorts, longs) {
174
+ return flags.every((f) => {
175
+ if (isShort(f))
176
+ return [...f.slice(1)].every((c) => shorts.includes(c) || /\d/.test(c));
177
+ return longs.includes(flagName(f));
178
+ });
179
+ }
180
+ const LISTISH = ['-l', '--list', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at'];
181
+ /**
182
+ * Same idea one level down: `git branch` lists, `git branch newbranch` creates,
183
+ * `git branch -D main` deletes — one subcommand, three effects.
184
+ */
185
+ function subcommandFlagsOk(verb, sub, args) {
186
+ const flags = args.filter((a) => a.startsWith('-'));
187
+ const pos = args.filter((a) => !a.startsWith('-')); // pos[0] === sub
188
+ const listish = flags.some((f) => LISTISH.includes(flagName(f)) || (isShort(f) && f.includes('l')));
189
+ if (verb === 'git') {
190
+ if (flags.some((f) => f.startsWith('--output') || f === '--ext-diff' || f === '-O' || f.startsWith('--open-files-in-pager')))
191
+ return false;
192
+ switch (sub) {
193
+ case 'branch':
194
+ return flagsWithin(flags, 'arvlq', ['--all', '--remotes', '--verbose', '--list', '--show-current', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format', '--column', '--no-column', '--color', '--no-color', '--abbrev', '--no-abbrev', '--ignore-case', '--omit-empty'])
195
+ && (pos.length <= 1 || listish);
196
+ case 'tag':
197
+ return flagsWithin(flags, 'lnv', ['--list', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format', '--column', '--no-column', '--color', '--no-color', '--verify', '--ignore-case', '--omit-empty'])
198
+ && (pos.length <= 1 || listish || flags.some((f) => f === '-v' || f === '--verify'));
199
+ case 'remote':
200
+ return flagsWithin(flags, 'vn', ['--verbose']) && (pos[1] === undefined || pos[1] === 'show' || pos[1] === 'get-url');
201
+ case 'config':
202
+ return flagsWithin(flags, 'lz', ['--get', '--get-all', '--get-regexp', '--list', '--show-origin', '--show-scope', '--global', '--local', '--system', '--worktree', '--name-only', '--null', '--includes', '--no-includes', '--type'])
203
+ && pos.length <= 2; // `config` itself + at most one key
204
+ case 'reflog': return pos[1] !== 'expire' && pos[1] !== 'delete';
205
+ default: return true;
206
+ }
207
+ }
208
+ if (sub === 'config' && (verb === 'npm' || verb === 'pnpm' || verb === 'yarn')) {
209
+ return pos[1] === undefined || pos[1] === 'get' || pos[1] === 'list' || pos[1] === 'ls';
210
+ }
211
+ if (verb === 'go') {
212
+ return !flags.some((f) => {
213
+ const n = f.replace(/^--?/, '-');
214
+ return n === '-w' || n === '-u' || n.startsWith('-vettool') || n.startsWith('-toolexec') || n === '-exec';
215
+ });
216
+ }
217
+ return true;
218
+ }
120
219
  function firstWords(command) {
121
220
  return command.trim().split(/\s+/).filter(Boolean);
122
221
  }
@@ -137,15 +236,21 @@ function isReadOnlyCommand(command) {
137
236
  return false;
138
237
  // `VAR=x cmd` — skip leading environment assignments to find the real verb.
139
238
  let i = 0;
140
- while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
239
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) {
240
+ if (UNSAFE_ENV.test(words[i]))
241
+ return false;
141
242
  i++;
243
+ }
142
244
  if (i >= words.length)
143
245
  return false;
144
246
  let verb = words[i];
145
- // Strip a path prefix: /usr/bin/git → git
247
+ // /usr/bin/git → git, but ONLY for a system bin dir (see SYSTEM_BIN_DIRS).
146
248
  const slash = verb.lastIndexOf('/');
147
- if (slash >= 0)
249
+ if (slash >= 0) {
250
+ if (!SYSTEM_BIN_DIRS.has(verb.slice(0, slash)))
251
+ return false;
148
252
  verb = verb.slice(slash + 1);
253
+ }
149
254
  // `sudo anything` is refused outright regardless of the verb behind it: plan
150
255
  // mode is a promise about this machine, and privilege escalation is exactly
151
256
  // where a mistaken allow is least recoverable.
@@ -159,16 +264,12 @@ function isReadOnlyCommand(command) {
159
264
  // Bare `git` / `npm` just prints help — harmless.
160
265
  return true;
161
266
  }
162
- if (subs.has(sub)) {
163
- // `git config --global x y` WRITES. Only the read form (no value) passes.
164
- if (verb === 'git' && sub === 'config') {
165
- const args = words.slice(i + 2).filter((w) => !w.startsWith('-'));
166
- return args.length <= 1;
167
- }
168
- return true;
169
- }
267
+ if (subs.has(sub))
268
+ return subcommandFlagsOk(verb, sub, words.slice(i + 1));
170
269
  const nested = READ_ONLY_NESTED[verb];
171
- if (nested && rest[1] && nested.has(rest[1]))
270
+ // `gh api list -X DELETE` has "list" in second position too — the noun must be a
271
+ // real resource noun, and `api` (arbitrary HTTP verbs) is not one.
272
+ if (nested && rest[1] && nested.has(rest[1]) && (verb !== 'gh' || GH_NOUNS.has(sub)))
172
273
  return true;
173
274
  return false;
174
275
  }
@@ -179,15 +280,42 @@ function isReadOnlyCommand(command) {
179
280
  if (verb === 'node' || verb === 'python' || verb === 'python3') {
180
281
  return words.slice(i + 1).every((w) => w === '--version' || w === '-V' || w === '-v');
181
282
  }
182
- // `find` can execute and delete.
183
- if (verb === 'find' && words.some((w) => w === '-exec' || w === '-execdir' || w === '-delete')) {
283
+ if (!toolFlagsOk(verb, words.slice(i + 1)))
184
284
  return false;
185
- }
186
285
  // Reading a file with `test`/`stat` is fine, but `tee` is not in the list at
187
286
  // all, and `echo`/`printf` are only safe because redirection is already
188
287
  // rejected by SHELL_CONTROL above.
189
288
  return true;
190
289
  }
290
+ /**
291
+ * Output redirections that cannot write anywhere interesting. Without this `ls 2>&1`
292
+ * and `cmd 2>/dev/null` — extremely common — would all count as "redirection".
293
+ */
294
+ const HARMLESS_REDIRECT = /\s*\d?>\s*&\d\b|\s*\d?>>?\s*\/dev\/null(?=\s|$)/g;
295
+ /**
296
+ * Is a (possibly piped / chained) command read-only on every segment?
297
+ *
298
+ * This is what the permission GATE uses to skip a prompt in ask/edit mode. Plan mode
299
+ * keeps the stricter whole-shape refusal above (`checkPlanMode`): a temporary lock can
300
+ * afford to say "no pipelines", an approval gate that says it about `git log | head`
301
+ * just trains people to click through prompts.
302
+ *
303
+ * Fail-closed throughout: anything that is not plainly a chain of read-only commands —
304
+ * a write redirect, `$(…)`, backticks, a lone `&`, an unknown verb, a segment split
305
+ * wrongly by a quote — returns false, which means "ask the user". The cost of a false
306
+ * false is one extra keypress; the cost of a false true is `rm` running unasked.
307
+ */
308
+ function isReadOnlyPipeline(command) {
309
+ const cmd = command.replace(HARMLESS_REDIRECT, ' ').trim();
310
+ if (!cmd)
311
+ return false;
312
+ if (/[>`]|\$\(|<\(/.test(cmd))
313
+ return false;
314
+ const segs = cmd.split(/\|\||&&|[;|\n\r]/).map((x) => x.trim()).filter(Boolean);
315
+ if (!segs.length)
316
+ return false;
317
+ return segs.every((seg) => !seg.includes('&') && isReadOnlyCommand(seg));
318
+ }
191
319
  /**
192
320
  * Should this tool call be refused because the session is in plan mode?
193
321
  * Returns null when the call is allowed.
@@ -0,0 +1,15 @@
1
+ import type { Message } from '../types';
2
+ export declare class ReadDedupe {
3
+ private readonly workDir?;
4
+ private readonly records;
5
+ constructor(workDir?: string | undefined);
6
+ private resolve;
7
+ private key;
8
+ /** The stub to return instead of re-reading, or null to read normally. */
9
+ check(input: Record<string, unknown>, messages: Message[]): string | null;
10
+ /** Remember a successful full read. Call only with a real (non-stub) read_file output. */
11
+ record(input: Record<string, unknown>, toolUseId: string): void;
12
+ /** Forget every recorded range of these absolute paths (a write tool touched them). */
13
+ invalidate(absPaths: string[]): void;
14
+ }
15
+ //# sourceMappingURL=readDedupe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readDedupe.d.ts","sourceRoot":"","sources":["../../src/agent/readDedupe.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAMxC,qBAAa,UAAU;IAGT,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IAFrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;gBAE5B,OAAO,CAAC,EAAE,MAAM,YAAA;IAE7C,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,GAAG;IAQX,0EAA0E;IAC1E,KAAK,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,GAAG,IAAI;IAezE,0FAA0F;IAC1F,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAS/D,uFAAuF;IACvF,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI;CAOrC"}
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ // read_file de-duplication — Claude Code's "file unchanged since last read" stub.
3
+ //
4
+ // Agents re-read the same file constantly (before an edit, after a test run, while
5
+ // re-checking a plan). Each re-read re-sent up to 25K tokens that were ALREADY in the
6
+ // conversation, and every later request then carried both copies. When the file has not
7
+ // changed and the earlier result is still intact in the history, a one-line stub
8
+ // pointing at it is equivalent for the model and costs ~40 tokens.
9
+ //
10
+ // Safety is the whole design: the stub is only correct if the model can still SEE the
11
+ // earlier content. So a hit requires ALL of:
12
+ // • same resolved path, offset and limit as a previous successful read in THIS run
13
+ // (one instance per runAgentLoop — a sub-agent starts with a fresh context and a
14
+ // fresh instance, so it never gets pointed at its parent's reads);
15
+ // • the file's mtime and size are unchanged since that read, and no write tool has
16
+ // touched the path since (explicit invalidation — mtime alone can miss a same-ms edit);
17
+ // • that read's tool_result is still in the live message array, unpruned — compaction,
18
+ // pruneOldToolResults or a rewind all remove or stub it, which turns the hit off.
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.ReadDedupe = void 0;
54
+ const fs = __importStar(require("fs"));
55
+ const path = __importStar(require("path"));
56
+ const PRUNED_TAIL = 'pruned to conserve context. Re-run the tool if you need the full result.]';
57
+ class ReadDedupe {
58
+ constructor(workDir) {
59
+ this.workDir = workDir;
60
+ this.records = new Map();
61
+ }
62
+ resolve(p) {
63
+ if (typeof p !== 'string' || !p)
64
+ return null;
65
+ const expanded = p.startsWith('~/') ? path.join(process.env.HOME ?? '', p.slice(2)) : p;
66
+ return this.workDir ? path.resolve(this.workDir, expanded) : path.resolve(expanded);
67
+ }
68
+ key(input) {
69
+ const abs = this.resolve(input.path);
70
+ if (!abs)
71
+ return null;
72
+ const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
73
+ const limit = typeof input.limit === 'number' ? Math.max(1, Math.floor(input.limit)) : 0;
74
+ return { key: `${abs}\0${offset}\0${limit}`, abs };
75
+ }
76
+ /** The stub to return instead of re-reading, or null to read normally. */
77
+ check(input, messages) {
78
+ const k = this.key(input);
79
+ if (!k)
80
+ return null;
81
+ const rec = this.records.get(k.key);
82
+ if (!rec)
83
+ return null;
84
+ let st;
85
+ try {
86
+ st = fs.statSync(k.abs);
87
+ }
88
+ catch {
89
+ this.records.delete(k.key);
90
+ return null;
91
+ }
92
+ if (st.mtimeMs !== rec.mtimeMs || st.size !== rec.size) {
93
+ this.records.delete(k.key);
94
+ return null;
95
+ }
96
+ if (!resultStillIntact(messages, rec.toolUseId)) {
97
+ this.records.delete(k.key);
98
+ return null;
99
+ }
100
+ return (`File unchanged since your last read_file of it (same range). The content in that earlier ` +
101
+ `tool result is still current — refer to it instead of re-reading.`);
102
+ }
103
+ /** Remember a successful full read. Call only with a real (non-stub) read_file output. */
104
+ record(input, toolUseId) {
105
+ const k = this.key(input);
106
+ if (!k)
107
+ return;
108
+ try {
109
+ const st = fs.statSync(k.abs);
110
+ this.records.set(k.key, { toolUseId, mtimeMs: st.mtimeMs, size: st.size });
111
+ }
112
+ catch { /* unreadable now — nothing to remember */ }
113
+ }
114
+ /** Forget every recorded range of these absolute paths (a write tool touched them). */
115
+ invalidate(absPaths) {
116
+ if (!absPaths.length || !this.records.size)
117
+ return;
118
+ const set = new Set(absPaths);
119
+ for (const key of [...this.records.keys()]) {
120
+ if (set.has(key.slice(0, key.indexOf('\0'))))
121
+ this.records.delete(key);
122
+ }
123
+ }
124
+ }
125
+ exports.ReadDedupe = ReadDedupe;
126
+ function resultStillIntact(messages, toolUseId) {
127
+ for (let i = messages.length - 1; i >= 0; i--) {
128
+ const m = messages[i];
129
+ if (m.role !== 'user' || !Array.isArray(m.content))
130
+ continue;
131
+ for (const b of m.content) {
132
+ if (b?.type !== 'tool_result' || b.tool_use_id !== toolUseId)
133
+ continue;
134
+ if (b.is_error)
135
+ return false;
136
+ const text = typeof b.content === 'string'
137
+ ? b.content
138
+ : Array.isArray(b.content)
139
+ ? b.content.map((c) => c.text ?? '').join('')
140
+ : '';
141
+ return text.startsWith('[File:') && !text.trimEnd().endsWith(PRUNED_TAIL);
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+ //# sourceMappingURL=readDedupe.js.map
@@ -19,7 +19,7 @@
19
19
  //
20
20
  // Consequently every pattern here is tuned for PRECISION over recall. A check that
21
21
  // fires on ordinary code was removed rather than loosened. Deep analysis is the
22
- // `security-auditor` agent's job; this only catches the flagrant cases at the
22
+ // a dedicated security review's job; this only catches the flagrant cases at the
23
23
  // moment they are written.
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.checkSecurity = checkSecurity;
@@ -116,7 +116,7 @@ const RULES = [
116
116
  // So the interpolation must name something that plausibly came from the
117
117
  // outside: req/request/params/query/body/input/user/args, or a bare
118
118
  // `'...' + ident`. This trades recall for precision on purpose — thorough SQL
119
- // review is the security-auditor agent's job, not an inline regex's.
119
+ // review is a dedicated security review's job, not an inline regex's.
120
120
  //
121
121
  // THE GAP IS PER-ALTERNATIVE, and that asymmetry is load-bearing. The concat
122
122
  // branch originally shared the no-quote gap, which made it miss the QUOTED