dsh-brief-sidebar 0.9.2 → 0.9.4

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.
@@ -20,8 +20,10 @@ import { applyDeliverablesEvent, CAPS, initDeliverablesState } from './deliverab
20
20
  import type { DeliverablesState } from './deliverables-fold'
21
21
  import { applySkillUsageEvent, CAPS as SKILL_CAPS, initSkillUsageState } from './skill-usage-fold'
22
22
  import type { SkillUsageState } from './skill-usage-fold'
23
- import { DELIVERABLES_KEY, SKILL_USAGE_KEY } from './keys'
24
- import type { DeliverablesView, SkillUsageView } from './keys'
23
+ import { applySearchRefEvent, CAPS as SEARCH_CAPS, initSearchRefState } from './search-ref-fold'
24
+ import type { SearchRefState } from './search-ref-fold'
25
+ import { DELIVERABLES_KEY, SEARCH_REFS_KEY, SKILL_USAGE_KEY } from './keys'
26
+ import type { DeliverablesView, SearchRefView, SkillUsageView } from './keys'
25
27
 
26
28
  /**
27
29
  * Structural view of the registry this plugin needs. Declared locally on
@@ -170,3 +172,81 @@ export function registerSkillUsageProjection(ctx: Context): void {
170
172
  })
171
173
  })
172
174
  }
175
+
176
+ /** Zod schema of the search-reference fold state. */
177
+ const searchStateSchema = z.object({
178
+ turn: z.number(),
179
+ calls: z.record(z.string(), z.object({ turn: z.number(), query: z.string() })),
180
+ latest: z.object({ turn: z.number(), urls: z.array(z.string()) }).nullable(),
181
+ items: z.array(z.object({
182
+ url: z.string(),
183
+ host: z.string(),
184
+ title: z.string().optional(),
185
+ snippet: z.string().optional(),
186
+ query: z.string(),
187
+ count: z.number(),
188
+ lastTurn: z.number(),
189
+ })),
190
+ queries: z.array(z.object({ query: z.string(), turn: z.number(), hits: z.number() })),
191
+ sourceTotal: z.number(),
192
+ queryTotal: z.number(),
193
+ })
194
+
195
+ /** Zod schema of the search-reference wire view. */
196
+ const searchViewSchema = z.object({
197
+ latest: z.object({ turn: z.number(), urls: z.array(z.string()) }).nullable(),
198
+ queries: z.array(z.object({ query: z.string(), turn: z.number(), hits: z.number() })),
199
+ sources: z.array(z.object({
200
+ url: z.string(),
201
+ host: z.string(),
202
+ title: z.string().optional(),
203
+ snippet: z.string().optional(),
204
+ query: z.string(),
205
+ count: z.number(),
206
+ lastTurn: z.number(),
207
+ })),
208
+ queryTotal: z.number(),
209
+ sourceTotal: z.number(),
210
+ })
211
+
212
+ /** Same reference-stability memo, for the search-reference unit. */
213
+ const searchViewMemo = new WeakMap<SearchRefState, SearchRefView>()
214
+
215
+ /** State → wire view, reference-stable across unchanged states. */
216
+ function searchView(state: SearchRefState): SearchRefView {
217
+ const memoized = searchViewMemo.get(state)
218
+ if (memoized !== undefined) return memoized
219
+ const next: SearchRefView = {
220
+ latest: state.latest === null
221
+ ? null
222
+ : { turn: state.latest.turn, urls: state.latest.urls.slice(0, SEARCH_CAPS.latest) },
223
+ queries: state.queries.slice(0, SEARCH_CAPS.queries),
224
+ sources: state.items.slice(0, SEARCH_CAPS.items),
225
+ queryTotal: state.queryTotal,
226
+ sourceTotal: state.sourceTotal,
227
+ }
228
+ searchViewMemo.set(state, next)
229
+ return next
230
+ }
231
+
232
+ /**
233
+ * Register the search-reference projection as an optional contribution.
234
+ *
235
+ * @param ctx - the host context the plugin's `apply` receives.
236
+ */
237
+ export function registerSearchRefProjection(ctx: Context): void {
238
+ ctx.inject(['sessionProjections'], (injected) => {
239
+ const registry = (injected as unknown as { sessionProjections?: ProjectionRegistryLike })
240
+ .sessionProjections
241
+ if (registry === undefined || typeof registry.register !== 'function') return
242
+ registry.register({
243
+ key: SEARCH_REFS_KEY,
244
+ stateSchema: searchStateSchema,
245
+ init: () => initSearchRefState(),
246
+ apply: applySearchRefEvent,
247
+ wire: { viewSchema: searchViewSchema, view: searchView },
248
+ // 0 = the first published shape of this key.
249
+ stateVersion: 0,
250
+ })
251
+ })
252
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Host-side fold of search-reference facts — the projection unit state for
3
+ * `dshSummarySearchRefs`: which queries this session ran through the user's
4
+ * `advanced_search` tool, and which external sources came back.
5
+ *
6
+ * Scope is deliberate: `advanced_search` only, not the harness's own
7
+ * `web_search`. The two tools are separate facts for the reader — one is the
8
+ * plugins the human installed and configures (engines, keys, prices), the other
9
+ * is host plumbing — and folding them together would make the section report
10
+ * searches the reader cannot inspect or change.
11
+ *
12
+ * Two channels, both structured, no prose scraping:
13
+ *
14
+ * - the query string comes from `tool/call.arguments.query`;
15
+ * - the returned sources come from `tool/result.meta.sources[]`, which
16
+ * `dsh-free-search` publishes through the tool's `output.presentationMeta`
17
+ * (shape mirrors the host's `searchMetaFromValue`: `sources[]` of
18
+ * url/title/snippet/publishedAt, plus `provider`/`answer`).
19
+ *
20
+ * Older `dsh-free-search` builds declare no `presentationMeta`, so their
21
+ * results carry no meta. That is a degraded reading, not a missed search: the
22
+ * query still counts, with zero sources listed. Dropping the row instead would
23
+ * tell the reader nothing happened when the model did search.
24
+ *
25
+ * Like the deliverables and skill folds this module is PURE — no cordis, no
26
+ * zod, no IO — so it can be tested against hand-written event fixtures.
27
+ * Success is required (an `isError` result is not a reference), and every list
28
+ * is bounded because the registry folds this once per committed event.
29
+ */
30
+ import type { FoldEvent } from './deliverables-fold'
31
+
32
+ /** One external source the session pulled back. */
33
+ export interface SearchRefItem {
34
+ /** The URL exactly as published, minus host case and a trailing slash. */
35
+ readonly url: string
36
+ /** Host, for grouping and for the row's quiet right-hand label. */
37
+ readonly host: string
38
+ readonly title?: string
39
+ /** Provider snippet, capped (see CAPS.snippet). */
40
+ readonly snippet?: string
41
+ /** The query that most recently surfaced this source. */
42
+ readonly query: string
43
+ /** How many settled searches surfaced it. */
44
+ readonly count: number
45
+ /** Turn whose result last advanced this row. */
46
+ readonly lastTurn: number
47
+ }
48
+
49
+ /** One search the session ran, with how many sources it returned. */
50
+ export interface SearchQueryRow {
51
+ readonly query: string
52
+ readonly turn: number
53
+ readonly hits: number
54
+ }
55
+
56
+ /** Fold state. Plain JSON, so the projection cache can persist it. */
57
+ export interface SearchRefState {
58
+ /** Turn number of the open (or latest seen) turn; 0 before the first turn. */
59
+ readonly turn: number
60
+ /** Open turns' `callId → query` map; only `advanced_search` calls are recorded. */
61
+ readonly calls: Readonly<Record<string, { readonly turn: number; readonly query: string }>>
62
+ /** URLs surfaced in the latest turn so far (`null` before the first turn). */
63
+ readonly latest: { readonly turn: number; readonly urls: readonly string[] } | null
64
+ /** Distinct sources, most-recent-first, capped. */
65
+ readonly items: readonly SearchRefItem[]
66
+ /** Distinct queries, most-recent-first, capped. */
67
+ readonly queries: readonly SearchQueryRow[]
68
+ /** Distinct sources over the whole session, uncapped. */
69
+ readonly sourceTotal: number
70
+ /** Distinct queries over the whole session, uncapped. */
71
+ readonly queryTotal: number
72
+ }
73
+
74
+ /**
75
+ * Capacity bounds. `snippet` is capped because it is the only long text in the
76
+ * state and the frame is republished per event; the provider already caps it
77
+ * once in its own `presentationMeta`, so this is a second, tighter belt.
78
+ */
79
+ export const CAPS = {
80
+ /** Distinct sources kept in state. */
81
+ items: 20,
82
+ /** Distinct queries kept in state. */
83
+ queries: 20,
84
+ /** URLs kept in the latest-turn bucket. */
85
+ latest: 20,
86
+ /** Snippet characters kept per source. */
87
+ snippet: 160,
88
+ } as const
89
+
90
+ /** The fold's initial state: no search has settled yet. */
91
+ export function initSearchRefState(): SearchRefState {
92
+ return { turn: 0, calls: {}, latest: null, items: [], queries: [], sourceTotal: 0, queryTotal: 0 }
93
+ }
94
+
95
+ /** The tool this fold owns. Nothing else counts, even with a `query` argument. */
96
+ const SEARCH_TOOL = 'advanced_search'
97
+
98
+ /**
99
+ * Extract the query string from one committed `tool/call`.
100
+ *
101
+ * @param name - wire tool name.
102
+ * @param argsRaw - model-produced JSON arguments (a string by the session contract).
103
+ * @returns the non-blank query, or null when this call is not a search.
104
+ */
105
+ export function queryFromArgs(name: string, argsRaw: string): string | null {
106
+ if (name !== SEARCH_TOOL) return null
107
+ let args: unknown
108
+ try {
109
+ args = JSON.parse(argsRaw) as unknown
110
+ } catch {
111
+ return null
112
+ }
113
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return null
114
+ const query = (args as Record<string, unknown>)['query']
115
+ return typeof query === 'string' && query.trim().length > 0 ? query : null
116
+ }
117
+
118
+ /**
119
+ * The dedupe key. Host case and a trailing slash are not meaningful for
120
+ * identity; path case IS, so it is left alone. Exported because the client
121
+ * matches a row against the latest-turn bucket with the same algorithm.
122
+ */
123
+ export function urlKey(url: string): string {
124
+ const trimmed = url.trim()
125
+ const withoutTrailingSlash = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
126
+ return withoutTrailingSlash.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, match => match.toLowerCase())
127
+ }
128
+
129
+ /** The host label of a URL, or the raw string when it does not parse. */
130
+ function hostOf(url: string): string {
131
+ try {
132
+ return new URL(url).hostname.replace(/^www\./i, '')
133
+ } catch {
134
+ return url.slice(0, 40)
135
+ }
136
+ }
137
+
138
+ /** Cap one snippet so one long provider blurb cannot dominate every frame. */
139
+ function capSnippet(text: string): string {
140
+ const one = text.replace(/\s+/g, ' ').trim()
141
+ return one.length > CAPS.snippet ? `${one.slice(0, CAPS.snippet)}…` : one
142
+ }
143
+
144
+ /** The sources of one result meta, or `[]` when the meta is absent or drifted. */
145
+ function readSources(meta: unknown): { url: string; title?: string; snippet?: string }[] {
146
+ if (typeof meta !== 'object' || meta === null) return []
147
+ const raw = (meta as Record<string, unknown>)['sources']
148
+ if (!Array.isArray(raw)) return []
149
+ const out: { url: string; title?: string; snippet?: string }[] = []
150
+ for (const entry of raw) {
151
+ if (typeof entry !== 'object' || entry === null) continue
152
+ const record = entry as Record<string, unknown>
153
+ if (typeof record['url'] !== 'string' || record['url'].trim().length === 0) continue
154
+ const source: { url: string; title?: string; snippet?: string } = { url: record['url'] }
155
+ if (typeof record['title'] === 'string' && record['title'].length > 0) source.title = record['title']
156
+ if (typeof record['snippet'] === 'string' && record['snippet'].length > 0) source.snippet = capSnippet(record['snippet'])
157
+ out.push(source)
158
+ }
159
+ return out
160
+ }
161
+
162
+ /**
163
+ * The next state after one committed event. Events this fold does not own
164
+ * return the SAME reference, which is what keeps the registry's `Object.is`
165
+ * drive from republishing the key on unrelated frames.
166
+ *
167
+ * @param state - the state covering all prior events.
168
+ * @param event - the next committed session event.
169
+ * @returns the next state (same reference when nothing the fold tracks moved).
170
+ */
171
+ export function applySearchRefEvent(state: SearchRefState, event: FoldEvent): SearchRefState {
172
+ if (event.type === 'turn/start') {
173
+ const turn = event.data as { turn?: unknown }
174
+ if (typeof turn?.turn !== 'number') return state
175
+ const calls: Record<string, { readonly turn: number; readonly query: string }> = {}
176
+ for (const [id, entry] of Object.entries(state.calls)) {
177
+ if (entry.turn >= turn.turn - 1) calls[id] = entry
178
+ }
179
+ return { ...state, turn: turn.turn, calls, latest: { turn: turn.turn, urls: [] } }
180
+ }
181
+
182
+ if (event.type === 'tool/call') {
183
+ const call = event.data as { callId?: unknown; name?: unknown; arguments?: unknown }
184
+ if (typeof call?.callId !== 'string') return state
185
+ if (typeof call.name !== 'string' || typeof call.arguments !== 'string') return state
186
+ const query = queryFromArgs(call.name, call.arguments)
187
+ // An unreadable query is not recorded: the later result then settles nothing,
188
+ // which is the honest reading of "we do not know what was searched".
189
+ if (query === null) return state
190
+ return { ...state, calls: { ...state.calls, [call.callId]: { turn: state.turn, query } } }
191
+ }
192
+
193
+ if (event.type !== 'tool/result') return state
194
+
195
+ const result = event.data as {
196
+ meta?: unknown
197
+ message?: { content?: readonly { isError?: unknown }[]; source?: { callId?: unknown } }
198
+ }
199
+ const callId = result?.message?.source?.callId
200
+ if (typeof callId !== 'string') return state
201
+ if (result?.message?.content?.[0]?.isError === true) return state
202
+
203
+ const recorded = state.calls[callId]
204
+ if (recorded === undefined) return state
205
+ const { query, turn } = recorded
206
+
207
+ const sources = readSources(result.meta)
208
+
209
+ // Sources: front-insert per published order, so the last one published ends
210
+ // up first. A repeat of a known URL updates that row instead of adding one.
211
+ const items = [...state.items]
212
+ let sourceTotal = state.sourceTotal
213
+ const urls: string[] = []
214
+ for (const source of sources) {
215
+ const key = urlKey(source.url)
216
+ urls.push(key)
217
+ const at = items.findIndex(item => urlKey(item.url) === key)
218
+ const row: SearchRefItem = {
219
+ url: source.url,
220
+ host: hostOf(source.url),
221
+ ...(source.title !== undefined ? { title: source.title } : {}),
222
+ ...(source.snippet !== undefined ? { snippet: source.snippet } : {}),
223
+ query,
224
+ count: at < 0 ? 1 : (items[at]?.count ?? 0) + 1,
225
+ lastTurn: turn,
226
+ }
227
+ if (at >= 0) items.splice(at, 1)
228
+ else sourceTotal += 1
229
+ items.unshift(row)
230
+ }
231
+
232
+ // Queries: newest first, one row per distinct query text, hits accumulating.
233
+ const queries = [...state.queries]
234
+ const qAt = queries.findIndex(row => row.query === query)
235
+ let queryTotal = state.queryTotal
236
+ if (qAt >= 0) {
237
+ const previous = queries[qAt]
238
+ queries.splice(qAt, 1)
239
+ queries.unshift({ query, turn, hits: (previous?.hits ?? 0) + sources.length })
240
+ } else {
241
+ queries.unshift({ query, turn, hits: sources.length })
242
+ queryTotal += 1
243
+ }
244
+
245
+ const latest = state.latest
246
+ const nextLatest = latest !== null && latest.turn === turn
247
+ ? { turn: latest.turn, urls: [...urls.reverse(), ...latest.urls].slice(0, CAPS.latest) }
248
+ : latest
249
+
250
+ return {
251
+ ...state,
252
+ items: items.slice(0, CAPS.items),
253
+ queries: queries.slice(0, CAPS.queries),
254
+ sourceTotal,
255
+ queryTotal,
256
+ latest: nextLatest,
257
+ }
258
+ }