better-dsh 0.2.4-c → 0.2.5-a

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.
@@ -0,0 +1,209 @@
1
+ # ctx:// Long-Session Navigability — Design
2
+
3
+ > Status: approved (user, 2026-10-02). Implements backlog N1–N6 plus findings F1/F2 from
4
+ > `docs/50_test-reports/2026-09-28-ctx-scheme长会话utility实测报告.md` §八.
5
+ > Live spec delta: `docs/specs/ctx/spec.md`. Plan: `docs/superpowers/plans/2026-10-02-ctx-navigation.md`.
6
+
7
+ ## 1. Scope ruling (carried from the report, §八)
8
+
9
+ This tool family is **text-keyword recallable navigation** — the phone book + index + pointer
10
+ for a long session. Semantics is out of scope: no embeddings, no semantic ranking, no
11
+ "fuzzy" retrieval. The model is a language model; synonym/substitution/keyword work is its
12
+ job. We guarantee only: **原文可取 + 可寻址** (原文 retrievable, and addressable).
13
+
14
+ Every number this scheme shows the model MUST either be directly usable as a selector in the
15
+ same URL family, or be explicitly labeled metadata with its unit. A bare, unlabeled number
16
+ that *looks* like a coordinate but is not addressable is the failure mode this change
17
+ eliminates.
18
+
19
+ ## 2. Coordinate model (the spine)
20
+
21
+ Two coordinate systems, both labeled wherever shown:
22
+
23
+ | Unit | Meaning | Used by |
24
+ |---|---|---|
25
+ | `seq` | event sequence number in the session log | `[<n\|seq>]` element/label addressing |
26
+ | `line` | transcript line number (1-based) | `:N-M` windows and `grep … ctx://session/transcript` |
27
+
28
+ The transcript is the **canonical coordinate space**. `grep` on `ctx://session/transcript`
29
+ materializes the transcript and returns transcript line numbers; therefore every exposed
30
+ `lines=` figure is a transcript line and pastes directly into `:N-M`, and episode windows are
31
+ transcript-relative.
32
+
33
+ **Decision (user, 2026-10-02): transcript-relative everywhere.** `compactions[label]:5300-5310`
34
+ means transcript lines 5300–5310, identical to `transcript:5300-5310`. The first episode starts
35
+ at transcript line 1, so episode-relative and transcript-relative coincide for it.
36
+
37
+ ## 3. Data model changes (in `better-dsh/src/url-schemes/handlers/ctx.ts`)
38
+
39
+ ### 3.1 Transcript line index (structural, N1)
40
+
41
+ Build the transcript **once** and carry per-entry line ranges, instead of the current two
42
+ independent renders (`transcript()` and each episode's `originalText`):
43
+
44
+ ```ts
45
+ interface TranscriptIndex {
46
+ text: string // the joined transcript (canonical face)
47
+ totalLines: number
48
+ lineOfSeq: Map<number, { start: number; end: number }> // per rendered entry, transcript lines
49
+ }
50
+
51
+ function buildTranscript(events, toolNameOf): TranscriptIndex {
52
+ const entries = events
53
+ .map(e => ({ seq: e.seq as number, text: renderEntry(e, toolNameOf) }))
54
+ .filter(t => t.text !== '')
55
+ const lineOfSeq = new Map()
56
+ let line = 1
57
+ for (let i = 0; i < entries.length; i++) {
58
+ const n = entries[i].text.split('\n').length
59
+ lineOfSeq.set(entries[i].seq, { start: line, end: line + n - 1 })
60
+ line += n + (i < entries.length - 1 ? 1 : 0) // one blank separator line between entries
61
+ }
62
+ return { text: entries.map(e => e.text).join('\n\n'), totalLines: line - 1, lineOfSeq }
63
+ }
64
+ ```
65
+
66
+ The episode's canonical span is then **derived** from the index (guarantees the invariant by
67
+ construction):
68
+
69
+ ```ts
70
+ // episode lines = min/max transcript line over the shadowed events that rendered
71
+ const lineStart = min(lineOfSeq.get(seq).start for shadowed seqs that rendered)
72
+ const lineEnd = max(lineOfSeq.get(seq).end for shadowed seqs that rendered)
73
+ const originalText = transcript.text.split('\n').slice(lineStart - 1, lineEnd).join('\n')
74
+ ```
75
+
76
+ If no shadowed event rendered (degenerate), the episode has an empty span (`lines: null`).
77
+
78
+ ### 3.2 Episode shape
79
+
80
+ Each episode gains `lines: { start, end } | null`, `fidelity: { userTurns, toolCalls }`, and a
81
+ computed `landmarks` block. The manifest line, the snapshot `compacted[]` entry, the snapshot
82
+ `segments[]` entry, and the episode prepared face all carry `lines=` and `seq=` labeled figures.
83
+
84
+ ### 3.3 `resultTextOf` shape robustness (F2)
85
+
86
+ `tool/result` messages in the current session format store **flat** text blocks
87
+ (`data.message.content: [{type:"text", text:"…"}]`); the nested `tool-result` block form is
88
+ the older shape. Today `resultTextOf` only flattens the nested form, so every result renders
89
+ empty. Fix: prefer nested, fall back to flat:
90
+
91
+ ```ts
92
+ function resultTextOf(event: PersistenceEvent): string {
93
+ const blocks = blocksOf(event)
94
+ const nested = blocks
95
+ .flatMap(b => (b as { content?: ContentBlock[] }).content ?? [])
96
+ .map(c => c.text ?? '').join('\n')
97
+ if (nested !== '') return nested
98
+ return textOf(blocks) // flat text blocks (current session format)
99
+ }
100
+ ```
101
+
102
+ ## 4. Backlog implementation
103
+
104
+ ### N1 — addressable line coordinates + explicit bounds (F7/F10)
105
+
106
+ - Manifest line per episode: `seq=<start>..<end> (N items, T tokens)` **and**
107
+ `lines=<lineStart>..<lineEnd>` (or `lines=<none>`), plus `fidelity=ut:tc`.
108
+ - Snapshot: `segments[]` → `{ segment, seq_start, seq_end, lines: {start,end}|null, items }`;
109
+ `compacted[]` gains `lines`, `fidelity`.
110
+ - Episode prepared face header prints `lines=` and `seq=` labeled.
111
+ - Episode line windows are transcript-relative and **clamped with an explicit boundary note**
112
+ when a requested range reaches past the span (never silent-empty):
113
+
114
+ ```ts
115
+ // transcript coords -> episode-local coords
116
+ function applyEpisodeLines(slice, lineStart, lineEnd, ranges): string
117
+ // out-of-span tail appends: [ctx:// note: episode span is transcript lines <s>-<e>]
118
+ ```
119
+
120
+ A window entirely outside the span returns the note alone (not empty). `:raw` still returns
121
+ the full span.
122
+
123
+ ### N2 — digest residence rule (design position §八.4)
124
+
125
+ - `latest` = the last episode in the chain; its digest is the compact-checkpoint message
126
+ already resident in live context.
127
+ - Compute the checkpoint's transcript lines (the `user/message` with `source.plugin === 'compact'`
128
+ at `checkpointSeq`; fall back to the seq when no line is mapped).
129
+ - Latest episode prepared face = **navigation block** (no digest dump):
130
+
131
+ ```text
132
+ [ctx://session/compactions[<label>] — digest is already in your live context]
133
+ lines=<ls>..<le> seq=<ss>..<se> items=<n> tokens=<t>
134
+ fidelity=<ut>:<tc>
135
+ digest resident at seq=<checkpointSeq> (transcript:<start>-<end> when mapped) — read it there,
136
+ or use :raw here for the shadowed span
137
+ <landmarks>
138
+ ```
139
+
140
+ - Older episode prepared face = **full digest** (`summaryText`) followed by the same pointer
141
+ block. `:raw` on any episode always returns the span, so a digest is never lost.
142
+
143
+ ### N3 — landmark roster (direction markers, all as line coordinates)
144
+
145
+ Computed over the episode's shadowed events; capped to stay one screen:
146
+
147
+ - `user turns (<n>): <line>, <line>, …` — transcript line of each rendered `user/message`
148
+ with `source.kind === 'user'` (cap 30, then `… +N more`).
149
+ - `failures (<n>): <line> <tool>, …` — `tool/result` events with
150
+ `data.message.isError === true` or a present `data.error` (cap 20).
151
+ - `touched paths: <path>×<count>, …` — path-valued strings extracted from `tool/call`
152
+ `arguments` (keys `path`/`file_path`/`filePath`/`file`, plus `edits[].path` and
153
+ `files[].path`; args larger than 64 000 chars skipped). Best-effort pointer, not an index.
154
+ (cap 12, sorted by count desc).
155
+ - `prior checkpoints in span: <n>` — count of `user/message` with `source.plugin === 'compact'`.
156
+
157
+ ### N4 — fidelity line
158
+
159
+ `fidelity=<userTurns>:<toolCalls>` per episode (tool-heavy ⇒ digest trustworthy and detail
160
+ regenerable; user-intent-heavy ⇒ must drill down). Inline in manifest, snapshot `compacted[]`,
161
+ and the episode pointer block.
162
+
163
+ ### N5 — `:path/` and `?q=` in ctx (F5/F8) + error echo
164
+
165
+ The selector grammar already parses `path`/`query` and `applySelector()` (selector.ts) already
166
+ implements JSON dot-path + line filter. Wire them into `applyFace`:
167
+
168
+ ```ts
169
+ if (selector.kind === 'path' || selector.kind === 'query') {
170
+ return applySelector(face === 'canonical' ? canonical : prepared, selector)
171
+ }
172
+ ```
173
+
174
+ So `ctx://session:path/totals.tool_calls` narrow-reads the snapshot JSON, and
175
+ `ctx://session/injections?q=subagent-settled` filters the index. The manifest route also runs
176
+ through `applySelector` (so `?q=` and `:N-M` windowing work on it too). Update
177
+ `CTX_BAD_SELECTOR` to echo the URL and the supported selector set.
178
+
179
+ ### N6 — snapshot `asOf`
180
+
181
+ Snapshot gains `asOf: { seq: <max seq>, line: <totalLines> }` and a hint that totals are live
182
+ at read time (a cached snapshot is knowingly stale).
183
+
184
+ ### F1 — element-collection index faces + error echo
185
+
186
+ - `user_prompts` bare → index list (one line per prompt: `seq=<s> <preview>`); canonical
187
+ `:raw` = all joined.
188
+ - `tool_calls` / `agent_responses` bare → bounded index (first 20 lines + `… +N more — use
189
+ :raw for all, ?q= to filter, [n|seq] for one`); canonical `:raw` = all joined.
190
+ - `[n|seq]` behavior unchanged.
191
+ - Error text for a bad element stops echoing an empty-bracket URL; it names the collection,
192
+ its count, and the 0-based/seq addressing (already mostly present — align the message).
193
+
194
+ ## 5. Out of scope
195
+
196
+ - F3 (`read` offset/limit silently ignored on URIs) — harness read-tool contract, not this handler.
197
+ - Semantic/vector retrieval; embeddings; "fuzzy" search (see §1).
198
+ - F4 (compaction label drift) — already mitigated by the error listing current labels; the
199
+ `compactionId` (uuid) stable-key idea is recorded but not implemented this wave.
200
+
201
+ ## 6. Verification
202
+
203
+ Unit: extend `better-dsh/test/url-schemes/ctx.spec.ts` (and add focused specs) with the
204
+ invariants above — notably **episode span ≡ transcript slice** and **episode `:N-M` ≡
205
+ `transcript:N-M`** for non-first episodes. Full `vitest run` + `tsc --noEmit` green.
206
+
207
+ First-person (4999 rig, dsh 0.2.0-rc.2): a real long-session instance reads the snapshot
208
+ (asOf + lines present), windows a deep episode by transcript line, filters an index with
209
+ `?q=`, narrow-reads with `:path/`, and confirms a large tool result is no longer empty.
@@ -1,10 +1,10 @@
1
- import { f as UrlSchemesError, t as buildFsLayerResolver } from "../wrap-Dr8XtIak.js";
1
+ import { f as UrlSchemesError, t as buildFsLayerResolver } from "../wrap-Bzdimp4p.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
3
  import { dirname, sep } from "node:path";
4
+ import { stat } from "node:fs/promises";
4
5
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
5
6
  import { FsError } from "@deepseek-ai/dsh-fs";
6
7
  import { writableRoots } from "@deepseek-ai/dsh-sandbox";
7
- import { stat } from "node:fs/promises";
8
8
 
9
9
  //#region ../upstream/deepseek-harness/packages/fs/fs-sandbox/lib/index.js
10
10
  /**
@@ -1,5 +1,5 @@
1
1
  import { n as captureNativeTools } from "../native-capture-x_Hos2e8.js";
2
- import { a as createHttpHandler, c as listDvcDevices, d as UrlResolver, f as UrlSchemesError, i as HTTP_SCHEMES, l as registerDvcDevice, m as SCHEME_NAMES, n as wrapFsWithSchemes, o as createDvcHandler, p as parseUrl, r as resolveDocsDir, s as dispatchDvcWrite, u as createDshHandler } from "../wrap-Dr8XtIak.js";
2
+ import { a as createHttpHandler, c as listDvcDevices, d as UrlResolver, f as UrlSchemesError, h as SCHEME_NAMES, i as HTTP_SCHEMES, l as registerDvcDevice, m as parseUrl, n as wrapFsWithSchemes, o as createDvcHandler, p as applySelector, r as resolveDocsDir, s as dispatchDvcWrite, u as createDshHandler } from "../wrap-Bzdimp4p.js";
3
3
  import { a as primaryServerForFile, c as serversForFile, n as findWorkspaceRoot, o as resolveCommandPath, r as installHintFor, s as serverByName } from "../lsp-server-registry-946ysdqg.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { randomUUID } from "node:crypto";
@@ -11,10 +11,10 @@ import { spawn } from "node:child_process";
11
11
  import { homedir, tmpdir } from "node:os";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
  import { defineTool } from "@deepseek-ai/dsh-tools";
14
- import { FsError } from "@deepseek-ai/dsh-fs";
15
- import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from "@deepseek-ai/dsh-sandbox";
16
14
  import * as fsPromises from "node:fs/promises";
17
15
  import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
16
+ import { FsError } from "@deepseek-ai/dsh-fs";
17
+ import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from "@deepseek-ai/dsh-sandbox";
18
18
  import { SessionId } from "@deepseek-ai/dsh-session";
19
19
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
20
20
  import "file-type";
@@ -2445,7 +2445,9 @@ function sourcePlugin(event) {
2445
2445
  return event.data?.source?.plugin ?? "";
2446
2446
  }
2447
2447
  function resultTextOf(event) {
2448
- return blocksOf(event).flatMap((b) => b.content ?? []).map((c) => c.text ?? "").join("\n");
2448
+ const blocks = blocksOf(event);
2449
+ const nested = blocks.flatMap((b) => b.content ?? []).map((c) => c.text ?? "").join("\n");
2450
+ return nested !== "" ? nested : textOf(blocks);
2449
2451
  }
2450
2452
  /** All reasoning blocks in event order (one message may carry several). */
2451
2453
  function thinkingItems(events) {
@@ -2530,13 +2532,137 @@ function sectionPreviews(text, n = 100) {
2530
2532
  }
2531
2533
  function applyLines(canonical, ranges) {
2532
2534
  const lines = canonical.split("\n");
2533
- return ranges.map(([a, b]) => lines.slice(Math.max(0, a - 1), b).join("\n")).join("\n");
2535
+ const total = lines.length;
2536
+ const out = [];
2537
+ let noted = false;
2538
+ for (const [aRaw, bRaw] of ranges) {
2539
+ const a = Math.max(1, aRaw);
2540
+ const b = bRaw === Infinity ? total : bRaw;
2541
+ if (b < 1 || a > total) {
2542
+ noted = true;
2543
+ continue;
2544
+ }
2545
+ out.push(...lines.slice(a - 1, Math.min(b, total)));
2546
+ if (bRaw !== Infinity && b > total) noted = true;
2547
+ }
2548
+ const body = out.join("\n");
2549
+ if (!noted) return body;
2550
+ const note = `[ctx:// note: canonical content ends at line ${total}]`;
2551
+ return body === "" ? note : `${body}\n\n${note}`;
2534
2552
  }
2535
2553
  function applyFace(prepared, canonical, selector, face = "prepared") {
2536
2554
  if (selector === void 0 || selector === null) return face === "canonical" ? canonical : prepared;
2537
2555
  if (selector.kind === "raw") return canonical;
2538
2556
  if (selector.kind === "lines") return applyLines(canonical, selector.ranges);
2539
- throw new UrlSchemesError("CTX_BAD_SELECTOR", `ctx://: unsupported selector kind "${selector.kind}"`);
2557
+ if (selector.kind === "path" || selector.kind === "query") return applySelector(face === "canonical" ? canonical : prepared, selector);
2558
+ throw new UrlSchemesError("CTX_BAD_SELECTOR", `ctx://: unsupported selector kind "${selector.kind}" (supported: :raw, :N-M, :path/, ?q=)`);
2559
+ }
2560
+ function buildTranscript(events, toolNameOf) {
2561
+ const entries = events.map((e) => ({
2562
+ seq: e.seq,
2563
+ text: renderEntry(e, toolNameOf)
2564
+ })).filter((t) => t.text !== "");
2565
+ const lineOfSeq = /* @__PURE__ */ new Map();
2566
+ let line = 1;
2567
+ entries.forEach((entry, i) => {
2568
+ const n = entry.text.split("\n").length;
2569
+ lineOfSeq.set(entry.seq, {
2570
+ start: line,
2571
+ end: line + n - 1
2572
+ });
2573
+ line += n + (i < entries.length - 1 ? 1 : 0);
2574
+ });
2575
+ return {
2576
+ text: entries.map((e) => e.text).join("\n\n"),
2577
+ totalLines: line - 1,
2578
+ lineOfSeq
2579
+ };
2580
+ }
2581
+ /** Apply transcript-relative line ranges to one episode span slice, with a boundary note. */
2582
+ function applyEpisodeLines(slice, lineStart, lineEnd, ranges) {
2583
+ const lines = slice.split("\n");
2584
+ const out = [];
2585
+ let noted = false;
2586
+ for (const [aRaw, bRaw] of ranges) {
2587
+ const a = Math.max(1, aRaw);
2588
+ const b = bRaw === Infinity ? lineEnd : bRaw;
2589
+ if (b < lineStart || a > lineEnd) {
2590
+ noted = true;
2591
+ continue;
2592
+ }
2593
+ const la = Math.max(a, lineStart) - lineStart + 1;
2594
+ const lb = Math.min(b, lineEnd) - lineStart + 1;
2595
+ out.push(...lines.slice(la - 1, lb));
2596
+ if (b > lineEnd || a < lineStart) noted = true;
2597
+ }
2598
+ return out.join("\n") + (noted ? `\n\n[ctx:// note: episode span is transcript lines ${lineStart}-${lineEnd}]` : "");
2599
+ }
2600
+ function isErrorOf(event) {
2601
+ return event.data?.message?.isError === true || event.data?.error !== void 0;
2602
+ }
2603
+ const PATH_KEYS = new Set([
2604
+ "path",
2605
+ "file_path",
2606
+ "filePath",
2607
+ "file"
2608
+ ]);
2609
+ function collectPaths(node, out, depth) {
2610
+ if (depth > 4 || node === null || node === void 0) return;
2611
+ if (typeof node === "string") return;
2612
+ if (Array.isArray(node)) {
2613
+ for (const x of node) collectPaths(x, out, depth + 1);
2614
+ return;
2615
+ }
2616
+ if (typeof node === "object") for (const [k, v] of Object.entries(node)) if (PATH_KEYS.has(k) && typeof v === "string" && v !== "") out.add(v);
2617
+ else collectPaths(v, out, depth + 1);
2618
+ }
2619
+ function touchedPathsOf(events) {
2620
+ const counts = /* @__PURE__ */ new Map();
2621
+ for (const e of events) {
2622
+ if (e.type !== "tool/call") continue;
2623
+ const args = e.data?.arguments;
2624
+ if (args === void 0 || args.length > 64e3) continue;
2625
+ let parsed;
2626
+ try {
2627
+ parsed = JSON.parse(args);
2628
+ } catch {
2629
+ continue;
2630
+ }
2631
+ const found = /* @__PURE__ */ new Set();
2632
+ collectPaths(parsed, found, 0);
2633
+ for (const p of found) counts.set(p, (counts.get(p) ?? 0) + 1);
2634
+ }
2635
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
2636
+ }
2637
+ function landmarkBlock(ep, tIdx, toolNameOf) {
2638
+ const lineOf = (e) => tIdx.lineOfSeq.get(e.seq)?.start;
2639
+ const userTurns = ep.shadowEvents.filter((e) => e.type === "user/message" && sourceKind(e) === "user").map(lineOf).filter((x) => x !== void 0);
2640
+ const failures = ep.shadowEvents.filter((e) => e.type === "tool/result" && isErrorOf(e)).map((e) => `${lineOf(e) ?? "?"} ${toolNameOf(e.sourceEventSeqs?.[0]) ?? "tool"}`);
2641
+ const paths = touchedPathsOf(ep.shadowEvents).slice(0, 12).map(([p, n]) => `${p}×${n}`);
2642
+ const priorCheckpoints = ep.shadowEvents.filter((e) => e.type === "user/message" && sourcePlugin(e) === "compact").length;
2643
+ const cap = (xs, n) => xs.length > n ? `${xs.slice(0, n).join(", ")} … +${xs.length - n} more` : xs.join(", ");
2644
+ return [
2645
+ `user turns (${userTurns.length}): ${cap(userTurns, 30)}`,
2646
+ `failures (${failures.length}): ${cap(failures, 20)}`,
2647
+ `touched paths: ${paths.length > 0 ? paths.join(", ") : "(none)"}`,
2648
+ `prior checkpoints in span: ${priorCheckpoints}`
2649
+ ].join("\n");
2650
+ }
2651
+ function episodePrepared(ep, list, tIdx, toolNameOf) {
2652
+ const isLatest = list.length > 0 && list[list.length - 1] === ep;
2653
+ const ls = ep.lines === null ? "<none>" : `${ep.lines.start}..${ep.lines.end}`;
2654
+ const pointer = [
2655
+ `[ctx://session/compactions[${ep.label}]${isLatest ? " — digest is already in your live context" : ""}]`,
2656
+ `lines=${ls} seq=${ep.shadowedRange.start}..${ep.shadowedRange.end} items=${ep.shadowedSeqs.length} tokens=${ep.shadowedTokenCount}`,
2657
+ `fidelity=${ep.fidelity.userTurns}:${ep.fidelity.toolCalls}`
2658
+ ];
2659
+ if (isLatest) {
2660
+ const ck = tIdx.lineOfSeq.get(ep.checkpointSeq);
2661
+ pointer.push(`digest resident at seq=${ep.checkpointSeq}${ck !== void 0 ? ` (transcript:${ck.start}-${ck.end})` : ""} — read it there, or use :raw here for the shadowed span`);
2662
+ } else if (ep.replacesCheckpoint !== null) pointer.push(`replaces_checkpoint=${ep.replacesCheckpoint}`);
2663
+ pointer.push(landmarkBlock(ep, tIdx, toolNameOf));
2664
+ const block = pointer.join("\n");
2665
+ return isLatest ? block : `${ep.summaryText}\n\n${block}`;
2540
2666
  }
2541
2667
  function createCtxHandler(deps) {
2542
2668
  const persistence = deps.sessionPersistence;
@@ -2575,15 +2701,25 @@ function createCtxHandler(deps) {
2575
2701
  const toolNameOf = (seq) => seq === void 0 ? void 0 : toolNameByCallSeq.get(seq);
2576
2702
  const userPromptEvents = events.filter((e) => e.type === "user/message" && sourceKind(e) === "user");
2577
2703
  const agentResponseEvents = events.filter((e) => e.type === "assistant/message");
2578
- const transcript = () => events.map((e) => renderEntry(e, toolNameOf)).filter((t) => t !== "").join("\n\n");
2704
+ const tIdx = buildTranscript(events, toolNameOf);
2579
2705
  const episodes = [];
2580
2706
  {
2581
2707
  let prevCheckpoint = null;
2582
- const nameOf = toolNameOf;
2583
2708
  for (const e of events) {
2584
2709
  if (e.type !== "compaction/summary") continue;
2585
2710
  const d = e.data;
2586
- const originalText = d.shadowedSeqs.map((seq) => events.find((ev) => ev.seq === seq)).filter((ev) => ev !== void 0).map((ev) => renderEntry(ev, nameOf)).filter((t) => t !== "").join("\n\n");
2711
+ const shadowSet = new Set(d.shadowedSeqs);
2712
+ const shadowEvents = events.filter((ev) => shadowSet.has(ev.seq));
2713
+ const shadowLines = d.shadowedSeqs.map((seq) => tIdx.lineOfSeq.get(seq)).filter((x) => x !== void 0);
2714
+ const lines = shadowLines.length === 0 ? null : {
2715
+ start: Math.min(...shadowLines.map((x) => x.start)),
2716
+ end: Math.max(...shadowLines.map((x) => x.end))
2717
+ };
2718
+ const originalText = lines === null ? "" : tIdx.text.split("\n").slice(lines.start - 1, lines.end).join("\n");
2719
+ const fidelity = {
2720
+ userTurns: shadowEvents.filter((ev) => ev.type === "user/message" && sourceKind(ev) === "user").length,
2721
+ toolCalls: shadowEvents.filter((ev) => ev.type === "tool/call").length
2722
+ };
2587
2723
  episodes.push({
2588
2724
  label: e.seq,
2589
2725
  checkpointSeq: e.seq + 1,
@@ -2594,7 +2730,10 @@ function createCtxHandler(deps) {
2594
2730
  shadowedTokenCount: d.shadowedTokenCount,
2595
2731
  replacesCheckpoint: prevCheckpoint !== null && d.shadowedSeqs.includes(prevCheckpoint) ? prevCheckpoint : null,
2596
2732
  summaryText: d.summary.map((s) => s.text).join(""),
2597
- originalText
2733
+ originalText,
2734
+ lines,
2735
+ fidelity,
2736
+ shadowEvents
2598
2737
  });
2599
2738
  prevCheckpoint = e.seq + 1;
2600
2739
  }
@@ -2613,26 +2752,31 @@ function createCtxHandler(deps) {
2613
2752
  const seg1 = segments[1] === void 0 ? void 0 : parseSeg(segments[1]);
2614
2753
  if (seg0 === void 0 || seg0.name === "transcript") {
2615
2754
  seg0?.name;
2616
- const canonical = transcript();
2617
- return applyFace(buildSnapshot(agent, events, episodes), canonical, sel, seg0?.name === "transcript" ? "canonical" : "prepared");
2755
+ const canonical = tIdx.text;
2756
+ return applyFace(buildSnapshot(agent, events, episodes, tIdx), canonical, sel, seg0?.name === "transcript" ? "canonical" : "prepared");
2618
2757
  }
2619
2758
  if (seg0.name === "compactions") {
2620
2759
  if (seg0.bracket === void 0) {
2621
- if (sel !== null && sel.kind !== "raw") throw new UrlSchemesError("CTX_BAD_SELECTOR", "ctx://session/compactions: only :raw (or no selector) is supported on the manifest");
2622
2760
  const body = episodes.map((e) => [
2623
2761
  `label=${e.label} (checkpoint_seq=${e.checkpointSeq})`,
2624
2762
  `compactionId=${e.compactionId}`,
2625
2763
  `at=${new Date(e.at ?? 0).toISOString()}`,
2626
- `shadowed=${e.shadowedRange.start}..${e.shadowedRange.end} (${e.shadowedSeqs.length} items, ${e.shadowedTokenCount} tokens)`,
2764
+ `seq=${e.shadowedRange.start}..${e.shadowedRange.end} (${e.shadowedSeqs.length} items, ${e.shadowedTokenCount} tokens)`,
2765
+ `lines=${e.lines === null ? "<none>" : `${e.lines.start}..${e.lines.end}`}`,
2766
+ `fidelity=${e.fidelity.userTurns}:${e.fidelity.toolCalls}`,
2627
2767
  `replaces_checkpoint=${e.replacesCheckpoint ?? "null"}`,
2628
2768
  `preview=${e.summaryText.slice(0, 120)}…`
2629
2769
  ].join(" | ")).join("\n");
2630
- return body === "" ? "(no compactions recorded)" : body;
2770
+ const text = body === "" ? "(no compactions recorded)" : body;
2771
+ if (sel === null || sel.kind === "raw") return text;
2772
+ if (sel.kind === "lines") return applyLines(text, sel.ranges);
2773
+ return applySelector(text, sel);
2631
2774
  }
2632
2775
  const episode = pickEpisode(episodes, seg0.bracket);
2633
2776
  if (seg1 === void 0) {
2634
2777
  if (sel !== null && sel.kind === "raw" && episode.originalText.length > 65536) return episode.originalText + `\n\n[ctx:// note: original span is ${episode.originalText.length} chars — page with :N-M line windows or grep this URL]`;
2635
- return applyFace(episode.summaryText, episode.originalText, sel, "prepared");
2778
+ if (sel !== null && sel.kind === "lines" && episode.lines !== null) return applyEpisodeLines(episode.originalText, episode.lines.start, episode.lines.end, sel.ranges);
2779
+ return applyFace(episodePrepared(episode, episodes, tIdx, toolNameOf), episode.originalText, sel, "prepared");
2636
2780
  }
2637
2781
  throw new UrlSchemesError("CTX_BAD_PATH", `ctx://${raw}: unknown sub-path "/${seg1.name}" ("/original" was superseded by the :raw / :raw:N-M selectors)`);
2638
2782
  }
@@ -2671,13 +2815,19 @@ function createCtxHandler(deps) {
2671
2815
  }));
2672
2816
  throw new UrlSchemesError("CTX_BAD_PATH", `ctx://${raw}: unknown collection "${name$1}"`);
2673
2817
  };
2674
- if (seg0.bracket !== void 0 || seg0.name === "user_prompts" || seg0.name === "tool_calls" || seg0.name === "agent_responses") {
2818
+ if (seg0.name === "user_prompts" || seg0.name === "tool_calls" || seg0.name === "agent_responses") {
2675
2819
  const items = collection(seg0.name);
2676
- const bracket = seg0.bracket ?? "";
2677
- const bySeq = items.find((it) => it.seq === Number(bracket));
2820
+ const canonical = items.map((it) => it.text).join("\n\n");
2821
+ if (seg0.bracket === void 0) {
2822
+ const bounded = seg0.name !== "user_prompts";
2823
+ const index = (bounded ? items.slice(0, 20) : items).map((it) => `seq=${it.seq} ${previewOf(it.text)}`).join("\n");
2824
+ const tail = bounded && items.length > 20 ? `\n… +${items.length - 20} more — use :raw for all, ?q= to filter, [n|seq] for one` : "";
2825
+ return applyFace(index === "" ? `(no ${seg0.name} items recorded)` : index + tail, canonical, sel, "prepared");
2826
+ }
2827
+ const bySeq = items.find((it) => it.seq === Number(seg0.bracket));
2678
2828
  if (bySeq !== void 0) return applyFace(bySeq.text, bySeq.text, sel, "prepared");
2679
- const ordinal = Number(bracket);
2680
- if (bracket === "" || !Number.isInteger(ordinal) || ordinal < 0 || ordinal >= items.length) throw new UrlSchemesError("CTX_NO_SUCH_ELEMENT", `ctx://…[${bracket}]: no such element (collection "${seg0.name}" has ${items.length} items, 0-based; labels are event seqs)`);
2829
+ const ordinal = Number(seg0.bracket);
2830
+ if (!Number.isInteger(ordinal) || ordinal < 0 || ordinal >= items.length) throw new UrlSchemesError("CTX_NO_SUCH_ELEMENT", `ctx://session/${seg0.name}[${seg0.bracket}]: no such element (collection "${seg0.name}" has ${items.length} items, 0-based; labels are event seqs)`);
2681
2831
  return applyFace(items[ordinal].text, items[ordinal].text, sel, "prepared");
2682
2832
  }
2683
2833
  throw new UrlSchemesError("CTX_UNKNOWN_KEY", `ctx://${raw}: unknown path (known: session, session/transcript, session/compactions[...], session/user_prompts[...], session/tool_calls[...], session/agent_responses[...], session/thinking[...], session/system[...], session/injections[...]; bare ctx:// lists the full roster)`);
@@ -2688,7 +2838,7 @@ function createCtxHandler(deps) {
2688
2838
  if (Number.isInteger(ordinal) && ordinal >= 0 && ordinal < list.length) return list[ordinal];
2689
2839
  throw new UrlSchemesError("CTX_NO_SUCH_ELEMENT", `ctx://…[${bracket}]: no such compaction episode (labels: ${list.map((e) => e.label).join(", ") || "none"})`);
2690
2840
  }
2691
- function buildSnapshot(agent$1, all, list) {
2841
+ function buildSnapshot(agent$1, all, list, tIdx$1) {
2692
2842
  const headers = all.filter((e) => e.type === "request/header");
2693
2843
  const system = (headers[headers.length - 1]?.data)?.header?.system;
2694
2844
  const counts = {};
@@ -2712,9 +2862,10 @@ function createCtxHandler(deps) {
2712
2862
  "tool_results"
2713
2863
  ]) counts[key] = counts[key] ?? 0;
2714
2864
  const header = agent$1.session.header;
2865
+ const lastSeq = all.reduce((m, e) => Math.max(m, e.seq), 0);
2715
2866
  return JSON.stringify({
2716
2867
  resource: "ctx://session",
2717
- syntax: "/sub-path [<label|n>] [:N-M]; :raw = canonical full content; :raw:N-M ≡ :N-M",
2868
+ syntax: "/sub-path [<label|n>] [:N-M|:path/|?q=]; :raw = canonical full content; :raw:N-M ≡ :N-M",
2718
2869
  session: {
2719
2870
  id: header.id ?? agent$1.id,
2720
2871
  createdAt: header.createdAt,
@@ -2728,19 +2879,17 @@ function createCtxHandler(deps) {
2728
2879
  totals: counts,
2729
2880
  segments: [...list.map((e) => ({
2730
2881
  segment: `compaction:${e.label}`,
2731
- start: e.shadowedRange?.start ?? null,
2732
- end: e.shadowedRange?.end ?? null,
2882
+ seq_start: e.shadowedRange?.start ?? null,
2883
+ seq_end: e.shadowedRange?.end ?? null,
2884
+ lines: e.lines,
2733
2885
  items: e.shadowedSeqs.length
2734
- })), (() => {
2735
- const lastSeq = all.reduce((m, e) => Math.max(m, e.seq), 0);
2736
- const start = (list.at(-1)?.shadowedRange?.end ?? -1) + 1;
2737
- return {
2738
- segment: "live",
2739
- start,
2740
- end: lastSeq,
2741
- items: all.filter((e) => e.seq >= start).length
2742
- };
2743
- })()],
2886
+ })), {
2887
+ segment: "live",
2888
+ seq_start: (list.at(-1)?.shadowedRange?.end ?? -1) + 1,
2889
+ seq_end: lastSeq,
2890
+ lines: null,
2891
+ items: all.filter((e) => e.seq >= (list.at(-1)?.shadowedRange?.end ?? -1) + 1).length
2892
+ }],
2744
2893
  system_prompt: {
2745
2894
  chars: system?.length ?? 0,
2746
2895
  preview: system === void 0 ? "" : system.slice(0, 200)
@@ -2750,16 +2899,24 @@ function createCtxHandler(deps) {
2750
2899
  checkpoint_seq: e.checkpointSeq,
2751
2900
  compactionId: e.compactionId,
2752
2901
  at: e.at,
2753
- shadowed_range: e.shadowedRange,
2902
+ seq_start: e.shadowedRange.start,
2903
+ seq_end: e.shadowedRange.end,
2904
+ lines: e.lines,
2754
2905
  shadowed_items: e.shadowedSeqs.length,
2755
2906
  shadowed_token_count: e.shadowedTokenCount,
2907
+ fidelity: e.fidelity,
2756
2908
  replaces_checkpoint: e.replacesCheckpoint,
2757
2909
  summary_preview: sectionPreviews(e.summaryText)
2758
2910
  })),
2911
+ asOf: {
2912
+ seq: lastSeq,
2913
+ line: tIdx$1.totalLines
2914
+ },
2759
2915
  hints: [
2760
2916
  "ctx://session:raw → full transcript",
2761
2917
  "ctx://session/compactions[<label>]:raw → original span",
2762
- "ctx://session/thinking / ctx://session/system / ctx://session/injections → index-faced collections"
2918
+ "ctx://session/thinking / system / injections / user_prompts / tool_calls / agent_responses → index faces",
2919
+ ":path/<dot-path> narrow-reads the snapshot · ?q=<text> filters an index"
2763
2920
  ]
2764
2921
  }, void 0, 2);
2765
2922
  }