@luziyang2026/dsh-question-nav 0.2.0 → 0.4.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/src/index.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  /**
2
- * Host loader entry for the dsh-client-ui-question-nav plugin — runs in the
3
- * DSH host process. The plugin is browser-only: the row in cordis.patch.yml
4
- * mounts this no-op half so the loader sees a real cordis plugin, while the
5
- * actual UI lives in the browser half (src/client).
2
+ * Host half of the dsh-question-nav plugin — runs in the DSH host process.
3
+ * Registers the `questionIndex` session projection unit: the ordered list of
4
+ * user questions (each tagged with its turn), folded from the session event
5
+ * log by the projection registry, persisted by the projection cache, and
6
+ * delivered to the browser through the standard projection carriers (history
7
+ * tail-page baseline + session/projection push frames). The navigation UI
8
+ * itself lives in the browser half (src/client).
6
9
  */
7
10
  import type { Context } from '@deepseek-ai/cordis'
11
+ import { questionIndexProjectionDefinition } from './projection.ts'
8
12
 
9
- /** Apply the host half (no host behavior for this plugin). */
10
- export function apply(_ctx: Context): void {}
13
+ /** Cordis plugin name. */
14
+ export const name = 'dsh-question-nav'
15
+
16
+ /**
17
+ * Register the `questionIndex` unit. The registry is an optional capability
18
+ * (absent in headless compositions), so registration rides `ctx.inject`:
19
+ * without it the host half simply contributes nothing and the browser strip
20
+ * falls back to live-window questions.
21
+ * @param ctx - plugin context.
22
+ */
23
+ export function apply(ctx: Context): void {
24
+ ctx.inject(['sessionProjections'], (inner) => {
25
+ inner.sessionProjections.register(questionIndexProjectionDefinition)
26
+ })
27
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The `questionIndex` session projection unit: a pure fold of the session
3
+ * event log into the ordered list of user questions, each tagged with the
4
+ * turn that claimed it. Registered on `ctx.sessionProjections` by the host
5
+ * half (src/index.ts); persistence, replay, and client delivery are the
6
+ * projection seam's (session-projection-cache checkpoints the state, the
7
+ * api-proxy carriers seed + push the wire view).
8
+ *
9
+ * Fold rules mirror the chat messageDefinition classification: an
10
+ * append-origin `user/message` with a human (`user`) source is a question;
11
+ * replacement copies (compaction checkpoints) and injected context are not.
12
+ * Turns come from `turn/start` boundaries, so retry/goal-continuation turns
13
+ * without a question simply produce no entry — dots may skip turn numbers,
14
+ * staying exactly aligned with the Trajectory view's turn labels.
15
+ *
16
+ * @module dsh-question-nav/projection
17
+ */
18
+
19
+ import { z } from 'zod'
20
+ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
21
+ import type { QuestionEntry } from './core/question-entry.ts'
22
+
23
+ /** Fold state: the last opened turn plus every question recorded so far. */
24
+ export interface QuestionIndexState {
25
+ /** Turn of the last `turn/start` (0 before any; turns are 1-based). */
26
+ turn: number
27
+ /** Every recorded question, in event order. */
28
+ questions: QuestionEntry[]
29
+ }
30
+
31
+ declare module '@deepseek-ai/dsh-session-projection/types' {
32
+ interface SessionProjectionMap {
33
+ questionIndex: QuestionEntry[]
34
+ }
35
+ interface SessionProjectionStateMap {
36
+ questionIndex: QuestionIndexState
37
+ }
38
+ }
39
+
40
+ const questionEntrySchema = z.object({
41
+ turn: z.number().int().nonnegative(),
42
+ id: z.string(),
43
+ seq: z.number().int().nonnegative(),
44
+ time: z.number().nonnegative(),
45
+ text: z.string(),
46
+ }).strict()
47
+
48
+ /** Validates persisted rows after their `ver` gate (the unit's input boundary). */
49
+ const questionIndexStateSchema = z.object({
50
+ turn: z.number().int().nonnegative(),
51
+ questions: z.array(questionEntrySchema),
52
+ }).strict()
53
+
54
+ /** Validates the wire payload before it leaves the host. */
55
+ const questionIndexViewSchema = z.array(questionEntrySchema)
56
+
57
+ /** First text block of a user message; empty string when absent. */
58
+ function messageText(content: readonly { type?: string; text?: string }[] | undefined): string {
59
+ if (content === undefined || content.length === 0) return ''
60
+ const first = content[0]
61
+ return typeof first?.text === 'string' ? first.text : ''
62
+ }
63
+
64
+ /** The `questionIndex` unit registered on `ctx.sessionProjections`. */
65
+ export const questionIndexProjectionDefinition: Omit<ProjectionDefinition<'questionIndex', QuestionIndexState>, 'wire'> & {
66
+ wire: NonNullable<ProjectionDefinition<'questionIndex', QuestionIndexState>['wire']>
67
+ } = {
68
+ key: 'questionIndex',
69
+ stateVersion: 1,
70
+ stateSchema: questionIndexStateSchema,
71
+ init: () => ({ turn: 0, questions: [] }),
72
+ apply: (state, event) => {
73
+ // Every uninteresting event returns the same reference (Object.is gates
74
+ // the change feed and the persisted-cache dirty check).
75
+ switch (event.type) {
76
+ case 'turn/start':
77
+ return event.data.turn === state.turn ? state : { ...state, turn: event.data.turn }
78
+ case 'user/message': {
79
+ if (event.surfaceOp !== 'append') return state
80
+ if (event.data.source?.kind !== 'user') return state
81
+ const entry: QuestionEntry = {
82
+ turn: state.turn,
83
+ id: String(event.data.id),
84
+ seq: event.seq,
85
+ time: event.time,
86
+ text: messageText(event.data.content),
87
+ }
88
+ return { ...state, questions: [...state.questions, entry] }
89
+ }
90
+ default:
91
+ return state
92
+ }
93
+ },
94
+ wire: {
95
+ viewSchema: questionIndexViewSchema,
96
+ view: state => state.questions,
97
+ },
98
+ }
@@ -1,97 +0,0 @@
1
- /**
2
- * Load-all orchestration for the question-nav strip.
3
- *
4
- * DSH sessions page history in fixed-size chunks: `chat.nodes` only ever holds
5
- * the currently loaded window, and questions that still sit behind the "load
6
- * older" button are invisible to the strip until the window is expanded
7
- * backwards. This loop pages `loadOlder()` until `hasMore` is false (the whole
8
- * history is materialized), so every user question becomes a dot.
9
- *
10
- * Pure-ish: takes injected ports (snapshot read, one paged loadOlder, view
11
- * liveness, clocks) so it is unit-testable without a browser or session.
12
- */
13
-
14
- export interface LoadAllSnapshot {
15
- openState: string
16
- hasMore: boolean
17
- loadingOlder: boolean
18
- }
19
-
20
- export interface LoadAllPorts {
21
- /** Read the current session snapshot; undefined when unavailable. */
22
- snapshot: () => LoadAllSnapshot | undefined
23
- /** Expand the window backwards by one page (may preserve scroll). */
24
- loadOlder: () => Promise<void>
25
- /** True while the chat view is active (a `[data-chat-flow]` is mounted). */
26
- isViewActive: () => boolean
27
- /** Monotonic ms clock. */
28
- now: () => number
29
- /** Async sleep. */
30
- sleep: (ms: number) => Promise<void>
31
- }
32
-
33
- export interface LoadAllOptions {
34
- /** Max older pages to fetch before giving up (default 400). */
35
- maxPages?: number
36
- /** Total wall-clock budget for the whole expansion (default 60s). */
37
- totalTimeoutMs?: number
38
- /** Poll interval for open/loading transitions (default 60ms). */
39
- pollMs?: number
40
- /** Abort the expansion; checked every iteration. */
41
- signal?: AbortSignal
42
- }
43
-
44
- export type LoadAllCode =
45
- | 'COMPLETE'
46
- | 'VIEW_INACTIVE'
47
- | 'NOT_OPEN'
48
- | 'BUDGET'
49
- | 'TIMEOUT'
50
- | 'CANCELLED'
51
-
52
- export interface LoadAllResult {
53
- ok: boolean
54
- code: LoadAllCode
55
- /** Number of `loadOlder` pages actually fetched. */
56
- pages: number
57
- }
58
-
59
- const DEFAULTS = {
60
- maxPages: 400,
61
- totalTimeoutMs: 60_000,
62
- pollMs: 60,
63
- }
64
-
65
- /**
66
- * Expand the session window backwards until the earliest history is loaded.
67
- * Waits while the session is still opening; aborts on cancellation, budget or
68
- * timeout. Safe to re-enter: once `hasMore` is false the loop returns
69
- * immediately with `COMPLETE`.
70
- */
71
- export async function loadAllOlder(ports: LoadAllPorts, options: LoadAllOptions = {}): Promise<LoadAllResult> {
72
- const cfg = { ...DEFAULTS, ...options }
73
- const deadline = ports.now() + cfg.totalTimeoutMs
74
- let pages = 0
75
-
76
- const cancelled = (): boolean => cfg.signal?.aborted === true
77
-
78
- while (true) {
79
- if (cancelled()) return { ok: false, code: 'CANCELLED', pages }
80
- if (!ports.isViewActive()) return { ok: false, code: 'VIEW_INACTIVE', pages }
81
- const snap = ports.snapshot()
82
- if (snap === undefined) return { ok: false, code: 'VIEW_INACTIVE', pages }
83
- if (snap.openState === 'error') return { ok: false, code: 'NOT_OPEN', pages }
84
- // Nothing older left: the whole history is in the window.
85
- if (snap.hasMore !== true) return { ok: true, code: 'COMPLETE', pages }
86
- if (pages >= cfg.maxPages) return { ok: false, code: 'BUDGET', pages }
87
- if (ports.now() > deadline) return { ok: false, code: 'TIMEOUT', pages }
88
- // Wait while the session is still opening or a page is already in flight
89
- // (a user-initiated "load older" click shares this same gate).
90
- if (snap.openState !== 'open' || snap.loadingOlder) {
91
- await ports.sleep(cfg.pollMs)
92
- continue
93
- }
94
- await ports.loadOlder()
95
- pages += 1
96
- }
97
- }