dsh-manage-session 0.5.7 → 2.0.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.
Files changed (67) hide show
  1. package/CHANGELOG.ja.md +33 -229
  2. package/CHANGELOG.ko.md +33 -229
  3. package/CHANGELOG.md +33 -363
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +41 -46
  10. package/README.ja.md +33 -57
  11. package/README.ko.md +32 -55
  12. package/README.md +64 -64
  13. package/cordis.patch.yml +19 -16
  14. package/dsh.plugin.json +4 -4
  15. package/lib/client.js +661 -3082
  16. package/lib/index.d.ts +79 -970
  17. package/lib/index.js +249 -2669
  18. package/locale/de.json +2 -2
  19. package/locale/en.json +2 -2
  20. package/locale/es.json +2 -2
  21. package/locale/fr.json +2 -2
  22. package/locale/it.json +2 -2
  23. package/locale/ja.json +2 -2
  24. package/locale/ko.json +2 -2
  25. package/locale/ru.json +2 -2
  26. package/locale/zh.json +2 -2
  27. package/package.json +15 -25
  28. package/screenshots.json +3 -3
  29. package/src/client/bridge-scope.ts +202 -213
  30. package/src/client/card.tsx +154 -154
  31. package/src/client/health-panel.tsx +49 -49
  32. package/src/client/host-api.ts +72 -6
  33. package/src/client/index.ts +594 -503
  34. package/src/client/locales.ts +893 -893
  35. package/src/client/manage-locales.ts +178 -0
  36. package/src/client/{search/manage.tsx → manage.tsx} +430 -404
  37. package/src/client/panel.tsx +131 -129
  38. package/src/client/session-menu-legacy.ts +2 -3
  39. package/src/client/session-menu.tsx +5 -5
  40. package/src/config.ts +43 -99
  41. package/src/host/health/attribution.ts +118 -118
  42. package/src/host/health/lossless.ts +128 -128
  43. package/src/host/health/repair.ts +2 -2
  44. package/src/host/health/source-kind.ts +350 -350
  45. package/src/host/history/archive-source.ts +250 -250
  46. package/src/host/history/archive.ts +249 -249
  47. package/src/host/history/favorites.ts +71 -71
  48. package/src/host/history/purge.ts +370 -370
  49. package/src/host/history/tombstones.ts +3 -3
  50. package/src/host/registry/archive-registry.ts +317 -317
  51. package/src/host/settings-bridge.ts +222 -264
  52. package/src/index.ts +66 -694
  53. package/src/client/search/card.tsx +0 -563
  54. package/src/client/search/host-api.ts +0 -213
  55. package/src/client/search/locales.ts +0 -861
  56. package/src/client/search/locate.ts +0 -330
  57. package/src/client/search/panel.tsx +0 -940
  58. package/src/client/search/platform.ts +0 -136
  59. package/src/host/index/archive-source.ts +0 -90
  60. package/src/host/index/drs-source.ts +0 -211
  61. package/src/host/index/engine.ts +0 -699
  62. package/src/host/index/extract.ts +0 -272
  63. package/src/host/index/peers.ts +0 -62
  64. package/src/host/index/rebuild.ts +0 -432
  65. package/src/host/index/schema.ts +0 -136
  66. package/src/host/index/snapshot.ts +0 -135
  67. package/src/host/index/sync.ts +0 -413
@@ -1,699 +0,0 @@
1
- /**
2
- * The independent switch-search retrieval engine.
3
- *
4
- * Owns one node:sqlite handle (the plugin's own file, never the official
5
- * session-query index), ingests complete session logs, and answers
6
- * session-grouped full-text queries with the strongest per-session hit.
7
- *
8
- * Search pipeline: text is word-segmented with Intl.Segmenter and stored in
9
- * the docs.index_text column; an external-content FTS5 (unicode61) virtual
10
- * table builds its inverted index from that column without duplicating text
11
- * storage. Queries run through the same segmentation, so CJK matches on word
12
- * boundaries with a trailing-token prefix for partial input.
13
- */
14
- import type { DatabaseSync } from 'node:sqlite'
15
- import { openIndexDatabase, type SwitchSqliteDriver } from './schema.ts'
16
- import { buildIndexDocuments, segmentForIndex, segmentQueryTerm, type SwitchRawEvent } from './extract.ts'
17
-
18
- /** One indexed session header row. */
19
- export interface SwitchIndexedSession {
20
- sessionId: string
21
- version: number
22
- title: string
23
- cwd: string
24
- updatedAt: number
25
- indexedAt: number
26
- archived: boolean
27
- }
28
-
29
- /** One session-grouped search hit. */
30
- export interface SwitchSearchHit {
31
- sessionId: string
32
- title: string
33
- seq: number
34
- type: string
35
- /** Timestamp of the strongest matching document. Per-hit, not per-session. */
36
- time: number
37
- /**
38
- * Session-level last-activity timestamp. Distinct from `time`: a session
39
- * may hold an old best match yet have moved one minute ago. This is the
40
- * field recency ordering and any client-side re-sort must key on.
41
- */
42
- updatedAt: number
43
- snippet: string
44
- }
45
-
46
- /**
47
- * Result ordering for one search.
48
- * - `relevance` (default) — weighted BM25, the historical behaviour.
49
- * - `time` — session recency first, relevance as the tie-break.
50
- */
51
- export type SwitchSearchSort = 'relevance' | 'time'
52
-
53
- /** Coarse type-filter buckets mapped onto raw session event types. */
54
- export type SwitchIndexContentType = 'all' | 'user' | 'reply' | 'tool'
55
-
56
- /** Coarse filter → raw event types. */
57
- const CONTENT_TYPE_GROUPS: Readonly<Record<Exclude<SwitchIndexContentType, 'all'>, readonly string[]>> = {
58
- user: ['user/message'],
59
- reply: ['assistant/message'],
60
- tool: ['tool/call', 'tool/result'],
61
- }
62
-
63
- /** Search weight per event type (message content outranks tool chatter). */
64
- const TYPE_WEIGHT: Readonly<Record<string, number>> = {
65
- 'user/message': 3,
66
- 'assistant/message': 3,
67
- 'tool/call': 1,
68
- 'tool/result': 1,
69
- }
70
-
71
- /** Maximum snippet length in characters, aligned with the official route. */
72
- const SNIPPET_CHARS = 240
73
-
74
- /** Maximum FTS matches inspected per query before session grouping. */
75
- const MATCH_SCAN_LIMIT = 5000
76
-
77
- /**
78
- * Sanitize free text into a safe FTS5 query over the segmented index: each
79
- * whitespace term is segmented into word tokens, quoted as an adjacent
80
- * phrase, and the last token carries a prefix `*` so partial input matches
81
- * ("正在搜" hits 正在搜索). Terms AND together.
82
- */
83
- export function sanitizeFtsQuery(query: string): string {
84
- const terms = query.split(/\s+/u).filter(Boolean)
85
- if (terms.length === 0) return ''
86
- const phrases: string[] = []
87
- for (const term of terms) {
88
- const words = segmentQueryTerm(term)
89
- .map(word => word.replace(/"/g, '""'))
90
- .filter(word => word !== '')
91
- if (words.length === 0) continue
92
- phrases.push(`"${words.join(' ')}"*`)
93
- }
94
- return phrases.join(' ')
95
- }
96
-
97
- /** Build a snippet around the first term occurrence, official-route aligned. */
98
- export function buildSnippet(text: string, query: string, max = SNIPPET_CHARS): string {
99
- const flat = text.replace(/\s+/gu, ' ').trim()
100
- if (flat.length <= max) return flat
101
- const lower = flat.toLowerCase()
102
- const terms = query.toLowerCase().split(/\s+/u).filter(Boolean)
103
- let anchor = -1
104
- for (const term of terms) {
105
- const at = lower.indexOf(term)
106
- if (at >= 0 && (anchor < 0 || at < anchor)) anchor = at
107
- }
108
- if (anchor < 0) return `${flat.slice(0, max)}…`
109
- const start = Math.max(0, anchor - Math.floor((max - 3) / 2))
110
- const end = Math.min(flat.length, start + max - 3)
111
- const head = start > 0 ? '…' : ''
112
- const tail = end < flat.length ? '…' : ''
113
- return `${head}${flat.slice(start, end)}${tail}`
114
- }
115
-
116
- /** Constructor options for one engine instance. */
117
- export interface SwitchIndexEngineOptions {
118
- /** Absolute path of the index file this engine owns. */
119
- path: string
120
- }
121
-
122
- /** One open index handle. All mutating calls are synchronous; callers pace
123
- * them off the HTTP hot path (background sync / rebuild tasks). */
124
- export class SwitchIndexEngine {
125
- private db: DatabaseSync | undefined
126
- private driver: SwitchSqliteDriver = 'node:sqlite'
127
- private inBatch = false
128
-
129
- constructor(private readonly options: SwitchIndexEngineOptions) {}
130
-
131
- /** Which SQLite driver is serving this handle. */
132
- get driverLabel(): SwitchSqliteDriver {
133
- return this.driver
134
- }
135
-
136
- /**
137
- * Run one write inside the current batched transaction, or its own
138
- * IMMEDIATE transaction when not batching (nested calls join the batch).
139
- */
140
- withWriteTx<T>(fn: () => T): T {
141
- const db = this.requireDb()
142
- if (this.inBatch) return fn()
143
- db.exec('BEGIN IMMEDIATE')
144
- this.inBatch = true
145
- try {
146
- const result = fn()
147
- db.exec('COMMIT')
148
- return result
149
- } catch (error) {
150
- try { db.exec('ROLLBACK') } catch { /* rollback of a broken txn is best-effort */ }
151
- throw error
152
- } finally {
153
- this.inBatch = false
154
- }
155
- }
156
-
157
- /**
158
- * Run one function as a single batched transaction (one fsync checkpoint):
159
- * upserts inside it join via withWriteTx instead of opening their own.
160
- */
161
- runBatched<T>(fn: () => T): T {
162
- return this.withWriteTx(fn)
163
- }
164
-
165
- /** Whether the handle is open. */
166
- get isOpen(): boolean {
167
- return this.db !== undefined
168
- }
169
-
170
- /** Open (creating or migrating) the index file. Idempotent. */
171
- async open(): Promise<void> {
172
- if (this.db !== undefined) return
173
- const opened = await openIndexDatabase(this.options.path)
174
- this.db = opened.db
175
- this.driver = opened.driver
176
- }
177
-
178
- /** Close the handle. Idempotent. */
179
- close(): void {
180
- this.db?.close()
181
- this.db = undefined
182
- }
183
-
184
- /** Remove one session's FTS entries for external-content bookkeeping. */
185
- private deleteSessionFts(db: DatabaseSync, sessionId: string): void {
186
- const existing = db.prepare('SELECT doc_id, index_text FROM docs WHERE session_id = ?')
187
- .all(sessionId) as { doc_id: number | bigint; index_text: string }[]
188
- const deleteFts = db.prepare(`
189
- INSERT INTO docs_fts(docs_fts, rowid, index_text) VALUES ('delete', ?, ?)
190
- `)
191
- for (const row of existing) deleteFts.run(Number(row.doc_id), row.index_text)
192
- }
193
-
194
- /** Insert or replace one session's documents and header row. */
195
- upsertSession(input: {
196
- sessionId: string
197
- version: number
198
- title?: string
199
- cwd?: string
200
- updatedAt?: number
201
- events: readonly SwitchRawEvent[]
202
- /**
203
- * 归档标记。缺省时**保留行上现有值**(归档态翻转由 setArchived 单独负责,
204
- * 全量重灌不改变归档状态);显式传入时以传入值为准(重建/导入路径)。
205
- */
206
- archived?: boolean
207
- }): number {
208
- const db = this.requireDb()
209
- const documents = buildIndexDocuments(input.sessionId, input.events)
210
- return this.withWriteTx(() => {
211
- this.deleteSessionFts(db, input.sessionId)
212
- db.prepare('DELETE FROM docs WHERE session_id = ?').run(input.sessionId)
213
- const insertDoc = db.prepare(`
214
- INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
215
- VALUES (?, ?, ?, ?, ?, ?, ?)
216
- `)
217
- const insertFts = db.prepare('INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)')
218
- for (const doc of documents) {
219
- const indexText = segmentForIndex(doc.text)
220
- const result = insertDoc.run(
221
- doc.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText,
222
- )
223
- insertFts.run(Number(result.lastInsertRowid), indexText)
224
- }
225
- db.prepare(`
226
- INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
227
- VALUES (?, ?, ?, ?, ?, ?, ?)
228
- ON CONFLICT(session_id) DO UPDATE SET
229
- version = excluded.version,
230
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
231
- cwd = excluded.cwd,
232
- updated_at = excluded.updated_at,
233
- indexed_at = excluded.indexed_at
234
- `).run(
235
- input.sessionId,
236
- input.version,
237
- input.title ?? '',
238
- input.cwd ?? '',
239
- input.updatedAt ?? 0,
240
- Date.now(),
241
- input.archived === true ? 1 : 0,
242
- )
243
- return documents.length
244
- })
245
- }
246
-
247
- /**
248
- * 该会话是否已有正文(issue #1:hollow 行判据)。
249
- * LIMIT 1 探测命中 docs_session 索引,每轮 poll 对每个未变更会话的开销可忽略。
250
- */
251
- hasSessionDocs(sessionId: string): boolean {
252
- const db = this.requireDb()
253
- return db.prepare('SELECT 1 FROM docs WHERE session_id = ? LIMIT 1').get(sessionId) !== undefined
254
- }
255
-
256
- /**
257
- * 「有表头无正文」的会话 id(升序)—— hollow 体检面。补录后仍 hollow 的行
258
- * 包含无可索引文本的真空会话(合法终态,见 sync 的 confirmedEmpty 缓存)。
259
- */
260
- listHollowSessions(): string[] {
261
- const db = this.requireDb()
262
- return (db.prepare(`
263
- SELECT s.session_id FROM sessions s
264
- WHERE NOT EXISTS (SELECT 1 FROM docs d WHERE d.session_id = s.session_id)
265
- ORDER BY s.session_id
266
- `).all() as { session_id: string }[]).map(row => row.session_id)
267
- }
268
-
269
- /**
270
- * `PRAGMA integrity_check` 首行:'ok' 或损坏报告串。swap 后完整性闸门用。
271
- */
272
- integrityCheck(): string {
273
- const db = this.requireDb()
274
- const row = db.prepare('PRAGMA integrity_check').get() as Record<string, unknown> | undefined
275
- if (row === undefined) return 'no result'
276
- const first = Object.values(row)[0]
277
- return String(first ?? 'no result')
278
- }
279
-
280
- /**
281
- * Write an archived session's header row without any document content.
282
- *
283
- * @deprecated 旧"归档即软删"语义的遗物,合并包 R1 已改为归档保留 docs;仅剩
284
- * 旧快照兼容路径可落 header 行。新代码一律 upsertSession + setArchived。
285
- */
286
- upsertArchivedHeader(input: {
287
- sessionId: string
288
- version: number
289
- title?: string
290
- cwd?: string
291
- updatedAt?: number
292
- }): void {
293
- const db = this.requireDb()
294
- this.withWriteTx(() => {
295
- this.deleteSessionFts(db, input.sessionId)
296
- db.prepare('DELETE FROM docs WHERE session_id = ?').run(input.sessionId)
297
- db.prepare(`
298
- INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
299
- VALUES (?, ?, ?, ?, ?, ?, 1)
300
- ON CONFLICT(session_id) DO UPDATE SET
301
- version = excluded.version,
302
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
303
- cwd = excluded.cwd,
304
- updated_at = excluded.updated_at,
305
- indexed_at = excluded.indexed_at,
306
- archived = 1
307
- `).run(
308
- input.sessionId,
309
- input.version,
310
- input.title ?? '',
311
- input.cwd ?? '',
312
- input.updatedAt ?? 0,
313
- Date.now(),
314
- )
315
- })
316
- }
317
-
318
- /**
319
- * Apply the official archive set: flip the archived flag both ways.
320
- *
321
- * 合表复用语义(R1):归档**保留全部 docs 与 FTS**——正文在归档态不可变
322
- * (归档门只拒新回合),翻转只是检索域成员资格的变化;恢复也不再需要重灌
323
- * (旧实现的 version = -1 强制重读随"归档即删 docs"一并废除)。
324
- */
325
- setArchived(archivedIds: ReadonlySet<string>): void {
326
- const db = this.requireDb()
327
- this.withWriteTx(() => {
328
- const rows = db.prepare('SELECT session_id, archived FROM sessions').all() as
329
- { session_id: string; archived: number }[]
330
- for (const row of rows) {
331
- const shouldBe = archivedIds.has(row.session_id) ? 1 : 0
332
- if (row.archived !== shouldBe) {
333
- db.prepare('UPDATE sessions SET archived = ? WHERE session_id = ?').run(shouldBe, row.session_id)
334
- }
335
- }
336
- })
337
- }
338
-
339
- /**
340
- * Flip one session's archived flag (steward write-side linkage). No-op when
341
- * the row does not exist — flag state on an unindexed session is meaningless.
342
- */
343
- setArchivedOne(sessionId: string, archived: boolean): void {
344
- const db = this.requireDb()
345
- this.withWriteTx(() => {
346
- db.prepare('UPDATE sessions SET archived = ? WHERE session_id = ?')
347
- .run(archived ? 1 : 0, sessionId)
348
- })
349
- }
350
-
351
- /** One session's stored documents, ascending seq (snapshot export face). */
352
- exportSessionDocs(sessionId: string): {
353
- seq: number
354
- type: string
355
- surface: string
356
- time: number
357
- text: string
358
- }[] {
359
- const db = this.requireDb()
360
- return db.prepare(`
361
- SELECT seq, type, surface, time, text FROM docs WHERE session_id = ? ORDER BY seq
362
- `).all(sessionId) as { seq: number; type: string; surface: string; time: number; text: string }[]
363
- }
364
-
365
- /**
366
- * Insert or replace one session from already-extracted documents
367
- * (snapshot import face; no re-extraction, what was exported is restored;
368
- * segmentation is recomputed for the current index format).
369
- */
370
- importSessionDocs(input: {
371
- sessionId: string
372
- version: number
373
- title?: string
374
- /** 归档标记随快照往返;旧快照缺省为活跃。 */
375
- archived?: boolean
376
- docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
377
- }): void {
378
- const db = this.requireDb()
379
- this.withWriteTx(() => {
380
- this.deleteSessionFts(db, input.sessionId)
381
- db.prepare('DELETE FROM docs WHERE session_id = ?').run(input.sessionId)
382
- const insertDoc = db.prepare(`
383
- INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
384
- VALUES (?, ?, ?, ?, ?, ?, ?)
385
- `)
386
- const insertFts = db.prepare('INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)')
387
- for (const doc of input.docs) {
388
- const indexText = segmentForIndex(doc.text)
389
- const result = insertDoc.run(
390
- input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText,
391
- )
392
- insertFts.run(Number(result.lastInsertRowid), indexText)
393
- }
394
- db.prepare(`
395
- INSERT INTO sessions (session_id, version, title, updated_at, indexed_at, archived)
396
- VALUES (?, ?, ?, 0, ?, ?)
397
- ON CONFLICT(session_id) DO UPDATE SET
398
- version = excluded.version,
399
- title = excluded.title,
400
- indexed_at = excluded.indexed_at,
401
- archived = excluded.archived
402
- `).run(input.sessionId, input.version, input.title ?? '', Date.now(), input.archived === true ? 1 : 0)
403
- })
404
- }
405
-
406
- /**
407
- * Insert or replace one session from corpus-source documents (T7.1 DRS
408
- * 语料源):docs 已由语料源抽好(surface 字符串),切分仍由本引擎重算。
409
- * 与 importSessionDocs 的三点差异,均为检索排序/水印所需:cwd 与
410
- * updated_at 直写(日期过滤/时间排序键);标题空串保留旧行(upsertSession
411
- * 同款 CASE——DRS 对老会话可能给不出标题,不能抹掉既有精修);归档旗标
412
- * 冲突时不动(翻转仍归 setArchived)。
413
- * @returns 落库 docs 数(0 = 该会话无可索引文本,合法终态)。
414
- */
415
- upsertSessionDocs(input: {
416
- sessionId: string
417
- version: number
418
- title?: string
419
- cwd?: string
420
- updatedAt?: number
421
- archived?: boolean
422
- docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
423
- }): number {
424
- const db = this.requireDb()
425
- return this.withWriteTx(() => {
426
- this.deleteSessionFts(db, input.sessionId)
427
- db.prepare('DELETE FROM docs WHERE session_id = ?').run(input.sessionId)
428
- const insertDoc = db.prepare(`
429
- INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
430
- VALUES (?, ?, ?, ?, ?, ?, ?)
431
- `)
432
- const insertFts = db.prepare('INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)')
433
- for (const doc of input.docs) {
434
- const indexText = segmentForIndex(doc.text)
435
- const result = insertDoc.run(
436
- input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText,
437
- )
438
- insertFts.run(Number(result.lastInsertRowid), indexText)
439
- }
440
- db.prepare(`
441
- INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
442
- VALUES (?, ?, ?, ?, ?, ?, ?)
443
- ON CONFLICT(session_id) DO UPDATE SET
444
- version = excluded.version,
445
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
446
- cwd = excluded.cwd,
447
- updated_at = excluded.updated_at,
448
- indexed_at = excluded.indexed_at
449
- `).run(
450
- input.sessionId,
451
- input.version,
452
- input.title ?? '',
453
- input.cwd ?? '',
454
- input.updatedAt ?? 0,
455
- Date.now(),
456
- input.archived === true ? 1 : 0,
457
- )
458
- return input.docs.length
459
- })
460
- }
461
-
462
- /**
463
- * 会话头直写(DRS 直连时代的同步轮专用,T7.3):只维护 sessions 元数据
464
- * (水位/标题/目录/末时间/归档),**不碰 docs/docs_fts**——内容检索
465
- * 已直连 dsh-reference-session-collector(op=search&group=session),本地正文索引
466
- * 退役,存量 docs 行自然老化不再新增。空标题保留旧行(同 upsertSessionDocs)。
467
- */
468
- upsertSessionHead(input: {
469
- sessionId: string
470
- version: number
471
- title?: string
472
- cwd?: string
473
- updatedAt?: number
474
- archived?: boolean
475
- }): void {
476
- const db = this.requireDb()
477
- this.withWriteTx(() => {
478
- db.prepare(`
479
- INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
480
- VALUES (?, ?, ?, ?, ?, ?, ?)
481
- ON CONFLICT(session_id) DO UPDATE SET
482
- version = excluded.version,
483
- title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
484
- cwd = excluded.cwd,
485
- updated_at = excluded.updated_at,
486
- indexed_at = excluded.indexed_at,
487
- archived = excluded.archived
488
- `).run(
489
- input.sessionId,
490
- input.version,
491
- input.title ?? '',
492
- input.cwd ?? '',
493
- input.updatedAt ?? 0,
494
- Date.now(),
495
- input.archived === true ? 1 : 0,
496
- )
497
- })
498
- }
499
-
500
- /** Remove one session and its documents entirely. */
501
- removeSession(sessionId: string): void {
502
- const db = this.requireDb()
503
- this.withWriteTx(() => {
504
- this.deleteSessionFts(db, sessionId)
505
- db.prepare('DELETE FROM docs WHERE session_id = ?').run(sessionId)
506
- db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId)
507
- })
508
- }
509
-
510
- /** Update only a session's header row (title backfill), keeping documents. */
511
- updateSessionHeader(input: { sessionId: string; title?: string; cwd?: string; updatedAt?: number }): void {
512
- const db = this.requireDb()
513
- const current = db.prepare('SELECT version FROM sessions WHERE session_id = ?').get(input.sessionId) as
514
- | { version: number }
515
- | undefined
516
- if (current === undefined) return
517
- db.prepare(`
518
- UPDATE sessions SET
519
- title = CASE WHEN ? != '' THEN ? ELSE title END,
520
- cwd = CASE WHEN ? != '' THEN ? ELSE cwd END,
521
- updated_at = CASE WHEN ? > 0 THEN ? ELSE updated_at END
522
- WHERE session_id = ?
523
- `).run(
524
- input.title ?? '', input.title ?? '',
525
- input.cwd ?? '', input.cwd ?? '',
526
- input.updatedAt ?? 0, input.updatedAt ?? 0,
527
- input.sessionId,
528
- )
529
- }
530
-
531
- /** One indexed session row, or undefined. */
532
- getSession(sessionId: string): SwitchIndexedSession | undefined {
533
- const db = this.requireDb()
534
- const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId) as
535
- | Record<string, unknown>
536
- | undefined
537
- return row === undefined ? undefined : rowToSession(row)
538
- }
539
-
540
- /** All indexed sessions (active AND archived), newest first — the title
541
- * corpus and the manage console both read this; the `archived` flag rides
542
- * each row so clients filter locally. */
543
- listIndexedSessions(): SwitchIndexedSession[] {
544
- const db = this.requireDb()
545
- const rows = db.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all() as Record<string, unknown>[]
546
- return rows.map(rowToSession)
547
- }
548
-
549
- /** Archived (soft-deleted) sessions, newest first — the archive viewer face. */
550
- listArchived(): SwitchIndexedSession[] {
551
- const db = this.requireDb()
552
- const rows = db.prepare('SELECT * FROM sessions WHERE archived = 1 ORDER BY updated_at DESC').all() as Record<string, unknown>[]
553
- return rows.map(rowToSession)
554
- }
555
-
556
- /** Number of indexed sessions (active + archived — the whole corpus). */
557
- countSessions(): number {
558
- const db = this.requireDb()
559
- const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number | bigint }
560
- return Number(row.n)
561
- }
562
-
563
- /** Number of archived (soft-deleted) sessions. */
564
- countArchived(): number {
565
- const db = this.requireDb()
566
- const row = db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE archived = 1').get() as { n: number | bigint }
567
- return Number(row.n)
568
- }
569
-
570
- /**
571
- * Run one session-grouped full-text search.
572
- *
573
- * One statement: the FTS match is bounded by rank in a subquery (its rowid
574
- * aligns with docs.doc_id), then the type/surface filters join in — no
575
- * second round-trip, no large IN parameter lists.
576
- * @param request - query text, coarse type filter, page size, ordering.
577
- * @returns hits ordered by `sortBy` (relevance by default).
578
- */
579
- search(request: {
580
- query: string
581
- types?: readonly SwitchIndexContentType[]
582
- limit?: number
583
- sortBy?: SwitchSearchSort
584
- /**
585
- * 检索域:`'active'` 仅活跃、`'archived'` 仅归档、`'all'`(缺省)两者。
586
- * 归档正文入索引后的筛选 chip 即此参数。
587
- */
588
- archived?: 'active' | 'archived' | 'all'
589
- /** 更新时间下界(epoch ms,含)。 */
590
- from?: number
591
- /** 更新时间上界(epoch ms,排他)。 */
592
- to?: number
593
- }): SwitchSearchHit[] {
594
- const db = this.requireDb()
595
- const match = sanitizeFtsQuery(request.query)
596
- if (match === '') return []
597
- const limit = Math.min(Math.max(1, request.limit ?? 20), 100)
598
- const types = resolveTypes(request.types)
599
- const placeholders = types.map(() => '?').join(', ')
600
- const archivedClause = request.archived === 'active'
601
- ? 'AND s.archived = 0'
602
- : request.archived === 'archived'
603
- ? 'AND s.archived = 1'
604
- : ''
605
- // 日期范围(R6):s.updated_at 上的半开区间 [from, to)。
606
- const dateClauses: string[] = []
607
- const dateParams: number[] = []
608
- if (typeof request.from === 'number' && Number.isFinite(request.from)) {
609
- dateClauses.push('AND s.updated_at >= ?')
610
- dateParams.push(request.from)
611
- }
612
- if (typeof request.to === 'number' && Number.isFinite(request.to)) {
613
- dateClauses.push('AND s.updated_at < ?')
614
- dateParams.push(request.to)
615
- }
616
- const dateClause = dateClauses.join(' ')
617
- const docs = db.prepare(`
618
- SELECT d.doc_id AS docId, d.session_id AS sessionId, d.seq, d.type, d.time, d.text,
619
- s.title, s.updated_at AS updatedAt, f.rank AS ftsRank
620
- FROM (
621
- SELECT rowid, rank FROM docs_fts WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?
622
- ) f
623
- JOIN docs d ON d.doc_id = f.rowid
624
- JOIN sessions s ON s.session_id = d.session_id
625
- WHERE d.type IN (${placeholders}) AND d.surface = 'current' ${archivedClause} ${dateClause}
626
- `).all(match, MATCH_SCAN_LIMIT, ...types, ...dateParams) as {
627
- docId: number | bigint
628
- sessionId: string
629
- seq: number
630
- type: string
631
- time: number
632
- text: string
633
- title: string
634
- updatedAt: number
635
- ftsRank: number
636
- }[]
637
- // Group by session; strongest hit = best weighted rank (bm25 rank is
638
- // negative-better, so weight scales its magnitude).
639
- const bestBySession = new Map<string, { doc: typeof docs[number]; score: number }>()
640
- for (const doc of docs) {
641
- const weight = TYPE_WEIGHT[doc.type] ?? 1
642
- const score = -Number(doc.ftsRank) * weight
643
- const best = bestBySession.get(doc.sessionId)
644
- if (best === undefined || score > best.score) bestBySession.set(doc.sessionId, { doc, score })
645
- }
646
- const grouped = [...bestBySession.values()]
647
- if (request.sortBy === 'time') {
648
- // Recency is a session-level concern, so it keys on `updatedAt` — not on
649
- // a hit's `time`, which only says when the best-matching document was
650
- // written. Relevance is the tie-break so equal timestamps stay sane.
651
- // Ordering happens before the slice: sorting the page afterwards would
652
- // only reorder an already relevance-truncated top-N.
653
- grouped.sort((a, b) => (b.doc.updatedAt - a.doc.updatedAt) || (b.score - a.score))
654
- } else {
655
- grouped.sort((a, b) => b.score - a.score)
656
- }
657
- return grouped
658
- .slice(0, limit)
659
- .map(({ doc }) => ({
660
- sessionId: doc.sessionId,
661
- title: doc.title,
662
- seq: doc.seq,
663
- type: doc.type,
664
- time: doc.time,
665
- updatedAt: Number(doc.updatedAt ?? 0),
666
- snippet: buildSnippet(doc.text, request.query),
667
- }))
668
- }
669
-
670
- private requireDb(): DatabaseSync {
671
- if (this.db === undefined) throw new Error('switch-search: index engine is not open')
672
- return this.db
673
- }
674
- }
675
-
676
- /** Materialize the coarse filter into raw event types (absent → user+reply). */
677
- export function resolveTypes(types: readonly SwitchIndexContentType[] | undefined): readonly string[] {
678
- if (types === undefined || types.length === 0) return ['user/message', 'assistant/message']
679
- const picked = new Set<Exclude<SwitchIndexContentType, 'all'>>()
680
- for (const entry of types) {
681
- if (entry === 'user' || entry === 'reply' || entry === 'tool') picked.add(entry)
682
- else return ['user/message', 'assistant/message', 'tool/call', 'tool/result']
683
- }
684
- if (picked.size === 0) return ['user/message', 'assistant/message']
685
- return [...picked].flatMap(entry => [...CONTENT_TYPE_GROUPS[entry]])
686
- }
687
-
688
- /** Map one raw sessions row onto the public face. */
689
- function rowToSession(row: Record<string, unknown>): SwitchIndexedSession {
690
- return {
691
- sessionId: String(row['session_id']),
692
- version: Number(row['version']),
693
- title: String(row['title'] ?? ''),
694
- cwd: String(row['cwd'] ?? ''),
695
- updatedAt: Number(row['updated_at'] ?? 0),
696
- indexedAt: Number(row['indexed_at'] ?? 0),
697
- archived: Number(row['archived'] ?? 0) === 1,
698
- }
699
- }