dsh-edit-turn 0.5.0 → 0.5.3
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 +72 -1
- package/lib/client.js +837 -134
- package/lib/index.js +158 -19
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -80,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
|
|
|
80
80
|
export const name = PLUGIN_ID
|
|
81
81
|
|
|
82
82
|
/** Keep in sync with package.json and lib/client.js. */
|
|
83
|
-
export const PLUGIN_VERSION = '0.5.
|
|
83
|
+
export const PLUGIN_VERSION = '0.5.3'
|
|
84
84
|
|
|
85
85
|
// A ring of the last few requests, answering "did the browser half even ask, and
|
|
86
86
|
// what did it get?" without needing its console. Read-only diagnostics: the
|
|
@@ -100,7 +100,15 @@ export function recentRequests() {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const ROUTE_PREFIX = '/dsh-edit-turn'
|
|
103
|
-
|
|
103
|
+
// A session id is an OPAQUE string to DSH: the host's own schema is
|
|
104
|
+
// `z.string().min(1)`, and a named session created in the GUI (`bili-whale`,
|
|
105
|
+
// stored as ~/.dsh/storages/<profile>/sessions/bili-whale.json) is exactly as
|
|
106
|
+
// real as a UUID one. A UUID-only test rejected those with a 400, so the panel
|
|
107
|
+
// answered "状态加载失败" for a session that plainly exists. The character class
|
|
108
|
+
// still refuses everything that could leave a path or a URL: `/`, `\`, `..`
|
|
109
|
+
// (the first character must be alphanumeric, so a leading dot is already out),
|
|
110
|
+
// whitespace and control characters.
|
|
111
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
104
112
|
const MAX_TEXT = 200_000
|
|
105
113
|
const FLUSH_DEADLINE_MS = 5_000
|
|
106
114
|
|
|
@@ -162,6 +170,10 @@ export const Config =
|
|
|
162
170
|
* replay splice otherwise; `patch` refuses to run without the
|
|
163
171
|
* projection; `replay` always uses the old path. Prompt edits always use
|
|
164
172
|
* the official surface replacement.
|
|
173
|
+
*
|
|
174
|
+
* There is deliberately no `replace` value: appending a fresh
|
|
175
|
+
* `assistant/message` with a replace op is refused by the session's own
|
|
176
|
+
* surface validation (see the note in the README).
|
|
165
177
|
*/
|
|
166
178
|
replyMode: z.string().default('auto'),
|
|
167
179
|
/**
|
|
@@ -1679,8 +1691,34 @@ export async function attachmentBytes(ctx, sessionId, seq, index) {
|
|
|
1679
1691
|
async function stateOf(ctx, sessionId, config) {
|
|
1680
1692
|
const events = await readEvents(ctx, sessionId)
|
|
1681
1693
|
if (!events) {
|
|
1682
|
-
|
|
1683
|
-
|
|
1694
|
+
// A named session with nothing in it yet is an EMPTY session, not a failed
|
|
1695
|
+
// one: the editor opens with nothing to edit, and the next refresh picks the
|
|
1696
|
+
// log up once it exists. Answering 404 here is what the panel printed as
|
|
1697
|
+
// "状态加载失败,稍后重试。". The capability probe still runs, because "what
|
|
1698
|
+
// can this deployment do" does not depend on the log.
|
|
1699
|
+
noteRequest({ kind: 'state', sessionId, ok: true, code: 'session-empty' })
|
|
1700
|
+
const store = resolveAttachmentStore(ctx)
|
|
1701
|
+
return {
|
|
1702
|
+
hidden: [],
|
|
1703
|
+
edits: 0,
|
|
1704
|
+
revisions: [],
|
|
1705
|
+
surface: [],
|
|
1706
|
+
turns: [],
|
|
1707
|
+
replies: [],
|
|
1708
|
+
patches: [],
|
|
1709
|
+
mode: config.patch === true ? 'patch' : 'replay',
|
|
1710
|
+
live: false,
|
|
1711
|
+
busy: false,
|
|
1712
|
+
lastSeq: -1,
|
|
1713
|
+
capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
|
|
1714
|
+
config: {
|
|
1715
|
+
confirm: config.confirm,
|
|
1716
|
+
replyMode: config.replyMode,
|
|
1717
|
+
patch: config.patch === true,
|
|
1718
|
+
editorStyle: config.editorStyle,
|
|
1719
|
+
},
|
|
1720
|
+
version: PLUGIN_VERSION,
|
|
1721
|
+
}
|
|
1684
1722
|
}
|
|
1685
1723
|
const folded = foldSurface(events)
|
|
1686
1724
|
const ledger = rollbackLedger(events)
|
|
@@ -1868,7 +1906,9 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
|
|
|
1868
1906
|
}
|
|
1869
1907
|
const events = await readEvents(ctx, sessionId)
|
|
1870
1908
|
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1871
|
-
|
|
1909
|
+
// No busy gate here: this path only appends the in-place patch record, which
|
|
1910
|
+
// leaves every surface node where it is. A running turn keeps writing its own
|
|
1911
|
+
// events; the record lands between them and the fold ignores it.
|
|
1872
1912
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1873
1913
|
const patches = patchesOf(events)
|
|
1874
1914
|
const plans = []
|
|
@@ -1900,7 +1940,11 @@ async function applyTargetsEdit(ctx, sessionId, body, config) {
|
|
|
1900
1940
|
buildPatchData(plans.map(({ plan, text }) => ({ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }))),
|
|
1901
1941
|
)
|
|
1902
1942
|
} catch (error) {
|
|
1903
|
-
|
|
1943
|
+
const message = String((error && error.message) || error)
|
|
1944
|
+
if (isProjectionUnavailable(message)) {
|
|
1945
|
+
throw new HttpError(409, 'projection-missing', 'the edit-turn projection is not registered for this session; restart DSH so the owning plugin registers it, then retry')
|
|
1946
|
+
}
|
|
1947
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
|
|
1904
1948
|
}
|
|
1905
1949
|
const flush = await flushSession(ctx, session)
|
|
1906
1950
|
return {
|
|
@@ -1927,22 +1971,23 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1927
1971
|
}
|
|
1928
1972
|
const events = await readEvents(ctx, sessionId)
|
|
1929
1973
|
if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
|
|
1930
|
-
|
|
1974
|
+
const busy = isBusy(events)
|
|
1931
1975
|
|
|
1932
1976
|
const surfaceNodes = surfaceOf(ctx, sessionId, events)
|
|
1933
1977
|
const patches = patchesOf(events)
|
|
1934
1978
|
const patchMode = config.patch === true
|
|
1979
|
+
const request = {
|
|
1980
|
+
// A block from another step of the same turn names its own seq; that is
|
|
1981
|
+
// the message the projection has to patch.
|
|
1982
|
+
seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
|
|
1983
|
+
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1984
|
+
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1985
|
+
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1986
|
+
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1987
|
+
}
|
|
1935
1988
|
let plan
|
|
1936
1989
|
try {
|
|
1937
|
-
plan = planRollback(events, surfaceNodes, {
|
|
1938
|
-
// A block from another step of the same turn names its own seq; that is
|
|
1939
|
-
// the message the projection has to patch.
|
|
1940
|
-
seq: typeof body.targetSeq === 'number' ? body.targetSeq : typeof body.seq === 'number' ? body.seq : undefined,
|
|
1941
|
-
messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
|
|
1942
|
-
turn: typeof body.turn === 'number' ? body.turn : undefined,
|
|
1943
|
-
blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
|
|
1944
|
-
kind: typeof body.kind === 'string' ? body.kind : undefined,
|
|
1945
|
-
}, { patches, patch: patchMode })
|
|
1990
|
+
plan = planRollback(events, surfaceNodes, request, { patches, patch: patchMode })
|
|
1946
1991
|
} catch (error) {
|
|
1947
1992
|
if (error instanceof EditPlanError) {
|
|
1948
1993
|
const status = error.code === 'not-editable' ? 400 : 409
|
|
@@ -1951,6 +1996,13 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1951
1996
|
throw error
|
|
1952
1997
|
}
|
|
1953
1998
|
|
|
1999
|
+
// A running turn is a hard stop for every path that moves the surface: the
|
|
2000
|
+
// prompt rewrite shadows a message the turn may still be reading, and the
|
|
2001
|
+
// replay splice shadows a window and regenerates the tail. The in-place record
|
|
2002
|
+
// below moves nothing, so it is allowed to land while the agent is still
|
|
2003
|
+
// writing - the model reads it on its next fold.
|
|
2004
|
+
if (busy && plan.patch !== true) throw new HttpError(409, 'busy', 'the session is still working')
|
|
2005
|
+
|
|
1954
2006
|
// The in-place reply path: one small plugin record, interpreted by this
|
|
1955
2007
|
// plugin's projection. Nothing leaves the surface, so there is no window to
|
|
1956
2008
|
// check, no turn to open and no tail to replay. The record is append-only and
|
|
@@ -1980,8 +2032,19 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1980
2032
|
buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
|
|
1981
2033
|
)
|
|
1982
2034
|
} catch (error) {
|
|
1983
|
-
|
|
2035
|
+
const message = String((error && error.message) || error)
|
|
2036
|
+
// The projection is not registered for this session (a live reload that
|
|
2037
|
+
// dropped it). The same edit can land through the replay path, which
|
|
2038
|
+
// needs no projection - so fall through instead of failing the save.
|
|
2039
|
+
if (isProjectionUnavailable(message)) {
|
|
2040
|
+
// The fallback replays the tail, which a running turn forbids.
|
|
2041
|
+
if (busy) throw new HttpError(409, 'busy', 'the session is still working')
|
|
2042
|
+
plan = planRollback(events, surfaceNodes, request, { patches, patch: false })
|
|
2043
|
+
} else {
|
|
2044
|
+
throw new HttpError(409, 'stale', `the session refused the patch record: ${message}`)
|
|
2045
|
+
}
|
|
1984
2046
|
}
|
|
2047
|
+
if (plan.patch === true) {
|
|
1985
2048
|
const flush = await flushSession(ctx, session)
|
|
1986
2049
|
return {
|
|
1987
2050
|
kind: 'reply',
|
|
@@ -1999,6 +2062,7 @@ async function applyEdit(ctx, sessionId, body, config) {
|
|
|
1999
2062
|
shadowed: [],
|
|
2000
2063
|
...flush,
|
|
2001
2064
|
}
|
|
2065
|
+
}
|
|
2002
2066
|
}
|
|
2003
2067
|
|
|
2004
2068
|
// The live surface is the append authority; a node that vanished between the
|
|
@@ -2433,10 +2497,85 @@ function requireSessionId(value) {
|
|
|
2433
2497
|
return value
|
|
2434
2498
|
}
|
|
2435
2499
|
|
|
2500
|
+
/**
|
|
2501
|
+
* Whether the host refused a plugin record because its projection is no longer
|
|
2502
|
+
* registered for this session (a live reload that dropped it, or a session
|
|
2503
|
+
* loaded before the owning plugin). Such a save can still go through the replay
|
|
2504
|
+
* path, so it is not fatal.
|
|
2505
|
+
*
|
|
2506
|
+
* @param message - the host's error text.
|
|
2507
|
+
* @returns true when the record's projection is gone.
|
|
2508
|
+
*/
|
|
2509
|
+
export function isProjectionUnavailable(message) {
|
|
2510
|
+
return typeof message === 'string' && /projection .*was removed or replaced/i.test(message)
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2436
2513
|
function failure(res, error) {
|
|
2514
|
+
const message = String((error && error.message) || error)
|
|
2515
|
+
// The host refuses to derive a session whose projection left the registry
|
|
2516
|
+
// (`SurfaceManager._assertProjections`). It is a known, self-explaining state
|
|
2517
|
+
// - the plugin was reloaded while that session was open - not an internal
|
|
2518
|
+
// fault, so it answers 409 with the host's own words and does not dump a stack
|
|
2519
|
+
// into the log on every request.
|
|
2520
|
+
if (isProjectionUnavailable(message)) {
|
|
2521
|
+
sendJson(res, 409, { ok: false, code: 'projection-missing', error: message })
|
|
2522
|
+
return
|
|
2523
|
+
}
|
|
2437
2524
|
const status = error instanceof HttpError ? error.status : 500
|
|
2438
2525
|
const code = error instanceof HttpError ? error.code : 'internal'
|
|
2439
|
-
|
|
2526
|
+
// An unexpected throw is the only case the client cannot explain; leave the
|
|
2527
|
+
// stack in the host log so a report can name the cause.
|
|
2528
|
+
if (!(error instanceof HttpError)) console.error('[dsh-edit-turn] internal error', error)
|
|
2529
|
+
sendJson(res, status, { ok: false, code, error: message })
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* The one projection object this PROCESS uses, kept across plugin reloads.
|
|
2534
|
+
*
|
|
2535
|
+
* The host registry compares projection IDENTITY, not type:
|
|
2536
|
+
* `SurfaceManager._assertProjections()` (dsh-session/lib/index.js) throws
|
|
2537
|
+
* `session message projection "<type>" was removed or replaced` when a
|
|
2538
|
+
* projection a LIVE session already folded with is no longer in the registry
|
|
2539
|
+
* array, and `registerMessageProjection`'s own docstring states the rule:
|
|
2540
|
+
* "Disposing the contribution makes sessions that used it refuse further
|
|
2541
|
+
* derivation."
|
|
2542
|
+
*
|
|
2543
|
+
* A reload disposes the previous fiber, which splices the old object out of the
|
|
2544
|
+
* registry. Building a fresh object then leaves every open session that carries
|
|
2545
|
+
* a patch event unable to derive anything: the surface read throws, so this
|
|
2546
|
+
* plugin's own save/state paths answer 500, and so does any sibling that reads
|
|
2547
|
+
* `session.surface.nodes` (verified live - dsh-delete-turn's /state answered 500
|
|
2548
|
+
* for such a session after a reload). Reusing one object from a process-global
|
|
2549
|
+
* hands the registry the SAME definition back, so a reloaded plugin keeps the
|
|
2550
|
+
* open sessions foldable.
|
|
2551
|
+
*
|
|
2552
|
+
* The object is a pure, stateless interpreter, which is what makes the reuse
|
|
2553
|
+
* safe. One consequence is deliberate: after a code change the projection keeps
|
|
2554
|
+
* running the PREVIOUS definition for sessions that are already open, until the
|
|
2555
|
+
* session is reopened or DSH restarts. That is the better half of the trade -
|
|
2556
|
+
* the alternative is a session that cannot be read at all.
|
|
2557
|
+
*
|
|
2558
|
+
* @returns the process-wide projection definition.
|
|
2559
|
+
*/
|
|
2560
|
+
function sharedPatchProjection() {
|
|
2561
|
+
const KEY = '__DSH_EDIT_TURN_PATCH_PROJECTION__'
|
|
2562
|
+
try {
|
|
2563
|
+
const stashed = globalThis[KEY]
|
|
2564
|
+
if (
|
|
2565
|
+
stashed !== undefined &&
|
|
2566
|
+
stashed !== null &&
|
|
2567
|
+
typeof stashed.project === 'function' &&
|
|
2568
|
+
stashed.type === PATCH_TYPE
|
|
2569
|
+
) {
|
|
2570
|
+
return stashed
|
|
2571
|
+
}
|
|
2572
|
+
const fresh = patchProjection()
|
|
2573
|
+
globalThis[KEY] = fresh
|
|
2574
|
+
return fresh
|
|
2575
|
+
} catch {
|
|
2576
|
+
// A frozen or absent globalThis is not worth failing the load over.
|
|
2577
|
+
return patchProjection()
|
|
2578
|
+
}
|
|
2440
2579
|
}
|
|
2441
2580
|
|
|
2442
2581
|
// --- plugin ------------------------------------------------------------------
|
|
@@ -2477,7 +2616,7 @@ export function apply(ctx, config) {
|
|
|
2477
2616
|
const existing = Array.isArray(registry)
|
|
2478
2617
|
? registry.find((entry) => entry && entry.type === PATCH_TYPE)
|
|
2479
2618
|
: undefined
|
|
2480
|
-
if (existing === undefined) sessionsService.registerMessageProjection(
|
|
2619
|
+
if (existing === undefined) sessionsService.registerMessageProjection(sharedPatchProjection())
|
|
2481
2620
|
patch = true
|
|
2482
2621
|
} catch (error) {
|
|
2483
2622
|
if (resolved.replyMode === 'patch') throw error
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edit-turn",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands; a model reply block (answer text or reasoning) is patched through a plugin record and a message projection, so the turn stays where it is, later turns are untouched and the log grows by one small record. Falls back to the replay splice on hosts without the projection API. The append-only session log is never rewritten.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|