dsh-context 0.52.2 → 0.53.1
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/README.md +17 -1
- package/lib/client.js +4734 -3122
- package/lib/index.d.ts +56 -1
- package/lib/index.js +712 -451
- package/package.json +1 -1
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
/**
|
|
30
|
-
function
|
|
31
|
-
return
|
|
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/
|
|
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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* (
|
|
166
|
-
*
|
|
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
|
|
170
|
-
if (!
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
*
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
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
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1905
|
-
*
|
|
1906
|
-
*
|
|
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
|
|
1909
|
-
const
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
let
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
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
|
|
1941
|
-
return
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
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
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
*
|
|
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
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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:
|
|
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. */
|
|
@@ -2686,7 +2943,8 @@ const SettingsSchema = z$1.object({
|
|
|
2686
2943
|
"count",
|
|
2687
2944
|
"latest",
|
|
2688
2945
|
"path"
|
|
2689
|
-
]).default("count").loose()
|
|
2946
|
+
]).default("count").loose(),
|
|
2947
|
+
insightsEntry: z$1.union(["show", "hide"]).default("show").loose()
|
|
2690
2948
|
});
|
|
2691
2949
|
/** Serve the namespace while a settings provider is composed; inert otherwise. */
|
|
2692
2950
|
function installSettings(ctx) {
|
|
@@ -2958,6 +3216,7 @@ function apply(ctx, config) {
|
|
|
2958
3216
|
if (harnessVersion !== void 0 && !meetsBaseline(harnessVersion)) {
|
|
2959
3217
|
ctx.sessionProjections.register(createFallbackTimelineDefinition(harnessVersion));
|
|
2960
3218
|
ctx.sessionProjections.register(createFallbackHeadersDefinition());
|
|
3219
|
+
ctx.sessionProjections.register(createFallbackActivityDefinition());
|
|
2961
3220
|
return;
|
|
2962
3221
|
}
|
|
2963
3222
|
const attribution = createToolAttribution(ctx);
|
|
@@ -2965,6 +3224,8 @@ function apply(ctx, config) {
|
|
|
2965
3224
|
const gate = watchDetailChannel(ctx, resolveBounds(config));
|
|
2966
3225
|
ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live));
|
|
2967
3226
|
ctx.sessionProjections.register(createContextHeadersDefinition((name) => attribution.ownerOf(name)));
|
|
3227
|
+
ctx.sessionProjections.register(createContextActivityDefinition());
|
|
3228
|
+
watchActivityBackfill(ctx);
|
|
2968
3229
|
installSettings(ctx);
|
|
2969
3230
|
}
|
|
2970
3231
|
//#endregion
|