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.
- package/dsh.plugin.json +1 -1
- package/lib/client.js +1025 -441
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +303 -11
- package/lib/index.mjs.map +1 -1
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/summary/ReferenceSection.d.ts +24 -0
- package/lib/types/client/summary/SearchRefSection.d.ts +21 -0
- package/lib/types/client/summary/SkillUsageSection.d.ts +6 -0
- package/lib/types/client/summary/search-refs.d.ts +38 -0
- package/lib/types/projection/keys.d.ts +42 -0
- package/lib/types/projection/register.d.ts +6 -0
- package/lib/types/projection/search-ref-fold.d.ts +116 -0
- package/package.json +1 -1
- package/src/client/SummaryTab.tsx +12 -18
- package/src/client/locales.ts +117 -0
- package/src/client/summary/ReferenceSection.tsx +211 -0
- package/src/client/summary/SearchRefSection.tsx +241 -0
- package/src/client/summary/SkillUsageSection.tsx +8 -2
- package/src/client/summary/search-refs.ts +116 -0
- package/src/index.ts +4 -1
- package/src/projection/keys.ts +43 -0
- package/src/projection/register.ts +82 -2
- package/src/projection/search-ref-fold.ts +258 -0
|
@@ -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 {
|
|
24
|
-
import type {
|
|
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
|
+
}
|