dsh-context 0.54.4 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { z } from "zod";
3
3
  import { SessionLogOffset, deriveEventMessage, interruptedTurnClosers } from "@deepseek-ai/dsh-session";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
- import { dirname, join, normalize, relative } from "node:path";
5
+ import { dirname, isAbsolute, join, normalize, relative } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import z$1 from "@deepseek-ai/schemastery";
8
8
  import { randomUUID } from "node:crypto";
@@ -330,30 +330,28 @@ function isInjection(source) {
330
330
  //#region src/host/logShapes.ts
331
331
  /**
332
332
  * Shape-driven readers over the durable session-event vocabulary — the ONE
333
- * place the plugin reconciles the two supported log generations:
333
+ * place the plugin's log spellings meet. The supported range (dsh
334
+ * 0.1.5-rc.1+, session formats V3/V4) speaks ONE dialect:
335
+ * `system/message` surface nodes, `assistant/message.data.stream` /
336
+ * `assistant/attempt.data.stream` (packed delta runs plus raw chunk
337
+ * records), `SurfaceOp { startSeq, endSeq }`, `tool/ptc-dispatch`.
334
338
  *
335
- * - V0 (dsh 0.1.2-rc.1): `request/header.header.system`, `assistant/chunk`
336
- * stream events, `SurfaceOp { start, end }`, `tool/code-dispatch`.
337
- * - V3 (dsh 0.1.5-alpha.x+): `system/message` surface nodes,
338
- * `assistant/message.data.stream` / `assistant/attempt.data.stream`,
339
- * `SurfaceOp { startSeq, endSeq }`, `tool/ptc-dispatch`.
340
- *
341
- * The fold reads SHAPES, never a detected harness version: a session log is
342
- * written by exactly one generation, the two spellings are mutually
343
- * exclusive within it, and a deployment's version probe can be wrong (a
344
- * healed profile mirror may name a different release than the running
345
- * harness). Every reader is total over untrusted input — a malformed record
346
- * yields "nothing here", never a throw (the projection registry drives the
347
- * fold without an error boundary; one throw stalls the unit's push feed and
348
- * the browser waits on "loading" forever).
339
+ * The fold still reads SHAPES, never a detected harness version: the raw log
340
+ * is untrusted input at every layer, and the supported harness refuses —
341
+ * never rewrites — a legacy-format log, so anything that reaches these
342
+ * readers is current-dialect or hostile. Every reader is total over
343
+ * untrusted input — a malformed record yields "nothing here", never a throw
344
+ * (the projection registry drives the fold without an error boundary; one
345
+ * throw stalls the unit's push feed and the browser waits on "loading"
346
+ * forever).
349
347
  *
350
348
  * @module dsh-context/host/log-shapes
351
349
  */
352
350
  /**
353
- * Whether one raw stream chunk carries a token delta — the first-token marker
354
- * both generations share. Mirrors dsh-llm's `isTokenDelta` (a non-empty text
355
- * or reasoning fragment, or any Tool-call delta carrying arguments or a name);
356
- * a malformed chunk is simply not a token.
351
+ * Whether one raw stream chunk carries a token delta — the first-token
352
+ * marker, mirroring dsh-llm's `isTokenDelta` (a non-empty text or reasoning
353
+ * fragment, or any Tool-call delta carrying arguments or a name); a
354
+ * malformed chunk is simply not a token.
357
355
  */
358
356
  function isTokenChunk(chunk) {
359
357
  if (chunk === null || typeof chunk !== "object") return false;
@@ -372,21 +370,28 @@ function decodeKindOfBlock(blockType) {
372
370
  if (blockType === "tool-call") return "toolarg";
373
371
  }
374
372
  /**
375
- * Per-kind decode spans inside one embedded assistant stream, tiling
376
- * [first block-start, endTime]: each `block-start` record owns the interval up
377
- * to the next one, the last one up to `endTime`. This is the V2+ shape, whose
378
- * timed stream rides the settlement (`assistant/message.data.stream`) instead
379
- * of separate `assistant/chunk` events. Total over untrusted input — a
380
- * malformed record is skipped, a non-finite boundary yields zero, and a stream
381
- * with no marker (or not an array) yields all zeros.
382
- */
383
- function decodeSpansOfStream(stream, endTime) {
384
- const spans = {
385
- reasoning: 0,
386
- text: 0,
387
- toolarg: 0
373
+ * Per-kind decode spans AND counted `block-start` markers inside one embedded
374
+ * assistant stream, tiling [first block-start, endTime]: each `block-start`
375
+ * record owns the interval up to the next one, the last one up to `endTime`.
376
+ * Total over untrusted input — a malformed record is skipped whole (a
377
+ * marker with an unusable time anchors no span and counts nothing), a
378
+ * non-finite boundary yields zero, and a stream with no marker (or not an
379
+ * array) yields all zeros.
380
+ */
381
+ function decodeTallyOfStream(stream, endTime) {
382
+ const tally = {
383
+ spans: {
384
+ reasoning: 0,
385
+ text: 0,
386
+ toolarg: 0
387
+ },
388
+ blocks: {
389
+ reasoning: 0,
390
+ text: 0,
391
+ toolarg: 0
392
+ }
388
393
  };
389
- if (!Array.isArray(stream) || !Number.isFinite(endTime)) return spans;
394
+ if (!Array.isArray(stream) || !Number.isFinite(endTime)) return tally;
390
395
  let kind;
391
396
  let since = 0;
392
397
  for (const record of stream) {
@@ -397,12 +402,13 @@ function decodeSpansOfStream(stream, endTime) {
397
402
  if (chunk.type !== "block-start") continue;
398
403
  const time = r.time;
399
404
  if (typeof time !== "number" || !Number.isFinite(time)) continue;
400
- if (kind !== void 0) spans[kind] += Math.max(0, time - since);
405
+ if (kind !== void 0) tally.spans[kind] += Math.max(0, time - since);
401
406
  kind = decodeKindOfBlock(chunk.blockType);
407
+ if (kind !== void 0) tally.blocks[kind] += 1;
402
408
  since = time;
403
409
  }
404
- if (kind !== void 0) spans[kind] += Math.max(0, endTime - since);
405
- return spans;
410
+ if (kind !== void 0) tally.spans[kind] += Math.max(0, endTime - since);
411
+ return tally;
406
412
  }
407
413
  /**
408
414
  * The first token's instant inside one PACKED delta run (`text-chunks` /
@@ -431,9 +437,8 @@ function runFirstTokenTime(record) {
431
437
  }
432
438
  /**
433
439
  * The first token's instant inside an embedded assistant stream
434
- * (`assistant/message.data.stream`, `assistant/attempt.data.stream` — the V2+
435
- * settlement that replaced the V0 `assistant/chunk` events), or undefined
436
- * when the stream carries no token. Mirrors dsh-llm's
440
+ * (`assistant/message.data.stream`, `assistant/attempt.data.stream`), or
441
+ * undefined when the stream carries no token. Mirrors dsh-llm's
437
442
  * `assistantStreamFirstTokenTime` over the compact record union.
438
443
  */
439
444
  function firstTokenTimeOfStream(stream) {
@@ -453,16 +458,14 @@ function firstTokenTimeOfStream(stream) {
453
458
  /**
454
459
  * The inclusive surface range a replacement op covers, or null for `append`
455
460
  * and for any unrecognized/hostile op (which the fold treats as an append).
456
- * Reads BOTH endpoint spellings: V3's `startSeq`/`endSeq` first, then V0's
457
- * `start`/`end` — each accepted only as a finite number, so a hostile op
458
- * with one good and one malformed endpoint degrades to append.
461
+ * The `startSeq`/`endSeq` endpoints are each accepted only as a finite
462
+ * number, so a hostile op with one malformed endpoint degrades to append.
459
463
  */
460
464
  function replaceRangeOf(surfaceOp) {
461
465
  if (surfaceOp === null || typeof surfaceOp !== "object") return null;
462
466
  const op = surfaceOp;
463
467
  if (op.op !== "replace") return null;
464
- const start = typeof op.startSeq === "number" ? op.startSeq : op.start;
465
- const end = typeof op.endSeq === "number" ? op.endSeq : op.end;
468
+ const { startSeq: start, endSeq: end } = op;
466
469
  if (typeof start !== "number" || !Number.isFinite(start)) return null;
467
470
  if (typeof end !== "number" || !Number.isFinite(end)) return null;
468
471
  return {
@@ -507,6 +510,24 @@ function kindOfCall(tool, args) {
507
510
  return kindOfTool(tool);
508
511
  }
509
512
  /**
513
+ * Whether a settled call's arguments can yield an op at all: the file tools
514
+ * plus `str_replace_editor`, whose purpose follows its `command`. The public
515
+ * face of the gate {@link opsOfCall} short-circuits on — callers that decide
516
+ * whether to parse or to KEEP a call's raw arguments ask here instead of
517
+ * restating the tool list.
518
+ */
519
+ function opBearingTool(tool) {
520
+ return tool === "str_replace_editor" || kindOfTool(tool) !== null;
521
+ }
522
+ /**
523
+ * Whether a call's raw arguments must ride the fold state
524
+ * (TimelineState.callNames): every op-bearing tool, plus `run_code`, whose
525
+ * `description` labels the ops its nested dispatches book at flush time.
526
+ */
527
+ function rawArgsNeeded(tool) {
528
+ return opBearingTool(tool) || tool === "run_code";
529
+ }
530
+ /**
510
531
  * The operation's target path: the path-ish argument of read/write tools;
511
532
  * for searches the narrowing `path`, else the pattern itself (a pathless
512
533
  * grep/glob's target IS the pattern — the workspace-wide search text).
@@ -645,7 +666,7 @@ function searchFilesOf(meta) {
645
666
  * resolve no target) rows nothing.
646
667
  */
647
668
  function opsOfCall(input) {
648
- const args = parseCallArgs(input.argsRaw);
669
+ const args = opBearingTool(input.tool) ? parseCallArgs(input.argsRaw) : null;
649
670
  const kind = kindOfCall(input.tool, args);
650
671
  if (kind === null) return [];
651
672
  const stamp = {
@@ -724,8 +745,15 @@ function countTurnRuns(requests) {
724
745
  return runs;
725
746
  }
726
747
  function trimState(st, bounds) {
727
- if (countTurnRuns(st.requests) > bounds.maxKeptTurns) st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns);
728
- if (st.requests.length > bounds.maxRequestSteps) st.requests = st.requests.slice(-bounds.maxRequestSteps);
748
+ st.turnRuns = st.turnRuns ?? countTurnRuns(st.requests);
749
+ if (st.turnRuns > bounds.maxKeptTurns) {
750
+ st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns);
751
+ st.turnRuns = countTurnRuns(st.requests);
752
+ }
753
+ if (st.requests.length > bounds.maxRequestSteps) {
754
+ st.requests = st.requests.slice(-bounds.maxRequestSteps);
755
+ st.turnRuns = countTurnRuns(st.requests);
756
+ }
729
757
  if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents);
730
758
  if (st.fileOps.length > bounds.maxFileOps) {
731
759
  const drop = st.fileOps.length - bounds.maxFileOps;
@@ -757,6 +785,7 @@ function createTimelineState() {
757
785
  systemTokens: 0,
758
786
  toolsTokens: 0,
759
787
  requests: [],
788
+ turnRuns: 0,
760
789
  events: [],
761
790
  archived: [],
762
791
  callNames: {},
@@ -867,8 +896,10 @@ function removeSurfaceSeqs(st, claimed, goneSeq) {
867
896
  /**
868
897
  * The message nested under an event payload's `message` field
869
898
  * (`system/message`, `assistant/message`, `tool/result`) — read structurally
870
- * rather than through `deriveEventMessage`, whose 0.1.2-rc.1 generation knows
871
- * nothing of the V3 `system/message` variant. A malformed payload reads null.
899
+ * because the fold needs a TOTAL read over untrusted payloads: a malformed
900
+ * message reads null here, never throws or guesses a shape. Purely a type
901
+ * cast: `deriveEventMessage` returns `event.data` for a user/message (no
902
+ * `data.message` indirection), so its result feeds every other case.
872
903
  */
873
904
  function messageOf$1(data) {
874
905
  const message = data?.message;
@@ -1118,17 +1149,32 @@ function ensureTiming(st) {
1118
1149
  };
1119
1150
  return st.timing;
1120
1151
  }
1152
+ /** The TimingTotals fields each decode bucket's span and count land in. */
1153
+ const DECODE_FIELDS = {
1154
+ reasoning: {
1155
+ ms: "reasoningMs",
1156
+ n: "reasoningBlocks"
1157
+ },
1158
+ text: {
1159
+ ms: "textMs",
1160
+ n: "textBlocks"
1161
+ },
1162
+ toolarg: {
1163
+ ms: "toolArgMs",
1164
+ n: "toolArgBlocks"
1165
+ }
1166
+ };
1121
1167
  /**
1122
- * Fold one block's decode span into the totals' generation split (see
1123
- * TimingTotals). A zero span stays ABSENT — the field then carries the
1124
- * "no time was decoded in this bucket" fact without adding dead properties to
1125
- * every pre-split-shaped state, and the card reads absence as 0.
1168
+ * Fold one block's decode span and count into the totals' generation split
1169
+ * (see TimingTotals). A zero span or count stays ABSENT — the field then
1170
+ * carries the "nothing was decoded/counted in this bucket" fact without adding
1171
+ * dead properties to every pre-split-shaped state, and the card reads absence
1172
+ * as 0.
1126
1173
  */
1127
- function addDecode(timing, kind, ms) {
1128
- if (!(ms > 0)) return;
1129
- if (kind === "reasoning") timing.reasoningMs = (timing.reasoningMs ?? 0) + ms;
1130
- else if (kind === "text") timing.textMs = (timing.textMs ?? 0) + ms;
1131
- else timing.toolArgMs = (timing.toolArgMs ?? 0) + ms;
1174
+ function addDecode(timing, kind, ms, blocks) {
1175
+ const field = DECODE_FIELDS[kind];
1176
+ if (ms > 0) timing[field.ms] = (timing[field.ms] ?? 0) + ms;
1177
+ if (blocks > 0) timing[field.n] = (timing[field.n] ?? 0) + blocks;
1132
1178
  }
1133
1179
  /**
1134
1180
  * Tally one completed tool call into the per-name ranking, bounded to
@@ -1161,22 +1207,42 @@ function bumpToolTotals(timing, name, ms) {
1161
1207
  ms: cur.ms + ms
1162
1208
  };
1163
1209
  }
1210
+ const CLONE_ALL = [
1211
+ "surface",
1212
+ "sums",
1213
+ "requests",
1214
+ "events",
1215
+ "archived",
1216
+ "callNames",
1217
+ "fileOps",
1218
+ "timing"
1219
+ ];
1164
1220
  function applyTimeline(state, event, bounds) {
1165
1221
  let st;
1166
- const ensure = () => st ??= {
1167
- ...state,
1168
- surface: [...state.surface],
1169
- sums: { ...state.sums },
1170
- requests: [...state.requests],
1171
- events: [...state.events],
1172
- archived: [...state.archived],
1173
- callNames: { ...state.callNames },
1174
- fileOps: [...state.fileOps],
1175
- ...state.pendingCodeOps !== void 0 ? { pendingCodeOps: { ...state.pendingCodeOps } } : {},
1176
- ...state.timing !== void 0 ? { timing: {
1222
+ /**
1223
+ * The event's private working state: a shallow object clone up front, with
1224
+ * only the named collections cloned into private copies. Everything else
1225
+ * stays shared with the persisted previous state — the apply contract is
1226
+ * "never mutate the previous state in place". Called EXACTLY ONCE per event
1227
+ * (each case names the collections its own path may write into; helpers
1228
+ * never call it), so building afresh per call is safe. The default is the
1229
+ * full clone — a future branch that omits its list degrades to the old
1230
+ * behavior instead of sharing a collection it mutates.
1231
+ */
1232
+ const ensure = (keys = CLONE_ALL) => {
1233
+ st = { ...state };
1234
+ for (const key of keys) if (key === "surface") st.surface = [...state.surface];
1235
+ else if (key === "sums") st.sums = { ...state.sums };
1236
+ else if (key === "requests") st.requests = [...state.requests];
1237
+ else if (key === "events") st.events = [...state.events];
1238
+ else if (key === "archived") st.archived = [...state.archived];
1239
+ else if (key === "callNames") st.callNames = { ...state.callNames };
1240
+ else if (key === "fileOps") st.fileOps = [...state.fileOps];
1241
+ else if (state.timing !== void 0) st.timing = {
1177
1242
  ...state.timing,
1178
1243
  tools: { ...state.timing.tools }
1179
- } } : {}
1244
+ };
1245
+ return st;
1180
1246
  };
1181
1247
  const data = event.data;
1182
1248
  try {
@@ -1184,22 +1250,8 @@ function applyTimeline(state, event, bounds) {
1184
1250
  case "request/header": {
1185
1251
  const header = data?.header ?? {};
1186
1252
  const tools = Array.isArray(header.tools) ? header.tools : [];
1187
- const s = ensure();
1253
+ const s = ensure(["events"]);
1188
1254
  s.toolsTokens = estimateToolsTotal(tools);
1189
- const systemText = header.system;
1190
- if (typeof systemText === "string" && systemText !== "") {
1191
- s.systems = [{
1192
- seq: event.seq,
1193
- time: event.time,
1194
- tokens: estimateSystemTokens(systemText)
1195
- }];
1196
- s.systemsFromHeader = true;
1197
- s.systemTokens = systemTokensOf(s.systems);
1198
- } else if (s.systemsFromHeader === true) {
1199
- s.systems = [];
1200
- delete s.systemsFromHeader;
1201
- s.systemTokens = 0;
1202
- }
1203
1255
  if (header.config && typeof header.config.model === "string") s.model = header.config.model;
1204
1256
  if (header.config && typeof header.config.provider === "string") s.provider = header.config.provider;
1205
1257
  if ((data?.reason === "change" || data?.reason === "resume") && s.model && s.lastModel && s.model !== s.lastModel) {
@@ -1216,7 +1268,11 @@ function applyTimeline(state, event, bounds) {
1216
1268
  break;
1217
1269
  }
1218
1270
  case "system/message": {
1219
- const s = ensure();
1271
+ const s = ensure([
1272
+ "surface",
1273
+ "sums",
1274
+ "archived"
1275
+ ]);
1220
1276
  delete s.pendingShadowedSeqs;
1221
1277
  delete s.pendingShadowEventSeq;
1222
1278
  const op = replaceRangeOf(event.surfaceOp);
@@ -1226,7 +1282,6 @@ function applyTimeline(state, event, bounds) {
1226
1282
  for (const n of s.surface) if (n.seq >= op.start && n.seq <= op.end) claimed.add(n.seq);
1227
1283
  if (removeSurfaceSeqs(s, claimed, event.seq).length > 0) bumpDetailRev(s);
1228
1284
  }
1229
- delete s.systemsFromHeader;
1230
1285
  pushSystem(s, {
1231
1286
  seq: event.seq,
1232
1287
  time: event.time,
@@ -1235,7 +1290,7 @@ function applyTimeline(state, event, bounds) {
1235
1290
  break;
1236
1291
  }
1237
1292
  case "request/context": {
1238
- const s = ensure();
1293
+ const s = ensure([]);
1239
1294
  if (data && typeof data.contextWindow === "number") s.contextWindow = data.contextWindow;
1240
1295
  if (data && typeof data.model === "string") s.model = data.model;
1241
1296
  if (data && typeof data.provider === "string") s.provider = data.provider;
@@ -1243,8 +1298,8 @@ function applyTimeline(state, event, bounds) {
1243
1298
  }
1244
1299
  case "tool/call":
1245
1300
  if (data && typeof data.callId === "string" && typeof data.name === "string") {
1246
- const s = ensure();
1247
- const argsRaw = argsRawOf(data.arguments);
1301
+ const s = ensure(["callNames"]);
1302
+ const argsRaw = rawArgsNeeded(data.name) ? argsRawOf(data.arguments) : void 0;
1248
1303
  s.callNames[data.callId] = {
1249
1304
  name: data.name,
1250
1305
  start: event.time,
@@ -1252,62 +1307,28 @@ function applyTimeline(state, event, bounds) {
1252
1307
  };
1253
1308
  }
1254
1309
  break;
1255
- case "tool/code-dispatch":
1256
1310
  case "tool/ptc-dispatch": {
1257
1311
  const rootCallId = data?.rootCallId;
1258
1312
  const name = data?.name;
1259
1313
  if (typeof rootCallId === "string" && typeof name === "string") {
1314
+ const argsRaw = opBearingTool(name) ? argsRawOf(data?.arguments) : void 0;
1260
1315
  const ops = opsOfCall({
1261
1316
  seq: event.seq,
1262
1317
  time: event.time,
1263
1318
  tool: name,
1264
- argsRaw: argsRawOf(data?.arguments),
1319
+ argsRaw,
1265
1320
  err: data?.isError === true
1266
1321
  });
1267
- if (ops.length > 0) bufferCodeOps(ensure(), rootCallId, ops);
1322
+ if (ops.length > 0) bufferCodeOps(ensure([]), rootCallId, ops);
1268
1323
  }
1269
1324
  break;
1270
1325
  }
1271
- case "assistant/chunk": {
1272
- const start = state.stepStart;
1273
- if (start === void 0) return state;
1274
- const chunk = data?.chunk;
1275
- if (chunk !== null && typeof chunk === "object" && chunk.type === "block-start") {
1276
- const kind = decodeKindOfBlock(chunk.blockType);
1277
- if (start.block === void 0 && kind === void 0) return state;
1278
- const s = ensure();
1279
- const decode = { ...start.decode ?? {
1280
- reasoning: 0,
1281
- text: 0,
1282
- toolarg: 0
1283
- } };
1284
- if (start.block !== void 0) decode[start.block.kind] += durOf(start.block.since, event.time);
1285
- s.stepStart = {
1286
- time: start.time,
1287
- ...start.firstToken !== void 0 ? { firstToken: start.firstToken } : {},
1288
- decode,
1289
- ...kind !== void 0 ? { block: {
1290
- kind,
1291
- since: event.time
1292
- } } : {}
1293
- };
1294
- break;
1295
- }
1296
- if (start.firstToken !== void 0) return state;
1297
- if (!isTokenChunk(data?.chunk)) return state;
1298
- const s = ensure();
1299
- s.stepStart = {
1300
- ...start,
1301
- firstToken: event.time
1302
- };
1303
- break;
1304
- }
1305
1326
  case "assistant/attempt": {
1306
1327
  const start = state.stepStart;
1307
1328
  if (start === void 0 || start.firstToken !== void 0) return state;
1308
1329
  const first = firstTokenTimeOfStream(data?.stream);
1309
1330
  if (first === void 0) return state;
1310
- const s = ensure();
1331
+ const s = ensure([]);
1311
1332
  s.stepStart = {
1312
1333
  time: start.time,
1313
1334
  firstToken: first
@@ -1315,21 +1336,26 @@ function applyTimeline(state, event, bounds) {
1315
1336
  break;
1316
1337
  }
1317
1338
  case "step/start": {
1318
- const s = ensure();
1339
+ const s = ensure([]);
1319
1340
  s.stepStart = { time: event.time };
1320
1341
  break;
1321
1342
  }
1322
1343
  case "step/end": {
1323
1344
  const start = state.stepStart;
1324
1345
  if (start === void 0) return state;
1325
- const s = ensure();
1346
+ const s = ensure(["timing"]);
1326
1347
  ensureTiming(s).wallMs += durOf(start.time, event.time);
1327
1348
  delete s.stepStart;
1328
1349
  break;
1329
1350
  }
1330
1351
  case "user/message": {
1331
1352
  const msg = deriveEventMessage(event);
1332
- const s = ensure();
1353
+ const s = ensure([
1354
+ "surface",
1355
+ "sums",
1356
+ "archived",
1357
+ "events"
1358
+ ]);
1333
1359
  bumpDetailRev(s);
1334
1360
  const node = applySurface(s, event, event.type, data, msg);
1335
1361
  const source = msg?.source;
@@ -1338,7 +1364,7 @@ function applyTimeline(state, event, bounds) {
1338
1364
  seq: event.seq,
1339
1365
  time: event.time,
1340
1366
  kind: "inject",
1341
- form: source.form || "context",
1367
+ form: typeof source.form === "string" && source.form !== "" ? source.form : "context",
1342
1368
  tokens: node.tokens
1343
1369
  };
1344
1370
  if (source.kind === "skill-invocation") {
@@ -1366,7 +1392,14 @@ function applyTimeline(state, event, bounds) {
1366
1392
  const blockId = firstBlock?.toolCallId;
1367
1393
  const pendingEntry = (typeof srcId === "string" ? state.callNames[srcId] : void 0) ?? (typeof blockId === "string" ? state.callNames[blockId] : void 0);
1368
1394
  const buffered = (typeof srcId === "string" ? state.pendingCodeOps?.[srcId] : void 0) ?? (typeof blockId === "string" ? state.pendingCodeOps?.[blockId] : void 0);
1369
- const s = ensure();
1395
+ const s = ensure([
1396
+ "surface",
1397
+ "sums",
1398
+ "archived",
1399
+ "events",
1400
+ "fileOps",
1401
+ "timing"
1402
+ ]);
1370
1403
  bumpDetailRev(s);
1371
1404
  const node = applySurface(s, event, event.type, data, toolMsg);
1372
1405
  if (node.tool === ASK_USER_TOOL) s.humanInputs = (s.humanInputs ?? 0) + 1;
@@ -1413,7 +1446,14 @@ function applyTimeline(state, event, bounds) {
1413
1446
  }
1414
1447
  case "assistant/message": {
1415
1448
  const usage = data?.usage;
1416
- const s = ensure();
1449
+ const s = ensure([
1450
+ "surface",
1451
+ "sums",
1452
+ "archived",
1453
+ "events",
1454
+ "requests",
1455
+ "timing"
1456
+ ]);
1417
1457
  bumpDetailRev(s);
1418
1458
  const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.skill + s.sums.assistant + s.sums.tool;
1419
1459
  const record = {
@@ -1449,6 +1489,8 @@ function applyTimeline(state, event, bounds) {
1449
1489
  }
1450
1490
  }
1451
1491
  s.requests.push(record);
1492
+ const prevLast = state.requests.at(-1);
1493
+ s.turnRuns = (state.turnRuns ?? countTurnRuns(state.requests)) + (prevLast === void 0 ? record.turn !== void 0 ? 1 : 0 : record.turn === prevLast.turn ? 0 : 1);
1452
1494
  const timing = ensureTiming(s);
1453
1495
  timing.calls += 1;
1454
1496
  const stepStart = state.stepStart;
@@ -1461,14 +1503,8 @@ function applyTimeline(state, event, bounds) {
1461
1503
  timing.speedMs = (timing.speedMs ?? 0) + durOf(firstToken, event.time);
1462
1504
  timing.speedTokens = (timing.speedTokens ?? 0) + output;
1463
1505
  }
1464
- if (stepStart.decode !== void 0) {
1465
- const decode = { ...stepStart.decode };
1466
- if (stepStart.block !== void 0) decode[stepStart.block.kind] += durOf(stepStart.block.since, event.time);
1467
- for (const kind of DECODE_KINDS) addDecode(timing, kind, decode[kind]);
1468
- } else {
1469
- const spans = decodeSpansOfStream(data?.stream, event.time);
1470
- for (const kind of DECODE_KINDS) addDecode(timing, kind, spans[kind]);
1471
- }
1506
+ const tally = decodeTallyOfStream(data?.stream, event.time);
1507
+ for (const kind of DECODE_KINDS) addDecode(timing, kind, tally.spans[kind], tally.blocks[kind]);
1472
1508
  }
1473
1509
  }
1474
1510
  const asstMsg = deriveEventMessage(event);
@@ -1477,7 +1513,7 @@ function applyTimeline(state, event, bounds) {
1477
1513
  }
1478
1514
  case "plan/mode":
1479
1515
  if (data && typeof data.active === "boolean") {
1480
- const s = ensure();
1516
+ const s = ensure(["events"]);
1481
1517
  s.events.push({
1482
1518
  seq: event.seq,
1483
1519
  time: event.time,
@@ -1489,7 +1525,7 @@ function applyTimeline(state, event, bounds) {
1489
1525
  break;
1490
1526
  case "compaction/summary":
1491
1527
  case "compaction/prune": {
1492
- const s = ensure();
1528
+ const s = ensure(["events"]);
1493
1529
  bumpDetailRev(s);
1494
1530
  if (data && Array.isArray(data.shadowedSeqs)) {
1495
1531
  s.pendingShadowedSeqs = data.shadowedSeqs.filter((x) => typeof x === "number");
@@ -2241,10 +2277,29 @@ function headerOf(record) {
2241
2277
  if (typeof header.id !== "string" || header.id === "") return null;
2242
2278
  return header;
2243
2279
  }
2280
+ /** The probe keys: the projection keys the cache must already serve. */
2281
+ const PROBE_KEYS = ["contextActivity", "contextTimeline"];
2282
+ /**
2283
+ * Probe the cache through the face the running generation serves. The older
2284
+ * spelling is tried first and the newer one answers the fallback: on a
2285
+ * 0.1.7-rc.2 cache the 3-arg call binds the branded offset onto `keys`,
2286
+ * where `new Set(0)` throws as soon as a served record is reached — while a
2287
+ * record-less session returns `undefined` without throwing, which is the
2288
+ * correct "not served" either way. On 0.1.5-rc.1 the 3-arg face answers
2289
+ * directly; if it ever throws there, the 2-arg retry degrades to the same
2290
+ * "not served" verdict through the caller's catch.
2291
+ */
2292
+ function cachedSnapshotOf(cache, header) {
2293
+ try {
2294
+ return cache.cachedSnapshot(header, SessionLogOffset(0), PROBE_KEYS);
2295
+ } catch {
2296
+ return cache.cachedSnapshot(header, PROBE_KEYS);
2297
+ }
2298
+ }
2244
2299
  /** Whether the cache already serves BOTH projection rows for this header (nothing to backfill). */
2245
2300
  function servesRows(cache, header) {
2246
2301
  try {
2247
- const values = asRecord(asRecord(cache.cachedSnapshot(header, SessionLogOffset(0), ["contextActivity", "contextTimeline"]))?.values);
2302
+ const values = asRecord(asRecord(cachedSnapshotOf(cache, header))?.values);
2248
2303
  return values !== null && values.contextActivity !== void 0 && values.contextTimeline !== void 0;
2249
2304
  } catch {
2250
2305
  return false;
@@ -2399,18 +2454,21 @@ function watchActivityBackfill(ctx) {
2399
2454
  //#endregion
2400
2455
  //#region src/host/config.ts
2401
2456
  /**
2402
- * dsh-context host configuration — the `config:` block of the `dsh-context`
2403
- * loader row in cordis.yml.
2404
- *
2405
- * Cordis validates the entry config against this exported `Config` schema
2406
- * (any Standard Schema v1 validator — zod is ours) before `apply` runs, fills
2407
- * per-field defaults, and fails the load loudly on invalid or unknown keys
2408
- * (`.strict()`). The official plugin-config principle this answers: "anything
2409
- * that two deployments may want to set differently is a configuration field".
2457
+ * dsh-context entry configuration — the `config:` block of the `dsh-context`
2458
+ * loader row in cordis.yml, plus the per-user display preferences.
2410
2459
  *
2411
- * The persisted projection state shape is independent of these bounds — they
2412
- * only tune the fold's retention / presentation slice, so changing them never
2413
- * requires a projection `stateVersion` bump.
2460
+ * The schema is schemastery so both consumers it must serve accept it: cordis
2461
+ * validates the entry config through the schema's Standard Schema face before
2462
+ * `apply` runs (defaults per field, unknown keys merged through), and the
2463
+ * harness's Config-form generation (dsh 0.1.7+) derives the Plugins page's
2464
+ * live form from this same schema — there the namespace is the entry id
2465
+ * (`dsh-context`) and only the `.volatile()` preference fields are served and
2466
+ * editable. Bounds edits remount the entry (the folds read them at apply);
2467
+ * preference edits commit volatile-only and never remount. On the older lines
2468
+ * the preferences are inert here — their surface is the registered settings
2469
+ * namespace (settings.ts). The `Config` type describes the RESOLVED shape
2470
+ * cordis hands to `apply`; the raw patch values are partial and the schema
2471
+ * fills every default.
2414
2472
  */
2415
2473
  const DEFAULT_BOUNDS = {
2416
2474
  maxRequestSteps: 1500,
@@ -2421,19 +2479,57 @@ const DEFAULT_BOUNDS = {
2421
2479
  maxFileOps: 400
2422
2480
  };
2423
2481
  /**
2424
- * The cordis `Config` validator: strict on keys, defaults on the schema fields; tolerates `undefined` (a patch row without a `config:`
2425
- * block — defaults win).
2426
- */
2427
- const Config = z.preprocess((v) => v ?? {}, z.object({
2428
- maxRequestSteps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxRequestSteps),
2429
- maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
2430
- maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
2431
- maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
2432
- maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes),
2433
- maxFileOps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxFileOps)
2434
- }).strict());
2482
+ * Mark a field live-editable where the harness's schemastery ships the
2483
+ * `.volatile()` modifier (the Config-form generation reads the mark to serve
2484
+ * the field on the Plugins page); a plain field on the older lines whose
2485
+ * schemastery predates the modifier.
2486
+ */
2487
+ function volatileField(field) {
2488
+ const volatile = field.volatile;
2489
+ return typeof volatile === "function" ? volatile.call(field) : field;
2490
+ }
2491
+ /** One bounded positive-integer count. */
2492
+ function count(defaultValue) {
2493
+ return z$1.number().min(1).step(1).default(defaultValue);
2494
+ }
2495
+ /** The cordis `Config` validator and the Config-form generation's served schema. */
2496
+ const Config = z$1.object({
2497
+ maxRequestSteps: count(DEFAULT_BOUNDS.maxRequestSteps),
2498
+ maxKeptTurns: count(DEFAULT_BOUNDS.maxKeptTurns),
2499
+ maxEvents: count(DEFAULT_BOUNDS.maxEvents),
2500
+ maxNodes: count(DEFAULT_BOUNDS.maxNodes),
2501
+ maxArchiveNodes: count(DEFAULT_BOUNDS.maxArchiveNodes),
2502
+ maxFileOps: count(DEFAULT_BOUNDS.maxFileOps),
2503
+ defaultPlacement: volatileField(z$1.union([
2504
+ "all",
2505
+ "tab",
2506
+ "sidebar"
2507
+ ]).default("all").loose()),
2508
+ defaultGranularity: volatileField(z$1.union(["step", "turn"]).default("step").loose()),
2509
+ defaultTrendMode: volatileField(z$1.union(["total", "delta"]).default("total").loose()),
2510
+ defaultToolSort: volatileField(z$1.union([
2511
+ "size",
2512
+ "count",
2513
+ "name"
2514
+ ]).default("count").loose()),
2515
+ defaultFileSort: volatileField(z$1.union([
2516
+ "count",
2517
+ "latest",
2518
+ "path"
2519
+ ]).default("count").loose()),
2520
+ insightsEntry: volatileField(z$1.union(["show", "hide"]).default("show").loose())
2521
+ });
2522
+ /** Resolve the fold's retention bounds (the schema fills every default). */
2435
2523
  function resolveBounds(config) {
2436
- return Config.parse(config ?? {});
2524
+ const resolved = Config(config ?? {});
2525
+ return {
2526
+ maxRequestSteps: resolved.maxRequestSteps,
2527
+ maxKeptTurns: resolved.maxKeptTurns,
2528
+ maxEvents: resolved.maxEvents,
2529
+ maxNodes: resolved.maxNodes,
2530
+ maxArchiveNodes: resolved.maxArchiveNodes,
2531
+ maxFileOps: resolved.maxFileOps
2532
+ };
2437
2533
  }
2438
2534
  //#endregion
2439
2535
  //#region src/host/detail.ts
@@ -2548,7 +2644,7 @@ function watchDetailChannel(ctx, bounds) {
2548
2644
  * (host/fallback.ts) instead of the real folds.
2549
2645
  */
2550
2646
  /** The oldest supported dsh release (see the matrix note above). */
2551
- const BASELINE_DSH_VERSION = "0.1.2-rc.1";
2647
+ const BASELINE_DSH_VERSION = "0.1.5-rc.1";
2552
2648
  /**
2553
2649
  * Release-channel rank at an equal X.Y.Z: a final release outranks its
2554
2650
  * release candidates, which outrank betas, which outrank alphas
@@ -2670,11 +2766,11 @@ const contextHeadersSchema = z.object({ headers: z.array(z.object({
2670
2766
  }).strict()) }).strict();
2671
2767
  function recordOf(event) {
2672
2768
  if (event.type !== "request/header") return null;
2673
- const rawHeader = event.data.header;
2769
+ const rawHeader = event.data?.header;
2674
2770
  if (rawHeader === null || rawHeader === void 0 || typeof rawHeader !== "object") return null;
2675
2771
  const header = rawHeader;
2676
2772
  const tools = Array.isArray(header.tools) ? header.tools : [];
2677
- const record = {
2773
+ return {
2678
2774
  seq: event.seq,
2679
2775
  time: event.time,
2680
2776
  tools: tools.map((t) => {
@@ -2687,8 +2783,6 @@ function recordOf(event) {
2687
2783
  return entry;
2688
2784
  })
2689
2785
  };
2690
- if (typeof header.system === "string" && header.system.length > 0) record.systemTokens = estimateSystemTokens(header.system);
2691
- return record;
2692
2786
  }
2693
2787
  /**
2694
2788
  * The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through
@@ -2887,8 +2981,11 @@ const timingTotalsSchema = z.object({
2887
2981
  ttftMs: z.number().nonnegative(),
2888
2982
  genMs: z.number().nonnegative(),
2889
2983
  reasoningMs: z.number().nonnegative().optional(),
2984
+ reasoningBlocks: z.number().int().nonnegative().optional(),
2890
2985
  textMs: z.number().nonnegative().optional(),
2986
+ textBlocks: z.number().int().nonnegative().optional(),
2891
2987
  toolArgMs: z.number().nonnegative().optional(),
2988
+ toolArgBlocks: z.number().int().nonnegative().optional(),
2892
2989
  speedTokens: z.number().nonnegative().optional(),
2893
2990
  speedMs: z.number().nonnegative().optional(),
2894
2991
  calls: z.number().int().nonnegative(),
@@ -2967,13 +3064,13 @@ const timelineStateSchema = z.object({
2967
3064
  }).strict(),
2968
3065
  systemTokens: z.number().int().nonnegative(),
2969
3066
  systems: z.array(systemPromptNodeSchema).optional(),
2970
- systemsFromHeader: z.literal(true).optional(),
2971
3067
  toolsTokens: z.number().int().nonnegative(),
2972
3068
  model: z.string().optional(),
2973
3069
  provider: z.string().optional(),
2974
3070
  lastModel: z.string().optional(),
2975
3071
  contextWindow: z.number().optional(),
2976
3072
  requests: z.array(requestRecordSchema),
3073
+ turnRuns: z.number().int().nonnegative().optional(),
2977
3074
  events: z.array(contextEventSchema),
2978
3075
  archived: z.array(surfaceNodeSchema),
2979
3076
  cost: costUsageSchema.optional(),
@@ -2983,20 +3080,7 @@ const timelineStateSchema = z.object({
2983
3080
  lastUser: z.string().optional(),
2984
3081
  stepStart: z.object({
2985
3082
  time: z.number(),
2986
- firstToken: z.number().optional(),
2987
- decode: z.object({
2988
- reasoning: z.number(),
2989
- text: z.number(),
2990
- toolarg: z.number()
2991
- }).strict().optional(),
2992
- block: z.object({
2993
- kind: z.enum([
2994
- "reasoning",
2995
- "text",
2996
- "toolarg"
2997
- ]),
2998
- since: z.number()
2999
- }).strict().optional()
3083
+ firstToken: z.number().optional()
3000
3084
  }).strict().optional(),
3001
3085
  callNames: z.record(z.string(), z.object({
3002
3086
  name: z.string(),
@@ -3390,11 +3474,15 @@ function probeAnchor(resolve, packageNames) {
3390
3474
  /**
3391
3475
  * Whether `candidate` lies inside `root`'s tree. A `..`-prefixed relative path
3392
3476
  * escapes it; a sibling such as `dsh-context-extra` is reached through `..` too,
3393
- * so it never counts as a child. A pathological child literally named `..foo`
3394
- * would degrade to the home anchor, which is the safe direction.
3477
+ * so it never counts as a child. On Windows, a cross-drive `relative()` returns
3478
+ * the candidate's ABSOLUTE path (no `..` prefix) — a different drive is never
3479
+ * inside this tree, so the absolute answer is rejected the same way. A
3480
+ * pathological child literally named `..foo` would degrade to the home anchor,
3481
+ * which is the safe direction.
3395
3482
  */
3396
3483
  function isInside(root, candidate) {
3397
- return !relative(root, candidate).startsWith("..");
3484
+ const rel = relative(root, candidate);
3485
+ return !rel.startsWith("..") && !isAbsolute(rel);
3398
3486
  }
3399
3487
  /**
3400
3488
  * The running module tree's answer, or undefined when it cannot be trusted.