dsh-context 0.52.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,381 +1,94 @@
1
1
  import { createRequire } from "node:module";
2
+ import { z } from "zod";
3
+ import { SessionLogOffset, deriveEventMessage, interruptedTurnClosers } from "@deepseek-ai/dsh-session";
2
4
  import { existsSync, readFileSync } from "node:fs";
3
5
  import { dirname, join, normalize, relative } from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
- import { z } from "zod";
6
- import { deriveEventMessage } from "@deepseek-ai/dsh-session";
7
7
  import z$1 from "@deepseek-ai/schemastery";
8
8
  import { randomUUID } from "node:crypto";
9
+ //#region src/shared/providers.ts
9
10
  /**
10
- * Recover the MCP server display label from a proxied tool name, or undefined
11
- * for non-MCP names. `dsh-mcp-client` names tools `mcp__<server>__<rawName>`
12
- * (normalized, and hash-appended when overlong/invalid — the label then shows
13
- * whatever of the server survived the truncation). The separating `__` is the
14
- * LAST one in the name, and it must sit AFTER the `mcp__` prefix: the prefix's
15
- * own separator (or an empty server right after it) is not a server.
11
+ * The provider-id seam between dsh request envelopes and the models.dev
12
+ * registry: client/cost.ts resolves price-book branches through it, and
13
+ * host/fold.ts uses the DeepSeek resolution to split the session-cost totals
14
+ * into peak/off-peak periods. Only the renames live here — an id absent
15
+ * from the table passes through verbatim.
16
16
  */
17
- function mcpServerOf(name) {
18
- if (!name.startsWith("mcp__")) return void 0;
19
- const cut = name.lastIndexOf("__");
20
- if (cut < 5) return void 0;
21
- const server = name.slice(5, cut);
22
- return server.length > 0 ? server : void 0;
23
- }
24
- /** The `mcp:<server>` display label of a proxied tool name, or undefined. */
25
- function mcpSourceOf(name) {
26
- const server = mcpServerOf(name);
27
- return server !== void 0 ? `mcp:${server}` : void 0;
17
+ const MODELS_DEV_PROVIDER_IDS = {
18
+ "deepseek-official": "deepseek",
19
+ "kimi-coding": "moonshotai",
20
+ "minimax-cn": "minimax",
21
+ "zai-coding-cn": "zhipuai"
22
+ };
23
+ /** The models.dev provider id that prices a dsh provider (identity for unmapped ids). */
24
+ function modelsDevProviderOf(dshProviderId) {
25
+ return MODELS_DEV_PROVIDER_IDS[dshProviderId] ?? dshProviderId;
28
26
  }
29
- /** The pinned package of a first-party tool name, or undefined. */
30
- function pinnedSourceOf(name) {
31
- return FIRST_PARTY_SOURCES[name];
27
+ /** Whether a dsh provider prices through DeepSeek's period-based list (peak / half-price off-peak). */
28
+ function isDeepSeekProvider(dshProviderId) {
29
+ return modelsDevProviderOf(dshProviderId) === "deepseek";
32
30
  }
33
- /**
34
- * Pinned first-party tool → plugin package map (see header comment). One entry
35
- * per model-facing name of the shipped tool packages in the official
36
- * tool-schema catalog of the supported dsh baseline; names are verified
37
- * stable across the supported harness range. Packages that mount under
38
- * distinct names per composition (bash/pwsh persistent variants, the
39
- * `subagent_fork` fixed-route alias) map to their primary package.
40
- */
41
- const FIRST_PARTY_SOURCES = Object.freeze({
42
- read: "@deepseek-ai/dsh-tool-fs",
43
- write: "@deepseek-ai/dsh-tool-fs",
44
- edit: "@deepseek-ai/dsh-tool-fs",
45
- read_image: "@deepseek-ai/dsh-tool-fs",
46
- glob: "@deepseek-ai/dsh-tool-fs-search",
47
- grep: "@deepseek-ai/dsh-tool-fs-search",
48
- str_replace_editor: "@deepseek-ai/dsh-tool-str-replace-editor",
49
- bash: "@deepseek-ai/dsh-tool-bash",
50
- pwsh: "@deepseek-ai/dsh-tool-pwsh",
51
- web_search: "@deepseek-ai/dsh-tool-web",
52
- web_fetch: "@deepseek-ai/dsh-tool-web",
53
- job_output: "@deepseek-ai/dsh-tool-jobs",
54
- job_list: "@deepseek-ai/dsh-tool-jobs",
55
- job_kill: "@deepseek-ai/dsh-tool-jobs",
56
- ask_user_question: "@deepseek-ai/dsh-tool-ask-user",
57
- plan: "@deepseek-ai/dsh-plan-mode",
58
- exit_plan_mode: "@deepseek-ai/dsh-plan-mode",
59
- skill: "@deepseek-ai/dsh-tool-skill",
60
- todo_write: "@deepseek-ai/dsh-tool-todo",
61
- subagent: "@deepseek-ai/dsh-tool-subagent",
62
- subagent_fork: "@deepseek-ai/dsh-tool-subagent",
63
- send_message: "@deepseek-ai/dsh-tool-subagent-control",
64
- interrupt_agent: "@deepseek-ai/dsh-tool-subagent-control",
65
- list_agents: "@deepseek-ai/dsh-tool-subagent-control",
66
- ralph: "@deepseek-ai/dsh-tool-ralph",
67
- workflow: "@deepseek-ai/dsh-tool-workflow",
68
- run_code: "@deepseek-ai/dsh-tools",
69
- schedule_create: "@deepseek-ai/dsh-schedule",
70
- schedule_list: "@deepseek-ai/dsh-schedule",
71
- schedule_delete: "@deepseek-ai/dsh-schedule",
72
- create_goal: "@deepseek-ai/dsh-tool-goal",
73
- get_goal: "@deepseek-ai/dsh-tool-goal",
74
- update_goal: "@deepseek-ai/dsh-tool-goal",
75
- lsp: "@deepseek-ai/dsh-tool-lsp"
76
- });
77
31
  //#endregion
78
- //#region src/host/attribution.ts
79
- /**
80
- * Live tool→plugin attribution layered on the static recovery in
81
- * toolSources.ts.
82
- *
83
- * The session log records tools as plain `ToolSchema` entries (name /
84
- * description / parameters) — the registering plugin is not in there.
85
- * toolSources.ts derives the deterministic sources (harness-logged field,
86
- * `mcp:<server>` naming, pinned first-party map). This module additionally
87
- * watches RUNTIME registrations: cordis fires the `internal/get` waterfall on
88
- * every context read of a service property, passing the READING context as the
89
- * first argument, so `reader.fiber.name` identifies the plugin that is about
90
- * to call `register()`.
91
- *
92
- * - The `internal/get` handler records who last read the `tools` service and
93
- * wraps that instance's `register` (once — an earlier wrapper of a previous
94
- * hook incarnation is peeled back to the original, so a plugin reload
95
- * re-wraps without stacking) to capture the reader at registration time
96
- * into a live map. Every wrapper is undone when the plugin unloads: the
97
- * original `register` goes back on the instance, unless a newer hook
98
- * incarnation re-wrapped it first (that incarnation's own cleanup then
99
- * owns the restore).
100
- * - When the reader slot is missing, root-named, or this plugin's own (e.g.
101
- * LOCAL-LINK plugins — dev installs via `dsh plugin add <path>` or
102
- * npm/pnpm link — whose anonymous entrypoints make cordis fall back to the
103
- * root name), the wrapped `register` falls back to the call stack: the first
104
- * frame outside this package is resolved to its nearest `package.json`
105
- * `name`. That covers both npm installs (`node_modules/<pkg>`) and local
106
- * links (any directory carrying a package.json), which never pass through
107
- * node_modules. Frames that resolve back to this package are skipped.
108
- * - `ownerOf(name)` prefers the name-derived `mcp:<server>` label (it names
109
- * the actual provider, where the live record would only ever name the MCP
110
- * proxy client), then the LIVE record — for a post-boot registration it is
111
- * the truth, even when the name collides with a pinned first-party tool —
112
- * then the pinned map (the boot-time guess for tools registered before the
113
- * hook), and finally tags tools that were ALREADY registered when the hook
114
- * installed (the boot snapshot — third-party bundles, e.g. local links like
115
- * dsh-file-claim, that applied before dsh-context) with the
116
- * `UNKNOWN_TOOL_SOURCE` sentinel: their registering plugin is unknowable,
117
- * and a bare gap would read as "no plugin" instead of "unknown plugin".
118
- *
119
- * Best-effort by design: a read separated from `register()` by an `await` can
120
- * be overwritten by another plugin's read (misattribution) and the stack
121
- * fallback needs a resolvable package.json — both degrade to the name/pinned
122
- * chain; registrations that predate the hook degrade to the unknown tag. The
123
- * hook costs roughly +1.4us per service-property read and is negligible on
124
- * the rare register path (the stack walk only runs when the reader slot is
125
- * unusable, and its package lookups are cached per directory).
126
- */
127
- /** This module's own file URL — the stack walk skips its own frames. */
128
- const selfUrl = normalize(fileURLToPath(import.meta.url));
129
- /** Directory → package-name cache for the synchronous walk below. */
130
- const packageCache = /* @__PURE__ */ new Map();
32
+ //#region src/shared/estimate.ts
131
33
  /**
132
- * Best-effort package name for a module file: walk up to the nearest
133
- * `package.json` carrying a `name`. Works for dependencies installed under
134
- * `node_modules` as well as local links whose package root is any on-disk
135
- * directory. The per-directory results are cached.
136
- * @param file - absolute path of a module file.
34
+ * Token heuristics shared by the host fold and the client boundary — the
35
+ * harness token-meter's own fixed-density figure (dsh-token-meter/estimate.ts:
36
+ * ~4 chars ≈ 1 token, +4 role framing). Priced identically on both sides so a
37
+ * legacy value normalized at the client boundary matches what the host view
38
+ * would have served.
137
39
  */
138
- function packageNameFrom(file) {
139
- let dir = dirname(file);
140
- for (let depth = 0; depth < 12; depth++) {
141
- const cached = packageCache.get(dir);
142
- if (cached !== void 0) return cached;
143
- const packageFile = join(dir, "package.json");
144
- if (existsSync(packageFile)) try {
145
- const name = JSON.parse(readFileSync(packageFile, "utf8")).name;
146
- if (typeof name === "string" && name) {
147
- packageCache.set(dir, name);
148
- return name;
149
- }
150
- } catch {}
151
- const parent = dirname(dir);
152
- packageCache.set(dir, void 0);
153
- if (parent === dir) return void 0;
154
- dir = parent;
155
- }
40
+ const CHARS_PER_TOKEN$1 = 4;
41
+ const ROLE_OVERHEAD$1 = 4;
42
+ /** Price rendered system-prompt text; 0 for absent/empty/non-string input. */
43
+ function estimateSystemTokens(text) {
44
+ if (typeof text !== "string" || text.length === 0) return 0;
45
+ return Math.ceil(text.length / CHARS_PER_TOKEN$1) + ROLE_OVERHEAD$1;
156
46
  }
157
- const FRAME_POSITION = /:\d+:\d+$/;
158
- /** Package name of this module's own package (self-fallbacks are filtered). */
159
- const selfPackage = packageNameFrom(selfUrl);
160
47
  /**
161
- * Resolve the registering package from a stack trace: walk frames from the
162
- * innermost out, skipping this module's own frames and frames that resolve to
163
- * this package, and return the package name of the first frame that resolves
164
- * elsewhere. Works with both `file://` URLs and bare absolute paths
165
- * (transpiled modules render without a scheme), with optional `fn (...)` and
166
- * `async` wrappers.
167
- * @param stack - `Error().stack`, or undefined when no fallback is desired.
48
+ * Price a `system/message` payload's content exactly like the harness's
49
+ * token-meter (`estimateSystemMessage`): text density over EVERY text block
50
+ * plus role framing, with no per-block overhead — an adapter serializes the
51
+ * prompt as plain text, so a text block costs its characters alone. Any other
52
+ * block (or a hostile element) falls back to its JSON length. 0 for empty
53
+ * content, which the harness reads as "no system prompt".
168
54
  */
169
- function callerPackageFrom(stack) {
170
- if (!stack) return void 0;
171
- for (const raw of stack.split("\n").slice(1)) {
172
- let line = raw.trim();
173
- if (!line.startsWith("at ")) continue;
174
- line = line.slice(3);
175
- if (line.startsWith("async ")) line = line.slice(6);
176
- line = line.replace(/\)\s*$/, "");
177
- const position = FRAME_POSITION.exec(line);
178
- if (!position) continue;
179
- let target = line.slice(0, -position[0].length);
180
- if (target.includes("(")) target = target.slice(target.lastIndexOf("(") + 1);
181
- if (target.startsWith("file://")) try {
182
- target = normalize(fileURLToPath(target));
183
- } catch {
55
+ function estimateSystemContent(blocks) {
56
+ if (!Array.isArray(blocks) || blocks.length === 0) return 0;
57
+ let characters = 0;
58
+ for (const block of blocks) {
59
+ const text = block !== null && typeof block === "object" && block.type === "text" ? block.text : void 0;
60
+ if (typeof text === "string") {
61
+ characters += text.length;
184
62
  continue;
185
63
  }
186
- else if (!/^[A-Za-z]:[\\/]/.test(target) && !target.startsWith("/") && !target.startsWith("\\\\")) continue;
187
- else target = normalize(target);
188
- if (target === selfUrl) continue;
189
- const name = packageNameFrom(target);
190
- if (name !== void 0 && name !== selfPackage) return name;
64
+ try {
65
+ const json = JSON.stringify(block);
66
+ if (typeof json === "string") characters += json.length;
67
+ } catch {}
191
68
  }
69
+ return Math.ceil(characters / CHARS_PER_TOKEN$1) + ROLE_OVERHEAD$1;
192
70
  }
71
+ //#endregion
72
+ //#region src/shared/imageTokens.ts
193
73
  /**
194
- * Install the runtime-attribution hook on a cordis app context. The hook
195
- * rides the calling fiber's lifetime (`ctx.on`, and an effect that restores
196
- * every patched `register`), so it is disposed with the plugin.
197
- * @param ctx - the context the dsh-context plugin runs in; its fiber name is
198
- * excluded from attributions.
199
- */
200
- function createToolAttribution(ctx) {
201
- const live = /* @__PURE__ */ new Map();
202
- const wrapped = /* @__PURE__ */ new WeakSet();
203
- const self = ctx.fiber.name;
204
- let lastReader;
205
- const patched = [];
206
- const wrapInstance = (tools) => {
207
- if (!tools || typeof tools !== "object" || wrapped.has(tools)) return;
208
- const register = tools.register;
209
- if (typeof register !== "function") return;
210
- wrapped.add(tools);
211
- const original = register.attributedOriginal ?? register;
212
- if (typeof original !== "function") return;
213
- const instance = tools;
214
- const wrappedRegister = function(definition) {
215
- const toolName = definition?.name;
216
- let owner = lastReader?.fiber.name;
217
- if (!owner || owner === "root" || owner === self) owner = callerPackageFrom((/* @__PURE__ */ new Error()).stack);
218
- const dispose = original.call(this, definition);
219
- if (typeof toolName === "string" && owner && owner !== "root" && owner !== self && owner !== selfPackage) {
220
- live.set(toolName, owner);
221
- if (typeof dispose === "function") return () => {
222
- try {
223
- return dispose();
224
- } finally {
225
- live.delete(toolName);
226
- }
227
- };
228
- }
229
- return dispose;
230
- };
231
- wrappedRegister.attributedOriginal = original;
232
- instance.register = wrappedRegister;
233
- patched.push(() => {
234
- if (instance.register === wrappedRegister) instance.register = original;
235
- });
236
- };
237
- ctx.on("internal/get", (reader, name, _error, next) => {
238
- if (name !== "tools") return next();
239
- const tools = next();
240
- lastReader = reader;
241
- wrapInstance(tools);
242
- return tools;
243
- });
244
- ctx.effect(() => () => {
245
- for (const restore of patched.splice(0)) restore();
246
- }, "tools.register attribution");
247
- const toolsService = ctx.get("tools", false);
248
- wrapInstance(toolsService);
249
- const boot = /* @__PURE__ */ new Set();
250
- try {
251
- const toolEntries = toolsService?.layers?.global?.tools;
252
- if (toolEntries !== void 0 && typeof toolEntries.entries === "function") for (const [name] of toolEntries.entries()) boot.add(name);
253
- } catch {}
254
- return { ownerOf: (name) => mcpSourceOf(name) ?? live.get(name) ?? pinnedSourceOf(name) ?? (boot.has(name) ? "<unknown-plugin>" : void 0) };
255
- }
256
- //#endregion
257
- //#region src/host/config.ts
258
- /**
259
- * dsh-context host configuration — the `config:` block of the `dsh-context`
260
- * loader row in cordis.yml.
261
- *
262
- * Cordis validates the entry config against this exported `Config` schema
263
- * (any Standard Schema v1 validator — zod is ours) before `apply` runs, fills
264
- * per-field defaults, and fails the load loudly on invalid or unknown keys
265
- * (`.strict()`). The official plugin-config principle this answers: "anything
266
- * that two deployments may want to set differently is a configuration field".
267
- *
268
- * The persisted projection state shape is independent of these bounds — they
269
- * only tune the fold's retention / presentation slice, so changing them never
270
- * requires a projection `stateVersion` bump.
271
- */
272
- const DEFAULT_BOUNDS = {
273
- maxRequestSteps: 1500,
274
- maxKeptTurns: 300,
275
- maxEvents: 400,
276
- maxNodes: 2e3,
277
- maxArchiveNodes: 400,
278
- maxFileOps: 400
279
- };
280
- /**
281
- * The cordis `Config` validator: strict on keys, defaults on the schema fields; tolerates `undefined` (a patch row without a `config:`
282
- * block — defaults win).
283
- */
284
- const Config = z.preprocess((v) => v ?? {}, z.object({
285
- maxRequestSteps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxRequestSteps),
286
- maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
287
- maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
288
- maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
289
- maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes),
290
- maxFileOps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxFileOps)
291
- }).strict());
292
- function resolveBounds(config) {
293
- return Config.parse(config ?? {});
294
- }
295
- //#endregion
296
- //#region src/shared/providers.ts
297
- /**
298
- * The provider-id seam between dsh request envelopes and the models.dev
299
- * registry: client/cost.ts resolves price-book branches through it, and
300
- * host/fold.ts uses the DeepSeek resolution to split the session-cost totals
301
- * into peak/off-peak periods. Only the renames live here — an id absent
302
- * from the table passes through verbatim.
303
- */
304
- const MODELS_DEV_PROVIDER_IDS = {
305
- "deepseek-official": "deepseek",
306
- "kimi-coding": "moonshotai",
307
- "minimax-cn": "minimax",
308
- "zai-coding-cn": "zhipuai"
309
- };
310
- /** The models.dev provider id that prices a dsh provider (identity for unmapped ids). */
311
- function modelsDevProviderOf(dshProviderId) {
312
- return MODELS_DEV_PROVIDER_IDS[dshProviderId] ?? dshProviderId;
313
- }
314
- /** Whether a dsh provider prices through DeepSeek's period-based list (peak / half-price off-peak). */
315
- function isDeepSeekProvider(dshProviderId) {
316
- return modelsDevProviderOf(dshProviderId) === "deepseek";
317
- }
318
- //#endregion
319
- //#region src/shared/estimate.ts
320
- /**
321
- * Token heuristics shared by the host fold and the client boundary — the
322
- * harness token-meter's own fixed-density figure (dsh-token-meter/estimate.ts:
323
- * ~4 chars ≈ 1 token, +4 role framing). Priced identically on both sides so a
324
- * legacy value normalized at the client boundary matches what the host view
325
- * would have served.
326
- */
327
- const CHARS_PER_TOKEN$1 = 4;
328
- const ROLE_OVERHEAD$1 = 4;
329
- /** Price rendered system-prompt text; 0 for absent/empty/non-string input. */
330
- function estimateSystemTokens(text) {
331
- if (typeof text !== "string" || text.length === 0) return 0;
332
- return Math.ceil(text.length / CHARS_PER_TOKEN$1) + ROLE_OVERHEAD$1;
333
- }
334
- /**
335
- * Price a `system/message` payload's content exactly like the harness's
336
- * token-meter (`estimateSystemMessage`): text density over EVERY text block
337
- * plus role framing, with no per-block overhead — an adapter serializes the
338
- * prompt as plain text, so a text block costs its characters alone. Any other
339
- * block (or a hostile element) falls back to its JSON length. 0 for empty
340
- * content, which the harness reads as "no system prompt".
341
- */
342
- function estimateSystemContent(blocks) {
343
- if (!Array.isArray(blocks) || blocks.length === 0) return 0;
344
- let characters = 0;
345
- for (const block of blocks) {
346
- const text = block !== null && typeof block === "object" && block.type === "text" ? block.text : void 0;
347
- if (typeof text === "string") {
348
- characters += text.length;
349
- continue;
350
- }
351
- try {
352
- const json = JSON.stringify(block);
353
- if (typeof json === "string") characters += json.length;
354
- } catch {}
355
- }
356
- return Math.ceil(characters / CHARS_PER_TOKEN$1) + ROLE_OVERHEAD$1;
357
- }
358
- //#endregion
359
- //#region src/shared/imageTokens.ts
360
- /**
361
- * Per-image token estimate for DeepSeek's vision model — a faithful port of
362
- * the official "图片 Token 计算器" (Image Token Calculator) shipped on the
363
- * DeepSeek API docs (https://api-docs.deepseek.com/zh-cn/quick_start/token_usage),
364
- * which implements the provider's own image→token conversion:
365
- *
366
- * - every image is aspect-preserved rescaled before entering the model:
367
- * below ~384×384 total pixels it is enlarged, above it is shrunk;
368
- * - tokens follow the patch grid (patch 14px, downsample 3), so every
369
- * image costs at least 117 and at most ~384 tokens (the documented cap).
370
- *
371
- * Verified against the docs calculator itself: 2048×1365→313, 800×600→341,
372
- * 2048×2048→349, 512×512→201, 100×100→117, 1920×1080→369, 400×900→249.
373
- * The DSH request pipeline's own 640k-pixel pre-resize does not change the
374
- * result (the provider formula rescales to the same patch grid), so the
375
- * durable attachment dimensions can be fed in directly.
376
- *
377
- * Pure math shared by the Host fold (message pricing) and the Client
378
- * (attachment card token badges) — no dependencies, never mutates.
74
+ * Per-image token estimate for DeepSeek's vision model — a faithful port of
75
+ * the official "图片 Token 计算器" (Image Token Calculator) shipped on the
76
+ * DeepSeek API docs (https://api-docs.deepseek.com/zh-cn/quick_start/token_usage),
77
+ * which implements the provider's own image→token conversion:
78
+ *
79
+ * - every image is aspect-preserved rescaled before entering the model:
80
+ * below ~384×384 total pixels it is enlarged, above it is shrunk;
81
+ * - tokens follow the patch grid (patch 14px, downsample 3), so every
82
+ * image costs at least 117 and at most ~384 tokens (the documented cap).
83
+ *
84
+ * Verified against the docs calculator itself: 2048×1365→313, 800×600→341,
85
+ * 2048×2048→349, 512×512→201, 100×100→117, 1920×1080→369, 400×900→249.
86
+ * The DSH request pipeline's own 640k-pixel pre-resize does not change the
87
+ * result (the provider formula rescales to the same patch grid), so the
88
+ * durable attachment dimensions can be fed in directly.
89
+ *
90
+ * Pure math shared by the Host fold (message pricing) and the Client
91
+ * (attachment card token badges) — no dependencies, never mutates.
379
92
  */
380
93
  const PATCH_SIZE = 14;
381
94
  const DOWNSAMPLE_RATIO = 3;
@@ -1292,6 +1005,10 @@ function applySurface(st, ev, type, data, message) {
1292
1005
  * schemas' `.int().nonnegative()` gates on EVERY later delivery, permanently
1293
1006
  * freezing the projection feed for the session (issue #44). NaN, infinities,
1294
1007
  * and non-numeric values read as absent.
1008
+ *
1009
+ * Exported for the activity unit (host/activity.ts): the daily ledger re-proves
1010
+ * the same durable usage buckets with the same sanitizer, so one raw figure
1011
+ * can never enter either fold's state.
1295
1012
  */
1296
1013
  function tokenCountOf(value) {
1297
1014
  if (typeof value === "number") return Number.isFinite(value) ? Math.max(0, Math.round(value)) : null;
@@ -1636,7 +1353,10 @@ function applyTimeline(state, event, bounds) {
1636
1353
  if (source.form === "notice" && typeof source.summary === "string" && source.summary !== "") rec.detail = source.summary;
1637
1354
  }
1638
1355
  s.events.push(rec);
1639
- } else s.humanInputs = (s.humanInputs ?? 0) + 1;
1356
+ } else {
1357
+ s.humanInputs = (s.humanInputs ?? 0) + 1;
1358
+ if (node.text !== void 0 && node.text !== "") s.lastUser = node.text;
1359
+ }
1640
1360
  break;
1641
1361
  }
1642
1362
  case "tool/result": {
@@ -1819,6 +1539,7 @@ function headFieldsOf(state) {
1819
1539
  images: state.surface.reduce((n, node) => n + (node.imgs ?? 0), 0),
1820
1540
  toolCalls: state.surface.reduce((n, node) => node.cat === "tool" || node.cat === "skill" && node.tool !== void 0 ? n + 1 : n, 0),
1821
1541
  humanInputs: state.humanInputs ?? 0,
1542
+ ...state.lastUser !== void 0 ? { lastUser: state.lastUser } : {},
1822
1543
  requests: [],
1823
1544
  events: [],
1824
1545
  nodes: [],
@@ -1848,114 +1569,640 @@ function headFieldsOf(state) {
1848
1569
  tools
1849
1570
  };
1850
1571
  }
1851
- if (state.systems !== void 0 && state.systems.length > 0) result.systems = state.systems.map((n) => ({ ...n }));
1852
- return result;
1572
+ if (state.systems !== void 0 && state.systems.length > 0) result.systems = state.systems.map((n) => ({ ...n }));
1573
+ return result;
1574
+ }
1575
+ /**
1576
+ * The heavy collections: copies of the retained request records and context
1577
+ * events (each event attached to the requests around it — the chart's ✂
1578
+ * anchoring), the bounded served surface window, and the removed-node
1579
+ * archive. Shared verbatim by the inline wire view (channel-less hosts) and
1580
+ * the on-demand detail payload (host/detail.ts).
1581
+ */
1582
+ function detailCollectionsOf(state, bounds) {
1583
+ const result = {
1584
+ requests: state.requests.map((r) => ({ ...r })),
1585
+ events: state.events.map((e) => ({ ...e })),
1586
+ nodes: [],
1587
+ droppedNodes: 0,
1588
+ archive: state.archived.map((n) => ({ ...n })),
1589
+ fileOps: state.fileOps.map((o) => ({ ...o })),
1590
+ ...state.fileOpsFloor !== void 0 ? { fileOpsFloor: state.fileOpsFloor } : {}
1591
+ };
1592
+ const overflowCount = Math.max(0, state.surface.length - bounds.maxNodes);
1593
+ const overflow = state.surface.slice(0, overflowCount);
1594
+ const tail = state.surface.slice(overflowCount);
1595
+ const pinned = overflow.filter((n) => n.cat === "inject" || n.cat === "skill");
1596
+ result.nodes = pinned.length > 0 ? [...pinned, ...tail] : tail;
1597
+ result.droppedNodes = overflowCount - pinned.length;
1598
+ if (result.droppedNodes > 0) {
1599
+ let floor = 0;
1600
+ for (const n of overflow) if (n.cat !== "inject" && n.cat !== "skill") floor = Math.max(floor, n.seq);
1601
+ result.surfaceFloor = floor;
1602
+ }
1603
+ if (state.archiveFloor !== void 0) result.archiveFloor = state.archiveFloor;
1604
+ const requests = result.requests;
1605
+ const events = result.events;
1606
+ let ri = 0;
1607
+ for (const ev of events) {
1608
+ while (ri < requests.length && requests[ri].seq <= ev.seq) ri++;
1609
+ const next = requests.at(ri);
1610
+ const prev = ri > 0 ? requests.at(ri - 1) : void 0;
1611
+ if (next !== void 0 && typeof next.turn === "number" && typeof next.step === "number") {
1612
+ ev.turn = next.turn;
1613
+ ev.step = next.step;
1614
+ }
1615
+ if (prev !== void 0 && typeof prev.turn === "number" && typeof prev.step === "number") {
1616
+ ev.fromTurn = prev.turn;
1617
+ ev.fromStep = prev.step;
1618
+ }
1619
+ }
1620
+ return result;
1621
+ }
1622
+ /**
1623
+ * The split generation's SLIM wire head: the envelope scalars plus the
1624
+ * precomputed count figures, the newest request's billing summary (the
1625
+ * headline's derived anchor), and the detail revision marker. Small enough
1626
+ * to ride every delivery channel whole (~1KB) — the heavy collections moved
1627
+ * to the on-demand detail channel (host/detail.ts).
1628
+ */
1629
+ function buildTimelineHead(state) {
1630
+ const result = headFieldsOf(state);
1631
+ const turns = /* @__PURE__ */ new Set();
1632
+ for (const r of state.requests) turns.add(r.turn ?? 0);
1633
+ let injects = 0;
1634
+ let compactions = 0;
1635
+ let prunes = 0;
1636
+ for (const e of state.events) if (e.kind === "inject") injects++;
1637
+ else if (e.kind === "compaction") compactions++;
1638
+ else if (e.kind === "prune") prunes++;
1639
+ result.counts = {
1640
+ turns: turns.size,
1641
+ steps: state.requests.length,
1642
+ injects,
1643
+ compactions,
1644
+ prunes
1645
+ };
1646
+ const last = state.requests.at(-1);
1647
+ if (last !== void 0) result.last = {
1648
+ seq: last.seq,
1649
+ total: last.total,
1650
+ ...typeof last.prompt === "number" ? { prompt: last.prompt } : {}
1651
+ };
1652
+ result.detailRev = state.detailRev ?? 0;
1653
+ return result;
1654
+ }
1655
+ /**
1656
+ * The on-demand detail payload (host/detail.ts serves it off the live fold
1657
+ * state): the heavy collections, the slim head at the SAME cut (the Agent
1658
+ * network's cold-node ring fetch renders composition off it), and the
1659
+ * revision marker the head carries.
1660
+ */
1661
+ function buildTimelineDetail(state, bounds) {
1662
+ return {
1663
+ rev: state.detailRev ?? 0,
1664
+ head: buildTimelineHead(state),
1665
+ ...detailCollectionsOf(state, bounds)
1666
+ };
1667
+ }
1668
+ /**
1669
+ * Serve the INLINE projection wire view (channel-less hosts): the head
1670
+ * scalars with the detail collections in place — the shape every delivery
1671
+ * channel carried before the split generation. Bound the surface nodes to
1672
+ * the newest tail and attach each event to the request around it; stamp
1673
+ * COPIES — the persisted state objects are never mutated.
1674
+ */
1675
+ function buildTimelineView(state, bounds) {
1676
+ return {
1677
+ ...headFieldsOf(state),
1678
+ ...detailCollectionsOf(state, bounds)
1679
+ };
1680
+ }
1681
+ //#endregion
1682
+ //#region src/shared/days.ts
1683
+ /** Zero-pad to two digits (no Intl allocation on the hot path). */
1684
+ function pad2(n) {
1685
+ return n < 10 ? "0" + String(n) : String(n);
1686
+ }
1687
+ /**
1688
+ * The local calendar-day key of an epoch-ms instant, or null when the time
1689
+ * is not a finite number or falls outside the representable date range —
1690
+ * untrusted log data can never produce a garbage key.
1691
+ */
1692
+ function dayKeyOf(time) {
1693
+ if (!Number.isFinite(time)) return null;
1694
+ const d = new Date(time);
1695
+ const y = d.getFullYear();
1696
+ if (!Number.isFinite(y) || y < 1970 || y > 9999) return null;
1697
+ return `${String(y).padStart(4, "0")}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
1698
+ }
1699
+ //#endregion
1700
+ //#region src/host/activity.ts
1701
+ /**
1702
+ * The `contextActivity` session projection unit — the per-day activity
1703
+ * ledger behind the Context Dashboard's heatmap.
1704
+ *
1705
+ * The timeline unit (fold.ts) is a "current snapshot" fold: it prices the
1706
+ * context as it stands NOW, which cannot draw a per-day chart. This unit
1707
+ * folds the same committed event stream into a ledger keyed by local
1708
+ * calendar day (shared/days.ts): every assistant settlement adds one
1709
+ * completed request to its day and, when the provider reported a usage
1710
+ * object, the day's billed-token figure grows by the disjoint buckets
1711
+ * (uncached input + cache read + cache write + output — the same semantics
1712
+ * the timeline's request records and the official token meter use). The
1713
+ * overview merges every listed session's ledger off the session list's
1714
+ * projection column (no per-session open needed) and colors its heatmap.
1715
+ *
1716
+ * One wire contract, one small state: a day entry is two integers and the
1717
+ * retention cap keeps at most {@link MAX_KEPT_DAYS} keys, so the value
1718
+ * riding every session-list row stays trivial next to the timeline head.
1719
+ * Same projection contract as the sibling units: pure init/apply/view,
1720
+ * unknown or malformed events return the state unchanged, and no
1721
+ * `undefined`-valued property ever materializes (the plain-JSON persisted-
1722
+ * state precondition).
1723
+ */
1724
+ /**
1725
+ * Retention cap on ledger days. A little over a year of daily-active
1726
+ * sessions; the oldest keys drop first (key order IS chronological order).
1727
+ */
1728
+ const MAX_KEPT_DAYS = 400;
1729
+ const activityDaySchema = z.object({
1730
+ tokens: z.number().int().nonnegative(),
1731
+ requests: z.number().int().nonnegative()
1732
+ }).strict();
1733
+ /** Validate the wire payload before it leaves the host (strict: no drift). */
1734
+ const contextActivitySchema = z.object({ days: z.record(z.string(), activityDaySchema) }).strict();
1735
+ /** The persisted fold-state schema — identical shape, validated before a checkpoint row seeds a fold. */
1736
+ const activityStateSchema = z.object({ days: z.record(z.string(), activityDaySchema) });
1737
+ /**
1738
+ * The day's billed increment from one durable usage object: the four
1739
+ * disjoint buckets summed after the shared per-bucket sanitizer
1740
+ * ({@link tokenCountOf} — fractions round, negatives clamp, garbage reads
1741
+ * absent). A usage object with NO readable bucket contributes nothing, so a
1742
+ * settlement that carried no usage still counts its request without
1743
+ * fabricating tokens.
1744
+ */
1745
+ function billedOfUsage(value) {
1746
+ if (value === null || typeof value !== "object") return 0;
1747
+ const usage = value;
1748
+ let any = false;
1749
+ let total = 0;
1750
+ for (const bucket of [
1751
+ usage.inputTokens,
1752
+ usage.cacheReadTokens,
1753
+ usage.cacheWriteTokens,
1754
+ usage.outputTokens
1755
+ ]) {
1756
+ const count = tokenCountOf(bucket);
1757
+ if (count !== null) {
1758
+ total += count;
1759
+ any = true;
1760
+ }
1761
+ }
1762
+ return any ? total : 0;
1763
+ }
1764
+ /**
1765
+ * Fold one committed event into the ledger. Only `assistant/message` (the
1766
+ * step settlement — both supported log generations) advances a day; every
1767
+ * other type returns the state reference unchanged, as does a settlement
1768
+ * with an unreadable time. The next state is a copy along the mutated path
1769
+ * only — the persisted previous state is never mutated in place.
1770
+ */
1771
+ function applyActivity(state, event) {
1772
+ if (event.type !== "assistant/message") return state;
1773
+ const key = dayKeyOf(event.time);
1774
+ if (key === null) return state;
1775
+ const data = event.data;
1776
+ const tokens = billedOfUsage(data?.usage);
1777
+ const prev = state.days[key];
1778
+ const entry = {
1779
+ tokens: prev === void 0 ? tokens : prev.tokens + tokens,
1780
+ requests: prev === void 0 ? 1 : prev.requests + 1
1781
+ };
1782
+ let days = {
1783
+ ...state.days,
1784
+ [key]: entry
1785
+ };
1786
+ const keys = Object.keys(days);
1787
+ if (keys.length > MAX_KEPT_DAYS) {
1788
+ keys.sort();
1789
+ const drop = new Set(keys.slice(0, keys.length - MAX_KEPT_DAYS));
1790
+ days = Object.fromEntries(Object.entries(days).filter(([k]) => !drop.has(k)));
1791
+ }
1792
+ return { days };
1793
+ }
1794
+ /**
1795
+ * The daily-activity projection unit, registered alongside the timeline and
1796
+ * headers units (host/index.ts); the overview reads it through the session
1797
+ * list's projection column. `stateVersion` 1: the ledger's first shape.
1798
+ */
1799
+ function createContextActivityDefinition() {
1800
+ return {
1801
+ key: "contextActivity",
1802
+ stateSchema: activityStateSchema,
1803
+ init: () => ({ days: {} }),
1804
+ apply: (state, event) => applyActivity(state, event),
1805
+ wire: {
1806
+ viewSchema: contextActivitySchema,
1807
+ view: (state) => ({ days: Object.fromEntries(Object.entries(state.days).map(([k, v]) => [k, { ...v }])) })
1808
+ },
1809
+ stateVersion: 1
1810
+ };
1811
+ }
1812
+ /**
1813
+ * Recover the MCP server display label from a proxied tool name, or undefined
1814
+ * for non-MCP names. `dsh-mcp-client` names tools `mcp__<server>__<rawName>`
1815
+ * (normalized, and hash-appended when overlong/invalid — the label then shows
1816
+ * whatever of the server survived the truncation). The separating `__` is the
1817
+ * LAST one in the name, and it must sit AFTER the `mcp__` prefix: the prefix's
1818
+ * own separator (or an empty server right after it) is not a server.
1819
+ */
1820
+ function mcpServerOf(name) {
1821
+ if (!name.startsWith("mcp__")) return void 0;
1822
+ const cut = name.lastIndexOf("__");
1823
+ if (cut < 5) return void 0;
1824
+ const server = name.slice(5, cut);
1825
+ return server.length > 0 ? server : void 0;
1826
+ }
1827
+ /** The `mcp:<server>` display label of a proxied tool name, or undefined. */
1828
+ function mcpSourceOf(name) {
1829
+ const server = mcpServerOf(name);
1830
+ return server !== void 0 ? `mcp:${server}` : void 0;
1831
+ }
1832
+ /** The pinned package of a first-party tool name, or undefined. */
1833
+ function pinnedSourceOf(name) {
1834
+ return FIRST_PARTY_SOURCES[name];
1835
+ }
1836
+ /**
1837
+ * Pinned first-party tool → plugin package map (see header comment). One entry
1838
+ * per model-facing name of the shipped tool packages in the official
1839
+ * tool-schema catalog of the supported dsh baseline; names are verified
1840
+ * stable across the supported harness range. Packages that mount under
1841
+ * distinct names per composition (bash/pwsh persistent variants, the
1842
+ * `subagent_fork` fixed-route alias) map to their primary package.
1843
+ */
1844
+ const FIRST_PARTY_SOURCES = Object.freeze({
1845
+ read: "@deepseek-ai/dsh-tool-fs",
1846
+ write: "@deepseek-ai/dsh-tool-fs",
1847
+ edit: "@deepseek-ai/dsh-tool-fs",
1848
+ read_image: "@deepseek-ai/dsh-tool-fs",
1849
+ glob: "@deepseek-ai/dsh-tool-fs-search",
1850
+ grep: "@deepseek-ai/dsh-tool-fs-search",
1851
+ str_replace_editor: "@deepseek-ai/dsh-tool-str-replace-editor",
1852
+ bash: "@deepseek-ai/dsh-tool-bash",
1853
+ pwsh: "@deepseek-ai/dsh-tool-pwsh",
1854
+ web_search: "@deepseek-ai/dsh-tool-web",
1855
+ web_fetch: "@deepseek-ai/dsh-tool-web",
1856
+ job_output: "@deepseek-ai/dsh-tool-jobs",
1857
+ job_list: "@deepseek-ai/dsh-tool-jobs",
1858
+ job_kill: "@deepseek-ai/dsh-tool-jobs",
1859
+ ask_user_question: "@deepseek-ai/dsh-tool-ask-user",
1860
+ plan: "@deepseek-ai/dsh-plan-mode",
1861
+ exit_plan_mode: "@deepseek-ai/dsh-plan-mode",
1862
+ skill: "@deepseek-ai/dsh-tool-skill",
1863
+ todo_write: "@deepseek-ai/dsh-tool-todo",
1864
+ subagent: "@deepseek-ai/dsh-tool-subagent",
1865
+ subagent_fork: "@deepseek-ai/dsh-tool-subagent",
1866
+ send_message: "@deepseek-ai/dsh-tool-subagent-control",
1867
+ interrupt_agent: "@deepseek-ai/dsh-tool-subagent-control",
1868
+ list_agents: "@deepseek-ai/dsh-tool-subagent-control",
1869
+ ralph: "@deepseek-ai/dsh-tool-ralph",
1870
+ workflow: "@deepseek-ai/dsh-tool-workflow",
1871
+ run_code: "@deepseek-ai/dsh-tools",
1872
+ schedule_create: "@deepseek-ai/dsh-schedule",
1873
+ schedule_list: "@deepseek-ai/dsh-schedule",
1874
+ schedule_delete: "@deepseek-ai/dsh-schedule",
1875
+ create_goal: "@deepseek-ai/dsh-tool-goal",
1876
+ get_goal: "@deepseek-ai/dsh-tool-goal",
1877
+ update_goal: "@deepseek-ai/dsh-tool-goal",
1878
+ lsp: "@deepseek-ai/dsh-tool-lsp"
1879
+ });
1880
+ //#endregion
1881
+ //#region src/host/attribution.ts
1882
+ /**
1883
+ * Live tool→plugin attribution layered on the static recovery in
1884
+ * toolSources.ts.
1885
+ *
1886
+ * The session log records tools as plain `ToolSchema` entries (name /
1887
+ * description / parameters) — the registering plugin is not in there.
1888
+ * toolSources.ts derives the deterministic sources (harness-logged field,
1889
+ * `mcp:<server>` naming, pinned first-party map). This module additionally
1890
+ * watches RUNTIME registrations: cordis fires the `internal/get` waterfall on
1891
+ * every context read of a service property, passing the READING context as the
1892
+ * first argument, so `reader.fiber.name` identifies the plugin that is about
1893
+ * to call `register()`.
1894
+ *
1895
+ * - The `internal/get` handler records who last read the `tools` service and
1896
+ * wraps that instance's `register` (once — an earlier wrapper of a previous
1897
+ * hook incarnation is peeled back to the original, so a plugin reload
1898
+ * re-wraps without stacking) to capture the reader at registration time
1899
+ * into a live map. Every wrapper is undone when the plugin unloads: the
1900
+ * original `register` goes back on the instance, unless a newer hook
1901
+ * incarnation re-wrapped it first (that incarnation's own cleanup then
1902
+ * owns the restore).
1903
+ * - When the reader slot is missing, root-named, or this plugin's own (e.g.
1904
+ * LOCAL-LINK plugins — dev installs via `dsh plugin add <path>` or
1905
+ * npm/pnpm link — whose anonymous entrypoints make cordis fall back to the
1906
+ * root name), the wrapped `register` falls back to the call stack: the first
1907
+ * frame outside this package is resolved to its nearest `package.json`
1908
+ * `name`. That covers both npm installs (`node_modules/<pkg>`) and local
1909
+ * links (any directory carrying a package.json), which never pass through
1910
+ * node_modules. Frames that resolve back to this package are skipped.
1911
+ * - `ownerOf(name)` prefers the name-derived `mcp:<server>` label (it names
1912
+ * the actual provider, where the live record would only ever name the MCP
1913
+ * proxy client), then the LIVE record — for a post-boot registration it is
1914
+ * the truth, even when the name collides with a pinned first-party tool —
1915
+ * then the pinned map (the boot-time guess for tools registered before the
1916
+ * hook), and finally tags tools that were ALREADY registered when the hook
1917
+ * installed (the boot snapshot — third-party bundles, e.g. local links like
1918
+ * dsh-file-claim, that applied before dsh-context) with the
1919
+ * `UNKNOWN_TOOL_SOURCE` sentinel: their registering plugin is unknowable,
1920
+ * and a bare gap would read as "no plugin" instead of "unknown plugin".
1921
+ *
1922
+ * Best-effort by design: a read separated from `register()` by an `await` can
1923
+ * be overwritten by another plugin's read (misattribution) and the stack
1924
+ * fallback needs a resolvable package.json — both degrade to the name/pinned
1925
+ * chain; registrations that predate the hook degrade to the unknown tag. The
1926
+ * hook costs roughly +1.4us per service-property read and is negligible on
1927
+ * the rare register path (the stack walk only runs when the reader slot is
1928
+ * unusable, and its package lookups are cached per directory).
1929
+ */
1930
+ /** This module's own file URL — the stack walk skips its own frames. */
1931
+ const selfUrl = normalize(fileURLToPath(import.meta.url));
1932
+ /** Directory → package-name cache for the synchronous walk below. */
1933
+ const packageCache = /* @__PURE__ */ new Map();
1934
+ /**
1935
+ * Best-effort package name for a module file: walk up to the nearest
1936
+ * `package.json` carrying a `name`. Works for dependencies installed under
1937
+ * `node_modules` as well as local links whose package root is any on-disk
1938
+ * directory. The per-directory results are cached.
1939
+ * @param file - absolute path of a module file.
1940
+ */
1941
+ function packageNameFrom(file) {
1942
+ let dir = dirname(file);
1943
+ for (let depth = 0; depth < 12; depth++) {
1944
+ const cached = packageCache.get(dir);
1945
+ if (cached !== void 0) return cached;
1946
+ const packageFile = join(dir, "package.json");
1947
+ if (existsSync(packageFile)) try {
1948
+ const name = JSON.parse(readFileSync(packageFile, "utf8")).name;
1949
+ if (typeof name === "string" && name) {
1950
+ packageCache.set(dir, name);
1951
+ return name;
1952
+ }
1953
+ } catch {}
1954
+ const parent = dirname(dir);
1955
+ packageCache.set(dir, void 0);
1956
+ if (parent === dir) return void 0;
1957
+ dir = parent;
1958
+ }
1853
1959
  }
1960
+ const FRAME_POSITION = /:\d+:\d+$/;
1961
+ /** Package name of this module's own package (self-fallbacks are filtered). */
1962
+ const selfPackage = packageNameFrom(selfUrl);
1854
1963
  /**
1855
- * The heavy collections: copies of the retained request records and context
1856
- * events (each event attached to the requests around it — the chart's ✂
1857
- * anchoring), the bounded served surface window, and the removed-node
1858
- * archive. Shared verbatim by the inline wire view (channel-less hosts) and
1859
- * the on-demand detail payload (host/detail.ts).
1964
+ * Resolve the registering package from a stack trace: walk frames from the
1965
+ * innermost out, skipping this module's own frames and frames that resolve to
1966
+ * this package, and return the package name of the first frame that resolves
1967
+ * elsewhere. Works with both `file://` URLs and bare absolute paths
1968
+ * (transpiled modules render without a scheme), with optional `fn (...)` and
1969
+ * `async` wrappers.
1970
+ * @param stack - `Error().stack`, or undefined when no fallback is desired.
1860
1971
  */
1861
- function detailCollectionsOf(state, bounds) {
1862
- const result = {
1863
- requests: state.requests.map((r) => ({ ...r })),
1864
- events: state.events.map((e) => ({ ...e })),
1865
- nodes: [],
1866
- droppedNodes: 0,
1867
- archive: state.archived.map((n) => ({ ...n })),
1868
- fileOps: state.fileOps.map((o) => ({ ...o })),
1869
- ...state.fileOpsFloor !== void 0 ? { fileOpsFloor: state.fileOpsFloor } : {}
1870
- };
1871
- const overflowCount = Math.max(0, state.surface.length - bounds.maxNodes);
1872
- const overflow = state.surface.slice(0, overflowCount);
1873
- const tail = state.surface.slice(overflowCount);
1874
- const pinned = overflow.filter((n) => n.cat === "inject" || n.cat === "skill");
1875
- result.nodes = pinned.length > 0 ? [...pinned, ...tail] : tail;
1876
- result.droppedNodes = overflowCount - pinned.length;
1877
- if (result.droppedNodes > 0) {
1878
- let floor = 0;
1879
- for (const n of overflow) if (n.cat !== "inject" && n.cat !== "skill") floor = Math.max(floor, n.seq);
1880
- result.surfaceFloor = floor;
1881
- }
1882
- if (state.archiveFloor !== void 0) result.archiveFloor = state.archiveFloor;
1883
- const requests = result.requests;
1884
- const events = result.events;
1885
- let ri = 0;
1886
- for (const ev of events) {
1887
- while (ri < requests.length && requests[ri].seq <= ev.seq) ri++;
1888
- const next = requests.at(ri);
1889
- const prev = ri > 0 ? requests.at(ri - 1) : void 0;
1890
- if (next !== void 0 && typeof next.turn === "number" && typeof next.step === "number") {
1891
- ev.turn = next.turn;
1892
- ev.step = next.step;
1893
- }
1894
- if (prev !== void 0 && typeof prev.turn === "number" && typeof prev.step === "number") {
1895
- ev.fromTurn = prev.turn;
1896
- ev.fromStep = prev.step;
1972
+ function callerPackageFrom(stack) {
1973
+ if (!stack) return void 0;
1974
+ for (const raw of stack.split("\n").slice(1)) {
1975
+ let line = raw.trim();
1976
+ if (!line.startsWith("at ")) continue;
1977
+ line = line.slice(3);
1978
+ if (line.startsWith("async ")) line = line.slice(6);
1979
+ line = line.replace(/\)\s*$/, "");
1980
+ const position = FRAME_POSITION.exec(line);
1981
+ if (!position) continue;
1982
+ let target = line.slice(0, -position[0].length);
1983
+ if (target.includes("(")) target = target.slice(target.lastIndexOf("(") + 1);
1984
+ if (target.startsWith("file://")) try {
1985
+ target = normalize(fileURLToPath(target));
1986
+ } catch {
1987
+ continue;
1897
1988
  }
1989
+ else if (!/^[A-Za-z]:[\\/]/.test(target) && !target.startsWith("/") && !target.startsWith("\\\\")) continue;
1990
+ else target = normalize(target);
1991
+ if (target === selfUrl) continue;
1992
+ const name = packageNameFrom(target);
1993
+ if (name !== void 0 && name !== selfPackage) return name;
1898
1994
  }
1899
- return result;
1900
1995
  }
1901
1996
  /**
1902
- * The split generation's SLIM wire head: the envelope scalars plus the
1903
- * precomputed count figures, the newest request's billing summary (the
1904
- * headline's derived anchor), and the detail revision marker. Small enough
1905
- * to ride every delivery channel whole (~1KB) — the heavy collections moved
1906
- * to the on-demand detail channel (host/detail.ts).
1997
+ * Install the runtime-attribution hook on a cordis app context. The hook
1998
+ * rides the calling fiber's lifetime (`ctx.on`, and an effect that restores
1999
+ * every patched `register`), so it is disposed with the plugin.
2000
+ * @param ctx - the context the dsh-context plugin runs in; its fiber name is
2001
+ * excluded from attributions.
1907
2002
  */
1908
- function buildTimelineHead(state) {
1909
- const result = headFieldsOf(state);
1910
- const turns = /* @__PURE__ */ new Set();
1911
- for (const r of state.requests) turns.add(r.turn ?? 0);
1912
- let injects = 0;
1913
- let compactions = 0;
1914
- let prunes = 0;
1915
- for (const e of state.events) if (e.kind === "inject") injects++;
1916
- else if (e.kind === "compaction") compactions++;
1917
- else if (e.kind === "prune") prunes++;
1918
- result.counts = {
1919
- turns: turns.size,
1920
- steps: state.requests.length,
1921
- injects,
1922
- compactions,
1923
- prunes
2003
+ function createToolAttribution(ctx) {
2004
+ const live = /* @__PURE__ */ new Map();
2005
+ const wrapped = /* @__PURE__ */ new WeakSet();
2006
+ const self = ctx.fiber.name;
2007
+ let lastReader;
2008
+ const patched = [];
2009
+ const wrapInstance = (tools) => {
2010
+ if (!tools || typeof tools !== "object" || wrapped.has(tools)) return;
2011
+ const register = tools.register;
2012
+ if (typeof register !== "function") return;
2013
+ wrapped.add(tools);
2014
+ const original = register.attributedOriginal ?? register;
2015
+ if (typeof original !== "function") return;
2016
+ const instance = tools;
2017
+ const wrappedRegister = function(definition) {
2018
+ const toolName = definition?.name;
2019
+ let owner = lastReader?.fiber.name;
2020
+ if (!owner || owner === "root" || owner === self) owner = callerPackageFrom((/* @__PURE__ */ new Error()).stack);
2021
+ const dispose = original.call(this, definition);
2022
+ if (typeof toolName === "string" && owner && owner !== "root" && owner !== self && owner !== selfPackage) {
2023
+ live.set(toolName, owner);
2024
+ if (typeof dispose === "function") return () => {
2025
+ try {
2026
+ return dispose();
2027
+ } finally {
2028
+ live.delete(toolName);
2029
+ }
2030
+ };
2031
+ }
2032
+ return dispose;
2033
+ };
2034
+ wrappedRegister.attributedOriginal = original;
2035
+ instance.register = wrappedRegister;
2036
+ patched.push(() => {
2037
+ if (instance.register === wrappedRegister) instance.register = original;
2038
+ });
1924
2039
  };
1925
- const last = state.requests.at(-1);
1926
- if (last !== void 0) result.last = {
1927
- seq: last.seq,
1928
- total: last.total,
1929
- ...typeof last.prompt === "number" ? { prompt: last.prompt } : {}
2040
+ ctx.on("internal/get", (reader, name, _error, next) => {
2041
+ if (name !== "tools") return next();
2042
+ const tools = next();
2043
+ lastReader = reader;
2044
+ wrapInstance(tools);
2045
+ return tools;
2046
+ });
2047
+ ctx.effect(() => () => {
2048
+ for (const restore of patched.splice(0)) restore();
2049
+ }, "tools.register attribution");
2050
+ const toolsService = ctx.get("tools", false);
2051
+ wrapInstance(toolsService);
2052
+ const boot = /* @__PURE__ */ new Set();
2053
+ try {
2054
+ const toolEntries = toolsService?.layers?.global?.tools;
2055
+ if (toolEntries !== void 0 && typeof toolEntries.entries === "function") for (const [name] of toolEntries.entries()) boot.add(name);
2056
+ } catch {}
2057
+ return { ownerOf: (name) => mcpSourceOf(name) ?? live.get(name) ?? pinnedSourceOf(name) ?? (boot.has(name) ? "<unknown-plugin>" : void 0) };
2058
+ }
2059
+ //#endregion
2060
+ //#region src/host/backfill.ts
2061
+ /** Inter-session pacing so a multi-hundred-session backfill never starve the host. */
2062
+ const YIELD_MS = 10;
2063
+ function asRecord(value) {
2064
+ return value !== null && typeof value === "object" ? value : null;
2065
+ }
2066
+ /** One listed record's header, re-proved: id and cwd are the two fields the run relies on. */
2067
+ function headerOf(record) {
2068
+ const header = asRecord(asRecord(record)?.header);
2069
+ if (header === null) return null;
2070
+ if (typeof header.id !== "string" || header.id === "") return null;
2071
+ return header;
2072
+ }
2073
+ /** Whether the cache already serves BOTH projection rows for this header (nothing to backfill). */
2074
+ function servesRows(cache, header) {
2075
+ try {
2076
+ const values = asRecord(asRecord(cache.cachedSnapshot(header, SessionLogOffset(0), ["contextActivity", "contextTimeline"]))?.values);
2077
+ return values !== null && values.contextActivity !== void 0 && values.contextTimeline !== void 0;
2078
+ } catch {
2079
+ return false;
2080
+ }
2081
+ }
2082
+ /** Read one stored log through a read handle, mirroring dsh-session-query's readColdSessionLog. */
2083
+ async function readColdLog(persistence, id, signal) {
2084
+ const handle = await persistence.open(id, "read", { signal });
2085
+ let events;
2086
+ try {
2087
+ const result = asRecord(await handle.read(0, void 0, { signal }))?.events;
2088
+ events = Array.isArray(result) ? result : [];
2089
+ } catch (error) {
2090
+ try {
2091
+ await handle.close();
2092
+ } catch {}
2093
+ throw error;
2094
+ }
2095
+ await handle.close();
2096
+ return {
2097
+ header: handle.header,
2098
+ inheritedEventCount: handle.inheritedEventCount,
2099
+ events: [...events, ...interruptedTurnClosers(events)]
1930
2100
  };
1931
- result.detailRev = state.detailRev ?? 0;
1932
- return result;
1933
2101
  }
1934
2102
  /**
1935
- * The on-demand detail payload (host/detail.ts serves it off the live fold
1936
- * state): the heavy collections, the slim head at the SAME cut (the Agent
1937
- * network's cold-node ring fetch renders composition off it), and the
1938
- * revision marker the head carries.
2103
+ * Whether one session is live (folds for itself — a cold write would only
2104
+ * race its own checkpoints). A throwing registry read conservatively skips
2105
+ * the session too: better to leave a row unfolded than to write over a
2106
+ * possibly-live one.
1939
2107
  */
1940
- function buildTimelineDetail(state, bounds) {
1941
- return {
1942
- rev: state.detailRev ?? 0,
1943
- head: buildTimelineHead(state),
1944
- ...detailCollectionsOf(state, bounds)
2108
+ function isLive(sessions, id) {
2109
+ if (sessions === null || typeof sessions.get !== "function") return false;
2110
+ try {
2111
+ return sessions.get(id) !== void 0;
2112
+ } catch {
2113
+ return true;
2114
+ }
2115
+ }
2116
+ /**
2117
+ * Arm the warm-up on whichever deployment composes the four cold-path
2118
+ * services; returns the deferred inject's disposer (abort on unload).
2119
+ */
2120
+ function watchActivityBackfill(ctx) {
2121
+ const handle = ctx.inject([
2122
+ "sessionQuery",
2123
+ "sessionProjectionCache",
2124
+ "sessionPersistence",
2125
+ "sessions"
2126
+ ], (raw) => {
2127
+ const injected = raw;
2128
+ const query = asRecord(injected.sessionQuery);
2129
+ const cache = asRecord(injected.sessionProjectionCache);
2130
+ const persistence = asRecord(injected.sessionPersistence);
2131
+ if (query === null || typeof query.listSessions !== "function") return;
2132
+ if (cache === null || typeof cache.cachedSnapshot !== "function" || typeof cache.coldSnapshot !== "function") return;
2133
+ if (persistence === null || typeof persistence.open !== "function") return;
2134
+ const sessions = asRecord(injected.sessions);
2135
+ const abort = new AbortController();
2136
+ const run = async () => {
2137
+ const listed = await query.listSessions(abort.signal);
2138
+ if (!Array.isArray(listed)) return;
2139
+ let folded = 0;
2140
+ for (const record of listed) {
2141
+ if (abort.signal.aborted) return;
2142
+ const header = headerOf(record);
2143
+ if (header === null || typeof header.cwd !== "string") continue;
2144
+ if (isLive(sessions, header.id)) continue;
2145
+ if (servesRows(cache, header)) continue;
2146
+ try {
2147
+ const log = await readColdLog(persistence, header.id, abort.signal);
2148
+ cache.coldSnapshot(log.header, log.inheritedEventCount, log.events);
2149
+ folded++;
2150
+ } catch (error) {
2151
+ ctx.logger.warn(`dsh-context: projection backfill skipped "${header.id}" (${String(error)})`);
2152
+ }
2153
+ await new Promise((resolve) => setTimeout(resolve, YIELD_MS));
2154
+ }
2155
+ if (folded > 0) ctx.logger.info(`dsh-context: projection rows backfilled for ${folded} session(s)`);
2156
+ };
2157
+ run().catch((error) => {
2158
+ if (!abort.signal.aborted) ctx.logger.warn(`dsh-context: projection backfill stopped early (${String(error)})`);
2159
+ });
2160
+ return () => {
2161
+ abort.abort();
2162
+ };
2163
+ });
2164
+ return () => {
2165
+ handle?.dispose?.();
1945
2166
  };
1946
2167
  }
2168
+ //#endregion
2169
+ //#region src/host/config.ts
1947
2170
  /**
1948
- * Serve the INLINE projection wire view (channel-less hosts): the head
1949
- * scalars with the detail collections in place — the shape every delivery
1950
- * channel carried before the split generation. Bound the surface nodes to
1951
- * the newest tail and attach each event to the request around it; stamp
1952
- * COPIES — the persisted state objects are never mutated.
2171
+ * dsh-context host configuration — the `config:` block of the `dsh-context`
2172
+ * loader row in cordis.yml.
2173
+ *
2174
+ * Cordis validates the entry config against this exported `Config` schema
2175
+ * (any Standard Schema v1 validator — zod is ours) before `apply` runs, fills
2176
+ * per-field defaults, and fails the load loudly on invalid or unknown keys
2177
+ * (`.strict()`). The official plugin-config principle this answers: "anything
2178
+ * that two deployments may want to set differently is a configuration field".
2179
+ *
2180
+ * The persisted projection state shape is independent of these bounds — they
2181
+ * only tune the fold's retention / presentation slice, so changing them never
2182
+ * requires a projection `stateVersion` bump.
1953
2183
  */
1954
- function buildTimelineView(state, bounds) {
1955
- return {
1956
- ...headFieldsOf(state),
1957
- ...detailCollectionsOf(state, bounds)
1958
- };
2184
+ const DEFAULT_BOUNDS = {
2185
+ maxRequestSteps: 1500,
2186
+ maxKeptTurns: 300,
2187
+ maxEvents: 400,
2188
+ maxNodes: 2e3,
2189
+ maxArchiveNodes: 400,
2190
+ maxFileOps: 400
2191
+ };
2192
+ /**
2193
+ * The cordis `Config` validator: strict on keys, defaults on the schema fields; tolerates `undefined` (a patch row without a `config:`
2194
+ * block — defaults win).
2195
+ */
2196
+ const Config = z.preprocess((v) => v ?? {}, z.object({
2197
+ maxRequestSteps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxRequestSteps),
2198
+ maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
2199
+ maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
2200
+ maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
2201
+ maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes),
2202
+ maxFileOps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxFileOps)
2203
+ }).strict());
2204
+ function resolveBounds(config) {
2205
+ return Config.parse(config ?? {});
1959
2206
  }
1960
2207
  //#endregion
1961
2208
  //#region src/host/detail.ts
@@ -2453,6 +2700,7 @@ const contextTimelineSchema = z.object({
2453
2700
  images: z.number().int().nonnegative().optional(),
2454
2701
  toolCalls: z.number().int().nonnegative().optional(),
2455
2702
  humanInputs: z.number().int().nonnegative().optional(),
2703
+ lastUser: z.string().optional(),
2456
2704
  counts: countsSchema.optional(),
2457
2705
  last: lastSchema.optional(),
2458
2706
  detailRev: z.number().int().nonnegative().optional(),
@@ -2499,6 +2747,7 @@ const timelineStateSchema = z.object({
2499
2747
  archiveFloor: z.number().optional(),
2500
2748
  timing: timingTotalsSchema.optional(),
2501
2749
  humanInputs: z.number().int().nonnegative().optional(),
2750
+ lastUser: z.string().optional(),
2502
2751
  stepStart: z.object({
2503
2752
  time: z.number(),
2504
2753
  firstToken: z.number().optional(),
@@ -2573,7 +2822,7 @@ function createContextTimelineDefinition(config, slim) {
2573
2822
  },
2574
2823
  init: () => createTimelineState(),
2575
2824
  apply: (state, event) => applyTimeline(state, event, bounds),
2576
- stateVersion: 19
2825
+ stateVersion: 20
2577
2826
  };
2578
2827
  }
2579
2828
  //#endregion
@@ -2664,6 +2913,14 @@ function createFallbackTimelineDefinition(current) {
2664
2913
  function createFallbackHeadersDefinition() {
2665
2914
  return fallbackDefinition("contextHeaders", contextHeadersSchema, { headers: [] });
2666
2915
  }
2916
+ /**
2917
+ * The fallback `contextActivity` unit: an empty ledger — the overview's
2918
+ * heatmap renders its empty note instead of waiting on a key that the gate
2919
+ * would never deliver.
2920
+ */
2921
+ function createFallbackActivityDefinition() {
2922
+ return fallbackDefinition("contextActivity", contextActivitySchema, { days: {} });
2923
+ }
2667
2924
  //#endregion
2668
2925
  //#region src/host/settings.ts
2669
2926
  /** The namespace is the join key between the Host registration and the browser card. */
@@ -2958,6 +3215,7 @@ function apply(ctx, config) {
2958
3215
  if (harnessVersion !== void 0 && !meetsBaseline(harnessVersion)) {
2959
3216
  ctx.sessionProjections.register(createFallbackTimelineDefinition(harnessVersion));
2960
3217
  ctx.sessionProjections.register(createFallbackHeadersDefinition());
3218
+ ctx.sessionProjections.register(createFallbackActivityDefinition());
2961
3219
  return;
2962
3220
  }
2963
3221
  const attribution = createToolAttribution(ctx);
@@ -2965,6 +3223,8 @@ function apply(ctx, config) {
2965
3223
  const gate = watchDetailChannel(ctx, resolveBounds(config));
2966
3224
  ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live));
2967
3225
  ctx.sessionProjections.register(createContextHeadersDefinition((name) => attribution.ownerOf(name)));
3226
+ ctx.sessionProjections.register(createContextActivityDefinition());
3227
+ watchActivityBackfill(ctx);
2968
3228
  installSettings(ctx);
2969
3229
  }
2970
3230
  //#endregion