@mars.liu/dsh-cybernetics 0.2.0 → 0.6.0
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 -2
- package/cordis.patch.yml +18 -1
- package/docs/protocol.md +219 -3
- package/lib/client.js +231 -13
- package/package.json +13 -1
- package/src/controller.ts +113 -3
- package/src/executor.ts +414 -0
- package/src/index.ts +610 -42
- package/src/projection.ts +763 -0
- package/src/upstream.d.ts +22 -0
- package/workflows/round.js +101 -1
- package/test/v03-offline.mjs +0 -203
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session projection that carries the plugin's WHOLE multi-goal board to
|
|
3
|
+
* the client, plus the whole-value event writer that feeds it.
|
|
4
|
+
*
|
|
5
|
+
* Why a projection and not a route: dsh-goal's own `goal` projection is
|
|
6
|
+
* single-slot (one focus goal per session), so the「目标 / Goal」tab could
|
|
7
|
+
* never show the `cyb-*` goals this plugin keeps in its own `boards` map.
|
|
8
|
+
* The host's projection registry (ctx.sessionProjections) is the sanctioned
|
|
9
|
+
* seam: one pure synchronous fold per registered key, driven eagerly over
|
|
10
|
+
* committed session events, with the client value pushed live — no polling.
|
|
11
|
+
*
|
|
12
|
+
* Whole-value event rule (upstream contract, load-bearing): a state-carrying
|
|
13
|
+
* event MUST carry the complete post-change state, never a delta. Every
|
|
14
|
+
* publish therefore snapshots the session's entire goal board.
|
|
15
|
+
*
|
|
16
|
+
* Zero runtime dependencies: the real `ProjectionDefinition` comes from
|
|
17
|
+
* @deepseek-ai/dsh-session-projection (a Zod schema and a Zod view schema).
|
|
18
|
+
* That package is not installed in this checkout (the plugin is `link:`ed
|
|
19
|
+
* into a dsh profile), and the host only ever calls `.parse(...)` on both
|
|
20
|
+
* schemas, so a duck-typed `{ parse: (v) => v }` is behaviourally exact.
|
|
21
|
+
* `src/upstream.d.ts` declares the two host modules just so the type-merge
|
|
22
|
+
* below type-checks standalone; the shape mirrors upstream's own
|
|
23
|
+
* merge-extensible tables.
|
|
24
|
+
* @module dsh-cybernetics/projection
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { appendFileSync, existsSync, mkdirSync, realpathSync } from 'node:fs'
|
|
28
|
+
import { createRequire } from 'node:module'
|
|
29
|
+
import { dirname, join } from 'node:path'
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
31
|
+
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
|
|
32
|
+
import type { LoopState } from './controller.ts'
|
|
33
|
+
|
|
34
|
+
/** One goal revision (v0.6 outer loop: agent proposes, the user confirms). */
|
|
35
|
+
export interface GoalRevisionView {
|
|
36
|
+
/** Monotonic revision number within the goal. */
|
|
37
|
+
version: number
|
|
38
|
+
/** Why the goal changed. */
|
|
39
|
+
reason: string
|
|
40
|
+
/** Epoch milliseconds of the revision. */
|
|
41
|
+
at: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Lifecycle as the tab renders it. */
|
|
45
|
+
export type GoalStatus = 'running' | 'paused' | 'met' | 'cancelled' | 'superseded' | 'failed' | 'pending'
|
|
46
|
+
|
|
47
|
+
/** Execution substrate (v0.6: orthogonal to depth). */
|
|
48
|
+
export type GoalExecutor = 'ephemeral' | 'persistent'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Statuses that END a goal's life. The tab's two-segment ordering puts the
|
|
52
|
+
* unfinished group first and this group last (user 2026-11-02 append-only model).
|
|
53
|
+
*/
|
|
54
|
+
export const COMPLETED_GOAL_STATUSES: ReadonlySet<GoalStatus> = new Set<GoalStatus>(['met', 'cancelled', 'superseded'])
|
|
55
|
+
|
|
56
|
+
/** One goal as the client tab renders it (wire-safe plain JSON). */
|
|
57
|
+
export interface GoalView {
|
|
58
|
+
goalId: string
|
|
59
|
+
title: string
|
|
60
|
+
objective: string
|
|
61
|
+
status: GoalStatus
|
|
62
|
+
depth: 'quick' | 'full'
|
|
63
|
+
executor: GoalExecutor
|
|
64
|
+
/**
|
|
65
|
+
* Name of the durable Agent Teams teammate executing this goal (v0.6
|
|
66
|
+
* `executor: 'persistent'`). Present only once a persistent round has
|
|
67
|
+
* provisioned the member; ephemeral goals never carry it.
|
|
68
|
+
*/
|
|
69
|
+
memberName?: string
|
|
70
|
+
/** Rounds completed (the next round is `round + 1`). */
|
|
71
|
+
round: number
|
|
72
|
+
/** Current subgoal of the inner correction loop. */
|
|
73
|
+
subgoal?: string
|
|
74
|
+
/** Append-only lineage: the goal this one was revised from. */
|
|
75
|
+
parentGoalId?: string
|
|
76
|
+
/** Set when a revision replaced this goal: the successor's goalId. */
|
|
77
|
+
supersededBy?: string
|
|
78
|
+
revisions: GoalRevisionView[]
|
|
79
|
+
/** Epoch milliseconds of the last content change (ordering key for the tab). */
|
|
80
|
+
updatedAt: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Fold state / wire value of the `cyberneticsGoals` projection. */
|
|
84
|
+
export interface CyberneticsGoalsState {
|
|
85
|
+
goals: GoalView[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Type merge into the host's projection tables (compile-time only).
|
|
89
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
90
|
+
interface SessionProjectionStateMap {
|
|
91
|
+
cyberneticsGoals: CyberneticsGoalsState
|
|
92
|
+
}
|
|
93
|
+
interface SessionProjectionMap {
|
|
94
|
+
cyberneticsGoals: CyberneticsGoalsState
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Type merge for the session event this plugin appends. The host accepts any
|
|
99
|
+
// registered event type at runtime (`Session.append(type, data)`); the merge
|
|
100
|
+
// keeps that call site typed instead of `any`.
|
|
101
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
102
|
+
interface PluginRecordMap {
|
|
103
|
+
'plugin:cybernetics/goals': CyberneticsGoalsState
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Projection key; also the session event type (one key, one event). */
|
|
108
|
+
export const PROJECTION_KEY = 'cyberneticsGoals' as const
|
|
109
|
+
/**
|
|
110
|
+
* The persisted record type. The host's persistence loader FAIL-CLOSES on an
|
|
111
|
+
* unknown, non-ignorable session event (validateStoredEvents →
|
|
112
|
+
* SessionFormatUnsupportedError), so this board must travel as a plugin
|
|
113
|
+
* record: `appendPluginRecord` writes `{type, data, ignorable: true}`, which
|
|
114
|
+
* every reader may safely skip when it does not know the type.
|
|
115
|
+
*/
|
|
116
|
+
export const EVENT_TYPE = 'plugin:cybernetics/goals' as const
|
|
117
|
+
/** Pre-fix event name; accepted on restore so older logs still fold. */
|
|
118
|
+
export const LEGACY_EVENT_TYPE = 'cybernetics/goals' as const
|
|
119
|
+
/** Persisted-cache invalidation version of the fold. */
|
|
120
|
+
export const STATE_VERSION = 2
|
|
121
|
+
|
|
122
|
+
/** Duck-typed Zod stand-in: the host only calls `parse`. */
|
|
123
|
+
interface SchemaLike<T> {
|
|
124
|
+
parse(value: unknown): T
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const passThrough = <T>(): SchemaLike<T> => ({ parse: (value) => value as T })
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Structural replica of the host's `ProjectionDefinition` (fields we use).
|
|
131
|
+
* Kept local because the upstream package is not a dependency of this
|
|
132
|
+
* zero-dep plugin; `cyberneticsGoalsProjection()` is handed to
|
|
133
|
+
* `ctx.sessionProjections.register()` where the host's own typing applies.
|
|
134
|
+
*/
|
|
135
|
+
export interface ProjectionDefinitionLike {
|
|
136
|
+
key: typeof PROJECTION_KEY
|
|
137
|
+
stateSchema: SchemaLike<CyberneticsGoalsState>
|
|
138
|
+
init(header: unknown, inheritedEventCount: number): CyberneticsGoalsState
|
|
139
|
+
apply(state: CyberneticsGoalsState, event: { type: string; data?: unknown }): CyberneticsGoalsState
|
|
140
|
+
wire: {
|
|
141
|
+
viewSchema: SchemaLike<CyberneticsGoalsState>
|
|
142
|
+
view(state: CyberneticsGoalsState): CyberneticsGoalsState
|
|
143
|
+
}
|
|
144
|
+
stateVersion: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Empty fold state (a fresh session with no goals yet). */
|
|
148
|
+
export function emptyGoalsState(): CyberneticsGoalsState {
|
|
149
|
+
return { goals: [] }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isGoalView(value: unknown): value is GoalView {
|
|
153
|
+
if (value === null || typeof value !== 'object') return false
|
|
154
|
+
const g = value as Record<string, unknown>
|
|
155
|
+
return typeof g.goalId === 'string' && typeof g.status === 'string'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Normalize one wire goal into a fresh plain-JSON object. */
|
|
159
|
+
function normalizeGoal(goal: GoalView): GoalView {
|
|
160
|
+
return {
|
|
161
|
+
goalId: String(goal.goalId),
|
|
162
|
+
title: typeof goal.title === 'string' ? goal.title : String(goal.goalId),
|
|
163
|
+
objective: typeof goal.objective === 'string' ? goal.objective : '',
|
|
164
|
+
status: goal.status,
|
|
165
|
+
depth: goal.depth === 'quick' ? 'quick' : 'full',
|
|
166
|
+
executor: goal.executor === 'persistent' ? 'persistent' : 'ephemeral',
|
|
167
|
+
...(typeof goal.memberName === 'string' && goal.memberName.length > 0
|
|
168
|
+
? { memberName: goal.memberName }
|
|
169
|
+
: {}),
|
|
170
|
+
round: Number.isFinite(goal.round) ? Number(goal.round) : 0,
|
|
171
|
+
...(typeof goal.subgoal === 'string' && goal.subgoal.length > 0 ? { subgoal: goal.subgoal } : {}),
|
|
172
|
+
...(typeof goal.parentGoalId === 'string' && goal.parentGoalId.length > 0
|
|
173
|
+
? { parentGoalId: goal.parentGoalId }
|
|
174
|
+
: {}),
|
|
175
|
+
...(typeof goal.supersededBy === 'string' && goal.supersededBy.length > 0
|
|
176
|
+
? { supersededBy: goal.supersededBy }
|
|
177
|
+
: {}),
|
|
178
|
+
revisions: Array.isArray(goal.revisions)
|
|
179
|
+
? goal.revisions.map((r) => ({
|
|
180
|
+
version: Number.isFinite(r?.version) ? Number(r.version) : 0,
|
|
181
|
+
reason: typeof r?.reason === 'string' ? r.reason : '',
|
|
182
|
+
at: Number.isFinite(r?.at) ? Number(r.at) : 0,
|
|
183
|
+
}))
|
|
184
|
+
: [],
|
|
185
|
+
updatedAt: Number.isFinite(goal.updatedAt) ? Number(goal.updatedAt) : 0,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The `cyberneticsGoals` projection unit: `{goals: [...]}` rebuilt from each
|
|
191
|
+
* whole-value `cybernetics/goals` event, byte-identical for unrelated events
|
|
192
|
+
* (same state reference — the host's zero-work fast path).
|
|
193
|
+
*/
|
|
194
|
+
export function cyberneticsGoalsProjection(): ProjectionDefinitionLike {
|
|
195
|
+
return {
|
|
196
|
+
key: PROJECTION_KEY,
|
|
197
|
+
stateSchema: passThrough<CyberneticsGoalsState>(),
|
|
198
|
+
init: () => emptyGoalsState(),
|
|
199
|
+
apply(state, event) {
|
|
200
|
+
if (event?.type !== EVENT_TYPE && event?.type !== LEGACY_EVENT_TYPE) return state
|
|
201
|
+
const data = event.data as CyberneticsGoalsState | undefined
|
|
202
|
+
if (data === null || typeof data !== 'object' || !Array.isArray(data.goals)) return state
|
|
203
|
+
const goals = data.goals.filter(isGoalView).map(normalizeGoal)
|
|
204
|
+
// A fresh object per owned event: the wire view is the state itself, and
|
|
205
|
+
// the host republishes only when the view reference changes.
|
|
206
|
+
return { goals }
|
|
207
|
+
},
|
|
208
|
+
wire: {
|
|
209
|
+
viewSchema: passThrough<CyberneticsGoalsState>(),
|
|
210
|
+
view: (state) => state,
|
|
211
|
+
},
|
|
212
|
+
stateVersion: STATE_VERSION,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Content fingerprint -> last-change timestamp, per loop object. `updatedAt`
|
|
218
|
+
* must move only when a goal's content moves (the tab sorts by it, and an
|
|
219
|
+
* unchanged republish must stay a no-op); deriving it from the fingerprint
|
|
220
|
+
* keeps that invariant in one place instead of sprinkling clock writes over
|
|
221
|
+
* every mutation site.
|
|
222
|
+
*/
|
|
223
|
+
const goalStamps = new WeakMap<object, { fingerprint: string; at: number }>()
|
|
224
|
+
|
|
225
|
+
function statusOf(loop: LoopState): GoalStatus {
|
|
226
|
+
const status = loop.status
|
|
227
|
+
if (status === undefined) return 'pending'
|
|
228
|
+
if (status === 'paused' && loop.paused?.reason === 'failed') return 'failed'
|
|
229
|
+
return status
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Project one loop onto the client view (fresh plain JSON). */
|
|
233
|
+
function toGoalView(loop: LoopState, now: number): GoalView {
|
|
234
|
+
const goalId = loop.goalId ?? ''
|
|
235
|
+
const revisions = Array.isArray(loop.revisions) ? loop.revisions : []
|
|
236
|
+
const view: GoalView = {
|
|
237
|
+
goalId,
|
|
238
|
+
title: loop.title ?? goalId,
|
|
239
|
+
objective: loop.objective ?? '',
|
|
240
|
+
status: statusOf(loop),
|
|
241
|
+
depth: loop.depth === 'quick' ? 'quick' : 'full',
|
|
242
|
+
executor: loop.executor === 'persistent' ? 'persistent' : 'ephemeral',
|
|
243
|
+
...(loop.memberName ? { memberName: loop.memberName } : {}),
|
|
244
|
+
round: Number.isFinite(loop.round) ? loop.round : 0,
|
|
245
|
+
...(loop.subgoal ? { subgoal: loop.subgoal } : {}),
|
|
246
|
+
...(loop.parentGoalId ? { parentGoalId: loop.parentGoalId } : {}),
|
|
247
|
+
...(loop.supersededBy ? { supersededBy: loop.supersededBy } : {}),
|
|
248
|
+
revisions: revisions.map((r) => ({ version: r.version, reason: r.reason, at: r.at })),
|
|
249
|
+
updatedAt: 0,
|
|
250
|
+
}
|
|
251
|
+
const fingerprint = JSON.stringify({ ...view, updatedAt: 0 })
|
|
252
|
+
const stamp = goalStamps.get(loop)
|
|
253
|
+
const at = stamp !== undefined && stamp.fingerprint === fingerprint ? stamp.at : now
|
|
254
|
+
goalStamps.set(loop, { fingerprint, at })
|
|
255
|
+
return { ...view, updatedAt: at }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Tab order (user 2026-11-02): unfinished goals first, finished goals after;
|
|
260
|
+
* inside each segment the most recently changed goal leads. Total and stable:
|
|
261
|
+
* `goalId` breaks exact `updatedAt` ties so the wire value never flips between
|
|
262
|
+
* publishes that carry identical timestamps.
|
|
263
|
+
*/
|
|
264
|
+
function compareGoals(a: GoalView, b: GoalView): number {
|
|
265
|
+
const groupA = COMPLETED_GOAL_STATUSES.has(a.status) ? 1 : 0
|
|
266
|
+
const groupB = COMPLETED_GOAL_STATUSES.has(b.status) ? 1 : 0
|
|
267
|
+
if (groupA !== groupB) return groupA - groupB
|
|
268
|
+
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt
|
|
269
|
+
return a.goalId.localeCompare(b.goalId)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Snapshot one session's whole goal board as the projection's wire value.
|
|
274
|
+
* The array is pre-sorted (two segments, see `compareGoals`) so the client can
|
|
275
|
+
* render it verbatim without re-ordering.
|
|
276
|
+
* @param loops - every loop on the session's board.
|
|
277
|
+
* @param now - epoch milliseconds (injectable for tests).
|
|
278
|
+
*/
|
|
279
|
+
export function goalsFromBoard(loops: Iterable<LoopState>, now = Date.now()): CyberneticsGoalsState {
|
|
280
|
+
const goals: GoalView[] = []
|
|
281
|
+
for (const loop of loops) {
|
|
282
|
+
if (loop.goalId === undefined) continue
|
|
283
|
+
goals.push(toGoalView(loop, now))
|
|
284
|
+
}
|
|
285
|
+
goals.sort(compareGoals)
|
|
286
|
+
return { goals }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Last successfully published JSON per session (idempotence guard). */
|
|
290
|
+
const published = new WeakMap<object, string>()
|
|
291
|
+
|
|
292
|
+
/** Record appender seam. */
|
|
293
|
+
export type RecordAppender = (session: unknown, type: string, data: unknown) => unknown
|
|
294
|
+
|
|
295
|
+
const MODULE_SPECIFIER = '@deepseek-ai/dsh-session'
|
|
296
|
+
/** Directory (inside a workspace root) that receives the record. */
|
|
297
|
+
export const DEBUG_DIR = '.cybernetics'
|
|
298
|
+
/** File name of the resolution/publish record. */
|
|
299
|
+
export const DEBUG_FILE = 'publish-debug.log'
|
|
300
|
+
/** Longest error excerpt kept per candidate in the publish record. */
|
|
301
|
+
export const MAX_DEBUG_ERROR_CHARS = 300
|
|
302
|
+
|
|
303
|
+
function errorText(error: unknown): string {
|
|
304
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function truncate(text: string, max: number): string {
|
|
308
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Outcome of resolving the host's own copy of `@deepseek-ai/dsh-session`. */
|
|
312
|
+
export interface HostSessionResolution {
|
|
313
|
+
/** Entry script the host resolution started from (`process.argv[1]`). */
|
|
314
|
+
argv1: string | undefined
|
|
315
|
+
/** File this module's static import binds to (its own copy). */
|
|
316
|
+
ownPath: string | undefined
|
|
317
|
+
/** File the host's entry script resolves `@deepseek-ai/dsh-session` to. */
|
|
318
|
+
hostPath: string | undefined
|
|
319
|
+
/**
|
|
320
|
+
* True when the host copy is a DIFFERENT file from this module's static
|
|
321
|
+
* import. Diagnostic only: both dynamic candidates are loaded regardless
|
|
322
|
+
* (a same-file resolution never proved the Session brand matched).
|
|
323
|
+
*/
|
|
324
|
+
needsHostImport: boolean
|
|
325
|
+
/** The base that actually resolved (differs from `argv1` for a launcher symlink). */
|
|
326
|
+
resolvedFrom?: string
|
|
327
|
+
/** Resolution failure text (diagnostics only, never payload data). */
|
|
328
|
+
error?: string
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Resolve the host's copy of `@deepseek-ai/dsh-session` WITHOUT importing it.
|
|
333
|
+
*
|
|
334
|
+
* Why this is load-bearing: the plugin is loaded through the profile's
|
|
335
|
+
* `node_modules` (a symlink), while the running host keeps its Session class
|
|
336
|
+
* in the copy bundled with the `dsh` install. `appendPluginRecord` commits
|
|
337
|
+
* through a module-private `#appendRecord`, and a Session instance from the
|
|
338
|
+
* other copy cannot satisfy that brand — the call throws
|
|
339
|
+
* `TypeError: Receiver must be an instance of class Session`, no
|
|
340
|
+
* `plugin:cybernetics/goals` event lands, and the「目标」tab silently falls
|
|
341
|
+
* back to dsh-goal's single slot.
|
|
342
|
+
*
|
|
343
|
+
* Resolution starts from the host's entry script (`process.argv[1]`), which is
|
|
344
|
+
* how the host itself finds the module — but node keeps `argv[1]` as the
|
|
345
|
+
* INVOKED path, and `dsh` is normally started through a launcher symlink
|
|
346
|
+
* (`/opt/homebrew/bin/dsh -> .../dsh/lib/bin.js`). Resolving from the symlink's
|
|
347
|
+
* own directory cannot find the package, so the realpath is tried next. Pure:
|
|
348
|
+
* only `require.resolve` runs, so tests can drive it with a fake `argv1`.
|
|
349
|
+
* @param argv1 - entry script; defaults to `process.argv[1]`.
|
|
350
|
+
* @param selfUrl - this module's URL; defaults to `import.meta.url`.
|
|
351
|
+
*/
|
|
352
|
+
export function resolveHostSessionCopy(
|
|
353
|
+
argv1: string | undefined = process.argv[1],
|
|
354
|
+
selfUrl: string = import.meta.url,
|
|
355
|
+
): HostSessionResolution {
|
|
356
|
+
const entry = typeof argv1 === 'string' && argv1.length > 0 ? argv1 : undefined
|
|
357
|
+
const bases: string[] = []
|
|
358
|
+
if (entry === undefined) {
|
|
359
|
+
bases.push(selfUrl)
|
|
360
|
+
} else {
|
|
361
|
+
bases.push(entry)
|
|
362
|
+
try {
|
|
363
|
+
const real = realpathSync(entry)
|
|
364
|
+
if (real !== entry) bases.push(real)
|
|
365
|
+
} catch {
|
|
366
|
+
// Not a readable file: nothing to follow, the entry base stays alone.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
let ownPath: string | undefined
|
|
370
|
+
let ownError: string | undefined
|
|
371
|
+
try {
|
|
372
|
+
// `require.resolve` returns the realpath, exactly like the ESM resolver
|
|
373
|
+
// that bound this module's static import — so the two are comparable.
|
|
374
|
+
ownPath = createRequire(selfUrl).resolve(MODULE_SPECIFIER)
|
|
375
|
+
} catch (cause) {
|
|
376
|
+
ownError = `own copy: ${errorText(cause)}`
|
|
377
|
+
}
|
|
378
|
+
let hostPath: string | undefined
|
|
379
|
+
let resolvedFrom: string | undefined
|
|
380
|
+
const failures: string[] = []
|
|
381
|
+
for (const base of bases) {
|
|
382
|
+
try {
|
|
383
|
+
hostPath = createRequire(base).resolve(MODULE_SPECIFIER)
|
|
384
|
+
resolvedFrom = base
|
|
385
|
+
break
|
|
386
|
+
} catch (cause) {
|
|
387
|
+
failures.push(`${base}: ${errorText(cause)}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const needsHostImport = hostPath !== undefined && hostPath !== ownPath
|
|
391
|
+
// Only a hard failure is an `error`: an unresolvable INVOKED path followed by
|
|
392
|
+
// a successful realpath (the launcher symlink) is the normal host shape.
|
|
393
|
+
const problems = [...(ownError !== undefined ? [ownError] : []), ...failures]
|
|
394
|
+
return {
|
|
395
|
+
argv1: entry,
|
|
396
|
+
ownPath,
|
|
397
|
+
hostPath,
|
|
398
|
+
needsHostImport,
|
|
399
|
+
...(resolvedFrom !== undefined ? { resolvedFrom } : {}),
|
|
400
|
+
...(hostPath === undefined && problems.length > 0 ? { error: problems.join('; ') } : {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Resolution performed once, at module init, from the host's entry script. */
|
|
405
|
+
const resolution = resolveHostSessionCopy()
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolve the copy of `@deepseek-ai/dsh-session` that sits under THIS package's
|
|
409
|
+
* own `node_modules` (this repo checks that path in as a symlink to the
|
|
410
|
+
* profile's install). It is a separate candidate from the host entry's copy:
|
|
411
|
+
* the Session instance the plugin is handed may come from either install, and
|
|
412
|
+
* only the copy that DEFINED the instance's class can pass
|
|
413
|
+
* `appendPluginRecord`'s private-field brand check.
|
|
414
|
+
*
|
|
415
|
+
* Zero-dep and pure: existence checks only, no import. Walks up to the package
|
|
416
|
+
* root (the first ancestor with a `package.json`) and then looks for the
|
|
417
|
+
* conventional nested path there.
|
|
418
|
+
* @param selfUrl - this module's URL; defaults to `import.meta.url`.
|
|
419
|
+
*/
|
|
420
|
+
export function resolvePluginSessionCopy(selfUrl: string = import.meta.url): string | undefined {
|
|
421
|
+
try {
|
|
422
|
+
let dir = dirname(fileURLToPath(selfUrl))
|
|
423
|
+
let packageRoot: string | undefined
|
|
424
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
425
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
426
|
+
packageRoot = dir
|
|
427
|
+
break
|
|
428
|
+
}
|
|
429
|
+
const parent = dirname(dir)
|
|
430
|
+
if (parent === dir) break
|
|
431
|
+
dir = parent
|
|
432
|
+
}
|
|
433
|
+
if (packageRoot === undefined) return undefined
|
|
434
|
+
const candidate = join(packageRoot, 'node_modules', '@deepseek-ai', 'dsh-session', 'lib', 'index.js')
|
|
435
|
+
return existsSync(candidate) ? candidate : undefined
|
|
436
|
+
} catch {
|
|
437
|
+
// A virtual/foreign URL: no third candidate, the other two still try.
|
|
438
|
+
return undefined
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** One module copy loaded (or being loaded) for the appender candidates. */
|
|
443
|
+
interface DynamicCopy {
|
|
444
|
+
/** The callable once the import settled successfully. */
|
|
445
|
+
appender?: RecordAppender
|
|
446
|
+
/** Load failure text (diagnostics only, never payload data). */
|
|
447
|
+
error?: string
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Loaded copies by realpath: one import per distinct file. */
|
|
451
|
+
const dynamicCopies = new Map<string, DynamicCopy>()
|
|
452
|
+
/** Dynamic imports still in flight. */
|
|
453
|
+
let dynamicPending = 0
|
|
454
|
+
/** True once every dynamic candidate settled (successfully or not). */
|
|
455
|
+
let hostSettled = false
|
|
456
|
+
/** Workspace root for the records; supplied by the caller. */
|
|
457
|
+
let debugRoot: string | undefined
|
|
458
|
+
/** Roots already carrying the one-shot resolution line. */
|
|
459
|
+
const debugWritten = new Set<string>()
|
|
460
|
+
|
|
461
|
+
/** Append one JSON line to `<workspace>/.cybernetics/publish-debug.log`. */
|
|
462
|
+
function appendDebugRecord(record: Record<string, unknown>): void {
|
|
463
|
+
const root = debugRoot
|
|
464
|
+
if (root === undefined) return
|
|
465
|
+
try {
|
|
466
|
+
mkdirSync(join(root, DEBUG_DIR), { recursive: true })
|
|
467
|
+
appendFileSync(join(root, DEBUG_DIR, DEBUG_FILE), `${JSON.stringify(record)}\n`)
|
|
468
|
+
} catch {
|
|
469
|
+
// A read-only workspace still publishes; the record is best-effort.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** One appender candidate, tried in list order until one accepts the record. */
|
|
474
|
+
export interface AppenderCandidate {
|
|
475
|
+
/** 0-based position in the active list (part of the debug label). */
|
|
476
|
+
index: number
|
|
477
|
+
/** Where the appender comes from. */
|
|
478
|
+
kind: 'static' | 'host' | 'plugin' | 'test'
|
|
479
|
+
/** Path summary of the module behind it (a path, never payload data). */
|
|
480
|
+
path: string
|
|
481
|
+
/** The callable, or undefined while its dynamic import is still pending. */
|
|
482
|
+
get(): RecordAppender | undefined
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Test-only shape: one appender with the label it reports. */
|
|
486
|
+
export interface AppenderCandidateInput {
|
|
487
|
+
kind?: 'static' | 'host' | 'plugin' | 'test'
|
|
488
|
+
path?: string
|
|
489
|
+
append: RecordAppender
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function candidateLabel(candidate: AppenderCandidate): string {
|
|
493
|
+
return `#${candidate.index} ${candidate.kind} ${candidate.path}`
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Start the dynamic import of one module copy.
|
|
498
|
+
*
|
|
499
|
+
* Deliberately ungated: BOTH dynamic candidates are always attempted. The old
|
|
500
|
+
* `needsHostImport === false → skip` short-circuit is exactly what left the
|
|
501
|
+
* host copy unloaded (`needsHostImport:false, hostAppendReady:false` in
|
|
502
|
+
* publish-debug.log) while the statically imported copy kept rejecting the
|
|
503
|
+
* host's Session with `TypeError: Receiver must be an instance of class
|
|
504
|
+
* Session`. Distinct files are deduplicated by realpath so one file is
|
|
505
|
+
* imported at most once.
|
|
506
|
+
*/
|
|
507
|
+
function loadDynamicCopy(
|
|
508
|
+
path: string | undefined,
|
|
509
|
+
label: string,
|
|
510
|
+
): { real: string; copy: DynamicCopy } | undefined {
|
|
511
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
512
|
+
let real: string
|
|
513
|
+
try {
|
|
514
|
+
real = realpathSync(path)
|
|
515
|
+
} catch {
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
const existing = dynamicCopies.get(real)
|
|
519
|
+
if (existing !== undefined) return { real, copy: existing }
|
|
520
|
+
const copy: DynamicCopy = {}
|
|
521
|
+
dynamicCopies.set(real, copy)
|
|
522
|
+
dynamicPending += 1
|
|
523
|
+
// Fire-and-forget: publishing must never wait on a module load; a candidate
|
|
524
|
+
// whose import is still pending is simply skipped for that publish.
|
|
525
|
+
import(pathToFileURL(real).href)
|
|
526
|
+
.then((loaded: { appendPluginRecord?: unknown }) => {
|
|
527
|
+
if (typeof loaded.appendPluginRecord === 'function') {
|
|
528
|
+
copy.appender = loaded.appendPluginRecord as RecordAppender
|
|
529
|
+
} else {
|
|
530
|
+
copy.error = `${label}: module exports no appendPluginRecord`
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
.catch((cause: unknown) => {
|
|
534
|
+
copy.error = `${label}: ${errorText(cause)}`
|
|
535
|
+
})
|
|
536
|
+
.finally(() => {
|
|
537
|
+
dynamicPending -= 1
|
|
538
|
+
settleDynamicImports()
|
|
539
|
+
})
|
|
540
|
+
return { real, copy }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function settleDynamicImports(): void {
|
|
544
|
+
if (hostSettled || dynamicPending > 0) return
|
|
545
|
+
hostSettled = true
|
|
546
|
+
flushPublishDebug()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Load errors across the dynamic candidates (diagnostics only). */
|
|
550
|
+
function dynamicLoadError(): string | undefined {
|
|
551
|
+
const errors: string[] = []
|
|
552
|
+
for (const copy of dynamicCopies.values()) {
|
|
553
|
+
if (typeof copy.error === 'string') errors.push(copy.error)
|
|
554
|
+
}
|
|
555
|
+
return errors.length > 0 ? errors.join('; ') : undefined
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Both dynamic candidates are loaded unconditionally (dedup is by realpath,
|
|
559
|
+
// inside `loadDynamicCopy`); the static import stays first in the list.
|
|
560
|
+
const pluginSessionCopyPath = resolvePluginSessionCopy()
|
|
561
|
+
const hostCopy = loadDynamicCopy(resolution.hostPath, 'host copy')
|
|
562
|
+
const pluginCopy = loadDynamicCopy(pluginSessionCopyPath, 'plugin copy')
|
|
563
|
+
|
|
564
|
+
function buildProductionCandidates(): AppenderCandidate[] {
|
|
565
|
+
const raw: Array<Pick<AppenderCandidate, 'kind' | 'path' | 'get'>> = [
|
|
566
|
+
{
|
|
567
|
+
kind: 'static',
|
|
568
|
+
path: resolution.ownPath ?? 'static import',
|
|
569
|
+
get: () => appendPluginRecord as unknown as RecordAppender,
|
|
570
|
+
},
|
|
571
|
+
]
|
|
572
|
+
if (hostCopy !== undefined) {
|
|
573
|
+
raw.push({ kind: 'host', path: hostCopy.real, get: () => hostCopy.copy.appender })
|
|
574
|
+
}
|
|
575
|
+
if (pluginCopy !== undefined) {
|
|
576
|
+
raw.push({ kind: 'plugin', path: pluginCopy.real, get: () => pluginCopy.copy.appender })
|
|
577
|
+
}
|
|
578
|
+
return raw.map((candidate, index) => ({ ...candidate, index }))
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const productionCandidates: AppenderCandidate[] = buildProductionCandidates()
|
|
582
|
+
|
|
583
|
+
// No dynamic candidate at all → the resolution record is ready immediately;
|
|
584
|
+
// otherwise it is flushed when the last in-flight import settles.
|
|
585
|
+
settleDynamicImports()
|
|
586
|
+
|
|
587
|
+
/** Test-only candidate override; `undefined` restores production. */
|
|
588
|
+
let testCandidates: AppenderCandidate[] | undefined
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Test seam: replace the candidate sequence (pass undefined to restore the
|
|
592
|
+
* production list). Lets a test prove the fallback chain — first candidate
|
|
593
|
+
* rejects the Session brand, second accepts — without a second real install.
|
|
594
|
+
*/
|
|
595
|
+
export function setAppenderCandidatesForTest(inputs: readonly AppenderCandidateInput[] | undefined): void {
|
|
596
|
+
if (inputs === undefined) {
|
|
597
|
+
testCandidates = undefined
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
testCandidates = inputs.map((input, index) => ({
|
|
601
|
+
index,
|
|
602
|
+
kind: input.kind ?? 'test',
|
|
603
|
+
path: input.path ?? input.kind ?? `candidate-${index}`,
|
|
604
|
+
get: () => input.append,
|
|
605
|
+
}))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function activeCandidates(): AppenderCandidate[] {
|
|
609
|
+
return testCandidates ?? productionCandidates
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
interface AttemptReport {
|
|
613
|
+
/** Debug label of the candidate that accepted the record (null on failure). */
|
|
614
|
+
candidate: string | null
|
|
615
|
+
/** One truncated error excerpt per rejected candidate. */
|
|
616
|
+
errors: string[]
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Outcome of the last `defaultAppender` call (read by `publishGoals`). */
|
|
620
|
+
let lastAttempt: AttemptReport | undefined
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Append the resolution/load outcome to
|
|
624
|
+
* `<workspace>/.cybernetics/publish-debug.log` (one JSON line, append mode).
|
|
625
|
+
* Written at most once per workspace root, as soon as both the root and the
|
|
626
|
+
* dynamic-candidate outcome are known — a multi-workspace host gets one line
|
|
627
|
+
* per workspace, each readable after a restart. Observability only: any
|
|
628
|
+
* failure is ignored.
|
|
629
|
+
*/
|
|
630
|
+
function flushPublishDebug(): void {
|
|
631
|
+
const root = debugRoot
|
|
632
|
+
if (root === undefined || !hostSettled || debugWritten.has(root)) return
|
|
633
|
+
debugWritten.add(root)
|
|
634
|
+
appendDebugRecord({
|
|
635
|
+
at: new Date().toISOString(),
|
|
636
|
+
event: 'host-session',
|
|
637
|
+
specifier: MODULE_SPECIFIER,
|
|
638
|
+
argv1: resolution.argv1 ?? null,
|
|
639
|
+
ownPath: resolution.ownPath ?? null,
|
|
640
|
+
hostPath: resolution.hostPath ?? null,
|
|
641
|
+
resolvedFrom: resolution.resolvedFrom ?? null,
|
|
642
|
+
needsHostImport: resolution.needsHostImport,
|
|
643
|
+
hostAppendReady: hostCopy?.copy.appender !== undefined,
|
|
644
|
+
pluginPath: pluginSessionCopyPath ?? null,
|
|
645
|
+
pluginAppendReady: pluginCopy?.copy.appender !== undefined,
|
|
646
|
+
error: resolution.error ?? dynamicLoadError() ?? null,
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Point the records at a workspace root (only the caller knows it — a session
|
|
652
|
+
* header's `cwd`, or dsh's working directory service). The resolution line is
|
|
653
|
+
* idempotent per root; publish lines are appended per publish.
|
|
654
|
+
*/
|
|
655
|
+
export function setPublishDebugRoot(root: string | undefined): void {
|
|
656
|
+
if (typeof root !== 'string' || root.length === 0) return
|
|
657
|
+
debugRoot = root
|
|
658
|
+
flushPublishDebug()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Diagnostic view of the copy resolution / load state (no side effects). */
|
|
662
|
+
export function hostSessionStatus(): HostSessionResolution & {
|
|
663
|
+
hostAppendReady: boolean
|
|
664
|
+
pluginAppendReady: boolean
|
|
665
|
+
pluginPath?: string
|
|
666
|
+
loadError?: string
|
|
667
|
+
} {
|
|
668
|
+
const loadError = dynamicLoadError()
|
|
669
|
+
return {
|
|
670
|
+
...resolution,
|
|
671
|
+
hostAppendReady: hostCopy?.copy.appender !== undefined,
|
|
672
|
+
pluginAppendReady: pluginCopy?.copy.appender !== undefined,
|
|
673
|
+
...(pluginSessionCopyPath !== undefined ? { pluginPath: pluginSessionCopyPath } : {}),
|
|
674
|
+
...(loadError !== undefined ? { loadError } : {}),
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Production appender: try every candidate in order, first success wins. The
|
|
680
|
+
* Session class comes from exactly one module copy, so the static import can
|
|
681
|
+
* reject the host's Session while the host copy accepts it (or vice versa).
|
|
682
|
+
* Only when every candidate rejects do we throw the LAST error — the publish
|
|
683
|
+
* contract (`failed`, round unaffected) is unchanged.
|
|
684
|
+
*/
|
|
685
|
+
const defaultAppender: RecordAppender = (session, type, data) => {
|
|
686
|
+
const errors: string[] = []
|
|
687
|
+
let lastError: unknown
|
|
688
|
+
for (const candidate of activeCandidates()) {
|
|
689
|
+
const append = candidate.get()
|
|
690
|
+
if (append === undefined) continue
|
|
691
|
+
try {
|
|
692
|
+
const result = append(session, type, data)
|
|
693
|
+
lastAttempt = { candidate: candidateLabel(candidate), errors }
|
|
694
|
+
return result
|
|
695
|
+
} catch (cause) {
|
|
696
|
+
lastError = cause
|
|
697
|
+
errors.push(`${candidateLabel(candidate)}: ${truncate(errorText(cause), MAX_DEBUG_ERROR_CHARS)}`)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
lastAttempt = { candidate: null, errors }
|
|
701
|
+
throw lastError ?? new Error('no appender candidate is available')
|
|
702
|
+
}
|
|
703
|
+
let recordAppender: RecordAppender = defaultAppender
|
|
704
|
+
/** Test seam: replace the plugin-record appender (pass undefined to restore). */
|
|
705
|
+
export function setRecordAppender(appender: RecordAppender | undefined): void {
|
|
706
|
+
recordAppender = appender ?? defaultAppender
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Outcome of one publish attempt. */
|
|
710
|
+
export type PublishResult = 'published' | 'unchanged' | 'skipped' | 'failed'
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Record the outcome of one publish. Paths/results only: the goal payload is
|
|
714
|
+
* never written to the log.
|
|
715
|
+
*/
|
|
716
|
+
function appendPublishRecord(result: 'published' | 'failed', report: AttemptReport | undefined): void {
|
|
717
|
+
appendDebugRecord({
|
|
718
|
+
at: new Date().toISOString(),
|
|
719
|
+
event: 'publish',
|
|
720
|
+
result,
|
|
721
|
+
candidate: report?.candidate ?? null,
|
|
722
|
+
errors: report?.errors ?? [],
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Append the whole goal board as one `cybernetics/goals` session event. The
|
|
728
|
+
* projection folds it into the client value; the host pushes the change to
|
|
729
|
+
* the tab. Publishing is observability, never workflow control: a missing or
|
|
730
|
+
* unhappy session degrades to a no-op instead of breaking a round.
|
|
731
|
+
*
|
|
732
|
+
* Idempotent by contract: republishing a byte-identical state appends
|
|
733
|
+
* nothing.
|
|
734
|
+
* @param session - the agent session whose tab shows the board (may be absent).
|
|
735
|
+
* @param state - the complete post-change board.
|
|
736
|
+
*/
|
|
737
|
+
export function publishGoals(session: unknown, state: CyberneticsGoalsState): PublishResult {
|
|
738
|
+
const target = session as { append?: unknown; header?: { cwd?: unknown } } | null | undefined
|
|
739
|
+
if (target === null || target === undefined || typeof target !== 'object') return 'skipped'
|
|
740
|
+
// A real Session exposes `append`; anything else is not a log we may write.
|
|
741
|
+
if (typeof target.append !== 'function') return 'skipped'
|
|
742
|
+
// Best-effort workspace hint for the debug records: a real Session carries
|
|
743
|
+
// its cwd in the header, so the log lands in the right `.cybernetics/` even
|
|
744
|
+
// when the caller cannot pass the root itself.
|
|
745
|
+
const cwd = target.header?.cwd
|
|
746
|
+
if (typeof cwd === 'string') setPublishDebugRoot(cwd)
|
|
747
|
+
const json = JSON.stringify(state)
|
|
748
|
+
if (published.get(target) === json) return 'unchanged'
|
|
749
|
+
const usingCandidates = recordAppender === defaultAppender
|
|
750
|
+
try {
|
|
751
|
+
recordAppender(target, EVENT_TYPE, state)
|
|
752
|
+
} catch (cause) {
|
|
753
|
+
// A non-loggable payload (or a session mid-teardown) must not fail the
|
|
754
|
+
// round that produced it; the tab simply keeps the previous snapshot.
|
|
755
|
+
appendPublishRecord('failed', usingCandidates
|
|
756
|
+
? lastAttempt
|
|
757
|
+
: { candidate: null, errors: [`injected: ${truncate(errorText(cause), MAX_DEBUG_ERROR_CHARS)}`] })
|
|
758
|
+
return 'failed'
|
|
759
|
+
}
|
|
760
|
+
published.set(target, json)
|
|
761
|
+
appendPublishRecord('published', usingCandidates ? lastAttempt : { candidate: 'injected', errors: [] })
|
|
762
|
+
return 'published'
|
|
763
|
+
}
|