dsh-retrace 0.4.2 → 0.4.5

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/forkmap.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * dsh-retrace — `retrace/forkmap` pure fold (PLAN §5.2, P2.1).
3
+ *
4
+ * The fork map is the BRANCH-TOPOLOGY presentation layer ("how we got here"):
5
+ * every surface replacement — recall / edit / regenerate / restore markers,
6
+ * compaction checkpoints, and any other replace — is a FORK POINT that shadows
7
+ * a range of the old path and continues a new path. This module mirrors the
8
+ * official `foldSurface` state transition (the same one the versions unit
9
+ * uses) and additionally records each boundary with the seqs it shadowed, so
10
+ * the client can render:
11
+ *
12
+ * spine — the current-surface node flow (markers sit ON the spine),
13
+ * branches — at each boundary, the shadowed (old-path) node cluster.
14
+ *
15
+ * Data notes (verified against the official source, 2026-08-26):
16
+ * - replace `start`/`end` are EVENT SEQS (not surface indices); the shadowed
17
+ * seqs are the surface nodes the splice removed — exactly
18
+ * `foldSurface(...).replacements[].shadowedSeqs`.
19
+ * - official `traceEvent` does NOT link a marker to its new path
20
+ * (new-path events never cite the marker, so `derivedEventSeqs` is empty)
21
+ * — the new path IS the spine: the surface nodes after the boundary
22
+ * (PLAN.md:295 "标记→新路径无需自解析" corrected by research).
23
+ * - boundaries ARE the versions (every replacement closes a version); the
24
+ * wire stays lean (no markerText — the client joins it from
25
+ * `retrace/versions` by boundarySeq).
26
+ *
27
+ * The module is import-free except for the shared boundary predicates, stays
28
+ * transport-neutral, and is registered alongside `retrace/versions` in the
29
+ * versioning seam. No side effects: pure fold only.
30
+ */
31
+ import {
32
+ classifyBoundaryKind,
33
+ isReplacementSurfaceEvent,
34
+ isSurfaceEvent,
35
+ } from './version-index.js'
36
+
37
+ /** Fork-map fold state (plain JSON — durable checkpoint rows validate it). */
38
+ export function createForkmapState() {
39
+ return {
40
+ /** Current surface nodes: `{ seq, type }` in fold order (mirror foldSurface). */
41
+ nodes: [],
42
+ /** Replacement boundaries in append order. */
43
+ boundaries: [],
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Fold one committed event into the fork-map state.
49
+ *
50
+ * Performance contract (load-bearing for the projection registry): when the
51
+ * event changes nothing — no surface move, no boundary — the SAME state
52
+ * reference is returned (`Object.is` gates the change feed).
53
+ *
54
+ * @param {ReturnType<typeof createForkmapState>} state
55
+ * @param {object} event
56
+ * @returns the next state (same reference when nothing changed).
57
+ */
58
+ export function applyForkmap(state, event) {
59
+ if (!event || typeof event.seq !== 'number') return state
60
+ let next = state
61
+ let shadowed = null
62
+
63
+ // 1. Surface fold (mirror foldSurface / the versions unit).
64
+ if (isSurfaceEvent(event)) {
65
+ const op = event.surfaceOp
66
+ if (op === 'append') {
67
+ next = { ...next, nodes: [...next.nodes, { seq: event.seq, type: event.type }] }
68
+ } else if (op && op.op === 'replace') {
69
+ const startIdx = next.nodes.findIndex((node) => node.seq === op.start)
70
+ const endIdx = next.nodes.findIndex((node) => node.seq === op.end)
71
+ if (startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx) {
72
+ shadowed = next.nodes.slice(startIdx, endIdx + 1).map((node) => node.seq)
73
+ const nodes = next.nodes.slice()
74
+ nodes.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, type: event.type })
75
+ next = { ...next, nodes }
76
+ }
77
+ }
78
+ }
79
+
80
+ // 2. Boundary (fork point). The shadowed range is the splice's removal when
81
+ // the span was live; otherwise fall back to the marker's own citation.
82
+ if (isReplacementSurfaceEvent(event)) {
83
+ const cited = Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs : []
84
+ const replacedSeqs = shadowed !== null ? shadowed : cited
85
+ const boundary = {
86
+ seq: event.seq,
87
+ kind: classifyBoundaryKind(event),
88
+ replacedSeqs,
89
+ }
90
+ next = { ...next, boundaries: [...next.boundaries, boundary] }
91
+ }
92
+
93
+ return next
94
+ }
95
+
96
+ /** The client-visible wire value (what `session/projection` frames serve). */
97
+ export function viewForkmap(state) {
98
+ return {
99
+ nodes: state.nodes.map((node) => ({ seq: node.seq, type: node.type })),
100
+ boundaries: state.boundaries.map((boundary) => ({
101
+ seq: boundary.seq,
102
+ kind: boundary.kind,
103
+ replacedSeqs: boundary.replacedSeqs,
104
+ })),
105
+ }
106
+ }
package/lib/http.js CHANGED
@@ -8,6 +8,9 @@
8
8
  * GET /api/plugins/retrace/versions?sessionId=
9
9
  * — live projection snapshot (HTTP fallback channel; the push channel is
10
10
  * `session/projection` frames via dsh-host-apiproxy).
11
+ * GET /api/plugins/retrace/forkmap?sessionId=
12
+ * — the fork-map projection snapshot (branch topology, P2.1; same
13
+ * push-frame + HTTP fallback dual channel as /versions).
11
14
  * GET /api/plugins/retrace/event?sessionId=&seq=&before=&after=
12
15
  * — one event + context window (sessionQuery.readEvent, lazy reads).
13
16
  * GET /api/plugins/retrace/surface?sessionId=
@@ -87,6 +90,15 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
87
90
  }
88
91
  }
89
92
 
93
+ function handleForkmap(req, res, sessionId, config) {
94
+ seam.setConfig(sessionId, config)
95
+ try {
96
+ sendJson(res, 200, { ok: true, value: seam.snapshotForkmap(sessionId) })
97
+ } catch (error) {
98
+ sendError(res, error)
99
+ }
100
+ }
101
+
90
102
  async function handleEvent(req, res, searchParams) {
91
103
  const sessionId = searchParams.get('sessionId') ?? ''
92
104
  const seq = Number(searchParams.get('seq'))
@@ -249,6 +261,8 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
249
261
  switch (op) {
250
262
  case 'versions':
251
263
  return handleVersions(req, res, sessionId, config)
264
+ case 'forkmap':
265
+ return handleForkmap(req, res, sessionId, config)
252
266
  case 'event':
253
267
  return void handleEvent(req, res, searchParams)
254
268
  case 'surface':
@@ -0,0 +1,69 @@
1
+ /**
2
+ * dsh-retrace — `retrace/forkmap` projection unit definition.
3
+ *
4
+ * Registered on the official `ctx.sessionProjections` registry alongside
5
+ * `retrace/versions` (PLAN §5.2, P2.1). Same contract as the versions unit:
6
+ *
7
+ * - `stateSchema` — zod schema of the RAW fold state (plain JSON; the
8
+ * durable checkpoint validates rows against it),
9
+ * - `init` / `apply` — the deterministic fold (lib/forkmap.js),
10
+ * - `wire` — the client-visible view ({viewSchema, view}); a unit WITHOUT
11
+ * `wire` registers as checkpoint-only and is never served in
12
+ * `session/projection` push frames or `snapshot()` (the framework
13
+ * contract the P0 versions definition missed; found by the P1 smoke).
14
+ *
15
+ * Unlike the versions unit, the fork-map state is NOT truncated: the whole
16
+ * branch topology must survive (VERSION_LIMIT=200 truncation would drop old
17
+ * fork points). Per-boundary payloads are tiny (seqs only), so the projection
18
+ * stays small even for long sessions; node content stays out of the fold
19
+ * (lazy reads via the HTTP surface when the client needs detail).
20
+ */
21
+ import { z } from 'zod'
22
+ import {
23
+ applyForkmap,
24
+ createForkmapState,
25
+ viewForkmap,
26
+ } from '../forkmap.js'
27
+
28
+ const FORK_KINDS = ['recall', 'edit', 'regenerate', 'restore', 'compaction', 'replace']
29
+
30
+ const forkmapNodeSchema = z.object({
31
+ seq: z.number().int().nonnegative(),
32
+ type: z.string(),
33
+ })
34
+
35
+ const forkmapBoundarySchema = z.object({
36
+ seq: z.number().int().nonnegative(),
37
+ kind: z.enum(FORK_KINDS),
38
+ replacedSeqs: z.array(z.number().int().nonnegative()),
39
+ })
40
+
41
+ /** Raw fold-state schema (durable checkpoint rows validate against it). */
42
+ export const forkmapStateSchema = z.object({
43
+ nodes: z.array(forkmapNodeSchema),
44
+ boundaries: z.array(forkmapBoundarySchema),
45
+ })
46
+
47
+ /** The wire view schema (what the registry validates and clients receive). */
48
+ export const forkmapViewSchema = forkmapStateSchema
49
+
50
+ /**
51
+ * The `retrace/forkmap` unit — register via
52
+ * `ctx.sessionProjections.register(forkmapProjectionDefinition)`.
53
+ */
54
+ export const forkmapProjectionDefinition = {
55
+ key: 'retrace/forkmap',
56
+ stateSchema: forkmapStateSchema,
57
+ init: () => createForkmapState(),
58
+ apply: (state, event) => applyForkmap(state, event),
59
+ wire: {
60
+ viewSchema: forkmapViewSchema,
61
+ view: (state) => viewForkmap(state),
62
+ },
63
+ /** Bump when the serialized fold state or fold semantics change. */
64
+ stateVersion: 1,
65
+ }
66
+
67
+ // Top-level aliases (unit tests drive the definition directly).
68
+ forkmapProjectionDefinition.schema = forkmapProjectionDefinition.wire.viewSchema
69
+ forkmapProjectionDefinition.view = forkmapProjectionDefinition.wire.view
package/lib/versioning.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  retraceDomainSpec,
25
25
  } from './artifact-store.js'
26
26
  import { versionsProjectionDefinition } from './projection/versions.js'
27
+ import { forkmapProjectionDefinition } from './projection/forkmap.js'
27
28
  import { createGitAdapter, createSubprocessRunner } from './git-adapter.js'
28
29
 
29
30
  /** Max bytes snapshotted per touched file (over → file skipped, version kept). */
@@ -221,6 +222,8 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
221
222
  ctx.inject(['sessionProjections', 'sessionQuery', 'storageDomain'], (seam) => {
222
223
  seamCtx = seam
223
224
  disposers.push(seam.sessionProjections.register(versionsProjectionDefinition))
225
+ // P2.1 — the fork-map unit: pure fold (branch topology), no side effects.
226
+ disposers.push(seam.sessionProjections.register(forkmapProjectionDefinition))
224
227
  disposers.push(
225
228
  seam.sessionProjections.onChanged((session, key, value, seq) => {
226
229
  if (key !== 'retrace/versions') return
@@ -243,7 +246,11 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
243
246
  const global = opened.global.get()
244
247
  if (global) {
245
248
  // Seed session configs with the durable defaults (per-request overrides win).
246
- for (const [id, config] of configs) setConfig(id, { ...config, ...global })
249
+ // The domain schema spells the git switch `gitEnabled` (0.4.x review:
250
+ // it was merged in but never read — consumers use `config.git`).
251
+ const seeded = { ...global, git: global.gitEnabled ?? configs.get('')?.git ?? true }
252
+ delete seeded.gitEnabled
253
+ for (const [id, config] of configs) setConfig(id, { ...config, ...seeded })
247
254
  }
248
255
  })
249
256
  .catch((error) => log(`retrace: versioning disabled: ${String(error)}`))
@@ -255,9 +262,16 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
255
262
 
256
263
  /** HTTP fallback channel: the live projection snapshot for one session. */
257
264
  function snapshot(sessionId) {
258
- if (!available()) {
259
- return { enabled: false, versions: [] }
260
- }
265
+ return snapshotKey(sessionId, 'retrace/versions', { enabled: false, versions: [] })
266
+ }
267
+
268
+ /** HTTP fallback channel: the fork-map projection snapshot (P2.1). */
269
+ function snapshotForkmap(sessionId) {
270
+ return snapshotKey(sessionId, 'retrace/forkmap', { enabled: false, nodes: [], boundaries: [] })
271
+ }
272
+
273
+ function snapshotKey(sessionId, key, empty) {
274
+ if (!available()) return empty
261
275
  const session = ctx.sessions.get(sessionId)
262
276
  if (!session) {
263
277
  const error = new Error(`session "${sessionId}" not found`)
@@ -265,8 +279,8 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
265
279
  throw error
266
280
  }
267
281
  const cut = seamCtx.sessionProjections.snapshot(session)
268
- const value = cut.values['retrace/versions']
269
- if (!value) return { enabled: false, versions: [] }
282
+ const value = cut.values[key]
283
+ if (!value) return empty
270
284
  return { enabled: configFor(sessionId).versioning, ...value }
271
285
  }
272
286
 
@@ -367,6 +381,7 @@ export function createVersioningSeam(ctx, log = () => {}, options = {}) {
367
381
  configFor,
368
382
  setConfig,
369
383
  snapshot,
384
+ snapshotForkmap,
370
385
  readEvent,
371
386
  readSurface,
372
387
  agentOf,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-retrace",
3
3
  "description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
4
- "version": "0.4.2",
4
+ "version": "0.4.5",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",