@te-river/opencode-team-mode 1.8.2 → 1.8.4

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 +499 -1012
  2. package/README.zh-CN.md +493 -909
  3. package/dist/host/v2-prune.d.ts +81 -1
  4. package/dist/host/v2-prune.d.ts.map +1 -1
  5. package/dist/host/v2-prune.js +303 -18
  6. package/dist/host/v2-prune.js.map +1 -1
  7. package/dist/host/v2-retry.d.ts +8 -0
  8. package/dist/host/v2-retry.d.ts.map +1 -1
  9. package/dist/host/v2-retry.js +8 -2
  10. package/dist/host/v2-retry.js.map +1 -1
  11. package/dist/host/v2-session.d.ts +8 -0
  12. package/dist/host/v2-session.d.ts.map +1 -1
  13. package/dist/host/v2-session.js +18 -5
  14. package/dist/host/v2-session.js.map +1 -1
  15. package/dist/host/v2-state.d.ts +165 -0
  16. package/dist/host/v2-state.d.ts.map +1 -0
  17. package/dist/host/v2-state.js +448 -0
  18. package/dist/host/v2-state.js.map +1 -0
  19. package/dist/host/v2-tail.d.ts +102 -0
  20. package/dist/host/v2-tail.d.ts.map +1 -0
  21. package/dist/host/v2-tail.js +198 -0
  22. package/dist/host/v2-tail.js.map +1 -0
  23. package/dist/host/v2-types.d.ts +2 -0
  24. package/dist/host/v2-types.d.ts.map +1 -1
  25. package/dist/host/v2-usage.d.ts +72 -2
  26. package/dist/host/v2-usage.d.ts.map +1 -1
  27. package/dist/host/v2-usage.js +142 -9
  28. package/dist/host/v2-usage.js.map +1 -1
  29. package/dist/host/v2.d.ts.map +1 -1
  30. package/dist/host/v2.js +187 -3
  31. package/dist/host/v2.js.map +1 -1
  32. package/dist/prompts/lead.d.ts +1 -1
  33. package/dist/prompts/lead.d.ts.map +1 -1
  34. package/dist/prompts/lead.js +12 -0
  35. package/dist/prompts/lead.js.map +1 -1
  36. package/dist/prompts/shared.d.ts +1 -1
  37. package/dist/prompts/shared.d.ts.map +1 -1
  38. package/dist/prompts/shared.js +5 -0
  39. package/dist/prompts/shared.js.map +1 -1
  40. package/dist/tm/config-layers.d.ts.map +1 -1
  41. package/dist/tm/config-layers.js +4 -0
  42. package/dist/tm/config-layers.js.map +1 -1
  43. package/dist/tm/config.d.ts +8 -0
  44. package/dist/tm/config.d.ts.map +1 -1
  45. package/dist/tm/config.js.map +1 -1
  46. package/dist/tm/dispatch.d.ts +17 -0
  47. package/dist/tm/dispatch.d.ts.map +1 -1
  48. package/dist/tm/dispatch.js +52 -7
  49. package/dist/tm/dispatch.js.map +1 -1
  50. package/dist/tm/pipelines.d.ts +9 -0
  51. package/dist/tm/pipelines.d.ts.map +1 -1
  52. package/dist/tm/pipelines.js +1 -1
  53. package/dist/tm/pipelines.js.map +1 -1
  54. package/dist/tm/stats.d.ts +11 -0
  55. package/dist/tm/stats.d.ts.map +1 -1
  56. package/dist/tm/stats.js +34 -1
  57. package/dist/tm/stats.js.map +1 -1
  58. package/docs/v2-port-notes.md +7 -3
  59. package/package.json +1 -1
@@ -21,6 +21,19 @@
21
21
  * protected STRING: the message may be pruned, but the stub must carry the
22
22
  * handle verbatim, which is exactly the "keep the pointer, drop the body"
23
23
  * contract.
24
+ * - **Nothing leaves that cannot be recovered.** A message carrying no handle
25
+ * of its own is offloaded to the run store BEFORE its body is replaced, so
26
+ * the stub carries a full `tm_fetch` handle (`pruneOffload`, default on). A
27
+ * failed offload falls back to today's echo note, one message at a time —
28
+ * losing a body is data loss, losing an offload is a counter. A stub with no
29
+ * handle to offer still carries the ADDRESSES the body named (board paths,
30
+ * `file:line`, URLs; at most three, deduplicated, in document order), because a
31
+ * pointer the reader can act on is the whole point of keeping one — and that
32
+ * scan is SINGLE-PASS and linear in the body length, not three global regexes
33
+ * over the whole text (which went quadratic on a long word run).
34
+ * - **A user pattern outranks the budget** (`pruneProtectPatterns`): a message
35
+ * whose text matches any compiled entry is never pruned, and the matches are
36
+ * counted (`protectMatched`) instead of silently changing the saving figure.
24
37
  * - **The threshold is derived, never hard-coded.** `TM_PRUNE_AT_PERCENT`
25
38
  * (default 70) of `limit.context`, the same denominator the desktop's own
26
39
  * usage meter reads. No `limit.context` → no prune, counted `no_limit`.
@@ -40,11 +53,24 @@ export interface PruneConfig {
40
53
  atPercent: number;
41
54
  /** Percent of `limit.context` kept verbatim at the tail (clamped 0–90). */
42
55
  keepTailPercent: number;
56
+ /**
57
+ * User-supplied protection regexes (`pruneProtectPatterns`, comma separated).
58
+ * A message whose text matches ANY of them is never pruned. Empty = off, and
59
+ * that is the byte-identical path: the list is consulted only when non-empty.
60
+ */
61
+ protectPatterns: RegExp[];
43
62
  }
63
+ /**
64
+ * Compile the comma-separated pattern list. One invalid pattern loses only
65
+ * itself (a typo must not disable the ones next to it, and must never throw into
66
+ * the request path); an empty / absent value compiles to `[]` = off.
67
+ */
68
+ export declare function parseProtectPatterns(raw: unknown): RegExp[];
44
69
  export declare function resolvePruneConfig(config?: {
45
70
  prune: string;
46
71
  pruneAtPercent: number;
47
72
  pruneKeepTailPercent: number;
73
+ pruneProtectPatterns?: string;
48
74
  }): PruneConfig;
49
75
  interface TextSlot {
50
76
  container: Record<string, unknown>;
@@ -86,13 +112,43 @@ export interface PruneHandle {
86
112
  ref: string;
87
113
  accessToken: string;
88
114
  expireAt: number;
115
+ /** Set when the wiring recognised this body and handed back an EXISTING handle. */
116
+ reused?: boolean;
89
117
  }
118
+ /** 地址行最多留几条——再多就不是「地址」,而是正文摘抄。 */
119
+ export declare const PRUNE_ADDRESS_MAX = 3;
120
+ /** 地址行的前缀;整行只在真的抽到地址时出现。 */
121
+ export declare const PRUNE_ADDRESS_PREFIX = "\u5730\u5740\uFF1A";
122
+ /**
123
+ * 单个 token 的字符上限。 超过它的不是地址,是正文——这条上限也是性能闸门:
124
+ * 长词串整段跳过,永远不会进到正则里。
125
+ */
126
+ export declare const PRUNE_ADDRESS_TOKEN_MAX = 2048;
127
+ /**
128
+ * The addresses a pruned body carried, in DOCUMENT ORDER, deduplicated, at most
129
+ * `PRUNE_ADDRESS_MAX`. Pure, and it only ever yields the address itself — never a
130
+ * fragment of the body around it, because the stub is a pointer, not a summary.
131
+ *
132
+ * LINEAR BY CONSTRUCTION (this is a performance contract, not a style note): the
133
+ * text is walked ONCE, token by token, and every pattern is anchored to a single
134
+ * token. The previous version ran three GLOBAL regexes over the whole body and
135
+ * blanked each URL match by rebuilding the string — quadratic on a long word run
136
+ * (`"a".repeat(200000)` was hundreds of seconds) and O(k·n) with many URLs. A
137
+ * token longer than `PRUNE_ADDRESS_TOKEN_MAX` is skipped without touching a regex.
138
+ *
139
+ * A longer address that CONTAINS a shorter one wins: `…/v2-prune.ts:120` is the
140
+ * pointer a reader can use, while `…/v2-prune.ts` would send them hunting for the
141
+ * line the message was about.
142
+ */
143
+ export declare function extractAddresses(text: string): string[];
90
144
  export interface PruneStubInput {
91
- kind: "handle" | "child" | "echo";
145
+ kind: "handle" | "child" | "echo" | "offload";
92
146
  originalTokens: number;
93
147
  handle?: PruneHandle;
94
148
  childId?: string;
95
149
  childState?: string;
150
+ /** Addresses found in the body — rendered as one `地址:` line, or omitted whole. */
151
+ addresses?: string[];
96
152
  }
97
153
  /**
98
154
  * The replacement text. It must be self-explanatory: a reader has to know what
@@ -109,6 +165,8 @@ export interface PrunePlanEntry {
109
165
  export interface PrunePlan {
110
166
  prune: PrunePlanEntry[];
111
167
  protectedCount: number;
168
+ /** Messages a user-supplied `pruneProtectPatterns` entry kept (a subset of the kept ones). */
169
+ protectMatched: number;
112
170
  keptCount: number;
113
171
  savedTokens: number;
114
172
  }
@@ -117,6 +175,8 @@ export interface PrunePlanOptions {
117
175
  budgetTokens?: number;
118
176
  /** Newest messages whose cumulative tokens fit here are kept verbatim. */
119
177
  keepTailTokens?: number;
178
+ /** User patterns: any hit keeps that message, whatever the budget says. */
179
+ protectPatterns?: RegExp[];
120
180
  }
121
181
  /**
122
182
  * Decide which messages to prune, oldest first, until the request fits the
@@ -138,8 +198,22 @@ export interface PruneReport {
138
198
  noMessages: number;
139
199
  foreignSkipped: number;
140
200
  threw: number;
201
+ /** Bodies stored in the run store before their message was replaced. */
202
+ offloaded: number;
203
+ /** Handle reused for a body an earlier request already stored (no second write). */
204
+ reused: number;
205
+ /** Messages whose offload failed (or returned no handle) → echo stub. */
206
+ offloadFailed: number;
207
+ /** Requests whose post-prune size still exceeds the budget (counted, never re-pruned). */
208
+ overBudget: number;
209
+ /** Bodies stored for a request whose plan never landed — payloads with no pointer. */
210
+ orphans: number;
141
211
  /** Parts whose `type` we did not recognise — a renamed shape shows up here. */
142
212
  unknownParts: number;
213
+ /** Compiled `pruneProtectPatterns` entries (a value that failed to parse is not counted here). */
214
+ protectPatterns: number;
215
+ /** Messages a protect pattern kept out of the plan this process. */
216
+ protectMatched: number;
143
217
  lastPercent: number;
144
218
  lastUsed: number;
145
219
  lastLimit: number;
@@ -151,6 +225,12 @@ export interface PruneInput {
151
225
  modelLimitOf?: (sessionID: string, messages: unknown[]) => Promise<number | null> | number | null;
152
226
  /** Per-decision trajectory line (counters alone are invisible in a CLI run). */
153
227
  onEvent?: (row: Record<string, unknown>) => void;
228
+ /**
229
+ * Store one message body and hand back its handle. Absent = today's
230
+ * behaviour (echo stub, body not kept) — the wiring passes it only while
231
+ * `pruneOffload` is on.
232
+ */
233
+ offload?: (text: string) => Promise<PruneHandle | null>;
154
234
  }
155
235
  export interface PruneLayer {
156
236
  registrations: V2Registration[];
@@ -1 +1 @@
1
- {"version":3,"file":"v2-prune.d.ts","sourceRoot":"","sources":["../../src/host/v2-prune.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"v2-prune.d.ts","sourceRoot":"","sources":["../../src/host/v2-prune.ts"],"names":[],"mappings":"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"}
@@ -21,6 +21,19 @@
21
21
  * protected STRING: the message may be pruned, but the stub must carry the
22
22
  * handle verbatim, which is exactly the "keep the pointer, drop the body"
23
23
  * contract.
24
+ * - **Nothing leaves that cannot be recovered.** A message carrying no handle
25
+ * of its own is offloaded to the run store BEFORE its body is replaced, so
26
+ * the stub carries a full `tm_fetch` handle (`pruneOffload`, default on). A
27
+ * failed offload falls back to today's echo note, one message at a time —
28
+ * losing a body is data loss, losing an offload is a counter. A stub with no
29
+ * handle to offer still carries the ADDRESSES the body named (board paths,
30
+ * `file:line`, URLs; at most three, deduplicated, in document order), because a
31
+ * pointer the reader can act on is the whole point of keeping one — and that
32
+ * scan is SINGLE-PASS and linear in the body length, not three global regexes
33
+ * over the whole text (which went quadratic on a long word run).
34
+ * - **A user pattern outranks the budget** (`pruneProtectPatterns`): a message
35
+ * whose text matches any compiled entry is never pruned, and the matches are
36
+ * counted (`protectMatched`) instead of silently changing the saving figure.
24
37
  * - **The threshold is derived, never hard-coded.** `TM_PRUNE_AT_PERCENT`
25
38
  * (default 70) of `limit.context`, the same denominator the desktop's own
26
39
  * usage meter reads. No `limit.context` → no prune, counted `no_limit`.
@@ -40,6 +53,28 @@ const CHILD_ID_RE = /ses_[A-Za-z0-9]+/;
40
53
  const SETTLED_STATE_RE = /state\s*=\s*"(?:completed|error)"/i;
41
54
  const RUNNING_STATE_RE = /state\s*=\s*"(?:running|pending|in[_-]?progress)"/i;
42
55
  const HANDLE_RE = /ref\s*:\s*"([^"]+)"[\s\S]*?access_token\s*:\s*"([^"]+)"[\s\S]*?expire_at\s*:\s*(\d+)/;
56
+ /**
57
+ * Compile the comma-separated pattern list. One invalid pattern loses only
58
+ * itself (a typo must not disable the ones next to it, and must never throw into
59
+ * the request path); an empty / absent value compiles to `[]` = off.
60
+ */
61
+ export function parseProtectPatterns(raw) {
62
+ if (typeof raw !== "string" || !raw.trim())
63
+ return [];
64
+ const out = [];
65
+ for (const part of raw.split(",")) {
66
+ const src = part.trim();
67
+ if (!src)
68
+ continue;
69
+ try {
70
+ out.push(new RegExp(src));
71
+ }
72
+ catch {
73
+ /* an invalid regex is ignored, never fatal */
74
+ }
75
+ }
76
+ return out;
77
+ }
43
78
  const isOff = (v) => typeof v === "string" && ["off", "false", "0", "no"].includes(v.trim().toLowerCase());
44
79
  export function resolvePruneConfig(config = resolveConfig()) {
45
80
  return {
@@ -49,6 +84,7 @@ export function resolvePruneConfig(config = resolveConfig()) {
49
84
  // prune is looking at the clamp, not at a broken layer.
50
85
  atPercent: config.pruneAtPercent,
51
86
  keepTailPercent: config.pruneKeepTailPercent,
87
+ protectPatterns: parseProtectPatterns(config.pruneProtectPatterns),
52
88
  };
53
89
  }
54
90
  /**
@@ -243,7 +279,142 @@ export function isProtectedMessage(msg) {
243
279
  return true;
244
280
  return false;
245
281
  }
282
+ /** 地址行最多留几条——再多就不是「地址」,而是正文摘抄。 */
283
+ export const PRUNE_ADDRESS_MAX = 3;
284
+ /** 地址行的前缀;整行只在真的抽到地址时出现。 */
285
+ export const PRUNE_ADDRESS_PREFIX = "地址:";
286
+ /**
287
+ * 单个 token 的字符上限。 超过它的不是地址,是正文——这条上限也是性能闸门:
288
+ * 长词串整段跳过,永远不会进到正则里。
289
+ */
290
+ export const PRUNE_ADDRESS_TOKEN_MAX = 2048;
291
+ /**
292
+ * Token separators. `<` / `>` are deliberately NOT separators: the board note
293
+ * publishes `<board-root>/…md`, and splitting on the angle brackets would strip
294
+ * the placeholder off the address it belongs to.
295
+ */
296
+ const ADDRESS_SEPARATORS = new Set([" ", "\t", "\n", "\r", "\f", "\v", '"', "'", "|", "`", ";", ",", "(", ")"]);
297
+ /** A cheap anchored pre-filter: only these suffixes can be a path address. */
298
+ const PATH_EXT_RE = /\.(?:md|json|jsonl|txt|log|ts|tsx|js|py|yml|yaml)$/;
299
+ /** `file:line` / `file:line:col`, ANCHORED inside one token (no per-start retry). */
300
+ const FILE_LINE_TOKEN_RE = /^(?:[A-Za-z]:)?[\w./\\-]+:\d+(?::\d+)?$/;
301
+ /**
302
+ * A path-shaped token with a KNOWN extension: `C:\…\01-team-report.md`,
303
+ * `/x/y/z.json`, `<board-root>/…md`, `src/host/v2-prune.ts`. The leading run is
304
+ * optional (drive letter, an angle-bracket placeholder, a relative first
305
+ * segment); every further segment must start with a separator, which is what
306
+ * keeps a bare `v2-prune.ts` from being called an address. BOTH ENDS ANCHORED:
307
+ * the engine never re-tries every start position, which is the whole point of
308
+ * the token pass.
309
+ */
310
+ const PATH_TOKEN_RE = /^(?:[A-Za-z]:|<[^>\s]+>|[^\s"'<>|`;,():\/\\]+)?(?:[\/\\][^\s"'<>|`;,():\/\\]+)+\.(?:md|json|jsonl|txt|log|ts|tsx|js|py|yml|yaml)$/;
311
+ /** Strip the `<`/`>` a prose writer may have wrapped an address in. */
312
+ const trimAngles = (s) => s.replace(/^</, "").replace(/>+$/, "");
313
+ /**
314
+ * The first `http(s)://` inside a token, or -1. `indexOf` is linear, and it is
315
+ * how an address glued to Chinese text (`看https://…`) is still recovered —
316
+ * without a cross-text regex that would try every start position.
317
+ */
318
+ function urlAt(token) {
319
+ const a = token.indexOf("http://");
320
+ const b = token.indexOf("https://");
321
+ if (a < 0)
322
+ return b;
323
+ if (b < 0)
324
+ return a;
325
+ return Math.min(a, b);
326
+ }
327
+ /** Retry a token from its first path separator: `见:/a/one.md` → `/a/one.md`. */
328
+ function tailFromSeparator(token) {
329
+ const a = token.indexOf("/");
330
+ const b = token.indexOf("\\");
331
+ const cut = a < 0 ? b : b < 0 ? a : Math.min(a, b);
332
+ return cut > 0 ? token.slice(cut) : null;
333
+ }
334
+ /**
335
+ * The addresses a pruned body carried, in DOCUMENT ORDER, deduplicated, at most
336
+ * `PRUNE_ADDRESS_MAX`. Pure, and it only ever yields the address itself — never a
337
+ * fragment of the body around it, because the stub is a pointer, not a summary.
338
+ *
339
+ * LINEAR BY CONSTRUCTION (this is a performance contract, not a style note): the
340
+ * text is walked ONCE, token by token, and every pattern is anchored to a single
341
+ * token. The previous version ran three GLOBAL regexes over the whole body and
342
+ * blanked each URL match by rebuilding the string — quadratic on a long word run
343
+ * (`"a".repeat(200000)` was hundreds of seconds) and O(k·n) with many URLs. A
344
+ * token longer than `PRUNE_ADDRESS_TOKEN_MAX` is skipped without touching a regex.
345
+ *
346
+ * A longer address that CONTAINS a shorter one wins: `…/v2-prune.ts:120` is the
347
+ * pointer a reader can use, while `…/v2-prune.ts` would send them hunting for the
348
+ * line the message was about.
349
+ */
350
+ export function extractAddresses(text) {
351
+ if (!text)
352
+ return [];
353
+ const found = [];
354
+ const n = text.length;
355
+ let i = 0;
356
+ while (i < n) {
357
+ while (i < n && ADDRESS_SEPARATORS.has(text[i]))
358
+ i++;
359
+ if (i >= n)
360
+ break;
361
+ const start = i;
362
+ while (i < n && !ADDRESS_SEPARATORS.has(text[i]))
363
+ i++;
364
+ const token = text.slice(start, i);
365
+ if (token.length > PRUNE_ADDRESS_TOKEN_MAX)
366
+ continue;
367
+ // URL first: its own path must never be counted as a second address.
368
+ const scheme = urlAt(token);
369
+ if (scheme >= 0) {
370
+ const address = trimAngles(token.slice(scheme));
371
+ if (address.length > 8)
372
+ found.push({ at: start + scheme, address });
373
+ continue;
374
+ }
375
+ // `file:line` — a prefix with no separator is a word (`expire_at:9`), not a path.
376
+ if (token.includes(":") && /\d$/.test(token)) {
377
+ const direct = FILE_LINE_TOKEN_RE.test(token) ? token : null;
378
+ const candidate = direct ?? tailFromSeparator(token);
379
+ if (candidate && FILE_LINE_TOKEN_RE.test(candidate)) {
380
+ const cut = candidate.lastIndexOf(":");
381
+ if (/[\/\\]/.test(candidate.slice(0, cut))) {
382
+ found.push({ at: start, address: candidate });
383
+ continue;
384
+ }
385
+ }
386
+ }
387
+ if (PATH_EXT_RE.test(token)) {
388
+ const direct = PATH_TOKEN_RE.test(token) ? token : null;
389
+ const tail = direct ? null : tailFromSeparator(token);
390
+ const candidate = direct ?? (tail && PATH_EXT_RE.test(tail) && PATH_TOKEN_RE.test(tail) ? tail : null);
391
+ if (candidate)
392
+ found.push({ at: start, address: candidate });
393
+ }
394
+ }
395
+ // Tokens are already in document order; the sort stays because an embedded URL
396
+ // reports an offset inside its token, and "document order" is what the reader
397
+ // expects either way.
398
+ found.sort((a, b) => a.at - b.at);
399
+ const out = [];
400
+ for (const { address } of found) {
401
+ if (out.some((kept) => kept.includes(address)))
402
+ continue;
403
+ if (found.some((o) => o.address !== address && o.address.includes(address)))
404
+ continue;
405
+ out.push(address);
406
+ if (out.length >= PRUNE_ADDRESS_MAX)
407
+ break;
408
+ }
409
+ return out;
410
+ }
246
411
  const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
412
+ /** One trailing line carrying the recoverable addresses; "" when none were found. */
413
+ function addressLine(addresses) {
414
+ if (!addresses || !addresses.length)
415
+ return "";
416
+ return `\n${PRUNE_ADDRESS_PREFIX}${addresses.join(" · ")}`;
417
+ }
247
418
  /**
248
419
  * The replacement text. It must be self-explanatory: a reader has to know what
249
420
  * used to be here and where to get it back. The handle / child id is copied
@@ -257,7 +428,15 @@ export function renderPruneStub(entry) {
257
428
  if (entry.kind === "child" && entry.childId) {
258
429
  return `${head} 子代理 ${entry.childId} 的报告已结算(state=${entry.childState ?? "completed"});需要原文用 tm_join { ids:["${entry.childId}"] } 取回。`;
259
430
  }
260
- return `${head} 已结算的工具输出/命令回显,正文已裁剪;如需重看请重跑原命令,或从轨迹(tm_stats { recent: N })取回。`;
431
+ if (entry.kind === "offload" && entry.handle) {
432
+ // Deliberately different wording from the tool-result stub above: this body
433
+ // had NO handle until the prune created one, so "re-run the command" would
434
+ // be wrong advice — the bytes are in the store, not behind a command.
435
+ return `${head} 本条消息已卸载:tm_fetch { ref:"${entry.handle.ref}", access_token:"${entry.handle.accessToken}", expire_at:${entry.handle.expireAt} }。原文可回取(TTL 由 blackboardTtlDays 决定),不要重跑。${addressLine(entry.addresses)}`;
436
+ }
437
+ // The echo stub is the one that keeps NO copy of the body, so the addresses it
438
+ // reports are the only thing the next round can still act on.
439
+ return `${head} 已结算的工具输出/命令回显,正文已裁剪;如需重看请重跑原命令,或从轨迹(tm_stats { recent: N })取回。${addressLine(entry.addresses)}`;
261
440
  }
262
441
  function extractHandle(text) {
263
442
  const m = HANDLE_RE.exec(text);
@@ -279,18 +458,21 @@ function buildStub(text, tokens) {
279
458
  const child = extractChild(text);
280
459
  if (child)
281
460
  return { kind: "child", stub: renderPruneStub({ kind: "child", originalTokens: tokens, childId: child.id, childState: child.state }) };
282
- return { kind: "echo", stub: renderPruneStub({ kind: "echo", originalTokens: tokens }) };
461
+ // Only the branches that carry no recoverable pointer get the address line: a
462
+ // handle already says where the bytes are, and a child id where the report is.
463
+ return { kind: "echo", stub: renderPruneStub({ kind: "echo", originalTokens: tokens, addresses: extractAddresses(text) }) };
283
464
  }
284
465
  /**
285
466
  * Decide which messages to prune, oldest first, until the request fits the
286
467
  * budget. Pure: it reads the messages and returns a plan; nothing is mutated.
287
468
  */
288
469
  export function prunePlan(messages, opts = {}) {
289
- const plan = { prune: [], protectedCount: 0, keptCount: 0, savedTokens: 0 };
470
+ const plan = { prune: [], protectedCount: 0, protectMatched: 0, keptCount: 0, savedTokens: 0 };
290
471
  if (!Array.isArray(messages) || messages.length === 0)
291
472
  return plan;
292
473
  const keepTailTokens = Math.max(0, opts.keepTailTokens ?? 0);
293
474
  const budgetTokens = opts.budgetTokens ?? Number.POSITIVE_INFINITY;
475
+ const protectPatterns = opts.protectPatterns ?? [];
294
476
  // The tail boundary: walk from the newest message until the cumulative token
295
477
  // count would exceed the tail budget. The newest message is ALWAYS protected.
296
478
  let acc = 0;
@@ -307,6 +489,13 @@ export function prunePlan(messages, opts = {}) {
307
489
  let saved = 0;
308
490
  for (let i = 0; i < tailStart; i++) {
309
491
  const msg = messages[i];
492
+ if (protectPatterns.length) {
493
+ const body = messageText(msg);
494
+ if (body && protectPatterns.some((re) => re.test(body))) {
495
+ plan.protectMatched++;
496
+ continue;
497
+ }
498
+ }
310
499
  if (isProtectedMessage(msg)) {
311
500
  plan.protectedCount++;
312
501
  continue;
@@ -342,6 +531,63 @@ export function applyPrunePlan(messages, plan) {
342
531
  slots[i].container[slots[i].key] = "";
343
532
  }
344
533
  }
534
+ /**
535
+ * Offload a planned body BEFORE its message is rewritten, so the stub can carry
536
+ * a real `tm_fetch` handle. Only `echo` entries are offloaded: a `handle` entry
537
+ * already points at a payload (offloading it again would duplicate the bytes) and
538
+ * a `child` entry points at a session (its body lives in the child, not here).
539
+ *
540
+ * Failure is BOUNDED and per message: one throw degrades that one stub to today's
541
+ * echo note and increments a counter — the remaining plan still runs, and the
542
+ * caller's hook never sees an exception. `savedTokens` is recomputed afterwards
543
+ * because a handle stub is longer than an echo note, and a reported saving that
544
+ * did not happen is exactly the kind of claim this product refuses.
545
+ */
546
+ async function offloadPlannedBodies(messages, plan, offload, onFailure, report, sessionID) {
547
+ if (!offload || plan.prune.length === 0)
548
+ return;
549
+ let saved = 0;
550
+ for (const entry of plan.prune) {
551
+ const text = entry.kind === "echo" ? messageText(messages[entry.index]) : "";
552
+ if (entry.kind === "echo" && text) {
553
+ let handle = null;
554
+ let failure = null;
555
+ try {
556
+ handle = await offload(text);
557
+ if (!handle || !handle.ref || !handle.accessToken || !Number.isFinite(handle.expireAt)) {
558
+ failure = { reason: handle ? "invalid" : "empty" };
559
+ }
560
+ }
561
+ catch (err) {
562
+ failure = { reason: "threw", error: String(err?.message ?? err).slice(0, 120) };
563
+ }
564
+ if (handle && !failure) {
565
+ entry.kind = "offload";
566
+ entry.stub = renderPruneStub({
567
+ kind: "offload",
568
+ originalTokens: entry.originalTokens,
569
+ handle,
570
+ // The offload stub points at bytes a reader may not want to page
571
+ // through; the addresses are what they can act on in one round.
572
+ addresses: extractAddresses(text),
573
+ });
574
+ // A dedupe hit means these bytes are ALREADY in the store — counting it as
575
+ // a fresh offload would report a saving that was not made, and would make
576
+ // the card's "上轮修剪" line grow on a replayed request.
577
+ if (handle.reused)
578
+ report.reused++;
579
+ else
580
+ report.offloaded++;
581
+ }
582
+ else {
583
+ report.offloadFailed++;
584
+ onFailure({ sessionID, index: entry.index, ...(failure ?? {}) });
585
+ }
586
+ }
587
+ saved += Math.max(0, entry.originalTokens - estimateTokens(entry.stub));
588
+ }
589
+ plan.savedTokens = saved;
590
+ }
345
591
  /** The default denominator: `ctx.model.list()` keyed by the session's model,
346
592
  * the same lookup the desktop's usage meter and this package's usage readers use. */
347
593
  function defaultModelLimitOf(ctx) {
@@ -409,6 +655,9 @@ function defaultModelLimitOf(ctx) {
409
655
  }
410
656
  export async function applyV2ContextPrune(ctx, input = {}) {
411
657
  const config = input.config ?? resolvePruneConfig();
658
+ // A hand-built config (a test, an embedder) may predate the pattern list; `[]` is
659
+ // the "off" path, so tolerating it changes no behaviour and throws nothing.
660
+ const protectPatterns = config.protectPatterns ?? [];
412
661
  const report = {
413
662
  enabled: config.enabled,
414
663
  atPercent: config.atPercent,
@@ -422,7 +671,14 @@ export async function applyV2ContextPrune(ctx, input = {}) {
422
671
  noMessages: 0,
423
672
  foreignSkipped: 0,
424
673
  threw: 0,
674
+ offloaded: 0,
675
+ reused: 0,
676
+ offloadFailed: 0,
677
+ overBudget: 0,
678
+ orphans: 0,
425
679
  unknownParts: 0,
680
+ protectPatterns: protectPatterns.length,
681
+ protectMatched: 0,
426
682
  lastPercent: 0,
427
683
  lastUsed: 0,
428
684
  lastLimit: 0,
@@ -475,21 +731,50 @@ export async function applyV2ContextPrune(ctx, input = {}) {
475
731
  }
476
732
  const budgetTokens = Math.floor((limit * config.atPercent) / 100);
477
733
  const keepTailTokens = Math.floor((limit * config.keepTailPercent) / 100);
478
- const plan = prunePlan(messages, { budgetTokens, keepTailTokens });
479
- applyPrunePlan(messages, plan);
480
- report.prunedMessages += plan.prune.length;
481
- report.prunedTokens += plan.savedTokens;
482
- report.skippedProtected += plan.protectedCount;
483
- emit("prune", {
484
- sessionID,
485
- percent: pct,
486
- used,
487
- limit,
488
- pruned: plan.prune.length,
489
- saved: plan.savedTokens,
490
- protected: plan.protectedCount,
491
- unknown: report.unknownParts,
492
- });
734
+ const plan = prunePlan(messages, { budgetTokens, keepTailTokens, protectPatterns });
735
+ const freshBefore = report.offloaded;
736
+ await offloadPlannedBodies(messages, plan, input.offload, (row) => emit("prune_offload_failed", row), report, sessionID);
737
+ try {
738
+ applyPrunePlan(messages, plan);
739
+ // A handle stub is longer than the echo note it replaced, so the plan's
740
+ // own arithmetic can land ABOVE the budget. Counted, never silently
741
+ // re-pruned: a second pass would have to offload again to be lossless.
742
+ const overBudget = messagesTokens(messages) > budgetTokens;
743
+ if (overBudget)
744
+ report.overBudget++;
745
+ report.prunedMessages += plan.prune.length;
746
+ report.prunedTokens += plan.savedTokens;
747
+ report.skippedProtected += plan.protectedCount;
748
+ report.protectMatched += plan.protectMatched;
749
+ emit("prune", {
750
+ sessionID,
751
+ percent: pct,
752
+ used,
753
+ limit,
754
+ pruned: plan.prune.length,
755
+ saved: plan.savedTokens,
756
+ protected: plan.protectedCount,
757
+ protect_matched: plan.protectMatched,
758
+ protect_patterns: protectPatterns.length,
759
+ unknown: report.unknownParts,
760
+ over_budget: overBudget,
761
+ reused: report.reused,
762
+ });
763
+ }
764
+ catch (err) {
765
+ // Bodies stored for THIS request but never pointed at by a message are
766
+ // orphans: counted, and named on their own trajectory row — a payload
767
+ // nobody can reach is otherwise invisible until the TTL sweep.
768
+ // R4: only the FRESH writes of this request count. A reused handle points
769
+ // at a payload an earlier request already stored, so calling it an orphan
770
+ // would invent garbage — and deleting nothing would be the fix anyway.
771
+ const freshThisRequest = Math.max(0, report.offloaded - freshBefore);
772
+ if (freshThisRequest > 0) {
773
+ report.orphans += freshThisRequest;
774
+ emit("prune_offload_failed", { sessionID, reason: "orphaned", orphan: freshThisRequest });
775
+ }
776
+ throw err;
777
+ }
493
778
  }
494
779
  catch (err) {
495
780
  report.threw++;