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.
Files changed (63) hide show
  1. package/CHANGELOG.ja.md +207 -242
  2. package/CHANGELOG.ko.md +207 -242
  3. package/CHANGELOG.md +337 -341
  4. package/INSTALL.ja.md +164 -179
  5. package/INSTALL.ko.md +164 -179
  6. package/INSTALL.md +164 -179
  7. package/INSTALL.zh.md +164 -179
  8. package/LICENSE +21 -21
  9. package/README.en.md +4 -12
  10. package/README.ja.md +0 -12
  11. package/README.ko.md +0 -12
  12. package/README.md +4 -14
  13. package/cordis.patch.yml +16 -11
  14. package/dsh.plugin.json +4 -4
  15. package/lib/client.js +3882 -690
  16. package/lib/index.d.ts +759 -29
  17. package/lib/index.js +2604 -93
  18. package/locale/de.json +6 -0
  19. package/locale/en.json +6 -0
  20. package/locale/es.json +6 -0
  21. package/locale/fr.json +6 -0
  22. package/locale/it.json +6 -0
  23. package/locale/ja.json +6 -0
  24. package/locale/ko.json +6 -0
  25. package/locale/ru.json +6 -0
  26. package/locale/zh.json +6 -0
  27. package/package.json +43 -32
  28. package/src/client/bridge-scope.ts +213 -0
  29. package/src/client/card.tsx +150 -150
  30. package/src/client/history-panel.tsx +319 -319
  31. package/src/client/index.ts +465 -354
  32. package/src/client/locales.ts +857 -698
  33. package/src/client/panel.tsx +139 -118
  34. package/src/client/search/card.tsx +563 -0
  35. package/src/client/search/host-api.ts +208 -0
  36. package/src/client/search/locales.ts +780 -0
  37. package/src/client/search/manage.tsx +400 -0
  38. package/src/client/search/panel.tsx +815 -0
  39. package/src/client/search/platform.ts +136 -0
  40. package/src/config.ts +82 -33
  41. package/src/host/health/attribution.ts +118 -118
  42. package/src/host/health/lossless.ts +128 -128
  43. package/src/host/health/source-kind.ts +350 -350
  44. package/src/host/history/archive-source.ts +250 -202
  45. package/src/host/history/archive.ts +249 -194
  46. package/src/host/history/favorites.ts +71 -0
  47. package/src/host/history/purge.ts +370 -370
  48. package/src/host/index/archive-source.ts +90 -0
  49. package/src/host/index/engine.ts +571 -0
  50. package/src/host/index/extract.ts +272 -0
  51. package/src/host/index/peers.ts +62 -0
  52. package/src/host/index/rebuild.ts +368 -0
  53. package/src/host/index/schema.ts +136 -0
  54. package/src/host/index/snapshot.ts +135 -0
  55. package/src/host/index/sync.ts +286 -0
  56. package/src/host/registry/archive-registry.ts +317 -0
  57. package/src/host/settings-bridge.ts +237 -0
  58. package/src/index.ts +1421 -532
  59. package/README.de.md +0 -120
  60. package/README.es.md +0 -120
  61. package/README.fr.md +0 -120
  62. package/README.it.md +0 -120
  63. 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
+ }