dsh-manage-session 0.5.1 → 0.5.3
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/CHANGELOG.ja.md +207 -242
- package/CHANGELOG.ko.md +207 -242
- package/CHANGELOG.md +337 -341
- package/INSTALL.ja.md +164 -179
- package/INSTALL.ko.md +164 -179
- package/INSTALL.md +164 -179
- package/INSTALL.zh.md +164 -179
- package/LICENSE +21 -21
- package/README.en.md +4 -12
- package/README.ja.md +0 -12
- package/README.ko.md +0 -12
- package/README.md +4 -14
- package/cordis.patch.yml +16 -11
- package/dsh.plugin.json +4 -4
- package/lib/client.js +3882 -690
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/card.tsx +150 -150
- package/src/client/history-panel.tsx +319 -319
- package/src/client/index.ts +465 -354
- package/src/client/locales.ts +857 -698
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +82 -33
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -202
- package/src/host/history/archive.ts +249 -194
- package/src/host/history/favorites.ts +71 -0
- package/src/host/history/purge.ts +370 -370
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
- package/README.de.md +0 -120
- package/README.es.md +0 -120
- package/README.fr.md +0 -120
- package/README.it.md +0 -120
- package/README.ru.md +0 -120
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-party semantic text extraction for the independent switch-search index.
|
|
3
|
+
*
|
|
4
|
+
* Semantics mirror the official session-query `extractSessionEventText` and the
|
|
5
|
+
* core-session surface fold, mirrored structurally: the plugin must not
|
|
6
|
+
* value-import official packages, so the event data is inspected as plain
|
|
7
|
+
* records. Structural boundaries, embedded raw streams, request envelopes, and
|
|
8
|
+
* unknown declaration-merged events contribute no text.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One raw session event the plugin reads through sessionQuery.readSession. */
|
|
12
|
+
export interface SwitchRawEvent {
|
|
13
|
+
seq: number
|
|
14
|
+
type: string
|
|
15
|
+
time?: number
|
|
16
|
+
ignorable?: boolean
|
|
17
|
+
surfaceOp?: unknown
|
|
18
|
+
data: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 从会话事件流里抽出**最后一帧标题**(`session/title` log-only 事件)。
|
|
23
|
+
*
|
|
24
|
+
* 这是标题的基础层:入索引时随事件白拿,不依赖 readTitleSnapshots(后者
|
|
25
|
+
* 对归档 id 可能整批失败——beta.4 标题全军覆没的根因)。数据形状按历史
|
|
26
|
+
* 演变兼容三种:`{ title: string }`、`{ title: { title } }`、`{ title: { val } }`。
|
|
27
|
+
*/
|
|
28
|
+
export function extractTitleFromEvents(events: readonly SwitchRawEvent[]): string {
|
|
29
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
30
|
+
const event = events[i]
|
|
31
|
+
if (event === null || typeof event !== 'object') continue
|
|
32
|
+
if (event.type !== 'session/title') continue
|
|
33
|
+
const data = event.data as { title?: unknown } | undefined
|
|
34
|
+
const title = data?.title
|
|
35
|
+
if (typeof title === 'string' && title.trim() !== '') return title
|
|
36
|
+
if (title !== null && typeof title === 'object') {
|
|
37
|
+
const inner = title as { title?: unknown; val?: unknown }
|
|
38
|
+
if (typeof inner.title === 'string' && inner.title.trim() !== '') return inner.title
|
|
39
|
+
if (typeof inner.val === 'string' && inner.val.trim() !== '') return inner.val
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Surface membership of one indexed document. */
|
|
46
|
+
export type SwitchSurface = 'current' | 'shadowed'
|
|
47
|
+
|
|
48
|
+
/** One searchable document projected from one raw event. */
|
|
49
|
+
export interface SwitchIndexDoc {
|
|
50
|
+
sessionId: string
|
|
51
|
+
seq: number
|
|
52
|
+
type: string
|
|
53
|
+
time: number
|
|
54
|
+
surface: SwitchSurface
|
|
55
|
+
text: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ICU word segmenter shared by index and query paths (Node >= 16 / all evergreen
|
|
60
|
+
* browsers, zero dependency). 'zh' sensitivity keeps CJK word granularity.
|
|
61
|
+
*/
|
|
62
|
+
const SEGMENTER = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
|
63
|
+
? new Intl.Segmenter('zh', { granularity: 'word' })
|
|
64
|
+
: undefined
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Space-separate word boundaries so the FTS5 unicode61 tokenizer indexes
|
|
68
|
+
* words instead of whole CJK runs: the index and query sides must apply the
|
|
69
|
+
* exact same segmentation for a token to meet its match.
|
|
70
|
+
* @param text - raw extracted text (or a query term).
|
|
71
|
+
* @returns text with a single space at every word boundary.
|
|
72
|
+
*/
|
|
73
|
+
export function segmentForIndex(text: string): string {
|
|
74
|
+
if (SEGMENTER === undefined) return text
|
|
75
|
+
const parts: string[] = []
|
|
76
|
+
for (const { segment } of SEGMENTER.segment(text)) {
|
|
77
|
+
const piece = segment.trim()
|
|
78
|
+
if (piece !== '') parts.push(piece)
|
|
79
|
+
}
|
|
80
|
+
return parts.join(' ')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Segment one whitespace-delimited query term into FTS5 phrase tokens.
|
|
85
|
+
* @returns word-like segments, or the raw term when segmentation is unavailable.
|
|
86
|
+
*/
|
|
87
|
+
export function segmentQueryTerm(term: string): string[] {
|
|
88
|
+
if (SEGMENTER === undefined) return [term]
|
|
89
|
+
const words: string[] = []
|
|
90
|
+
for (const { segment, isWordLike } of SEGMENTER.segment(term)) {
|
|
91
|
+
const piece = segment.trim()
|
|
92
|
+
if (piece !== '' && isWordLike === true) words.push(piece)
|
|
93
|
+
}
|
|
94
|
+
return words.length > 0 ? words : [term]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether a runtime value is a plain record. */
|
|
98
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Trimmed text of one string-or-undefined part list, joined by newlines. */
|
|
103
|
+
function joinText(parts: readonly (string | undefined)[]): string {
|
|
104
|
+
return parts.map(part => (typeof part === 'string' ? part.trim() : '')).filter(Boolean).join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Extracted text of one content block; unknown blocks contribute nothing. */
|
|
108
|
+
function blockText(block: unknown): string[] {
|
|
109
|
+
if (!isRecord(block)) return []
|
|
110
|
+
switch (block['type']) {
|
|
111
|
+
case 'text':
|
|
112
|
+
return typeof block['text'] === 'string' ? [block['text']] : []
|
|
113
|
+
case 'reasoning':
|
|
114
|
+
return []
|
|
115
|
+
case 'tool-call':
|
|
116
|
+
return [block['name'], block['arguments']].filter((v): v is string => typeof v === 'string')
|
|
117
|
+
case 'tool-result': {
|
|
118
|
+
const content = block['content']
|
|
119
|
+
return Array.isArray(content) ? content.flatMap(blockText) : []
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return []
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Text of one message content (array of blocks, or a plain string). */
|
|
127
|
+
function contentText(content: unknown): string {
|
|
128
|
+
if (typeof content === 'string') return content.trim()
|
|
129
|
+
if (!Array.isArray(content)) return ''
|
|
130
|
+
return joinText(content.flatMap(blockText))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Turn-end reason text; completed turns contribute nothing. */
|
|
134
|
+
function turnEndText(data: Record<string, unknown>): string {
|
|
135
|
+
const reason = data['reason']
|
|
136
|
+
if (!isRecord(reason)) return ''
|
|
137
|
+
switch (reason['kind']) {
|
|
138
|
+
case 'error': {
|
|
139
|
+
const error = reason['error']
|
|
140
|
+
const message = isRecord(error) && typeof error['message'] === 'string' ? error['message'] : ''
|
|
141
|
+
return joinText(['error', message])
|
|
142
|
+
}
|
|
143
|
+
case 'aborted':
|
|
144
|
+
return 'aborted'
|
|
145
|
+
case 'max-tokens':
|
|
146
|
+
case 'interrupted':
|
|
147
|
+
return String(reason['kind'])
|
|
148
|
+
case 'completed':
|
|
149
|
+
return ''
|
|
150
|
+
// Unknown outcomes stay out until their owner defines search semantics.
|
|
151
|
+
default:
|
|
152
|
+
return ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract searchable semantic text from one raw session event.
|
|
158
|
+
* @param event - event to inspect.
|
|
159
|
+
* @returns newline-joined semantic text, or an empty string when non-searchable.
|
|
160
|
+
*/
|
|
161
|
+
export function extractSessionEventText(event: SwitchRawEvent): string {
|
|
162
|
+
const data = isRecord(event.data) ? event.data : {}
|
|
163
|
+
switch (event.type) {
|
|
164
|
+
case 'user/message':
|
|
165
|
+
return contentText(data['content'])
|
|
166
|
+
case 'assistant/message': {
|
|
167
|
+
const message = isRecord(data['message']) ? data['message'] : {}
|
|
168
|
+
return contentText(message['content'])
|
|
169
|
+
}
|
|
170
|
+
case 'tool/call':
|
|
171
|
+
return joinText([data['name'], data['arguments']].filter((v): v is string => typeof v === 'string'))
|
|
172
|
+
case 'tool/result': {
|
|
173
|
+
const message = isRecord(data['message']) ? data['message'] : {}
|
|
174
|
+
const error = isRecord(data['error']) ? data['error'] : {}
|
|
175
|
+
return joinText([
|
|
176
|
+
contentText(message['content']),
|
|
177
|
+
typeof error['name'] === 'string' ? error['name'] : '',
|
|
178
|
+
typeof error['code'] === 'string' ? error['code'] : '',
|
|
179
|
+
])
|
|
180
|
+
}
|
|
181
|
+
case 'todo/write': {
|
|
182
|
+
const todos = Array.isArray(data['todos']) ? data['todos'] : []
|
|
183
|
+
return joinText(todos.flatMap((todo) => {
|
|
184
|
+
if (!isRecord(todo)) return []
|
|
185
|
+
return [todo['status'], todo['content']].filter((v): v is string => typeof v === 'string')
|
|
186
|
+
}))
|
|
187
|
+
}
|
|
188
|
+
case 'turn/end':
|
|
189
|
+
return turnEndText(data)
|
|
190
|
+
case 'turn/start':
|
|
191
|
+
case 'step/start':
|
|
192
|
+
case 'step/end':
|
|
193
|
+
case 'assistant/attempt':
|
|
194
|
+
case 'request/header':
|
|
195
|
+
return ''
|
|
196
|
+
default:
|
|
197
|
+
return ''
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Event types whose ops participate in the surface fold. */
|
|
202
|
+
const SURFACE_ELIGIBLE_TYPES: ReadonlySet<string> = new Set([
|
|
203
|
+
'user/message',
|
|
204
|
+
'assistant/message',
|
|
205
|
+
'tool/call',
|
|
206
|
+
'tool/result',
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Classify raw-log events into current vs shadowed surface membership.
|
|
211
|
+
*
|
|
212
|
+
* Simplified fold of the official `foldSurface`: append events join the
|
|
213
|
+
* surface, and a `replace` op shadows the declared inclusive seq range plus
|
|
214
|
+
* removes it from the surface. Validation is intentionally lax — a broken op
|
|
215
|
+
* degrades to append rather than failing the whole index build.
|
|
216
|
+
* @param events - complete contiguous raw event log.
|
|
217
|
+
* @returns seq → surface map; entries absent from the map are log-only.
|
|
218
|
+
*/
|
|
219
|
+
export function classifySurface(events: readonly SwitchRawEvent[]): Map<number, SwitchSurface> {
|
|
220
|
+
const surface = new Map<number, SwitchSurface>()
|
|
221
|
+
const nodes: number[] = []
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
if (!SURFACE_ELIGIBLE_TYPES.has(event.type)) continue
|
|
224
|
+
const op = event.surfaceOp
|
|
225
|
+
if (op === 'append' || op === undefined) {
|
|
226
|
+
nodes.push(event.seq)
|
|
227
|
+
surface.set(event.seq, 'current')
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (isRecord(op) && op['op'] === 'replace'
|
|
231
|
+
&& typeof op['startSeq'] === 'number' && typeof op['endSeq'] === 'number') {
|
|
232
|
+
const shadowed = new Set<number>()
|
|
233
|
+
for (const seq of nodes) {
|
|
234
|
+
if (seq >= (op['startSeq'] as number) && seq <= (op['endSeq'] as number)) shadowed.add(seq)
|
|
235
|
+
}
|
|
236
|
+
const kept = nodes.filter(seq => !shadowed.has(seq))
|
|
237
|
+
nodes.length = 0
|
|
238
|
+
nodes.push(...kept, event.seq)
|
|
239
|
+
for (const seq of shadowed) surface.set(seq, 'shadowed')
|
|
240
|
+
surface.set(event.seq, 'current')
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
// Unknown op shape: degrade to append.
|
|
244
|
+
nodes.push(event.seq)
|
|
245
|
+
surface.set(event.seq, 'current')
|
|
246
|
+
}
|
|
247
|
+
return surface
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Project one complete raw log into searchable documents.
|
|
252
|
+
* @param sessionId - session that owns the log.
|
|
253
|
+
* @param events - complete contiguous raw event log.
|
|
254
|
+
* @returns documents in ascending seq order; structural events are omitted.
|
|
255
|
+
*/
|
|
256
|
+
export function buildIndexDocuments(sessionId: string, events: readonly SwitchRawEvent[]): SwitchIndexDoc[] {
|
|
257
|
+
const surfaceBySeq = classifySurface(events)
|
|
258
|
+
const documents: SwitchIndexDoc[] = []
|
|
259
|
+
for (const event of events) {
|
|
260
|
+
const text = extractSessionEventText(event)
|
|
261
|
+
if (text.length === 0) continue
|
|
262
|
+
documents.push({
|
|
263
|
+
sessionId,
|
|
264
|
+
seq: event.seq,
|
|
265
|
+
type: event.type,
|
|
266
|
+
time: typeof event.time === 'number' ? event.time : 0,
|
|
267
|
+
surface: surfaceBySeq.get(event.seq) ?? 'shadowed',
|
|
268
|
+
text,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
return documents
|
|
272
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence probe for the peer plugin that owns the archived-session domain.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* This package *reads* the official archive set — archived sessions are
|
|
7
|
+
* excluded from the index — but it no longer *owns* archiving: browsing and
|
|
8
|
+
* disposing of archived sessions moved to `dsh-manage-session`. The settings
|
|
9
|
+
* card went on reporting a bare archived count, with no pointer to the plugin
|
|
10
|
+
* that can act on it. A number the user cannot act on, next to a capability
|
|
11
|
+
* that lives somewhere else, reads as a broken feature.
|
|
12
|
+
*
|
|
13
|
+
* The pointer has to say different things depending on whether the owner is
|
|
14
|
+
* actually installed, and only the host half can answer that: the client
|
|
15
|
+
* bundle cannot resolve another package, and the slot registry exposes no
|
|
16
|
+
* "is anything registered under this id" query.
|
|
17
|
+
*
|
|
18
|
+
* Three states, never two. A probe that cannot run must not be reported as
|
|
19
|
+
* "missing" — that would tell the user to install something they already have.
|
|
20
|
+
*/
|
|
21
|
+
import { createRequire } from 'node:module'
|
|
22
|
+
|
|
23
|
+
/** Resolution outcome for a peer plugin package. */
|
|
24
|
+
export type PeerPresence = 'installed' | 'missing' | 'unknown'
|
|
25
|
+
|
|
26
|
+
/** The package that owns the archived-session domain. */
|
|
27
|
+
export const STEWARD_PACKAGE = 'dsh-manage-session'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Probe whether a package resolves from this plugin's own module graph.
|
|
31
|
+
*
|
|
32
|
+
* The bundle lives at `<profile>/node_modules/dsh-search-index/lib/index.mjs`,
|
|
33
|
+
* so resolution runs against `<profile>/node_modules` — exactly where a
|
|
34
|
+
* profile dependency lands, and therefore the same place the host would load
|
|
35
|
+
* the peer from.
|
|
36
|
+
*
|
|
37
|
+
* @param name - the package name to resolve.
|
|
38
|
+
* @param base - resolution base; defaults to this module's own URL, i.e. the
|
|
39
|
+
* plugin's install directory. Injectable so the classification can be tested
|
|
40
|
+
* against a fixture that fails in a way this checkout cannot produce.
|
|
41
|
+
* @returns `installed` when it resolves; `missing` only for a genuine
|
|
42
|
+
* module-not-found; `unknown` for every other failure, because an
|
|
43
|
+
* unanswerable probe is not evidence of absence.
|
|
44
|
+
*/
|
|
45
|
+
export function probePeer(name: string, base: string = import.meta.url): PeerPresence {
|
|
46
|
+
try {
|
|
47
|
+
createRequire(base).resolve(name)
|
|
48
|
+
return 'installed'
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const code = (err as { code?: unknown } | null | undefined)?.code
|
|
51
|
+
return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND' ? 'missing' : 'unknown'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Probe whether {@link STEWARD_PACKAGE} is installed alongside this plugin.
|
|
57
|
+
*
|
|
58
|
+
* @returns the resolution state, never a throw.
|
|
59
|
+
*/
|
|
60
|
+
export function detectSteward(): PeerPresence {
|
|
61
|
+
return probePeer(STEWARD_PACKAGE)
|
|
62
|
+
}
|