dsh-retrace 0.3.0 → 0.4.2

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.
@@ -29,10 +29,112 @@ return {
29
29
 
30
30
  const EDITOR_PLUGIN = 'retrace'
31
31
 
32
- /** Message-id prefix every event this plugin appends carries (client discriminator). */
32
+ /**
33
+ * Message-id prefix every event this plugin appends carries (client discriminator).
34
+ * RENAME RULE: when the plugin changes identity, KEEP this prefix unchanged for
35
+ * new markers — or, if it must change, add the old value to the legacy prefix
36
+ * lists in lib/client.js (MARKER_PREFIXES / LEGACY_MARKER_PREFIXES) and
37
+ * lib/version-index.js (MARKER_ID_PREFIXES) so old markers keep rendering and
38
+ * classifying. Recognition must never be broken by a rename.
39
+ */
33
40
  const MARKER_ID_PREFIX = 'retrace'
34
41
 
35
- function createEditorApi(ctx, sessions, agents, log = () => {}) {
42
+ /** A fresh marker event id: `<prefix>-<op>-<time36>-<rand>`. */
43
+ function editorId(op) {
44
+ return `${MARKER_ID_PREFIX}-${op}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
45
+ }
46
+
47
+ /** An error carrying a stable wire `code` (mirrors the editor result shape). */
48
+ function editorError(code, message) {
49
+ const error = new Error(message)
50
+ error.code = code
51
+ return error
52
+ }
53
+
54
+ /** Latest known provider/model: from the last request header, else last assistant message. */
55
+ function lastModelSource(session) {
56
+ const events = session.events
57
+ for (let i = events.length - 1; i >= 0; i--) {
58
+ const event = events[i]
59
+ if (event.type === 'request/header') {
60
+ const config = event.data?.header?.config
61
+ if (
62
+ config &&
63
+ typeof config.provider === 'string' && config.provider.length > 0 &&
64
+ typeof config.model === 'string' && config.model.length > 0
65
+ ) {
66
+ return { provider: config.provider, model: config.model }
67
+ }
68
+ }
69
+ if (event.type === 'assistant/message') {
70
+ const source = event.data?.message?.source
71
+ if (
72
+ source && source.kind === 'model' &&
73
+ typeof source.provider === 'string' && source.provider.length > 0 &&
74
+ typeof source.model === 'string' && source.model.length > 0
75
+ ) {
76
+ return { provider: source.provider, model: source.model }
77
+ }
78
+ }
79
+ }
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * Append the invisible replacement marker that shadows [span.start..span.end].
85
+ * Shared by recall/edit/regenerate (L1) and context rollback (P1): an empty
86
+ * assistant message is a valid surface node yet derives to *no* model message,
87
+ * so the LLM context simply rewinds. `turn: null` declares a session-level
88
+ * event (the client location index maps it to SESSION_LOCATION, keeping it out
89
+ * of every turn's node list). `editor` carries the reference facts the UI uses
90
+ * for the optional "original input" comparison row.
91
+ */
92
+ /**
93
+ * Append the invisible replacement marker that shadows [span.start..span.end].
94
+ * Shared by recall/edit/regenerate (L1) and context rollback (P1): an empty
95
+ * assistant message is a valid surface node yet derives to *no* model message,
96
+ * so the LLM context simply rewinds. `turn: null` declares a session-level
97
+ * event (the client location index maps it to SESSION_LOCATION, keeping it out
98
+ * of every turn's node list). `editor` carries the reference facts the UI uses
99
+ * for the optional "original input" comparison row.
100
+ *
101
+ * The optional `validate` hook receives the would-be event envelope BEFORE it
102
+ * is committed (validate first, commit later — the 8-25 incident's fix): a
103
+ * throwing validator aborts the append, so a contract-violating marker can
104
+ * never reach the durable log. The hook may be async.
105
+ */
106
+ async function appendEditorMarker(session, span, op, targetSeq, originalText, validate) {
107
+ const model = lastModelSource(session)
108
+ if (!model) {
109
+ throw editorError(
110
+ 'no-model-header',
111
+ 'This session has no model header yet; send at least one message before recalling or editing.',
112
+ )
113
+ }
114
+ const marker = {
115
+ id: editorId(op),
116
+ role: 'assistant',
117
+ content: [],
118
+ source: { kind: 'model', provider: model.provider, model: model.model },
119
+ }
120
+ const data = {
121
+ turn: null,
122
+ step: null,
123
+ message: marker,
124
+ editor: {
125
+ targetSeq,
126
+ text: typeof originalText === 'string' ? originalText.slice(0, 2000) : '',
127
+ },
128
+ }
129
+ const surfaceOp = { op: 'replace', start: span.start, end: span.end }
130
+ const sourceEventSeqs = span.shadowedSeqs.slice()
131
+ if (typeof validate === 'function') {
132
+ await validate(session, { type: 'assistant/message', data, surfaceOp, sourceEventSeqs })
133
+ }
134
+ return session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
135
+ }
136
+
137
+ function createEditorApi(ctx, sessions, agents, log = () => {}, hooks = {}) {
36
138
  /** One in-flight op per session; later ops wait for the earlier one. */
37
139
  const locks = new Map()
38
140
 
@@ -46,16 +148,6 @@ return {
46
148
  return next
47
149
  }
48
150
 
49
- function editorId(op) {
50
- return `${MARKER_ID_PREFIX}-${op}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
51
- }
52
-
53
- function editorError(code, message) {
54
- const error = new Error(message)
55
- error.code = code
56
- return error
57
- }
58
-
59
151
  function requireSession(sessionId) {
60
152
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
61
153
  throw editorError('bad-request', 'sessionId must be a non-empty string')
@@ -74,35 +166,6 @@ return {
74
166
  }
75
167
  }
76
168
 
77
- /** Latest known provider/model: from the last request header, else last assistant message. */
78
- function lastModelSource(session) {
79
- const events = session.events
80
- for (let i = events.length - 1; i >= 0; i--) {
81
- const event = events[i]
82
- if (event.type === 'request/header') {
83
- const config = event.data?.header?.config
84
- if (
85
- config &&
86
- typeof config.provider === 'string' && config.provider.length > 0 &&
87
- typeof config.model === 'string' && config.model.length > 0
88
- ) {
89
- return { provider: config.provider, model: config.model }
90
- }
91
- }
92
- if (event.type === 'assistant/message') {
93
- const source = event.data?.message?.source
94
- if (
95
- source && source.kind === 'model' &&
96
- typeof source.provider === 'string' && source.provider.length > 0 &&
97
- typeof source.model === 'string' && source.model.length > 0
98
- ) {
99
- return { provider: source.provider, model: source.model }
100
- }
101
- }
102
- }
103
- return null
104
- }
105
-
106
169
  /** Locate the durable seq of a user/assistant message by its stable message id. */
107
170
  function findMessageSeq(session, messageId) {
108
171
  if (typeof messageId !== 'string' || messageId.length === 0) {
@@ -178,42 +241,6 @@ return {
178
241
  }
179
242
  }
180
243
 
181
- /**
182
- * Append the invisible replacement marker that shadows [span.start..span.end].
183
- * An empty assistant message is a valid surface node yet derives to *no*
184
- * model message, so the LLM context simply rewinds. `turn: null` declares a
185
- * session-level event (the client location index maps it to SESSION_LOCATION,
186
- * keeping it out of every turn's node list). `editor` carries the reference
187
- * facts the UI uses for the optional "original input" comparison row.
188
- */
189
- function appendEditorMarker(session, span, op, targetSeq, originalText) {
190
- const model = lastModelSource(session)
191
- if (!model) {
192
- throw editorError(
193
- 'no-model-header',
194
- 'This session has no model header yet; send at least one message before recalling or editing.',
195
- )
196
- }
197
- const marker = {
198
- id: editorId(op),
199
- role: 'assistant',
200
- content: [],
201
- source: { kind: 'model', provider: model.provider, model: model.model },
202
- }
203
- return session.append('assistant/message', {
204
- turn: null,
205
- step: null,
206
- message: marker,
207
- editor: {
208
- targetSeq,
209
- text: typeof originalText === 'string' ? originalText.slice(0, 2000) : '',
210
- },
211
- }, {
212
- surfaceOp: { op: 'replace', start: span.start, end: span.end },
213
- sourceEventSeqs: span.shadowedSeqs.slice(),
214
- })
215
- }
216
-
217
244
  function extractUserText(content) {
218
245
  if (!Array.isArray(content)) return ''
219
246
  return content
@@ -276,7 +303,7 @@ return {
276
303
  if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
277
304
  const span = roundSpanFrom(session, seq)
278
305
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
279
- const markerEvent = appendEditorMarker(session, span, 'recall', seq, messageTextOf(session, seq))
306
+ const markerEvent = await appendEditorMarker(session, span, 'recall', seq, messageTextOf(session, seq), hooks.validateMarker)
280
307
  await flushSafely(session)
281
308
  return {
282
309
  op: 'recall',
@@ -318,7 +345,7 @@ return {
318
345
  if (startSeq === undefined) throw editorError('empty-surface', 'This session has no conversation to edit.')
319
346
  const span = shadowSpanFrom(session, startSeq)
320
347
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
321
- appendEditorMarker(session, span, 'edit', seq, originalText)
348
+ await appendEditorMarker(session, span, 'edit', seq, originalText, hooks.validateMarker)
322
349
  await flushSafely(session)
323
350
  const message = resendMessage(text.trim(), 'resend')
324
351
  agent.followup(message)
@@ -367,7 +394,7 @@ return {
367
394
  }
368
395
  const span = shadowSpanFrom(session, userSeq)
369
396
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
370
- appendEditorMarker(session, span, 'regenerate', userSeq, text)
397
+ await appendEditorMarker(session, span, 'regenerate', userSeq, text, hooks.validateMarker)
371
398
  await flushSafely(session)
372
399
  const message = resendMessage(text.trim(), 'resend')
373
400
  agent.followup(message)
@@ -0,0 +1,177 @@
1
+ /**
2
+ * dsh-retrace — GitAdapter (PLAN.md §4.4).
3
+ *
4
+ * All git activity rides the host subprocess seam (`ctx.subprocess`):
5
+ * read-only commands (`rev-parse`, `status`, `ls-tree`) run freely; write
6
+ * commands (`init`, `update-ref`, `checkout`, the one-time initial commit)
7
+ * are only ever issued from user-confirmed flows (one-click init, artifact
8
+ * rollback). Nothing here touches the working tree directly — every file
9
+ * change goes through `ctx.fs` (workspace sandbox) or `git checkout` with a
10
+ * strict path allow-list.
11
+ *
12
+ * The adapter is transport-shaped for tests: the caller injects `run` (one
13
+ * spawn spec in → one outcome out) and `writeText` (workspace sandbox write),
14
+ * so the test suite exercises the full decision logic with a fake runner
15
+ * instead of a real git binary.
16
+ */
17
+
18
+ import { isAbsolute, join, relative, resolve } from 'node:path'
19
+
20
+ /** Minimal `.gitignore` written by the one-click init (never touches user files). */
21
+ export const MINIMAL_GITIGNORE = [
22
+ '# created by dsh-retrace (one-click git init) — remove freely',
23
+ 'node_modules/',
24
+ 'dist/',
25
+ '*.log',
26
+ '',
27
+ ].join('\n')
28
+
29
+ const SPILL_LIMIT = 256 * 1024
30
+
31
+ /** Run-spec → outcome shim over the real host subprocess seam. */
32
+ export function createSubprocessRunner(ctx, log = () => {}) {
33
+ const command = async (argv, cwd, signal) => {
34
+ const git = await ctx.subprocess.resolveExecutable('git')
35
+ const handle = ctx.subprocess.spawn({
36
+ argv: [git, ...argv],
37
+ cwd,
38
+ stdio: {
39
+ stdin: 'ignore',
40
+ stdout: { maxBytes: 256 * 1024, spill: { maxBytes: SPILL_LIMIT } },
41
+ stderr: { maxBytes: 256 * 1024, spill: { maxBytes: SPILL_LIMIT } },
42
+ },
43
+ graceMs: 30_000,
44
+ signal,
45
+ env: {},
46
+ })
47
+ const outcome = await handle.done
48
+ const collected = handle.collected
49
+ const read = (reader) => {
50
+ const value = reader.readFrom(0)
51
+ return value.text ?? ''
52
+ }
53
+ return {
54
+ ok: outcome.exitCode === 0,
55
+ exitCode: outcome.exitCode,
56
+ stdout: read(collected.stdout),
57
+ stderr: read(collected.stderr),
58
+ }
59
+ }
60
+ const writeText = async (target, content, sandboxPolicy) => {
61
+ await ctx.fs.writeText(target, content, undefined, undefined, sandboxPolicy)
62
+ }
63
+ return { command, writeText }
64
+ }
65
+
66
+ /**
67
+ * Create the git adapter.
68
+ * @param {object} deps
69
+ * @param {(argv: string[], cwd: string, signal?: AbortSignal) => Promise<{ok:boolean, exitCode?:number, stdout:string, stderr:string}>} deps.command
70
+ * @param {(target: string, content: string, sandboxPolicy: any) => Promise<void>} deps.writeText — workspace sandbox write (one-click init's .gitignore).
71
+ * @param {(line: string) => void} [deps.log]
72
+ */
73
+ export function createGitAdapter({ command, writeText, log = () => {} }) {
74
+ /** Detect the enclosing repository root from `cwd` (outer repos included). */
75
+ async function detect(cwd) {
76
+ if (typeof cwd !== 'string' || cwd.length === 0) return null
77
+ const run = await command(['rev-parse', '--show-toplevel'], cwd)
78
+ if (!run.ok) return null
79
+ const root = run.stdout.trim()
80
+ return root.length > 0 ? root : null
81
+ }
82
+
83
+ /**
84
+ * Read-only repo status at `cwd`: HEAD hash + dirty flag + porcelain paths.
85
+ * Returns null when `cwd` is not inside a repository.
86
+ */
87
+ async function status(cwd, signal) {
88
+ if (typeof cwd !== 'string' || cwd.length === 0) return null
89
+ const run = await command(['rev-parse', '--show-toplevel'], cwd, signal)
90
+ if (!run.ok) return null
91
+ const root = run.stdout.trim()
92
+ if (root.length === 0) return null
93
+ const head = await command(['rev-parse', 'HEAD'], cwd, signal)
94
+ if (!head.ok) return { root, headHash: null, dirty: false, paths: [] }
95
+ const headHash = head.stdout.trim()
96
+ const dirtyRun = await command(['status', '--porcelain'], cwd, signal)
97
+ const paths = dirtyRun.ok
98
+ ? dirtyRun.stdout.split('\n').map((line) => line.replace(/^.../, '')).filter((p) => p.length > 0)
99
+ : []
100
+ return { root, headHash, dirty: paths.length > 0, paths }
101
+ }
102
+
103
+ /** Files present at `headHash` among `paths` (rollback allow-list pre-filter). */
104
+ async function presentAt(cwd, headHash, paths, signal) {
105
+ if (paths.length === 0) return []
106
+ const run = await command(['ls-tree', '-r', '--name-only', headHash, '--', ...paths], cwd, signal)
107
+ if (!run.ok) return []
108
+ const present = new Set(run.stdout.split('\n').filter((p) => p.length > 0))
109
+ return paths.filter((p) => present.has(p))
110
+ }
111
+
112
+ /** Guard every path: relative, inside cwd, no `..` escape, not absolute. */
113
+ function guardPaths(cwd, paths) {
114
+ const base = resolve(cwd)
115
+ const safe = []
116
+ for (const path of paths) {
117
+ if (typeof path !== 'string' || path.length === 0) continue
118
+ if (isAbsolute(path)) continue
119
+ const resolved = resolve(base, path)
120
+ const rel = relative(base, resolved)
121
+ if (rel.startsWith('..') || isAbsolute(rel)) continue
122
+ safe.push(rel)
123
+ }
124
+ return safe
125
+ }
126
+
127
+ /**
128
+ * Roll `paths` back to their content at `headHash` (`git checkout <hash> -- <paths>`).
129
+ * Only paths present at the commit are touched; absent ones are no-ops.
130
+ * @returns {{ok: boolean, checked: string[], skipped: string[], stderr?: string}}
131
+ */
132
+ async function checkout(cwd, headHash, paths, signal) {
133
+ const safe = guardPaths(cwd, paths)
134
+ if (safe.length === 0) return { ok: true, checked: [], skipped: [] }
135
+ const present = await presentAt(cwd, headHash, safe, signal)
136
+ const skipped = safe.filter((p) => !present.includes(p))
137
+ if (present.length === 0) return { ok: true, checked: [], skipped }
138
+ const run = await command(['checkout', headHash, '--', ...present], cwd, signal)
139
+ if (!run.ok) {
140
+ log(`retrace: git checkout failed: ${run.stderr.trim()}`)
141
+ return { ok: false, checked: [], skipped: safe, stderr: run.stderr }
142
+ }
143
+ return { ok: true, checked: present, skipped }
144
+ }
145
+
146
+ /**
147
+ * One-click init for a non-repository workspace: `git init` + minimal
148
+ * `.gitignore` + a baseline commit + the `refs/dsh/versions` ref (the
149
+ * plugin's own ref; the default branch is only touched by the baseline
150
+ * commit). Deleting `refs/dsh/versions` afterwards restores the pre-plugin
151
+ * git state (the baseline commit and .gitignore remain).
152
+ * @returns {{ok: boolean, root: string, headHash?: string, stderr?: string}}
153
+ */
154
+ async function init(cwd, signal) {
155
+ const base = resolve(cwd)
156
+ const initRun = await command(['init', '-q'], base, signal)
157
+ if (!initRun.ok) return { ok: false, root: base, stderr: initRun.stderr }
158
+ const gitignoreTarget = join(base, '.gitignore')
159
+ try {
160
+ await writeText(gitignoreTarget, MINIMAL_GITIGNORE, undefined)
161
+ } catch (error) {
162
+ return { ok: false, root: base, stderr: String(error) }
163
+ }
164
+ const add = await command(['add', '.gitignore'], base, signal)
165
+ if (!add.ok) return { ok: false, root: base, stderr: add.stderr }
166
+ const commit = await command(['commit', '-q', '-m', 'chore(dsh-retrace): initialize version tracking'], base, signal)
167
+ if (!commit.ok) return { ok: false, root: base, stderr: commit.stderr }
168
+ const head = await command(['rev-parse', 'HEAD'], base, signal)
169
+ const headHash = head.ok ? head.stdout.trim() : undefined
170
+ if (headHash) {
171
+ await command(['update-ref', 'refs/dsh/versions', headHash], base, signal)
172
+ }
173
+ return { ok: true, root: base, headHash }
174
+ }
175
+
176
+ return { detect, status, checkout, init }
177
+ }
package/lib/host-core.js CHANGED
@@ -20,10 +20,112 @@
20
20
 
21
21
  export const EDITOR_PLUGIN = 'retrace'
22
22
 
23
- /** Message-id prefix every event this plugin appends carries (client discriminator). */
23
+ /**
24
+ * Message-id prefix every event this plugin appends carries (client discriminator).
25
+ * RENAME RULE: when the plugin changes identity, KEEP this prefix unchanged for
26
+ * new markers — or, if it must change, add the old value to the legacy prefix
27
+ * lists in lib/client.js (MARKER_PREFIXES / LEGACY_MARKER_PREFIXES) and
28
+ * lib/version-index.js (MARKER_ID_PREFIXES) so old markers keep rendering and
29
+ * classifying. Recognition must never be broken by a rename.
30
+ */
24
31
  export const MARKER_ID_PREFIX = 'retrace'
25
32
 
26
- export function createEditorApi(ctx, sessions, agents, log = () => {}) {
33
+ /** A fresh marker event id: `<prefix>-<op>-<time36>-<rand>`. */
34
+ export function editorId(op) {
35
+ return `${MARKER_ID_PREFIX}-${op}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
36
+ }
37
+
38
+ /** An error carrying a stable wire `code` (mirrors the editor result shape). */
39
+ export function editorError(code, message) {
40
+ const error = new Error(message)
41
+ error.code = code
42
+ return error
43
+ }
44
+
45
+ /** Latest known provider/model: from the last request header, else last assistant message. */
46
+ export function lastModelSource(session) {
47
+ const events = session.events
48
+ for (let i = events.length - 1; i >= 0; i--) {
49
+ const event = events[i]
50
+ if (event.type === 'request/header') {
51
+ const config = event.data?.header?.config
52
+ if (
53
+ config &&
54
+ typeof config.provider === 'string' && config.provider.length > 0 &&
55
+ typeof config.model === 'string' && config.model.length > 0
56
+ ) {
57
+ return { provider: config.provider, model: config.model }
58
+ }
59
+ }
60
+ if (event.type === 'assistant/message') {
61
+ const source = event.data?.message?.source
62
+ if (
63
+ source && source.kind === 'model' &&
64
+ typeof source.provider === 'string' && source.provider.length > 0 &&
65
+ typeof source.model === 'string' && source.model.length > 0
66
+ ) {
67
+ return { provider: source.provider, model: source.model }
68
+ }
69
+ }
70
+ }
71
+ return null
72
+ }
73
+
74
+ /**
75
+ * Append the invisible replacement marker that shadows [span.start..span.end].
76
+ * Shared by recall/edit/regenerate (L1) and context rollback (P1): an empty
77
+ * assistant message is a valid surface node yet derives to *no* model message,
78
+ * so the LLM context simply rewinds. `turn: null` declares a session-level
79
+ * event (the client location index maps it to SESSION_LOCATION, keeping it out
80
+ * of every turn's node list). `editor` carries the reference facts the UI uses
81
+ * for the optional "original input" comparison row.
82
+ */
83
+ /**
84
+ * Append the invisible replacement marker that shadows [span.start..span.end].
85
+ * Shared by recall/edit/regenerate (L1) and context rollback (P1): an empty
86
+ * assistant message is a valid surface node yet derives to *no* model message,
87
+ * so the LLM context simply rewinds. `turn: null` declares a session-level
88
+ * event (the client location index maps it to SESSION_LOCATION, keeping it out
89
+ * of every turn's node list). `editor` carries the reference facts the UI uses
90
+ * for the optional "original input" comparison row.
91
+ *
92
+ * The optional `validate` hook receives the would-be event envelope BEFORE it
93
+ * is committed (validate first, commit later — the 8-25 incident's fix): a
94
+ * throwing validator aborts the append, so a contract-violating marker can
95
+ * never reach the durable log. The hook may be async.
96
+ */
97
+ export async function appendEditorMarker(session, span, op, targetSeq, originalText, validate) {
98
+ const model = lastModelSource(session)
99
+ if (!model) {
100
+ throw editorError(
101
+ 'no-model-header',
102
+ 'This session has no model header yet; send at least one message before recalling or editing.',
103
+ )
104
+ }
105
+ const marker = {
106
+ id: editorId(op),
107
+ role: 'assistant',
108
+ content: [],
109
+ source: { kind: 'model', provider: model.provider, model: model.model },
110
+ }
111
+ const data = {
112
+ turn: null,
113
+ step: null,
114
+ message: marker,
115
+ editor: {
116
+ targetSeq,
117
+ text: typeof originalText === 'string' ? originalText.slice(0, 2000) : '',
118
+ },
119
+ }
120
+ const surfaceOp = { op: 'replace', start: span.start, end: span.end }
121
+ const sourceEventSeqs = span.shadowedSeqs.slice()
122
+ if (typeof validate === 'function') {
123
+ await validate(session, { type: 'assistant/message', data, surfaceOp, sourceEventSeqs })
124
+ }
125
+ return session.append('assistant/message', data, { surfaceOp, sourceEventSeqs })
126
+ }
127
+
128
+ export function createEditorApi(ctx, sessions, agents, log = () => {}, hooks = {}) {
27
129
  /** One in-flight op per session; later ops wait for the earlier one. */
28
130
  const locks = new Map()
29
131
 
@@ -37,16 +139,6 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}) {
37
139
  return next
38
140
  }
39
141
 
40
- function editorId(op) {
41
- return `${MARKER_ID_PREFIX}-${op}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
42
- }
43
-
44
- function editorError(code, message) {
45
- const error = new Error(message)
46
- error.code = code
47
- return error
48
- }
49
-
50
142
  function requireSession(sessionId) {
51
143
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
52
144
  throw editorError('bad-request', 'sessionId must be a non-empty string')
@@ -65,35 +157,6 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}) {
65
157
  }
66
158
  }
67
159
 
68
- /** Latest known provider/model: from the last request header, else last assistant message. */
69
- function lastModelSource(session) {
70
- const events = session.events
71
- for (let i = events.length - 1; i >= 0; i--) {
72
- const event = events[i]
73
- if (event.type === 'request/header') {
74
- const config = event.data?.header?.config
75
- if (
76
- config &&
77
- typeof config.provider === 'string' && config.provider.length > 0 &&
78
- typeof config.model === 'string' && config.model.length > 0
79
- ) {
80
- return { provider: config.provider, model: config.model }
81
- }
82
- }
83
- if (event.type === 'assistant/message') {
84
- const source = event.data?.message?.source
85
- if (
86
- source && source.kind === 'model' &&
87
- typeof source.provider === 'string' && source.provider.length > 0 &&
88
- typeof source.model === 'string' && source.model.length > 0
89
- ) {
90
- return { provider: source.provider, model: source.model }
91
- }
92
- }
93
- }
94
- return null
95
- }
96
-
97
160
  /** Locate the durable seq of a user/assistant message by its stable message id. */
98
161
  function findMessageSeq(session, messageId) {
99
162
  if (typeof messageId !== 'string' || messageId.length === 0) {
@@ -169,42 +232,6 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}) {
169
232
  }
170
233
  }
171
234
 
172
- /**
173
- * Append the invisible replacement marker that shadows [span.start..span.end].
174
- * An empty assistant message is a valid surface node yet derives to *no*
175
- * model message, so the LLM context simply rewinds. `turn: null` declares a
176
- * session-level event (the client location index maps it to SESSION_LOCATION,
177
- * keeping it out of every turn's node list). `editor` carries the reference
178
- * facts the UI uses for the optional "original input" comparison row.
179
- */
180
- function appendEditorMarker(session, span, op, targetSeq, originalText) {
181
- const model = lastModelSource(session)
182
- if (!model) {
183
- throw editorError(
184
- 'no-model-header',
185
- 'This session has no model header yet; send at least one message before recalling or editing.',
186
- )
187
- }
188
- const marker = {
189
- id: editorId(op),
190
- role: 'assistant',
191
- content: [],
192
- source: { kind: 'model', provider: model.provider, model: model.model },
193
- }
194
- return session.append('assistant/message', {
195
- turn: null,
196
- step: null,
197
- message: marker,
198
- editor: {
199
- targetSeq,
200
- text: typeof originalText === 'string' ? originalText.slice(0, 2000) : '',
201
- },
202
- }, {
203
- surfaceOp: { op: 'replace', start: span.start, end: span.end },
204
- sourceEventSeqs: span.shadowedSeqs.slice(),
205
- })
206
- }
207
-
208
235
  function extractUserText(content) {
209
236
  if (!Array.isArray(content)) return ''
210
237
  return content
@@ -267,7 +294,7 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}) {
267
294
  if (seq === -1) throw editorError('message-not-found', 'Message not found in this session.')
268
295
  const span = roundSpanFrom(session, seq)
269
296
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
270
- const markerEvent = appendEditorMarker(session, span, 'recall', seq, messageTextOf(session, seq))
297
+ const markerEvent = await appendEditorMarker(session, span, 'recall', seq, messageTextOf(session, seq), hooks.validateMarker)
271
298
  await flushSafely(session)
272
299
  return {
273
300
  op: 'recall',
@@ -309,7 +336,7 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}) {
309
336
  if (startSeq === undefined) throw editorError('empty-surface', 'This session has no conversation to edit.')
310
337
  const span = shadowSpanFrom(session, startSeq)
311
338
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
312
- appendEditorMarker(session, span, 'edit', seq, originalText)
339
+ await appendEditorMarker(session, span, 'edit', seq, originalText, hooks.validateMarker)
313
340
  await flushSafely(session)
314
341
  const message = resendMessage(text.trim(), 'resend')
315
342
  agent.followup(message)
@@ -358,7 +385,7 @@ export function createEditorApi(ctx, sessions, agents, log = () => {}) {
358
385
  }
359
386
  const span = shadowSpanFrom(session, userSeq)
360
387
  if (!span) throw editorError('target-shadowed', 'This message is no longer part of the active conversation.')
361
- appendEditorMarker(session, span, 'regenerate', userSeq, text)
388
+ await appendEditorMarker(session, span, 'regenerate', userSeq, text, hooks.validateMarker)
362
389
  await flushSafely(session)
363
390
  const message = resendMessage(text.trim(), 'resend')
364
391
  agent.followup(message)