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.
- package/README.md +40 -27
- package/README.zh.md +37 -28
- package/lib/artifact-store.js +9 -0
- package/lib/client.bundle.js +475 -12
- package/lib/client.js +600 -13
- package/lib/dynamic-client.js +475 -12
- package/lib/dynamic-host.js +107 -80
- package/lib/git-adapter.js +177 -0
- package/lib/host-core.js +107 -80
- package/lib/http.js +104 -3
- package/lib/index.js +15 -2
- package/lib/prewrite-guard.js +71 -0
- package/lib/projection/versions.js +32 -10
- package/lib/rollback.js +283 -0
- package/lib/version-index.js +15 -4
- package/lib/versioning.js +188 -1
- package/package.json +13 -10
package/lib/dynamic-host.js
CHANGED
|
@@ -29,10 +29,112 @@ return {
|
|
|
29
29
|
|
|
30
30
|
const EDITOR_PLUGIN = 'retrace'
|
|
31
31
|
|
|
32
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|