dsh-brief-sidebar 0.3.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 +178 -0
- package/cordis.patch.yml +18 -0
- package/dsh.plugin.json +16 -0
- package/lib/client.js +960 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +5620 -0
- package/lib/index.mjs.map +1 -0
- package/lib/types/client/BriefIcon.d.ts +13 -0
- package/lib/types/client/SummaryTab.d.ts +18 -0
- package/lib/types/client/TodoBoardTab.d.ts +31 -0
- package/lib/types/client/brief/board.d.ts +50 -0
- package/lib/types/client/brief/dock-shadow.d.ts +49 -0
- package/lib/types/client/brief/use-todos.d.ts +13 -0
- package/lib/types/client/file-open.d.ts +50 -0
- package/lib/types/client/index.d.ts +23 -0
- package/lib/types/client/locales.d.ts +53 -0
- package/lib/types/client/summary/DeliverablesSection.d.ts +18 -0
- package/lib/types/client/summary/deliverables.d.ts +40 -0
- package/lib/types/client/use-projection.d.ts +54 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/projection/deliverables-fold.d.ts +85 -0
- package/lib/types/projection/keys.d.ts +25 -0
- package/lib/types/projection/register.d.ts +9 -0
- package/package.json +94 -0
- package/src/client/BriefIcon.tsx +56 -0
- package/src/client/SummaryTab.tsx +73 -0
- package/src/client/TodoBoardTab.tsx +188 -0
- package/src/client/brief/board.ts +104 -0
- package/src/client/brief/dock-shadow.tsx +74 -0
- package/src/client/brief/use-todos.ts +27 -0
- package/src/client/file-open.ts +108 -0
- package/src/client/index.tsx +144 -0
- package/src/client/locales.ts +78 -0
- package/src/client/summary/DeliverablesSection.tsx +192 -0
- package/src/client/summary/deliverables.ts +93 -0
- package/src/client/use-projection.ts +117 -0
- package/src/index.ts +32 -0
- package/src/projection/deliverables-fold.ts +206 -0
- package/src/projection/keys.ts +27 -0
- package/src/projection/register.ts +99 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a live session projection from the client Session Controller.
|
|
3
|
+
*
|
|
4
|
+
* better-sidebar tab bodies are NOT rendered inside a DSH slot, so the
|
|
5
|
+
* framework's `useProjection` standard prop is not available to them. The
|
|
6
|
+
* equivalent face is reachable directly: the client Session Controller is a
|
|
7
|
+
* cordis service on the root context, and each session binding exposes its
|
|
8
|
+
* projections store (`SessionFace.projections.faceOf(key)`), which is the very
|
|
9
|
+
* observable `useProjection` resolves. Since it is a bare
|
|
10
|
+
* `{ getSnapshot, subscribe }` source, `useSyncExternalStore` binds it as-is —
|
|
11
|
+
* no local mirror, no folding, and the value reference only changes when the
|
|
12
|
+
* Host pushes a frame.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is resolved defensively: a host without the Session
|
|
15
|
+
* Controller, or a session that is not open any more, degrades to "capability
|
|
16
|
+
* absent" (undefined) rather than throwing inside a render.
|
|
17
|
+
*
|
|
18
|
+
* This module is key-agnostic; the per-projection knowledge (which key, how to
|
|
19
|
+
* narrow the raw value) lives with the feature modules that call it.
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
|
22
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
23
|
+
|
|
24
|
+
/** Structural view of a projection face (`ObservableSnapshot<unknown>`). */
|
|
25
|
+
export interface ProjectionFace {
|
|
26
|
+
getSnapshot(): unknown
|
|
27
|
+
subscribe(listener: () => void): () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Structural view of one session binding's projection store. */
|
|
31
|
+
export interface ProjectionsLike {
|
|
32
|
+
faceOf(key: string): ProjectionFace
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Structural view of the client Session Controller face this plugin uses. */
|
|
36
|
+
export interface SessionsLike {
|
|
37
|
+
binding(id: string): { readonly session: { readonly projections: ProjectionsLike } } | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A no-op unsubscribe, kept as one identity so effects never thrash. */
|
|
41
|
+
const NO_SUBSCRIBE = (): void => {}
|
|
42
|
+
|
|
43
|
+
/** A source that never has a value — the absent-capability face. */
|
|
44
|
+
const ABSENT: ProjectionFace = { getSnapshot: () => undefined, subscribe: () => NO_SUBSCRIBE }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a value really is a projection face.
|
|
48
|
+
*
|
|
49
|
+
* The store contract promises an identity-stable face for every key, so this
|
|
50
|
+
* never fires against a healthy host. It exists because the fallback is worse
|
|
51
|
+
* than the check: `useSyncExternalStore` would throw on a face-less key, and a
|
|
52
|
+
* thrown render is a blank sidebar rather than a diagnosable "unavailable".
|
|
53
|
+
*/
|
|
54
|
+
function isFace(value: unknown): value is ProjectionFace {
|
|
55
|
+
if (value === null || typeof value !== 'object') return false
|
|
56
|
+
const face = value as Partial<ProjectionFace>
|
|
57
|
+
return typeof face.getSnapshot === 'function' && typeof face.subscribe === 'function'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve one projection face for one session.
|
|
62
|
+
*
|
|
63
|
+
* `ctx.get('sessions')` is the safe accessor (a direct `ctx.sessions` read can
|
|
64
|
+
* throw on a context that does not carry the service yet).
|
|
65
|
+
*
|
|
66
|
+
* @param ctx - the client root context handed to the tab body.
|
|
67
|
+
* @param sessionId - the tab's session scope; undefined means "no session".
|
|
68
|
+
* @param key - the projection key to resolve.
|
|
69
|
+
* @returns the projection face, or {@link ABSENT} when unreachable.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveFace(ctx: Context | undefined, sessionId: string | undefined, key: string): ProjectionFace {
|
|
72
|
+
if (ctx === undefined || sessionId === undefined) return ABSENT
|
|
73
|
+
const sessions = ctx.get('sessions') as SessionsLike | undefined
|
|
74
|
+
if (sessions === undefined || typeof sessions.binding !== 'function') return ABSENT
|
|
75
|
+
|
|
76
|
+
const binding = sessions.binding(sessionId)
|
|
77
|
+
const projections = binding?.session?.projections
|
|
78
|
+
if (projections === undefined || typeof projections.faceOf !== 'function') return ABSENT
|
|
79
|
+
|
|
80
|
+
const face: unknown = projections.faceOf(key)
|
|
81
|
+
return isFace(face) ? face : ABSENT
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read one frame of a projection without subscribing.
|
|
86
|
+
*
|
|
87
|
+
* The one-shot sibling of {@link useProjectionValue}, for consumers that are
|
|
88
|
+
* not React components — the tab badge is a plain function called on every
|
|
89
|
+
* tab-bar render, so it must neither hold a subscription nor allocate one.
|
|
90
|
+
*
|
|
91
|
+
* @param ctx - the client root context.
|
|
92
|
+
* @param sessionId - the session whose projection is read.
|
|
93
|
+
* @param key - the projection key to read.
|
|
94
|
+
* @returns the raw snapshot: `undefined` while the capability is absent; the
|
|
95
|
+
* semantics of any other value belong to the projection's own contract.
|
|
96
|
+
*/
|
|
97
|
+
export function projectionSnapshot(ctx: Context | undefined, sessionId: string | undefined, key: string): unknown {
|
|
98
|
+
return resolveFace(ctx, sessionId, key).getSnapshot()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Subscribe to one session projection by key.
|
|
103
|
+
*
|
|
104
|
+
* @param ctx - the client root context.
|
|
105
|
+
* @param sessionId - the session whose projection is shown.
|
|
106
|
+
* @param key - the projection key to subscribe to.
|
|
107
|
+
* @returns the raw snapshot value, identity-stable across frames the host did
|
|
108
|
+
* not change. Narrowing is the caller's job.
|
|
109
|
+
*/
|
|
110
|
+
export function useProjectionValue(ctx: Context | undefined, sessionId: string | undefined, key: string): unknown {
|
|
111
|
+
const face = useMemo(() => resolveFace(ctx, sessionId, key), [ctx, sessionId, key])
|
|
112
|
+
|
|
113
|
+
const subscribe = useCallback((listener: () => void) => face.subscribe(listener), [face])
|
|
114
|
+
const getSnapshot = useCallback(() => face.getSnapshot(), [face])
|
|
115
|
+
|
|
116
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
117
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node half of the brief sidebar plugin.
|
|
3
|
+
*
|
|
4
|
+
* The Cordis loader needs an entry point for the profile row; the browser half
|
|
5
|
+
* (`./client`, served by `dsh-client-modules`) renders the summary tab.
|
|
6
|
+
*
|
|
7
|
+
* Since the summary upgrade, this half owns ONE behaviour: contributing the
|
|
8
|
+
* `dshSummaryDeliverables` projection unit (see `./projection/register`), which
|
|
9
|
+
* folds successful mutation calls into a live produced-file feed. The
|
|
10
|
+
* contribution is optional — `registerDeliverablesProjection` waits for the
|
|
11
|
+
* `sessionProjections` service via `ctx.inject`, so a composition without the
|
|
12
|
+
* registry still loads this profile row and simply hides the deliverables
|
|
13
|
+
* section client-side.
|
|
14
|
+
*
|
|
15
|
+
* In particular this half still never touches the filesystem at load time: a
|
|
16
|
+
* profile row is instantiated on the boot path, and a synchronous read there
|
|
17
|
+
* would delay the host's startup. The fold is event-driven and stores paths
|
|
18
|
+
* only.
|
|
19
|
+
*/
|
|
20
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import { registerDeliverablesProjection } from './projection/register'
|
|
22
|
+
|
|
23
|
+
/** Profile row identity. */
|
|
24
|
+
export const name = 'dsh-brief-sidebar'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Node-face apply.
|
|
28
|
+
* @param ctx - the host context the loader hands this profile row.
|
|
29
|
+
*/
|
|
30
|
+
export function apply(ctx: Context): void {
|
|
31
|
+
registerDeliverablesProjection(ctx)
|
|
32
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side fold of produced-file facts — the projection unit state for
|
|
3
|
+
* `dshSummaryDeliverables`.
|
|
4
|
+
*
|
|
5
|
+
* The vocabulary is the one the harness's own deliverables experience uses
|
|
6
|
+
* (`ui-deliverables/turn-deliverables.ts`): the arguments of successful
|
|
7
|
+
* first-party mutation calls (`write`, `edit`, and the mutating
|
|
8
|
+
* `str_replace_editor` commands), never presentation data or prose. Reads,
|
|
9
|
+
* deletes, unsupported tools, malformed calls, and failed results contribute
|
|
10
|
+
* nothing; a file written and then edited in the same turn is one entry, in
|
|
11
|
+
* first-seen order.
|
|
12
|
+
*
|
|
13
|
+
* This module is deliberately PURE — no cordis, no zod, no IO — so the fold
|
|
14
|
+
* can be tested directly against hand-written event fixtures. The projection
|
|
15
|
+
* registry drives it once per committed `session/event`, which is what makes
|
|
16
|
+
* the produced list update mid-turn, the gap the official turn-tail row has.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One bucket: the paths produced so far in one turn, first-seen order. */
|
|
20
|
+
export interface DeliverablesTurnBucket {
|
|
21
|
+
readonly turn: number
|
|
22
|
+
readonly paths: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fold state. Plain JSON, so the projection cache can persist it. */
|
|
26
|
+
export interface DeliverablesState {
|
|
27
|
+
/** Turn number of the open (or latest seen) turn; 0 before the first turn. */
|
|
28
|
+
readonly turn: number
|
|
29
|
+
/** Open turn's `callId → mutation path` map (`null` = not a mutation call). */
|
|
30
|
+
readonly calls: Readonly<Record<string, { readonly turn: number; readonly path: string | null }>>
|
|
31
|
+
/** Paths accumulated in the latest turn so far (`null` before the first turn). */
|
|
32
|
+
readonly latest: DeliverablesTurnBucket | null
|
|
33
|
+
/** Every distinct produced path, most-recent-first, capped (see CAPS). */
|
|
34
|
+
readonly sessionPaths: readonly string[]
|
|
35
|
+
/** Distinct produced paths over the whole session, uncapped. */
|
|
36
|
+
readonly sessionTotal: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wire-view capacity bounds. The projection rides every event frame, so the
|
|
41
|
+
* lists are capped; the uncapped `sessionTotal` keeps the summary truthful
|
|
42
|
+
* beyond the cap.
|
|
43
|
+
*/
|
|
44
|
+
export const CAPS = {
|
|
45
|
+
/** Latest-turn paths kept in the wire view. */
|
|
46
|
+
latest: 50,
|
|
47
|
+
/** Session-wide paths kept in the wire view (most recent win). */
|
|
48
|
+
session: 100,
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
/** The fold's initial state: nothing has happened yet. */
|
|
52
|
+
export function initDeliverablesState(): DeliverablesState {
|
|
53
|
+
return { turn: 0, calls: {}, latest: null, sessionPaths: [], sessionTotal: 0 }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Structural view of one committed session event. The registry hands the
|
|
58
|
+
* host's discriminated union; the fold only needs `type` and `data`, and the
|
|
59
|
+
* tool-event shapes mirror `dsh-session` types: `tool/call` carries
|
|
60
|
+
* `{ turn, step, callId, name, arguments }`, `tool/result` carries
|
|
61
|
+
* `{ turn, step, message: { content: [{ isError? }], source: { callId } } }`,
|
|
62
|
+
* `turn/start` carries `{ turn }`.
|
|
63
|
+
*/
|
|
64
|
+
export interface FoldEvent {
|
|
65
|
+
readonly type: string
|
|
66
|
+
readonly data: unknown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The next state after one committed event. Events the fold does not own
|
|
71
|
+
* return the SAME reference — an unchanged reference produces zero downstream
|
|
72
|
+
* work in the registry's `Object.is` drive.
|
|
73
|
+
*
|
|
74
|
+
* @param state - the state covering all prior events.
|
|
75
|
+
* @param event - the next committed session event.
|
|
76
|
+
* @returns the next state (same reference when nothing the fold tracks moved).
|
|
77
|
+
*/
|
|
78
|
+
export function applyDeliverablesEvent(state: DeliverablesState, event: FoldEvent): DeliverablesState {
|
|
79
|
+
if (event.type === 'turn/start') {
|
|
80
|
+
const turn = event.data as { turn?: unknown }
|
|
81
|
+
if (typeof turn?.turn !== 'number') return state
|
|
82
|
+
// Keep the previous turn's call records until its results settled; only
|
|
83
|
+
// older turns' unsettled entries are pruned (bounded-state hygiene).
|
|
84
|
+
const calls: Record<string, { readonly turn: number; readonly path: string | null }> = {}
|
|
85
|
+
for (const [id, entry] of Object.entries(state.calls)) {
|
|
86
|
+
if (entry.turn >= turn.turn - 1) calls[id] = entry
|
|
87
|
+
}
|
|
88
|
+
return { ...state, turn: turn.turn, calls, latest: { turn: turn.turn, paths: [] } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (event.type === 'tool/call') {
|
|
92
|
+
const call = event.data as { callId?: unknown; name?: unknown; arguments?: unknown }
|
|
93
|
+
if (typeof call?.callId !== 'string') return state
|
|
94
|
+
const path = typeof call.name === 'string' && typeof call.arguments === 'string'
|
|
95
|
+
? mutationPath(call.name, call.arguments)
|
|
96
|
+
: null
|
|
97
|
+
return { ...state, calls: { ...state.calls, [call.callId]: { turn: state.turn, path } } }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (event.type !== 'tool/result') return state
|
|
101
|
+
|
|
102
|
+
const result = event.data as {
|
|
103
|
+
message?: { content?: readonly { isError?: unknown }[]; source?: { callId?: unknown } }
|
|
104
|
+
}
|
|
105
|
+
const callId = result?.message?.source?.callId
|
|
106
|
+
if (typeof callId !== 'string') return state
|
|
107
|
+
if (result?.message?.content?.[0]?.isError === true) return state
|
|
108
|
+
|
|
109
|
+
const recorded = state.calls[callId]
|
|
110
|
+
const path = recorded?.path
|
|
111
|
+
if (path === undefined || path === null) return state
|
|
112
|
+
|
|
113
|
+
// A path written and edited again in the same turn is one bucket entry. A
|
|
114
|
+
// stray result that settles after a LATER turn opened never rewrites the
|
|
115
|
+
// newer bucket — its path still counts session-wide below.
|
|
116
|
+
const latest = state.latest
|
|
117
|
+
const sameTurn = latest !== null && latest.turn === recorded.turn
|
|
118
|
+
const nextLatest: DeliverablesTurnBucket | null = sameTurn && !latest.paths.includes(path)
|
|
119
|
+
? { turn: latest.turn, paths: [...latest.paths, path].slice(-CAPS.latest) }
|
|
120
|
+
: latest
|
|
121
|
+
|
|
122
|
+
// Session-wide: distinct paths, most-recent-first, capped. The filter is
|
|
123
|
+
// non-empty-preserving, so a length change means the path was NEW here.
|
|
124
|
+
const withoutDuplicate = state.sessionPaths.filter(existing => existing !== path)
|
|
125
|
+
const sessionPaths = [path, ...withoutDuplicate].slice(0, CAPS.session)
|
|
126
|
+
const sessionTotal = withoutDuplicate.length === state.sessionPaths.length
|
|
127
|
+
? state.sessionTotal + 1
|
|
128
|
+
: state.sessionTotal
|
|
129
|
+
|
|
130
|
+
return { ...state, latest: nextLatest, sessionPaths, sessionTotal }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract the path from a supported first-party mutation call.
|
|
135
|
+
*
|
|
136
|
+
* Ported from the harness deliverables fold so both carriers agree on what a
|
|
137
|
+
* "produced file" is. Session `tool/call` events are root calls; PTC dispatch
|
|
138
|
+
* children do not enter this fold independently.
|
|
139
|
+
*
|
|
140
|
+
* @param name - wire tool name.
|
|
141
|
+
* @param argsRaw - model-produced JSON arguments.
|
|
142
|
+
* @returns the mutation path, or null when the call is not a supported mutation.
|
|
143
|
+
*/
|
|
144
|
+
export function mutationPath(name: string, argsRaw: string): string | null {
|
|
145
|
+
let args: unknown
|
|
146
|
+
try {
|
|
147
|
+
args = JSON.parse(argsRaw) as unknown
|
|
148
|
+
} catch {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
if (!isRecord(args)) return null
|
|
152
|
+
switch (name) {
|
|
153
|
+
case 'write':
|
|
154
|
+
return typeof args.content === 'string' ? pathValue(args.file_path) : null
|
|
155
|
+
case 'edit':
|
|
156
|
+
return validEditArgs(args) ? pathValue(args.file_path) : null
|
|
157
|
+
case 'str_replace_editor':
|
|
158
|
+
return editorMutationPath(args)
|
|
159
|
+
default:
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Validate the fields that an `edit` execution requires. */
|
|
165
|
+
function validEditArgs(args: Readonly<Record<string, unknown>>): boolean {
|
|
166
|
+
return typeof args.old_string === 'string'
|
|
167
|
+
&& args.old_string.length > 0
|
|
168
|
+
&& typeof args.new_string === 'string'
|
|
169
|
+
&& args.old_string !== args.new_string
|
|
170
|
+
&& (args.replace_all === undefined || typeof args.replace_all === 'boolean')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Extract a path only from a complete mutating editor command. */
|
|
174
|
+
function editorMutationPath(args: Readonly<Record<string, unknown>>): string | null {
|
|
175
|
+
const path = pathValue(args.path)
|
|
176
|
+
if (path === null) return null
|
|
177
|
+
switch (args.command) {
|
|
178
|
+
case 'create':
|
|
179
|
+
return typeof args.file_text === 'string' ? path : null
|
|
180
|
+
case 'str_replace':
|
|
181
|
+
return typeof args.old_str === 'string'
|
|
182
|
+
&& args.old_str.length > 0
|
|
183
|
+
&& (args.new_str === undefined || typeof args.new_str === 'string')
|
|
184
|
+
? path
|
|
185
|
+
: null
|
|
186
|
+
case 'insert':
|
|
187
|
+
return typeof args.insert_line === 'number'
|
|
188
|
+
&& Number.isInteger(args.insert_line)
|
|
189
|
+
&& args.insert_line >= 0
|
|
190
|
+
&& typeof args.new_str === 'string'
|
|
191
|
+
? path
|
|
192
|
+
: null
|
|
193
|
+
default:
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A non-blank path preserves the exact spelling supplied to the tool. */
|
|
199
|
+
function pathValue(value: unknown): string | null {
|
|
200
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Narrow parsed JSON to an argument object. */
|
|
204
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
205
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
206
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The projection key this plugin contributes to the session-projection
|
|
3
|
+
* registry, and its wire view.
|
|
4
|
+
*
|
|
5
|
+
* The key is namespaced with the plugin name on purpose: the registry refuses
|
|
6
|
+
* to share a key across differing `stateVersion`s, so a collision with a
|
|
7
|
+
* future host-side deliverables unit would be a hard load error, not a silent
|
|
8
|
+
* shadow. The view type is declared structurally (the plugin has no compile
|
|
9
|
+
* dependency on `@deepseek-ai/dsh-session-projection`); the registry accepts
|
|
10
|
+
* any string key at runtime.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The projection key registered by this plugin's host half. */
|
|
14
|
+
export const DELIVERABLES_KEY = 'dshSummaryDeliverables'
|
|
15
|
+
|
|
16
|
+
/** Client view of the produced-file fold (JSON-safe). */
|
|
17
|
+
export interface DeliverablesView {
|
|
18
|
+
/** Paths produced in the latest turn, first-seen order (capped). */
|
|
19
|
+
readonly latest: {
|
|
20
|
+
readonly turn: number
|
|
21
|
+
readonly paths: readonly string[]
|
|
22
|
+
} | null
|
|
23
|
+
/** Distinct produced paths session-wide, most-recent-first (capped). */
|
|
24
|
+
readonly sessionPaths: readonly string[]
|
|
25
|
+
/** Distinct produced paths session-wide, uncapped — the truthful total. */
|
|
26
|
+
readonly sessionTotal: number
|
|
27
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration of the `dshSummaryDeliverables` projection unit.
|
|
3
|
+
*
|
|
4
|
+
* The unit is an OPTIONAL contribution: a composition without the
|
|
5
|
+
* session-projection registry (or an older host) simply never registers it,
|
|
6
|
+
* and the client reads the key as capability absence — the deliverables
|
|
7
|
+
* section stays hidden. That is why this module routes through
|
|
8
|
+
* `ctx.inject(['sessionProjections'], …)` (wait for the service, register only
|
|
9
|
+
* when it appears) instead of declaring the service in the plugin's own
|
|
10
|
+
* `inject` list, which would make the whole profile row fail to load.
|
|
11
|
+
*
|
|
12
|
+
* The registry drives every registered unit once per committed session event
|
|
13
|
+
* and lazily replays the in-memory log for late units, so a session's history
|
|
14
|
+
* is folded even when this plugin mounts after work started.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
import { applyDeliverablesEvent, CAPS, initDeliverablesState } from './deliverables-fold'
|
|
19
|
+
import type { DeliverablesState } from './deliverables-fold'
|
|
20
|
+
import { DELIVERABLES_KEY } from './keys'
|
|
21
|
+
import type { DeliverablesView } from './keys'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Structural view of the registry this plugin needs. Declared locally on
|
|
25
|
+
* purpose: the plugin has no compile dependency on the session-projection
|
|
26
|
+
* package, and `register` accepts any string key at runtime.
|
|
27
|
+
*/
|
|
28
|
+
interface ProjectionRegistryLike {
|
|
29
|
+
register(definition: {
|
|
30
|
+
key: string
|
|
31
|
+
stateSchema: { parse(value: unknown): unknown }
|
|
32
|
+
init(): DeliverablesState
|
|
33
|
+
apply(state: DeliverablesState, event: { type: string; data: unknown }): DeliverablesState
|
|
34
|
+
wire: { viewSchema: { parse(value: unknown): unknown }; view(state: DeliverablesState): DeliverablesView }
|
|
35
|
+
stateVersion: number
|
|
36
|
+
}): () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Zod schema of the fold state — validates persisted states before replay. */
|
|
40
|
+
const stateSchema = z.object({
|
|
41
|
+
turn: z.number(),
|
|
42
|
+
calls: z.record(z.string(), z.object({ turn: z.number(), path: z.string().nullable() })),
|
|
43
|
+
latest: z.object({ turn: z.number(), paths: z.array(z.string()) }).nullable(),
|
|
44
|
+
sessionPaths: z.array(z.string()),
|
|
45
|
+
sessionTotal: z.number(),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Zod schema of the wire view — validates the payload before it leaves the host. */
|
|
49
|
+
const viewSchema = z.object({
|
|
50
|
+
latest: z.object({ turn: z.number(), paths: z.array(z.string()) }).nullable(),
|
|
51
|
+
sessionPaths: z.array(z.string()),
|
|
52
|
+
sessionTotal: z.number(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The view memo. The registry compares consecutive `view` results by
|
|
57
|
+
* `Object.is`, so a fresh object per event would republish the key on every
|
|
58
|
+
* unrelated frame. States are immutable references, so a WeakMap keyed by
|
|
59
|
+
* state reuses the view reference until the state really changes.
|
|
60
|
+
*/
|
|
61
|
+
const viewMemo = new WeakMap<DeliverablesState, DeliverablesView>()
|
|
62
|
+
|
|
63
|
+
/** State → wire view, reference-stable across unchanged states. */
|
|
64
|
+
function view(state: DeliverablesState): DeliverablesView {
|
|
65
|
+
const memoized = viewMemo.get(state)
|
|
66
|
+
if (memoized !== undefined) return memoized
|
|
67
|
+
const next: DeliverablesView = {
|
|
68
|
+
latest: state.latest === null
|
|
69
|
+
? null
|
|
70
|
+
: { turn: state.latest.turn, paths: state.latest.paths.slice(-CAPS.latest) },
|
|
71
|
+
sessionPaths: state.sessionPaths.slice(0, CAPS.session),
|
|
72
|
+
sessionTotal: state.sessionTotal,
|
|
73
|
+
}
|
|
74
|
+
viewMemo.set(state, next)
|
|
75
|
+
return next
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register the deliverables projection as an optional contribution.
|
|
80
|
+
*
|
|
81
|
+
* @param ctx - the host context the plugin's `apply` receives.
|
|
82
|
+
* @returns nothing; the registration's disposer rides the injected fiber, so
|
|
83
|
+
* unloading the plugin removes the key (clients then read capability absence).
|
|
84
|
+
*/
|
|
85
|
+
export function registerDeliverablesProjection(ctx: Context): void {
|
|
86
|
+
ctx.inject(['sessionProjections'], (injected) => {
|
|
87
|
+
const registry = (injected as unknown as { sessionProjections?: ProjectionRegistryLike })
|
|
88
|
+
.sessionProjections
|
|
89
|
+
if (registry === undefined || typeof registry.register !== 'function') return
|
|
90
|
+
registry.register({
|
|
91
|
+
key: DELIVERABLES_KEY,
|
|
92
|
+
stateSchema,
|
|
93
|
+
init: () => initDeliverablesState(),
|
|
94
|
+
apply: applyDeliverablesEvent,
|
|
95
|
+
wire: { viewSchema, view },
|
|
96
|
+
stateVersion: 0,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|