dsh-context 0.43.0 → 0.45.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/client.js +3605 -1765
- package/lib/index.d.ts +171 -24
- package/lib/index.js +910 -251
- package/package.json +6 -4
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { deriveEventMessage } from "@deepseek-ai/dsh-session";
|
|
7
7
|
import z$1 from "@deepseek-ai/schemastery";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
/**
|
|
9
10
|
* Recover the MCP server display label from a proxied tool name, or undefined
|
|
10
11
|
* for non-MCP names. `dsh-mcp-client` names tools `mcp__<server>__<rawName>`
|
|
@@ -273,7 +274,8 @@ const DEFAULT_BOUNDS = {
|
|
|
273
274
|
maxKeptTurns: 300,
|
|
274
275
|
maxEvents: 400,
|
|
275
276
|
maxNodes: 2e3,
|
|
276
|
-
maxArchiveNodes: 400
|
|
277
|
+
maxArchiveNodes: 400,
|
|
278
|
+
maxFileOps: 400
|
|
277
279
|
};
|
|
278
280
|
/**
|
|
279
281
|
* The cordis `Config` validator: strict on keys, defaults on the schema fields; tolerates `undefined` (a patch row without a `config:`
|
|
@@ -284,74 +286,27 @@ const Config = z.preprocess((v) => v ?? {}, z.object({
|
|
|
284
286
|
maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
|
|
285
287
|
maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
|
|
286
288
|
maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
|
|
287
|
-
maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes)
|
|
289
|
+
maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes),
|
|
290
|
+
maxFileOps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxFileOps)
|
|
288
291
|
}).strict());
|
|
289
292
|
function resolveBounds(config) {
|
|
290
293
|
return Config.parse(config ?? {});
|
|
291
294
|
}
|
|
292
295
|
//#endregion
|
|
293
|
-
//#region src/shared/
|
|
294
|
-
/**
|
|
295
|
-
* The harness-version gate's shared arithmetic — the supported dsh baseline
|
|
296
|
-
* and the version compare behind it. Runtime code shared by BOTH halves (the
|
|
297
|
-
* host probes and gates; the client displays what the wire record carries),
|
|
298
|
-
* so this module must stay dependency-free.
|
|
299
|
-
*
|
|
300
|
-
* The baseline mirrors the support matrix (AGENTS.md "Compatibility" and the
|
|
301
|
-
* package's `dsh.compatibility.dshReleases` declaration): the oldest dsh
|
|
302
|
-
* release this plugin works on. A harness BELOW it gets the fallback units
|
|
303
|
-
* (host/fallback.ts) instead of the real folds.
|
|
304
|
-
*/
|
|
305
|
-
/** The oldest supported dsh release (see the matrix note above). */
|
|
306
|
-
const BASELINE_DSH_VERSION = "0.1.2-rc.1";
|
|
307
|
-
/**
|
|
308
|
-
* Release-channel rank at an equal X.Y.Z: a final release outranks its
|
|
309
|
-
* release candidates, which outrank betas, which outrank alphas
|
|
310
|
-
* (正式版 > RC > Beta > Alpha).
|
|
311
|
-
*/
|
|
312
|
-
function channelRank(channel) {
|
|
313
|
-
return channel === "rc" ? 3 : channel === "beta" ? 2 : 1;
|
|
314
|
-
}
|
|
315
|
-
const RELEASE_RANK = 4;
|
|
316
|
-
/**
|
|
317
|
-
* Parse `v?[major].[minor].[patch][-(alpha|beta|rc)[.N]][+build]`, or null
|
|
318
|
-
* when the string is not that shape. Channels other than alpha/beta/rc
|
|
319
|
-
* (nightly, dev, …) do not parse — the gate fails open on them.
|
|
320
|
-
*/
|
|
321
|
-
function parseVersion(version) {
|
|
322
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)(?:\.(\d+))?)?(?:\+[0-9a-z.-]+)?$/i.exec(version.trim());
|
|
323
|
-
if (match === null) return null;
|
|
324
|
-
const channel = match[4]?.toLowerCase();
|
|
325
|
-
return {
|
|
326
|
-
major: Number(match[1]),
|
|
327
|
-
minor: Number(match[2]),
|
|
328
|
-
patch: Number(match[3]),
|
|
329
|
-
rank: channel === void 0 ? RELEASE_RANK : channelRank(channel),
|
|
330
|
-
serial: match[5] ? Number(match[5]) : 0
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Total order over parsed versions: X.Y.Z numerically first, then the
|
|
335
|
-
* channel rank, then the prerelease serial.
|
|
336
|
-
*/
|
|
337
|
-
function compareParsed(a, b) {
|
|
338
|
-
if (a.major !== b.major) return a.major - b.major;
|
|
339
|
-
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
340
|
-
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
341
|
-
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
342
|
-
return a.serial - b.serial;
|
|
343
|
-
}
|
|
296
|
+
//#region src/shared/estimate.ts
|
|
344
297
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
298
|
+
* Token heuristics shared by the host fold and the client boundary — the
|
|
299
|
+
* harness token-meter's own fixed-density figure (dsh-token-meter/estimate.ts:
|
|
300
|
+
* ~4 chars ≈ 1 token, +4 role framing). Priced identically on both sides so a
|
|
301
|
+
* legacy value normalized at the client boundary matches what the host view
|
|
302
|
+
* would have served.
|
|
349
303
|
*/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
304
|
+
const CHARS_PER_TOKEN$1 = 4;
|
|
305
|
+
const ROLE_OVERHEAD$1 = 4;
|
|
306
|
+
/** Price rendered system-prompt text; 0 for absent/empty/non-string input. */
|
|
307
|
+
function estimateSystemTokens(text) {
|
|
308
|
+
if (typeof text !== "string" || text.length === 0) return 0;
|
|
309
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN$1) + ROLE_OVERHEAD$1;
|
|
355
310
|
}
|
|
356
311
|
//#endregion
|
|
357
312
|
//#region src/shared/imageTokens.ts
|
|
@@ -499,11 +454,11 @@ function estimateImageTokens(width, height) {
|
|
|
499
454
|
* (shared/imageTokens.ts), falling back to the meter's JSON price when the
|
|
500
455
|
* attachment's dimensions are unknown.
|
|
501
456
|
*/
|
|
502
|
-
const CHARS_PER_TOKEN
|
|
457
|
+
const CHARS_PER_TOKEN = 4;
|
|
503
458
|
const BLOCK_OVERHEAD = 4;
|
|
504
|
-
const ROLE_OVERHEAD
|
|
459
|
+
const ROLE_OVERHEAD = 4;
|
|
505
460
|
function estimateToolsTotal(tools) {
|
|
506
|
-
return tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN
|
|
461
|
+
return tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD : 0;
|
|
507
462
|
}
|
|
508
463
|
/** The `ContentBlock` walkers take `unknown`: block arrays ride the untrusted
|
|
509
464
|
* log, so their element shapes (null and primitives included) are re-proved
|
|
@@ -520,10 +475,10 @@ function estimateBlocks(blocks) {
|
|
|
520
475
|
switch (block.type) {
|
|
521
476
|
case "text":
|
|
522
477
|
case "reasoning":
|
|
523
|
-
tokens += Math.ceil((block.text || "").length / CHARS_PER_TOKEN
|
|
478
|
+
tokens += Math.ceil((block.text || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
|
524
479
|
break;
|
|
525
480
|
case "tool-call":
|
|
526
|
-
tokens += Math.ceil((block.name || "").length / CHARS_PER_TOKEN
|
|
481
|
+
tokens += Math.ceil((block.name || "").length / CHARS_PER_TOKEN) + Math.ceil((block.arguments || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
|
527
482
|
break;
|
|
528
483
|
case "tool-result":
|
|
529
484
|
tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD;
|
|
@@ -531,10 +486,10 @@ function estimateBlocks(blocks) {
|
|
|
531
486
|
case "image": {
|
|
532
487
|
const ref = block.attachment;
|
|
533
488
|
const priced = ref !== null && typeof ref === "object" && typeof ref.width === "number" && typeof ref.height === "number" ? estimateImageTokens(ref.width, ref.height) : null;
|
|
534
|
-
tokens += (priced ?? Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN
|
|
489
|
+
tokens += (priced ?? Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)) + BLOCK_OVERHEAD;
|
|
535
490
|
break;
|
|
536
491
|
}
|
|
537
|
-
default: tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN
|
|
492
|
+
default: tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN);
|
|
538
493
|
}
|
|
539
494
|
}
|
|
540
495
|
return tokens;
|
|
@@ -546,12 +501,12 @@ function estimateBlocks(blocks) {
|
|
|
546
501
|
*/
|
|
547
502
|
function estimateMessage(message, emptyIsZero = false) {
|
|
548
503
|
if (emptyIsZero && (message === null || message === void 0 || !Array.isArray(message.content) || message.content.length === 0)) return 0;
|
|
549
|
-
return estimateBlocks(message?.content) + ROLE_OVERHEAD
|
|
504
|
+
return estimateBlocks(message?.content) + ROLE_OVERHEAD;
|
|
550
505
|
}
|
|
551
506
|
/** The shared meter heuristic over rendered system-prompt text (shared/estimate.ts). */
|
|
552
507
|
/** Per-tool price for the top-tools display (the total uses dsh's whole-array price). */
|
|
553
508
|
function estimateToolSchema(tool) {
|
|
554
|
-
return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN
|
|
509
|
+
return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
|
555
510
|
}
|
|
556
511
|
/**
|
|
557
512
|
* Count image blocks in a message payload, recursing into nested content (tool-result blocks carry their inner blocks) — seeds each node's
|
|
@@ -610,157 +565,231 @@ function isInjection(source) {
|
|
|
610
565
|
return source !== null && source !== void 0 && (typeof source.kind === "string" && source.kind !== "" && source.kind !== "user" || typeof source.form === "string");
|
|
611
566
|
}
|
|
612
567
|
//#endregion
|
|
613
|
-
//#region src/shared/
|
|
568
|
+
//#region src/shared/fileOps.ts
|
|
569
|
+
/** Parse a call's raw JSON arguments; non-string/malformed/non-record inputs yield null. */
|
|
570
|
+
function parseCallArgs(raw) {
|
|
571
|
+
if (typeof raw !== "string" || raw === "") return null;
|
|
572
|
+
try {
|
|
573
|
+
const parsed = JSON.parse(raw);
|
|
574
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
575
|
+
} catch {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const KIND_BY_TOOL = {
|
|
580
|
+
read: "read",
|
|
581
|
+
read_image: "read",
|
|
582
|
+
write: "write",
|
|
583
|
+
edit: "write",
|
|
584
|
+
grep: "search",
|
|
585
|
+
glob: "search"
|
|
586
|
+
};
|
|
587
|
+
/** The file purpose of a tool, or null for non-file tools (bash, web_search…). */
|
|
588
|
+
function kindOfTool(tool) {
|
|
589
|
+
if (tool === void 0) return null;
|
|
590
|
+
return KIND_BY_TOOL[tool] ?? null;
|
|
591
|
+
}
|
|
614
592
|
/**
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
593
|
+
* The file purpose of one executed call. Like {@link kindOfTool} except for
|
|
594
|
+
* the one file tool whose purpose follows its arguments: `str_replace_editor`
|
|
595
|
+
* reads on `view` and writes on every other command (create / str_replace /
|
|
596
|
+
* insert — an unknown command writes too; the call failed and the row keeps
|
|
597
|
+
* its error flag).
|
|
620
598
|
*/
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
function estimateSystemTokens(text) {
|
|
625
|
-
if (typeof text !== "string" || text.length === 0) return 0;
|
|
626
|
-
return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD;
|
|
599
|
+
function kindOfCall(tool, args) {
|
|
600
|
+
if (tool === "str_replace_editor") return args !== null && args.command === "view" ? "read" : "write";
|
|
601
|
+
return kindOfTool(tool);
|
|
627
602
|
}
|
|
628
|
-
//#endregion
|
|
629
|
-
//#region src/host/headers.ts
|
|
630
603
|
/**
|
|
631
|
-
* The
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
* The hot `contextTimeline` unit carries only token prices of the system
|
|
635
|
-
* prompt and tool schemas; this companion unit keeps the per-epoch METADATA
|
|
636
|
-
* (epoch seq/time boundaries, per-tool token prices and plugin attribution)
|
|
637
|
-
* so the Context browser can pick the header epoch in force at any step and
|
|
638
|
-
* size its sections immediately. The epoch CONTENT (full system prompt text,
|
|
639
|
-
* full tool JSON schemas) deliberately does NOT ride the projection VALUE:
|
|
640
|
-
* session projections are served whole in every `session.list` row, control
|
|
641
|
-
* baseline, push frame, and change notification, so carrying content here
|
|
642
|
-
* multiplied it by sessions × epochs across every channel. The client
|
|
643
|
-
* fetches one epoch's `request/header` event on demand — a seq-anchored
|
|
644
|
-
* history read off the epoch's `seq`, the same targeted read the browser
|
|
645
|
-
* already uses for message content — and caches it per session (history is
|
|
646
|
-
* immutable).
|
|
647
|
-
*
|
|
648
|
-
* Read-compat over the persisted state (the pinned decision behind keeping
|
|
649
|
-
* `stateVersion` at 1): the harness serves a cold session's projections from
|
|
650
|
-
* its CACHED checkpoint rows and has no refresh channel for an idle session
|
|
651
|
-
* — a version bump invalidates every row and orphans the key until the
|
|
652
|
-
* session goes live again (the #37 regression). The state therefore still
|
|
653
|
-
* ACCEPTS the v1 content-bearing record shape, current folds append
|
|
654
|
-
* metadata-only records alongside any seeded legacy ones, and the view
|
|
655
|
-
* normalizes BOTH to the metadata-only wire shape (pricing the legacy system
|
|
656
|
-
* text at read time). Cached v1 rows keep working, new checkpoint writes
|
|
657
|
-
* shrink as legacy epochs age out of the capped list, and the wire — the
|
|
658
|
-
* part every delivery channel carries — is metadata-only from day one.
|
|
659
|
-
*
|
|
660
|
-
* Same projection contract as the timeline unit: pure init/apply/view,
|
|
661
|
-
* `Object.is` reference stability for uninteresting events, plain-JSON
|
|
662
|
-
* bounded state (epoch list capped — see HEADERS_MAX).
|
|
604
|
+
* The operation's target path: the path-ish argument of read/write tools;
|
|
605
|
+
* for searches the narrowing `path`, else the pattern itself (a pathless
|
|
606
|
+
* grep/glob's target IS the pattern — the workspace-wide search text).
|
|
663
607
|
*/
|
|
664
|
-
|
|
665
|
-
|
|
608
|
+
function pathOfArgs(tool, args) {
|
|
609
|
+
if (args === null) return null;
|
|
610
|
+
if (tool === "grep" || tool === "glob") {
|
|
611
|
+
const p = args.path;
|
|
612
|
+
if (typeof p === "string" && p !== "") return p;
|
|
613
|
+
const pattern = args.pattern;
|
|
614
|
+
return typeof pattern === "string" && pattern !== "" ? pattern : null;
|
|
615
|
+
}
|
|
616
|
+
for (const k of [
|
|
617
|
+
"file_path",
|
|
618
|
+
"filePath",
|
|
619
|
+
"path"
|
|
620
|
+
]) {
|
|
621
|
+
const v = args[k];
|
|
622
|
+
if (typeof v === "string" && v !== "") return v;
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
/** Rendered line count: '' is 0, a trailing newline closes its own line. */
|
|
627
|
+
function linesOf(s) {
|
|
628
|
+
if (s === "") return 0;
|
|
629
|
+
let n = 0;
|
|
630
|
+
for (let i = 0; i < s.length; i++) if (s[i] === "\n") n++;
|
|
631
|
+
return s.endsWith("\n") ? n : n + 1;
|
|
632
|
+
}
|
|
633
|
+
/** The added/removed pair of one content-bearing argument set, or zeros. */
|
|
634
|
+
function pairOf(added, removed) {
|
|
635
|
+
return {
|
|
636
|
+
added: typeof added === "string" ? linesOf(added) : 0,
|
|
637
|
+
removed: typeof removed === "string" ? linesOf(removed) : 0
|
|
638
|
+
};
|
|
639
|
+
}
|
|
666
640
|
/**
|
|
667
|
-
* The
|
|
668
|
-
*
|
|
641
|
+
* The signed line footprint of one call: an edit removes its old string and
|
|
642
|
+
* adds its new one; a write adds its content (the pre-existing body, if any,
|
|
643
|
+
* is unknowable from the arguments — the estimate stays honest about that);
|
|
644
|
+
* `str_replace_editor` splits the same shapes across its commands. Callers
|
|
645
|
+
* reach here only with parsed args (a null parse yields no path).
|
|
669
646
|
*/
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
systemTokens: z.number().int().nonnegative().optional(),
|
|
682
|
-
tools: z.array(storedToolSchema)
|
|
683
|
-
}).strict();
|
|
684
|
-
const contextHeadersStateSchema = z.object({ headers: z.array(storedEpochSchema) }).strict();
|
|
685
|
-
/** The wire schema: strict metadata — the shape every delivery channel carries. */
|
|
686
|
-
const headerToolWireSchema = z.object({
|
|
687
|
-
name: z.string(),
|
|
688
|
-
tokens: z.number().int().nonnegative(),
|
|
689
|
-
plugin: z.string().optional()
|
|
690
|
-
}).strict();
|
|
691
|
-
/** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
|
|
692
|
-
const contextHeadersSchema = z.object({ headers: z.array(z.object({
|
|
693
|
-
seq: z.number(),
|
|
694
|
-
time: z.number(),
|
|
695
|
-
systemTokens: z.number().int().nonnegative().optional(),
|
|
696
|
-
tools: z.array(headerToolWireSchema)
|
|
697
|
-
}).strict()) }).strict();
|
|
698
|
-
function recordOf(event) {
|
|
699
|
-
if (event.type !== "request/header") return null;
|
|
700
|
-
const rawHeader = event.data.header;
|
|
701
|
-
if (rawHeader === null || rawHeader === void 0 || typeof rawHeader !== "object") return null;
|
|
702
|
-
const header = rawHeader;
|
|
703
|
-
const tools = Array.isArray(header.tools) ? header.tools : [];
|
|
704
|
-
const record = {
|
|
705
|
-
seq: event.seq,
|
|
706
|
-
time: event.time,
|
|
707
|
-
tools: tools.map((t) => {
|
|
708
|
-
const tool = t !== null && typeof t === "object" ? t : {};
|
|
709
|
-
const entry = {
|
|
710
|
-
name: typeof tool.name === "string" ? tool.name : "?",
|
|
711
|
-
tokens: estimateToolSchema(t)
|
|
712
|
-
};
|
|
713
|
-
if (typeof tool.plugin === "string" && tool.plugin !== "") entry.plugin = tool.plugin;
|
|
714
|
-
return entry;
|
|
715
|
-
})
|
|
647
|
+
function deltaOf(tool, args) {
|
|
648
|
+
if (tool === "edit") return pairOf(args.new_string, args.old_string);
|
|
649
|
+
if (tool === "write") return pairOf(args.content, void 0);
|
|
650
|
+
if (tool === "str_replace_editor") {
|
|
651
|
+
if (args.command === "str_replace") return pairOf(args.new_str, args.old_str);
|
|
652
|
+
if (args.command === "insert") return pairOf(args.new_str, void 0);
|
|
653
|
+
if (args.command === "create") return pairOf(args.file_text, void 0);
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
added: 0,
|
|
657
|
+
removed: 0
|
|
716
658
|
};
|
|
717
|
-
|
|
718
|
-
|
|
659
|
+
}
|
|
660
|
+
/** The exact window a read's result meta reports: `offset` plus the retained
|
|
661
|
+
* `lines` array (the same bounded payload the read card renders from). Null
|
|
662
|
+
* for a foreign or malformed meta. */
|
|
663
|
+
function readWindowOf(meta) {
|
|
664
|
+
if (meta === null || typeof meta !== "object") return null;
|
|
665
|
+
const m = meta;
|
|
666
|
+
if (typeof m.path !== "string" || m.path === "") return null;
|
|
667
|
+
if (typeof m.offset !== "number" || !Number.isFinite(m.offset) || m.offset < 1) return null;
|
|
668
|
+
if (!Array.isArray(m.lines) || m.lines.length === 0) return null;
|
|
669
|
+
return {
|
|
670
|
+
start: m.offset,
|
|
671
|
+
count: m.lines.length
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/** The limit estimate: the tool reads up to `limit` lines from `offset`;
|
|
675
|
+
* absent when the call reads unbounded. */
|
|
676
|
+
function readEstimateOf(args) {
|
|
677
|
+
const limit = args.limit;
|
|
678
|
+
return typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? {
|
|
679
|
+
count: Math.floor(limit),
|
|
680
|
+
est: true
|
|
681
|
+
} : void 0;
|
|
682
|
+
}
|
|
683
|
+
/** What a read op shows: the exact window off the result meta, else the
|
|
684
|
+
* limit estimate, else nothing (an unbounded read names no footprint). */
|
|
685
|
+
function readOf(meta, args) {
|
|
686
|
+
const win = readWindowOf(meta);
|
|
687
|
+
if (win !== null) return {
|
|
688
|
+
start: win.start,
|
|
689
|
+
count: win.count
|
|
690
|
+
};
|
|
691
|
+
return readEstimateOf(args);
|
|
719
692
|
}
|
|
720
693
|
/**
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
* Contract mirror with a REQUIRED `wire` block (see compat.ts).
|
|
724
|
-
* @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so
|
|
725
|
-
* epochs folded without attribution still render a tag when the source is known.
|
|
694
|
+
* A search op's detail: the pattern, with the include filter appended when
|
|
695
|
+
* one narrowed the call. A patternless (malformed) search has no detail.
|
|
726
696
|
*/
|
|
727
|
-
function
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
697
|
+
function searchDetailOf(args) {
|
|
698
|
+
const pattern = args?.pattern;
|
|
699
|
+
if (typeof pattern !== "string" || pattern === "") return void 0;
|
|
700
|
+
const include = args?.include;
|
|
701
|
+
return typeof include === "string" && include !== "" ? `${pattern} (${include})` : pattern;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* The files a search demonstrably reached, read off the result's bounded
|
|
705
|
+
* presentation meta (grep groups matched lines by file; glob lists paths).
|
|
706
|
+
* Only the COMPLETE list attributes: a capped search (`truncated`) names a
|
|
707
|
+
* partial file set, and a malformed meta names none — both fall back to the
|
|
708
|
+
* call's own target. Each entry carries the reported match count.
|
|
709
|
+
*/
|
|
710
|
+
function searchFilesOf(meta) {
|
|
711
|
+
if (meta === null || typeof meta !== "object") return null;
|
|
712
|
+
const m = meta;
|
|
713
|
+
if (m.truncated !== false) return null;
|
|
714
|
+
const files = [];
|
|
715
|
+
if (m.shape === "matches" && Array.isArray(m.files)) for (const f of m.files) {
|
|
716
|
+
if (f === null || typeof f !== "object") continue;
|
|
717
|
+
const group = f;
|
|
718
|
+
if (typeof group.path === "string" && group.path !== "" && Array.isArray(group.matches)) files.push({
|
|
719
|
+
path: group.path,
|
|
720
|
+
hits: group.matches.length
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
else if (m.shape === "paths" && Array.isArray(m.paths)) {
|
|
724
|
+
for (const p of m.paths) if (typeof p === "string" && p !== "") files.push({
|
|
725
|
+
path: p,
|
|
726
|
+
hits: 0
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return files.length > 0 ? files : null;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* The one-shot per-call op assembly, uniform across every producer: the
|
|
733
|
+
* host's call/result pairing (args off the armed call, meta off the
|
|
734
|
+
* result), the nested Code-Mode settle (no meta exists on a dispatch — the
|
|
735
|
+
* read window and per-file search attribution degrade to the argument-only
|
|
736
|
+
* forms), and the client's inline-generation join fallback. Returns zero to
|
|
737
|
+
* N records: a search with the complete matched-file meta rows per file;
|
|
738
|
+
* any other file call rows once; a non-file tool (or a call whose arguments
|
|
739
|
+
* resolve no target) rows nothing.
|
|
740
|
+
*/
|
|
741
|
+
function opsOfCall(input) {
|
|
742
|
+
const args = parseCallArgs(input.argsRaw);
|
|
743
|
+
const kind = kindOfCall(input.tool, args);
|
|
744
|
+
if (kind === null) return [];
|
|
745
|
+
const stamp = {
|
|
746
|
+
seq: input.seq,
|
|
747
|
+
kind,
|
|
748
|
+
tool: input.tool,
|
|
749
|
+
err: input.err === true,
|
|
750
|
+
added: 0,
|
|
751
|
+
removed: 0,
|
|
752
|
+
path: "",
|
|
753
|
+
...input.time !== void 0 ? { time: input.time } : {},
|
|
754
|
+
...input.gone !== void 0 ? { gone: input.gone } : {},
|
|
755
|
+
...input.parent !== void 0 ? { parent: input.parent } : {},
|
|
756
|
+
...input.program !== void 0 ? { program: input.program } : {}
|
|
763
757
|
};
|
|
758
|
+
if (kind === "search") {
|
|
759
|
+
const files = searchFilesOf(input.meta);
|
|
760
|
+
if (files !== null) {
|
|
761
|
+
const detail = searchDetailOf(args);
|
|
762
|
+
const target = args !== null ? pathOfArgs(input.tool, args) : null;
|
|
763
|
+
const narrowed = args !== null && typeof args.path === "string" && args.path !== "";
|
|
764
|
+
return [...target !== null && !files.some((f) => f.path === target) ? [{
|
|
765
|
+
...stamp,
|
|
766
|
+
path: target,
|
|
767
|
+
...narrowed && detail !== void 0 ? { detail } : {},
|
|
768
|
+
...narrowed ? {} : { pattern: true }
|
|
769
|
+
}] : [], ...files.map((f) => ({
|
|
770
|
+
...stamp,
|
|
771
|
+
path: f.path,
|
|
772
|
+
...detail !== void 0 ? { detail } : {},
|
|
773
|
+
...f.hits > 0 ? { hits: f.hits } : {}
|
|
774
|
+
}))];
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (args === null) return [];
|
|
778
|
+
const path = pathOfArgs(input.tool, args);
|
|
779
|
+
if (path === null) return [];
|
|
780
|
+
const { added, removed } = deltaOf(input.tool, args);
|
|
781
|
+
const narrowed = typeof args.path === "string" && args.path !== "";
|
|
782
|
+
const detail = kind === "search" && narrowed ? searchDetailOf(args) : void 0;
|
|
783
|
+
const read = kind === "read" ? readOf(input.meta, args) : void 0;
|
|
784
|
+
return [{
|
|
785
|
+
...stamp,
|
|
786
|
+
path,
|
|
787
|
+
added,
|
|
788
|
+
removed,
|
|
789
|
+
...detail !== void 0 ? { detail } : {},
|
|
790
|
+
...read !== void 0 ? { read } : {},
|
|
791
|
+
...kind === "search" && !narrowed ? { pattern: true } : {}
|
|
792
|
+
}];
|
|
764
793
|
}
|
|
765
794
|
//#endregion
|
|
766
795
|
//#region src/host/fold.ts
|
|
@@ -792,6 +821,11 @@ function trimState(st, bounds) {
|
|
|
792
821
|
if (countTurnRuns(st.requests) > bounds.maxKeptTurns) st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns);
|
|
793
822
|
if (st.requests.length > bounds.maxRequestSteps) st.requests = st.requests.slice(-bounds.maxRequestSteps);
|
|
794
823
|
if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents);
|
|
824
|
+
if (st.fileOps.length > bounds.maxFileOps) {
|
|
825
|
+
const drop = st.fileOps.length - bounds.maxFileOps;
|
|
826
|
+
st.fileOpsFloor = Math.max(st.fileOpsFloor ?? 0, st.fileOps[drop - 1].seq);
|
|
827
|
+
st.fileOps = st.fileOps.slice(drop);
|
|
828
|
+
}
|
|
795
829
|
if (st.archived.length > 0) {
|
|
796
830
|
let drop = 0;
|
|
797
831
|
const oldestReq = st.requests.length > 0 ? st.requests[0].seq : void 0;
|
|
@@ -818,7 +852,8 @@ function createTimelineState() {
|
|
|
818
852
|
requests: [],
|
|
819
853
|
events: [],
|
|
820
854
|
archived: [],
|
|
821
|
-
callNames: {}
|
|
855
|
+
callNames: {},
|
|
856
|
+
fileOps: []
|
|
822
857
|
};
|
|
823
858
|
}
|
|
824
859
|
function categoryOf(type, message) {
|
|
@@ -828,13 +863,58 @@ function categoryOf(type, message) {
|
|
|
828
863
|
return "user";
|
|
829
864
|
}
|
|
830
865
|
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
866
|
+
* Mark the detail collections dirty (TimelineState.detailRev). Every caller
|
|
867
|
+
* is a fold branch that just mutated the requests/events/surface/archive;
|
|
868
|
+
* branches that touch only the working slots (stepStart, callNames, the
|
|
869
|
+
* shadow claim) or the envelope scalars do NOT bump — the served detail is
|
|
870
|
+
* unchanged, and an open tab has nothing to refetch.
|
|
834
871
|
*/
|
|
835
|
-
function
|
|
836
|
-
|
|
837
|
-
|
|
872
|
+
function bumpDetailRev(st) {
|
|
873
|
+
st.detailRev = (st.detailRev ?? 0) + 1;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Bound on the buffered nested Code-Mode ops (TimelineState.pendingCodeOps)
|
|
877
|
+
* — a hostile log that dispatches without settling the parent run_code
|
|
878
|
+
* cannot grow the persisted state past this.
|
|
879
|
+
*/
|
|
880
|
+
const PENDING_CODE_OPS_MAX = 200;
|
|
881
|
+
/** JSON-stringify an unknown argument payload; a hostile (cyclic) value yields no args. */
|
|
882
|
+
function argsRawOf(value) {
|
|
883
|
+
if (typeof value === "string") return value;
|
|
884
|
+
if (value === void 0 || value === null) return void 0;
|
|
885
|
+
try {
|
|
886
|
+
return JSON.stringify(value);
|
|
887
|
+
} catch {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/** Append op records to the fold-derived log (the trim lives in trimState, with the other collections). */
|
|
892
|
+
function pushFileOps(st, ops) {
|
|
893
|
+
for (const op of ops) st.fileOps.push(op);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Buffer nested Code-Mode ops under their top run_code call id (they flush
|
|
897
|
+
* when the parent's result folds — the ops' locate target). A full buffer
|
|
898
|
+
* drops new arrivals wholesale (defensive logs only).
|
|
899
|
+
*/
|
|
900
|
+
function bufferCodeOps(st, rootCallId, ops) {
|
|
901
|
+
const pending = st.pendingCodeOps ?? {};
|
|
902
|
+
let total = 0;
|
|
903
|
+
for (const k in pending) total += pending[k].length;
|
|
904
|
+
if (total + ops.length > PENDING_CODE_OPS_MAX) return;
|
|
905
|
+
st.pendingCodeOps = {
|
|
906
|
+
...pending,
|
|
907
|
+
[rootCallId]: [...pending[rootCallId] ?? [], ...ops]
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Archive removed surface nodes as stamped COPIES — the objects leaving
|
|
912
|
+
* `st.surface` are shared with the persisted previous state, so `gone` must
|
|
913
|
+
* never be written onto them directly.
|
|
914
|
+
*/
|
|
915
|
+
function archiveRemoved(st, removed, goneSeq) {
|
|
916
|
+
for (const n of removed) st.archived.push({
|
|
917
|
+
...n,
|
|
838
918
|
gone: goneSeq
|
|
839
919
|
});
|
|
840
920
|
}
|
|
@@ -1130,6 +1210,8 @@ function applyTimeline(state, event, bounds) {
|
|
|
1130
1210
|
events: [...state.events],
|
|
1131
1211
|
archived: [...state.archived],
|
|
1132
1212
|
callNames: { ...state.callNames },
|
|
1213
|
+
fileOps: [...state.fileOps],
|
|
1214
|
+
...state.pendingCodeOps !== void 0 ? { pendingCodeOps: { ...state.pendingCodeOps } } : {},
|
|
1133
1215
|
...state.timing !== void 0 ? { timing: {
|
|
1134
1216
|
...state.timing,
|
|
1135
1217
|
tools: { ...state.timing.tools }
|
|
@@ -1146,13 +1228,16 @@ function applyTimeline(state, event, bounds) {
|
|
|
1146
1228
|
s.systemTokens = estimateSystemTokens(header.system);
|
|
1147
1229
|
if (header.config && typeof header.config.model === "string") s.model = header.config.model;
|
|
1148
1230
|
if (header.config && typeof header.config.provider === "string") s.provider = header.config.provider;
|
|
1149
|
-
if ((data?.reason === "change" || data?.reason === "resume") && s.model && s.lastModel && s.model !== s.lastModel)
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1231
|
+
if ((data?.reason === "change" || data?.reason === "resume") && s.model && s.lastModel && s.model !== s.lastModel) {
|
|
1232
|
+
s.events.push({
|
|
1233
|
+
seq: event.seq,
|
|
1234
|
+
time: event.time,
|
|
1235
|
+
kind: "model",
|
|
1236
|
+
from: s.lastModel,
|
|
1237
|
+
to: s.model
|
|
1238
|
+
});
|
|
1239
|
+
bumpDetailRev(s);
|
|
1240
|
+
}
|
|
1156
1241
|
if (s.model) s.lastModel = s.model;
|
|
1157
1242
|
break;
|
|
1158
1243
|
}
|
|
@@ -1166,12 +1251,29 @@ function applyTimeline(state, event, bounds) {
|
|
|
1166
1251
|
case "tool/call":
|
|
1167
1252
|
if (data && typeof data.callId === "string" && typeof data.name === "string") {
|
|
1168
1253
|
const s = ensure();
|
|
1254
|
+
const argsRaw = argsRawOf(data.arguments);
|
|
1169
1255
|
s.callNames[data.callId] = {
|
|
1170
1256
|
name: data.name,
|
|
1171
|
-
start: event.time
|
|
1257
|
+
start: event.time,
|
|
1258
|
+
...argsRaw !== void 0 ? { argsRaw } : {}
|
|
1172
1259
|
};
|
|
1173
1260
|
}
|
|
1174
1261
|
break;
|
|
1262
|
+
case "tool/code-dispatch": {
|
|
1263
|
+
const rootCallId = data?.rootCallId;
|
|
1264
|
+
const name = data?.name;
|
|
1265
|
+
if (typeof rootCallId === "string" && typeof name === "string") {
|
|
1266
|
+
const ops = opsOfCall({
|
|
1267
|
+
seq: event.seq,
|
|
1268
|
+
time: event.time,
|
|
1269
|
+
tool: name,
|
|
1270
|
+
argsRaw: argsRawOf(data?.arguments),
|
|
1271
|
+
err: data?.isError === true
|
|
1272
|
+
});
|
|
1273
|
+
if (ops.length > 0) bufferCodeOps(ensure(), rootCallId, ops);
|
|
1274
|
+
}
|
|
1275
|
+
break;
|
|
1276
|
+
}
|
|
1175
1277
|
case "assistant/chunk": {
|
|
1176
1278
|
const start = state.stepStart;
|
|
1177
1279
|
if (start === void 0 || start.firstToken !== void 0) return state;
|
|
@@ -1199,6 +1301,7 @@ function applyTimeline(state, event, bounds) {
|
|
|
1199
1301
|
case "user/message": {
|
|
1200
1302
|
const msg = deriveEventMessage(event);
|
|
1201
1303
|
const s = ensure();
|
|
1304
|
+
bumpDetailRev(s);
|
|
1202
1305
|
const node = applySurface(s, event, event.type, data, msg);
|
|
1203
1306
|
const source = msg?.source;
|
|
1204
1307
|
if (isInjection(source)) {
|
|
@@ -1223,8 +1326,34 @@ function applyTimeline(state, event, bounds) {
|
|
|
1223
1326
|
}
|
|
1224
1327
|
case "tool/result": {
|
|
1225
1328
|
const toolMsg = deriveEventMessage(event);
|
|
1329
|
+
const srcId = (toolMsg?.source)?.callId;
|
|
1330
|
+
const firstBlock = toolMsg?.content?.[0];
|
|
1331
|
+
const blockId = firstBlock?.toolCallId;
|
|
1332
|
+
const pendingEntry = (typeof srcId === "string" ? state.callNames[srcId] : void 0) ?? (typeof blockId === "string" ? state.callNames[blockId] : void 0);
|
|
1333
|
+
const buffered = (typeof srcId === "string" ? state.pendingCodeOps?.[srcId] : void 0) ?? (typeof blockId === "string" ? state.pendingCodeOps?.[blockId] : void 0);
|
|
1226
1334
|
const s = ensure();
|
|
1335
|
+
bumpDetailRev(s);
|
|
1227
1336
|
const node = applySurface(s, event, event.type, data, toolMsg);
|
|
1337
|
+
if (pendingEntry !== void 0) pushFileOps(s, opsOfCall({
|
|
1338
|
+
seq: event.seq,
|
|
1339
|
+
time: event.time,
|
|
1340
|
+
tool: pendingEntry.name,
|
|
1341
|
+
argsRaw: pendingEntry.argsRaw,
|
|
1342
|
+
meta: data?.meta,
|
|
1343
|
+
err: Boolean(data?.error) || firstBlock?.isError === true
|
|
1344
|
+
}));
|
|
1345
|
+
if (buffered !== void 0 && buffered.length > 0) {
|
|
1346
|
+
const program = parseCallArgs(pendingEntry?.argsRaw)?.description;
|
|
1347
|
+
pushFileOps(s, buffered.map((op) => ({
|
|
1348
|
+
...op,
|
|
1349
|
+
parent: event.seq,
|
|
1350
|
+
...typeof program === "string" && program !== "" ? { program } : {}
|
|
1351
|
+
})));
|
|
1352
|
+
const kept = {};
|
|
1353
|
+
for (const k in s.pendingCodeOps) if (k !== srcId && k !== blockId) kept[k] = s.pendingCodeOps[k];
|
|
1354
|
+
if (Object.keys(kept).length > 0) s.pendingCodeOps = kept;
|
|
1355
|
+
else delete s.pendingCodeOps;
|
|
1356
|
+
}
|
|
1228
1357
|
if (node.tool === "skill" || node.tool === void 0) {
|
|
1229
1358
|
const name = skillNameOf(toolMsg);
|
|
1230
1359
|
if (name !== "") {
|
|
@@ -1245,6 +1374,7 @@ function applyTimeline(state, event, bounds) {
|
|
|
1245
1374
|
case "assistant/message": {
|
|
1246
1375
|
const usage = data?.usage;
|
|
1247
1376
|
const s = ensure();
|
|
1377
|
+
bumpDetailRev(s);
|
|
1248
1378
|
const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.assistant + s.sums.tool;
|
|
1249
1379
|
const record = {
|
|
1250
1380
|
time: event.time,
|
|
@@ -1289,16 +1419,21 @@ function applyTimeline(state, event, bounds) {
|
|
|
1289
1419
|
break;
|
|
1290
1420
|
}
|
|
1291
1421
|
case "plan/mode":
|
|
1292
|
-
if (data && typeof data.active === "boolean")
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1422
|
+
if (data && typeof data.active === "boolean") {
|
|
1423
|
+
const s = ensure();
|
|
1424
|
+
s.events.push({
|
|
1425
|
+
seq: event.seq,
|
|
1426
|
+
time: event.time,
|
|
1427
|
+
kind: "mode",
|
|
1428
|
+
name: data.active ? "plan.on" : "plan.off"
|
|
1429
|
+
});
|
|
1430
|
+
bumpDetailRev(s);
|
|
1431
|
+
}
|
|
1298
1432
|
break;
|
|
1299
1433
|
case "compaction/summary":
|
|
1300
1434
|
case "compaction/prune": {
|
|
1301
1435
|
const s = ensure();
|
|
1436
|
+
bumpDetailRev(s);
|
|
1302
1437
|
if (data && Array.isArray(data.shadowedSeqs)) {
|
|
1303
1438
|
s.pendingShadowedSeqs = data.shadowedSeqs.filter((x) => typeof x === "number");
|
|
1304
1439
|
s.pendingShadowEventSeq = event.seq;
|
|
@@ -1324,11 +1459,15 @@ function applyTimeline(state, event, bounds) {
|
|
|
1324
1459
|
return state;
|
|
1325
1460
|
}
|
|
1326
1461
|
/**
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
* — the
|
|
1462
|
+
* The envelope scalars both wire generations share: current composition, the
|
|
1463
|
+
* live-surface counters, and the copied cost/timing totals. Served value
|
|
1464
|
+
* fields are COPIES — the served value must never alias persisted state.
|
|
1465
|
+
* Optional scalars use conditional spread: an unknown value must not
|
|
1466
|
+
* materialize an `undefined`-valued property (the lossless-JSON pipeline —
|
|
1467
|
+
* a single such property can fail the whole push, the failure mode behind
|
|
1468
|
+
* issue #29).
|
|
1330
1469
|
*/
|
|
1331
|
-
function
|
|
1470
|
+
function headFieldsOf(state) {
|
|
1332
1471
|
const surfaceTotal = state.sums.user + state.sums.inject + state.sums.assistant + state.sums.tool;
|
|
1333
1472
|
const result = {
|
|
1334
1473
|
ok: true,
|
|
@@ -1346,11 +1485,11 @@ function buildTimelineView(state, bounds) {
|
|
|
1346
1485
|
},
|
|
1347
1486
|
images: state.surface.reduce((n, node) => n + (node.imgs ?? 0), 0),
|
|
1348
1487
|
toolCalls: state.surface.reduce((n, node) => node.cat === "tool" ? n + 1 : n, 0),
|
|
1349
|
-
requests:
|
|
1350
|
-
events:
|
|
1488
|
+
requests: [],
|
|
1489
|
+
events: [],
|
|
1351
1490
|
nodes: [],
|
|
1352
1491
|
droppedNodes: 0,
|
|
1353
|
-
archive:
|
|
1492
|
+
archive: []
|
|
1354
1493
|
};
|
|
1355
1494
|
if (state.cost !== void 0) {
|
|
1356
1495
|
const copyFam = (f) => {
|
|
@@ -1375,6 +1514,25 @@ function buildTimelineView(state, bounds) {
|
|
|
1375
1514
|
tools
|
|
1376
1515
|
};
|
|
1377
1516
|
}
|
|
1517
|
+
return result;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* The heavy collections: copies of the retained request records and context
|
|
1521
|
+
* events (each event attached to the requests around it — the chart's ✂
|
|
1522
|
+
* anchoring), the bounded served surface window, and the removed-node
|
|
1523
|
+
* archive. Shared verbatim by the inline wire view (channel-less hosts) and
|
|
1524
|
+
* the on-demand detail payload (host/detail.ts).
|
|
1525
|
+
*/
|
|
1526
|
+
function detailCollectionsOf(state, bounds) {
|
|
1527
|
+
const result = {
|
|
1528
|
+
requests: state.requests.map((r) => ({ ...r })),
|
|
1529
|
+
events: state.events.map((e) => ({ ...e })),
|
|
1530
|
+
nodes: [],
|
|
1531
|
+
droppedNodes: 0,
|
|
1532
|
+
archive: state.archived.map((n) => ({ ...n })),
|
|
1533
|
+
fileOps: state.fileOps.map((o) => ({ ...o })),
|
|
1534
|
+
...state.fileOpsFloor !== void 0 ? { fileOpsFloor: state.fileOpsFloor } : {}
|
|
1535
|
+
};
|
|
1378
1536
|
const overflowCount = Math.max(0, state.surface.length - bounds.maxNodes);
|
|
1379
1537
|
const overflow = state.surface.slice(0, overflowCount);
|
|
1380
1538
|
const tail = state.surface.slice(overflowCount);
|
|
@@ -1405,6 +1563,354 @@ function buildTimelineView(state, bounds) {
|
|
|
1405
1563
|
}
|
|
1406
1564
|
return result;
|
|
1407
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* The split generation's SLIM wire head: the envelope scalars plus the
|
|
1568
|
+
* precomputed count figures, the newest request's billing summary (the
|
|
1569
|
+
* headline's derived anchor), and the detail revision marker. Small enough
|
|
1570
|
+
* to ride every delivery channel whole (~1KB) — the heavy collections moved
|
|
1571
|
+
* to the on-demand detail channel (host/detail.ts).
|
|
1572
|
+
*/
|
|
1573
|
+
function buildTimelineHead(state) {
|
|
1574
|
+
const result = headFieldsOf(state);
|
|
1575
|
+
const turns = /* @__PURE__ */ new Set();
|
|
1576
|
+
for (const r of state.requests) turns.add(r.turn ?? 0);
|
|
1577
|
+
let injects = 0;
|
|
1578
|
+
let compactions = 0;
|
|
1579
|
+
let prunes = 0;
|
|
1580
|
+
for (const e of state.events) if (e.kind === "inject") injects++;
|
|
1581
|
+
else if (e.kind === "compaction") compactions++;
|
|
1582
|
+
else if (e.kind === "prune") prunes++;
|
|
1583
|
+
result.counts = {
|
|
1584
|
+
turns: turns.size,
|
|
1585
|
+
steps: state.requests.length,
|
|
1586
|
+
injects,
|
|
1587
|
+
compactions,
|
|
1588
|
+
prunes
|
|
1589
|
+
};
|
|
1590
|
+
const last = state.requests.at(-1);
|
|
1591
|
+
if (last !== void 0) result.last = {
|
|
1592
|
+
seq: last.seq,
|
|
1593
|
+
total: last.total,
|
|
1594
|
+
...typeof last.prompt === "number" ? { prompt: last.prompt } : {}
|
|
1595
|
+
};
|
|
1596
|
+
result.detailRev = state.detailRev ?? 0;
|
|
1597
|
+
return result;
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* The on-demand detail payload (host/detail.ts serves it off the live fold
|
|
1601
|
+
* state): the heavy collections plus the revision marker the head carries.
|
|
1602
|
+
*/
|
|
1603
|
+
function buildTimelineDetail(state, bounds) {
|
|
1604
|
+
return {
|
|
1605
|
+
rev: state.detailRev ?? 0,
|
|
1606
|
+
...detailCollectionsOf(state, bounds)
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Serve the INLINE projection wire view (channel-less hosts): the head
|
|
1611
|
+
* scalars with the detail collections in place — the shape every delivery
|
|
1612
|
+
* channel carried before the split generation. Bound the surface nodes to
|
|
1613
|
+
* the newest tail and attach each event to the request around it; stamp
|
|
1614
|
+
* COPIES — the persisted state objects are never mutated.
|
|
1615
|
+
*/
|
|
1616
|
+
function buildTimelineView(state, bounds) {
|
|
1617
|
+
return {
|
|
1618
|
+
...headFieldsOf(state),
|
|
1619
|
+
...detailCollectionsOf(state, bounds)
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
//#endregion
|
|
1623
|
+
//#region src/host/detail.ts
|
|
1624
|
+
/** The plugin's generic Connection RPC channel (the pre-v0.9 name, kept). */
|
|
1625
|
+
const DETAIL_CHANNEL = "/dsh-context";
|
|
1626
|
+
/** The RPC failure envelope the transport expects (ConnectionRpcFailure). */
|
|
1627
|
+
function failure(code, message) {
|
|
1628
|
+
return {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
error: {
|
|
1631
|
+
code,
|
|
1632
|
+
message,
|
|
1633
|
+
details: {}
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Arm the detail endpoint whenever the connection and sessions services are
|
|
1639
|
+
* both composed (see the module header for the load-order contract). The
|
|
1640
|
+
* registration rides the injected fiber: either service unloading withdraws
|
|
1641
|
+
* the channel and closes the gate.
|
|
1642
|
+
*/
|
|
1643
|
+
function watchDetailChannel(ctx, bounds) {
|
|
1644
|
+
const gate = { live: false };
|
|
1645
|
+
ctx.inject(["connection", "sessions"], (c) => {
|
|
1646
|
+
const connection = c.get("connection");
|
|
1647
|
+
const sessions = c.get("sessions");
|
|
1648
|
+
const handle = typeof connection?.rpc?.handle === "function" ? connection.rpc.handle.bind(connection.rpc) : void 0;
|
|
1649
|
+
const getSession = typeof sessions?.get === "function" ? sessions.get.bind(sessions) : void 0;
|
|
1650
|
+
if (handle === void 0 || getSession === void 0) return;
|
|
1651
|
+
const projections = ctx.sessionProjections;
|
|
1652
|
+
const handler = async (endpoint, payload) => {
|
|
1653
|
+
if (endpoint !== "detail") return failure("dsh-context/unknown-endpoint", `unknown endpoint: ${endpoint}`);
|
|
1654
|
+
const sessionId = payload !== null && typeof payload === "object" ? payload.sessionId : void 0;
|
|
1655
|
+
if (typeof sessionId !== "string" || sessionId === "") return failure("dsh-context/bad-request", "missing sessionId");
|
|
1656
|
+
try {
|
|
1657
|
+
const session = getSession(sessionId);
|
|
1658
|
+
if (session !== void 0 && session !== null) {
|
|
1659
|
+
const state = projections.stateOf(session, "contextTimeline");
|
|
1660
|
+
if (state === void 0) return {
|
|
1661
|
+
ok: true,
|
|
1662
|
+
value: null
|
|
1663
|
+
};
|
|
1664
|
+
return {
|
|
1665
|
+
ok: true,
|
|
1666
|
+
value: buildTimelineDetail(state, bounds)
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
const query = ctx.get("sessionQuery");
|
|
1670
|
+
const observe = typeof query?.observeSession === "function" ? query.observeSession.bind(query) : void 0;
|
|
1671
|
+
if (observe === void 0) return {
|
|
1672
|
+
ok: true,
|
|
1673
|
+
value: null
|
|
1674
|
+
};
|
|
1675
|
+
const observation = await observe(sessionId, { projectionMode: "none" });
|
|
1676
|
+
const events = observation?.events;
|
|
1677
|
+
if (!Array.isArray(events)) return {
|
|
1678
|
+
ok: true,
|
|
1679
|
+
value: null
|
|
1680
|
+
};
|
|
1681
|
+
let state = createTimelineState();
|
|
1682
|
+
try {
|
|
1683
|
+
for (const ev of events) state = applyTimeline(state, ev, bounds);
|
|
1684
|
+
} finally {
|
|
1685
|
+
const dispose = observation?.[Symbol.dispose];
|
|
1686
|
+
if (typeof dispose === "function") dispose.call(observation);
|
|
1687
|
+
}
|
|
1688
|
+
return {
|
|
1689
|
+
ok: true,
|
|
1690
|
+
value: buildTimelineDetail(state, bounds)
|
|
1691
|
+
};
|
|
1692
|
+
} catch (err) {
|
|
1693
|
+
return failure("gateway/internal", err instanceof Error ? err.message : String(err));
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
try {
|
|
1697
|
+
c.effect(() => {
|
|
1698
|
+
const unregister = handle(DETAIL_CHANNEL, handler);
|
|
1699
|
+
return () => {
|
|
1700
|
+
unregister();
|
|
1701
|
+
};
|
|
1702
|
+
}, "dsh-context: detail channel");
|
|
1703
|
+
} catch {
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
gate.live = true;
|
|
1707
|
+
return () => {
|
|
1708
|
+
gate.live = false;
|
|
1709
|
+
};
|
|
1710
|
+
});
|
|
1711
|
+
return gate;
|
|
1712
|
+
}
|
|
1713
|
+
//#endregion
|
|
1714
|
+
//#region src/shared/version.ts
|
|
1715
|
+
/**
|
|
1716
|
+
* The harness-version gate's shared arithmetic — the supported dsh baseline
|
|
1717
|
+
* and the version compare behind it. Runtime code shared by BOTH halves (the
|
|
1718
|
+
* host probes and gates; the client displays what the wire record carries),
|
|
1719
|
+
* so this module must stay dependency-free.
|
|
1720
|
+
*
|
|
1721
|
+
* The baseline mirrors the support matrix (AGENTS.md "Compatibility" and the
|
|
1722
|
+
* package's `dsh.compatibility.dshReleases` declaration): the oldest dsh
|
|
1723
|
+
* release this plugin works on. A harness BELOW it gets the fallback units
|
|
1724
|
+
* (host/fallback.ts) instead of the real folds.
|
|
1725
|
+
*/
|
|
1726
|
+
/** The oldest supported dsh release (see the matrix note above). */
|
|
1727
|
+
const BASELINE_DSH_VERSION = "0.1.2-rc.1";
|
|
1728
|
+
/**
|
|
1729
|
+
* Release-channel rank at an equal X.Y.Z: a final release outranks its
|
|
1730
|
+
* release candidates, which outrank betas, which outrank alphas
|
|
1731
|
+
* (正式版 > RC > Beta > Alpha).
|
|
1732
|
+
*/
|
|
1733
|
+
function channelRank(channel) {
|
|
1734
|
+
return channel === "rc" ? 3 : channel === "beta" ? 2 : 1;
|
|
1735
|
+
}
|
|
1736
|
+
const RELEASE_RANK = 4;
|
|
1737
|
+
/**
|
|
1738
|
+
* Parse `v?[major].[minor].[patch][-(alpha|beta|rc)[.N]][+build]`, or null
|
|
1739
|
+
* when the string is not that shape. Channels other than alpha/beta/rc
|
|
1740
|
+
* (nightly, dev, …) do not parse — the gate fails open on them.
|
|
1741
|
+
*/
|
|
1742
|
+
function parseVersion(version) {
|
|
1743
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)(?:\.(\d+))?)?(?:\+[0-9a-z.-]+)?$/i.exec(version.trim());
|
|
1744
|
+
if (match === null) return null;
|
|
1745
|
+
const channel = match[4]?.toLowerCase();
|
|
1746
|
+
return {
|
|
1747
|
+
major: Number(match[1]),
|
|
1748
|
+
minor: Number(match[2]),
|
|
1749
|
+
patch: Number(match[3]),
|
|
1750
|
+
rank: channel === void 0 ? RELEASE_RANK : channelRank(channel),
|
|
1751
|
+
serial: match[5] ? Number(match[5]) : 0
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Total order over parsed versions: X.Y.Z numerically first, then the
|
|
1756
|
+
* channel rank, then the prerelease serial.
|
|
1757
|
+
*/
|
|
1758
|
+
function compareParsed(a, b) {
|
|
1759
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
1760
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
1761
|
+
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
1762
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
1763
|
+
return a.serial - b.serial;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Whether `version` satisfies the supported baseline. FAIL OPEN by design: a
|
|
1767
|
+
* version that cannot be parsed (a dev/nightly harness build) must not blank
|
|
1768
|
+
* a working deployment, so it passes — the gate trips only on a proven
|
|
1769
|
+
* below-baseline release.
|
|
1770
|
+
*/
|
|
1771
|
+
function meetsBaseline(version, baseline = BASELINE_DSH_VERSION) {
|
|
1772
|
+
const v = parseVersion(version);
|
|
1773
|
+
const b = parseVersion(baseline);
|
|
1774
|
+
if (v === null || b === null) return true;
|
|
1775
|
+
return compareParsed(v, b) >= 0;
|
|
1776
|
+
}
|
|
1777
|
+
//#endregion
|
|
1778
|
+
//#region src/host/headers.ts
|
|
1779
|
+
/**
|
|
1780
|
+
* The `contextHeaders` session projection unit — the request-header EPOCH
|
|
1781
|
+
* METADATA behind the timeline's envelope figures.
|
|
1782
|
+
*
|
|
1783
|
+
* The hot `contextTimeline` unit carries only token prices of the system
|
|
1784
|
+
* prompt and tool schemas; this companion unit keeps the per-epoch METADATA
|
|
1785
|
+
* (epoch seq/time boundaries, per-tool token prices and plugin attribution)
|
|
1786
|
+
* so the Context browser can pick the header epoch in force at any step and
|
|
1787
|
+
* size its sections immediately. The epoch CONTENT (full system prompt text,
|
|
1788
|
+
* full tool JSON schemas) deliberately does NOT ride the projection VALUE:
|
|
1789
|
+
* session projections are served whole in every `session.list` row, control
|
|
1790
|
+
* baseline, push frame, and change notification, so carrying content here
|
|
1791
|
+
* multiplied it by sessions × epochs across every channel. The client
|
|
1792
|
+
* fetches one epoch's `request/header` event on demand — a seq-anchored
|
|
1793
|
+
* history read off the epoch's `seq`, the same targeted read the browser
|
|
1794
|
+
* already uses for message content — and caches it per session (history is
|
|
1795
|
+
* immutable).
|
|
1796
|
+
*
|
|
1797
|
+
* Read-compat over the persisted state (the pinned decision behind keeping
|
|
1798
|
+
* `stateVersion` at 1): the harness serves a cold session's projections from
|
|
1799
|
+
* its CACHED checkpoint rows and has no refresh channel for an idle session
|
|
1800
|
+
* — a version bump invalidates every row and orphans the key until the
|
|
1801
|
+
* session goes live again (the #37 regression). The state therefore still
|
|
1802
|
+
* ACCEPTS the v1 content-bearing record shape, current folds append
|
|
1803
|
+
* metadata-only records alongside any seeded legacy ones, and the view
|
|
1804
|
+
* normalizes BOTH to the metadata-only wire shape (pricing the legacy system
|
|
1805
|
+
* text at read time). Cached v1 rows keep working, new checkpoint writes
|
|
1806
|
+
* shrink as legacy epochs age out of the capped list, and the wire — the
|
|
1807
|
+
* part every delivery channel carries — is metadata-only from day one.
|
|
1808
|
+
*
|
|
1809
|
+
* Same projection contract as the timeline unit: pure init/apply/view,
|
|
1810
|
+
* `Object.is` reference stability for uninteresting events, plain-JSON
|
|
1811
|
+
* bounded state (epoch list capped — see HEADERS_MAX).
|
|
1812
|
+
*/
|
|
1813
|
+
/** Retention cap on header epochs (metadata only; changes are rare; 50 is generous). */
|
|
1814
|
+
const HEADERS_MAX = 50;
|
|
1815
|
+
/**
|
|
1816
|
+
* The persisted-state schema: the SUPERSET of both record generations, so a
|
|
1817
|
+
* cached v1 row (content-bearing) seeds the fold instead of being discarded.
|
|
1818
|
+
*/
|
|
1819
|
+
const storedToolSchema = z.object({
|
|
1820
|
+
name: z.string(),
|
|
1821
|
+
tokens: z.number().int().nonnegative(),
|
|
1822
|
+
description: z.string().optional(),
|
|
1823
|
+
plugin: z.string().optional(),
|
|
1824
|
+
schema: z.unknown().optional()
|
|
1825
|
+
}).strict();
|
|
1826
|
+
const storedEpochSchema = z.object({
|
|
1827
|
+
seq: z.number(),
|
|
1828
|
+
time: z.number(),
|
|
1829
|
+
system: z.string().optional(),
|
|
1830
|
+
systemTokens: z.number().int().nonnegative().optional(),
|
|
1831
|
+
tools: z.array(storedToolSchema)
|
|
1832
|
+
}).strict();
|
|
1833
|
+
const contextHeadersStateSchema = z.object({ headers: z.array(storedEpochSchema) }).strict();
|
|
1834
|
+
/** The wire schema: strict metadata — the shape every delivery channel carries. */
|
|
1835
|
+
const headerToolWireSchema = z.object({
|
|
1836
|
+
name: z.string(),
|
|
1837
|
+
tokens: z.number().int().nonnegative(),
|
|
1838
|
+
plugin: z.string().optional()
|
|
1839
|
+
}).strict();
|
|
1840
|
+
/** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
|
|
1841
|
+
const contextHeadersSchema = z.object({ headers: z.array(z.object({
|
|
1842
|
+
seq: z.number(),
|
|
1843
|
+
time: z.number(),
|
|
1844
|
+
systemTokens: z.number().int().nonnegative().optional(),
|
|
1845
|
+
tools: z.array(headerToolWireSchema)
|
|
1846
|
+
}).strict()) }).strict();
|
|
1847
|
+
function recordOf(event) {
|
|
1848
|
+
if (event.type !== "request/header") return null;
|
|
1849
|
+
const rawHeader = event.data.header;
|
|
1850
|
+
if (rawHeader === null || rawHeader === void 0 || typeof rawHeader !== "object") return null;
|
|
1851
|
+
const header = rawHeader;
|
|
1852
|
+
const tools = Array.isArray(header.tools) ? header.tools : [];
|
|
1853
|
+
const record = {
|
|
1854
|
+
seq: event.seq,
|
|
1855
|
+
time: event.time,
|
|
1856
|
+
tools: tools.map((t) => {
|
|
1857
|
+
const tool = t !== null && typeof t === "object" ? t : {};
|
|
1858
|
+
const entry = {
|
|
1859
|
+
name: typeof tool.name === "string" ? tool.name : "?",
|
|
1860
|
+
tokens: estimateToolSchema(t)
|
|
1861
|
+
};
|
|
1862
|
+
if (typeof tool.plugin === "string" && tool.plugin !== "") entry.plugin = tool.plugin;
|
|
1863
|
+
return entry;
|
|
1864
|
+
})
|
|
1865
|
+
};
|
|
1866
|
+
if (typeof header.system === "string" && header.system.length > 0) record.systemTokens = estimateSystemTokens(header.system);
|
|
1867
|
+
return record;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through
|
|
1871
|
+
* `useProjection('contextHeaders')` and fetch an epoch's full content on demand via the session history (historyPage.ts).
|
|
1872
|
+
* Contract mirror with a REQUIRED `wire` block (see compat.ts).
|
|
1873
|
+
* @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so
|
|
1874
|
+
* epochs folded without attribution still render a tag when the source is known.
|
|
1875
|
+
*/
|
|
1876
|
+
function createContextHeadersDefinition(resolve) {
|
|
1877
|
+
const view = (state) => ({ headers: state.headers.map((h) => {
|
|
1878
|
+
const record = {
|
|
1879
|
+
seq: h.seq,
|
|
1880
|
+
time: h.time,
|
|
1881
|
+
tools: h.tools.map((t) => {
|
|
1882
|
+
const entry = {
|
|
1883
|
+
name: t.name,
|
|
1884
|
+
tokens: t.tokens
|
|
1885
|
+
};
|
|
1886
|
+
const plugin = t.plugin ?? (resolve !== void 0 ? resolve(t.name) : void 0);
|
|
1887
|
+
if (plugin !== void 0) entry.plugin = plugin;
|
|
1888
|
+
return entry;
|
|
1889
|
+
})
|
|
1890
|
+
};
|
|
1891
|
+
const systemTokens = h.systemTokens ?? (typeof h.system === "string" && h.system !== "" ? estimateSystemTokens(h.system) : void 0);
|
|
1892
|
+
if (systemTokens !== void 0) record.systemTokens = systemTokens;
|
|
1893
|
+
return record;
|
|
1894
|
+
}) });
|
|
1895
|
+
return {
|
|
1896
|
+
key: "contextHeaders",
|
|
1897
|
+
stateSchema: contextHeadersStateSchema,
|
|
1898
|
+
wire: {
|
|
1899
|
+
viewSchema: contextHeadersSchema,
|
|
1900
|
+
view
|
|
1901
|
+
},
|
|
1902
|
+
init: () => ({ headers: [] }),
|
|
1903
|
+
apply: (state, event) => {
|
|
1904
|
+
const record = recordOf(event);
|
|
1905
|
+
if (record === null) return state;
|
|
1906
|
+
const last = state.headers.at(-1);
|
|
1907
|
+
if (last !== void 0 && last.seq === record.seq) return state;
|
|
1908
|
+
const headers = [...state.headers, record];
|
|
1909
|
+
return { headers: headers.length > HEADERS_MAX ? headers.slice(-50) : headers };
|
|
1910
|
+
},
|
|
1911
|
+
stateVersion: 1
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1408
1914
|
//#endregion
|
|
1409
1915
|
//#region src/host/timeline.ts
|
|
1410
1916
|
/**
|
|
@@ -1485,6 +1991,34 @@ const contextEventSchema = z.object({
|
|
|
1485
1991
|
turn: z.number().optional(),
|
|
1486
1992
|
step: z.number().optional()
|
|
1487
1993
|
}).strict();
|
|
1994
|
+
/** The fold-derived file-operation record (shared/types.ts FileOpRecord). */
|
|
1995
|
+
const fileOpSchema = z.object({
|
|
1996
|
+
seq: z.number().int().nonnegative(),
|
|
1997
|
+
path: z.string(),
|
|
1998
|
+
kind: z.enum([
|
|
1999
|
+
"read",
|
|
2000
|
+
"write",
|
|
2001
|
+
"search"
|
|
2002
|
+
]),
|
|
2003
|
+
tool: z.string(),
|
|
2004
|
+
time: z.number().optional(),
|
|
2005
|
+
err: z.boolean(),
|
|
2006
|
+
added: z.number().int().nonnegative(),
|
|
2007
|
+
removed: z.number().int().nonnegative(),
|
|
2008
|
+
detail: z.string().optional(),
|
|
2009
|
+
hits: z.number().int().positive().optional(),
|
|
2010
|
+
read: z.union([z.object({
|
|
2011
|
+
start: z.number().int().positive(),
|
|
2012
|
+
count: z.number().int().nonnegative()
|
|
2013
|
+
}).strict(), z.object({
|
|
2014
|
+
count: z.number().int().positive(),
|
|
2015
|
+
est: z.literal(true)
|
|
2016
|
+
}).strict()]).optional(),
|
|
2017
|
+
parent: z.number().int().nonnegative().optional(),
|
|
2018
|
+
program: z.string().optional(),
|
|
2019
|
+
pattern: z.literal(true).optional(),
|
|
2020
|
+
gone: z.number().int().nonnegative().optional()
|
|
2021
|
+
}).strict();
|
|
1488
2022
|
const currentSchema = z.object({
|
|
1489
2023
|
system: z.number().int().nonnegative(),
|
|
1490
2024
|
tools: z.number().int().nonnegative(),
|
|
@@ -1522,7 +2056,28 @@ const unsupportedSchema = z.object({
|
|
|
1522
2056
|
current: z.string(),
|
|
1523
2057
|
minimum: z.string()
|
|
1524
2058
|
}).strict();
|
|
1525
|
-
/**
|
|
2059
|
+
/** The stats board's precomputed count figures (the split head — see Snapshot.counts). */
|
|
2060
|
+
const countsSchema = z.object({
|
|
2061
|
+
turns: z.number().int().nonnegative(),
|
|
2062
|
+
steps: z.number().int().nonnegative(),
|
|
2063
|
+
injects: z.number().int().nonnegative(),
|
|
2064
|
+
compactions: z.number().int().nonnegative(),
|
|
2065
|
+
prunes: z.number().int().nonnegative()
|
|
2066
|
+
}).strict();
|
|
2067
|
+
/** The newest retained request's billing summary (the split head's headline anchor). */
|
|
2068
|
+
const lastSchema = z.object({
|
|
2069
|
+
seq: z.number(),
|
|
2070
|
+
total: z.number().int().nonnegative(),
|
|
2071
|
+
prompt: z.number().int().nonnegative().optional()
|
|
2072
|
+
}).strict();
|
|
2073
|
+
/**
|
|
2074
|
+
* One wire contract for both generations: the SPLIT head (envelope scalars +
|
|
2075
|
+
* counts/last/detailRev; the heavy collections stay absent — they ride the
|
|
2076
|
+
* on-demand detail channel, host/detail.ts) and the INLINE value
|
|
2077
|
+
* (channel-less hosts and the fallback unit carry the collections in place).
|
|
2078
|
+
* The collections are therefore optional on the schema; the split marker is
|
|
2079
|
+
* `detailRev` (present ⟺ split).
|
|
2080
|
+
*/
|
|
1526
2081
|
const contextTimelineSchema = z.object({
|
|
1527
2082
|
ok: z.literal(true),
|
|
1528
2083
|
unsupported: unsupportedSchema.optional(),
|
|
@@ -1532,18 +2087,23 @@ const contextTimelineSchema = z.object({
|
|
|
1532
2087
|
current: currentSchema,
|
|
1533
2088
|
images: z.number().int().nonnegative().optional(),
|
|
1534
2089
|
toolCalls: z.number().int().nonnegative().optional(),
|
|
1535
|
-
|
|
1536
|
-
|
|
2090
|
+
counts: countsSchema.optional(),
|
|
2091
|
+
last: lastSchema.optional(),
|
|
2092
|
+
detailRev: z.number().int().nonnegative().optional(),
|
|
2093
|
+
requests: z.array(requestRecordSchema).optional(),
|
|
2094
|
+
events: z.array(contextEventSchema).optional(),
|
|
1537
2095
|
cost: z.object({
|
|
1538
2096
|
flash: costFamilySchema.optional(),
|
|
1539
2097
|
pro: costFamilySchema.optional()
|
|
1540
2098
|
}).strict().optional(),
|
|
1541
2099
|
timing: timingTotalsSchema.optional(),
|
|
1542
|
-
nodes: z.array(surfaceNodeSchema),
|
|
1543
|
-
droppedNodes: z.number().int().nonnegative(),
|
|
1544
|
-
archive: z.array(surfaceNodeSchema),
|
|
2100
|
+
nodes: z.array(surfaceNodeSchema).optional(),
|
|
2101
|
+
droppedNodes: z.number().int().nonnegative().optional(),
|
|
2102
|
+
archive: z.array(surfaceNodeSchema).optional(),
|
|
1545
2103
|
surfaceFloor: z.number().int().nonnegative().optional(),
|
|
1546
|
-
archiveFloor: z.number().int().nonnegative().optional()
|
|
2104
|
+
archiveFloor: z.number().int().nonnegative().optional(),
|
|
2105
|
+
fileOps: z.array(fileOpSchema).optional(),
|
|
2106
|
+
fileOpsFloor: z.number().int().nonnegative().optional()
|
|
1547
2107
|
}).strict();
|
|
1548
2108
|
/**
|
|
1549
2109
|
* The persisted fold-state schema (the registry's `stateSchema`
|
|
@@ -1580,10 +2140,15 @@ const timelineStateSchema = z.object({
|
|
|
1580
2140
|
}).strict().optional(),
|
|
1581
2141
|
callNames: z.record(z.string(), z.object({
|
|
1582
2142
|
name: z.string(),
|
|
1583
|
-
start: z.number()
|
|
2143
|
+
start: z.number(),
|
|
2144
|
+
argsRaw: z.string().optional()
|
|
1584
2145
|
}).strict()),
|
|
1585
2146
|
pendingShadowedSeqs: z.array(z.number()).optional(),
|
|
1586
|
-
pendingShadowEventSeq: z.number().optional()
|
|
2147
|
+
pendingShadowEventSeq: z.number().optional(),
|
|
2148
|
+
detailRev: z.number().int().nonnegative().optional(),
|
|
2149
|
+
fileOps: z.array(fileOpSchema),
|
|
2150
|
+
fileOpsFloor: z.number().int().nonnegative().optional(),
|
|
2151
|
+
pendingCodeOps: z.record(z.string(), z.array(fileOpSchema)).optional()
|
|
1587
2152
|
});
|
|
1588
2153
|
/**
|
|
1589
2154
|
* The context-timeline projection unit, created per plugin instance with its
|
|
@@ -1603,10 +2168,24 @@ const timelineStateSchema = z.object({
|
|
|
1603
2168
|
* `wire` block the registry treats the unit as host-only and never delivers
|
|
1604
2169
|
* `contextTimeline` to the browser (the Context tab would stay on its
|
|
1605
2170
|
* loading screen forever).
|
|
2171
|
+
*
|
|
2172
|
+
* `slim` selects the wire generation PER SERVE (a liveness probe, not a
|
|
2173
|
+
* fixed flag): while the on-demand detail channel is live (host/detail.ts),
|
|
2174
|
+
* the wire value is the SLIM head (buildTimelineHead) — the heavy
|
|
2175
|
+
* collections no longer ride every session.list row, control baseline,
|
|
2176
|
+
* follow snapshot, and push frame. Before the channel arms (the connection
|
|
2177
|
+
* service may activate after this plugin) or on a deployment whose
|
|
2178
|
+
* connection/sessions services never compose, the unit serves the INLINE
|
|
2179
|
+
* value so the tab keeps working end to end. Both generations validate
|
|
2180
|
+
* against the same schema (the collections are optional on it), and both
|
|
2181
|
+
* fold the SAME state — the split is view-only, so no `stateVersion` bump
|
|
2182
|
+
* and no cached-row invalidation comes with it (the `detailRev` state field
|
|
2183
|
+
* is additive-optional: older rows restore without it and read as revision
|
|
2184
|
+
* 0).
|
|
1606
2185
|
*/
|
|
1607
|
-
function createContextTimelineDefinition(config) {
|
|
2186
|
+
function createContextTimelineDefinition(config, slim) {
|
|
1608
2187
|
const bounds = resolveBounds(config);
|
|
1609
|
-
const view = (state) => buildTimelineView(state, bounds);
|
|
2188
|
+
const view = (state) => slim() ? buildTimelineHead(state) : buildTimelineView(state, bounds);
|
|
1610
2189
|
return {
|
|
1611
2190
|
key: "contextTimeline",
|
|
1612
2191
|
stateSchema: timelineStateSchema,
|
|
@@ -1616,7 +2195,7 @@ function createContextTimelineDefinition(config) {
|
|
|
1616
2195
|
},
|
|
1617
2196
|
init: () => createTimelineState(),
|
|
1618
2197
|
apply: (state, event) => applyTimeline(state, event, bounds),
|
|
1619
|
-
stateVersion:
|
|
2198
|
+
stateVersion: 15
|
|
1620
2199
|
};
|
|
1621
2200
|
}
|
|
1622
2201
|
//#endregion
|
|
@@ -1727,6 +2306,84 @@ function installSettings(ctx) {
|
|
|
1727
2306
|
});
|
|
1728
2307
|
}
|
|
1729
2308
|
//#endregion
|
|
2309
|
+
//#region src/host/stepIdentity.ts
|
|
2310
|
+
/**
|
|
2311
|
+
* Step-boundary message identity guard (issue #51 compatibility).
|
|
2312
|
+
*
|
|
2313
|
+
* The harness refuses to LOAD any session whose durable log carries a
|
|
2314
|
+
* `user/message` event whose message lacks a non-empty string `id`
|
|
2315
|
+
* (`assertMessageEventShape`) — while the runtime append path runs no such
|
|
2316
|
+
* check. One unidentified message therefore persists silently and permanently
|
|
2317
|
+
* bricks the session at its next load (`... failed validation: session event
|
|
2318
|
+
* at seq N lacks an identified message`). The observed archive held a wrap-up
|
|
2319
|
+
* notice whose `id` (and `summary`) had been stripped upstream of the append —
|
|
2320
|
+
* a shape no shipped harness producer emits, i.e. a delivery-boundary rebuild
|
|
2321
|
+
* outside the audited paths of both this plugin and the harness.
|
|
2322
|
+
*
|
|
2323
|
+
* This guard hardens the durability boundary from the plugin side. The agent
|
|
2324
|
+
* loop appends every step-boundary `user/message` from the `agent/pre-step`
|
|
2325
|
+
* decision's `messages` — the one seam all claimed inbox input (prompts,
|
|
2326
|
+
* steering, tool-deferred context) flows through before it persists. A
|
|
2327
|
+
* prepended listener sits OUTERMOST in that waterfall, so after `next()`
|
|
2328
|
+
* resolves it sees the final message list: any message that would persist
|
|
2329
|
+
* unidentified gets a fresh id; everything else passes by reference, and the
|
|
2330
|
+
* decision object is copied only when a mint happened.
|
|
2331
|
+
*
|
|
2332
|
+
* Fail-open by contract: a hostile entry (one that throws on property access),
|
|
2333
|
+
* a missing list, or any unexpected shape leaves the decision verbatim — a
|
|
2334
|
+
* guard must never break the turn it protects. Minted ids carry the `dshctx-`
|
|
2335
|
+
* prefix so a backfilled message stays identifiable in the wild.
|
|
2336
|
+
*/
|
|
2337
|
+
/** Prefix marking an id this guard minted (forensically distinguishable). */
|
|
2338
|
+
const MINTED_ID_PREFIX = "dshctx-";
|
|
2339
|
+
/**
|
|
2340
|
+
* Whether the message fails the harness's restore-time identity check (a
|
|
2341
|
+
* non-empty string id). Non-object entries are unfixable (an id needs a
|
|
2342
|
+
* container) and stay verbatim.
|
|
2343
|
+
*/
|
|
2344
|
+
function lacksId(message) {
|
|
2345
|
+
if (typeof message !== "object" || message === null) return false;
|
|
2346
|
+
const id = message.id;
|
|
2347
|
+
return typeof id !== "string" || id === "";
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Mint ids for the messages that would persist unidentified.
|
|
2351
|
+
* @param messages - the decision's message list, in append order.
|
|
2352
|
+
* @returns the rewritten list (untouched entries by reference), or undefined
|
|
2353
|
+
* when every entry already carries an identity.
|
|
2354
|
+
*/
|
|
2355
|
+
function identifiedMessages(messages) {
|
|
2356
|
+
let copy;
|
|
2357
|
+
for (const [index, message] of messages.entries()) {
|
|
2358
|
+
if (!lacksId(message)) {
|
|
2359
|
+
copy?.push(message);
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
copy ??= messages.slice(0, index);
|
|
2363
|
+
copy.push({
|
|
2364
|
+
...message,
|
|
2365
|
+
id: MINTED_ID_PREFIX + randomUUID()
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
return copy;
|
|
2369
|
+
}
|
|
2370
|
+
/** Arm the prepended `agent/pre-step` guard on the plugin's context. */
|
|
2371
|
+
function watchStepIdentity(ctx) {
|
|
2372
|
+
ctx.on("agent/pre-step", async (_input, next) => {
|
|
2373
|
+
const decision = await next();
|
|
2374
|
+
try {
|
|
2375
|
+
if (decision.kind !== "enter" || !Array.isArray(decision.messages)) return decision;
|
|
2376
|
+
const identified = identifiedMessages(decision.messages);
|
|
2377
|
+
return identified === void 0 ? decision : {
|
|
2378
|
+
...decision,
|
|
2379
|
+
messages: identified
|
|
2380
|
+
};
|
|
2381
|
+
} catch {
|
|
2382
|
+
return decision;
|
|
2383
|
+
}
|
|
2384
|
+
}, { prepend: true });
|
|
2385
|
+
}
|
|
2386
|
+
//#endregion
|
|
1730
2387
|
//#region src/host/version.ts
|
|
1731
2388
|
/**
|
|
1732
2389
|
* Runtime harness-version probe behind the baseline gate (host/index.ts).
|
|
@@ -1844,7 +2501,9 @@ function apply(ctx, config) {
|
|
|
1844
2501
|
return;
|
|
1845
2502
|
}
|
|
1846
2503
|
const attribution = createToolAttribution(ctx);
|
|
1847
|
-
ctx
|
|
2504
|
+
watchStepIdentity(ctx);
|
|
2505
|
+
const gate = watchDetailChannel(ctx, resolveBounds(config));
|
|
2506
|
+
ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live));
|
|
1848
2507
|
ctx.sessionProjections.register(createContextHeadersDefinition((name) => attribution.ownerOf(name)));
|
|
1849
2508
|
installSettings(ctx);
|
|
1850
2509
|
}
|