dsh-manage-session 0.5.1 → 0.5.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/CHANGELOG.ja.md +207 -242
- package/CHANGELOG.ko.md +207 -242
- package/CHANGELOG.md +341 -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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official archive-set source resolution.
|
|
3
|
+
*
|
|
4
|
+
* Primary: the in-process `workspaceRegistry` service (the same fact the UI
|
|
5
|
+
* filters by). Fallback: read the canonical storage hub file directly — the
|
|
6
|
+
* workspace domain persists `archivedSessionIds` under the `global` segment
|
|
7
|
+
* (storage-json: `~/.dsh/storages/workspace.json`; storage-sqlite variant
|
|
8
|
+
* exists but the JSON fallback file is what stock web profiles ship).
|
|
9
|
+
* Resolution order is decided per read; failures degrade to "no archive set"
|
|
10
|
+
* and are reported through the diagnostics face.
|
|
11
|
+
*
|
|
12
|
+
* READ-ONLY by contract. The archive set is WRITTEN by dsh-manage-session
|
|
13
|
+
* (会话管家 → 病案室); this package only consumes it to keep archived sessions
|
|
14
|
+
* out of the index. Format contract: `dsh-归档文件格式契约-20260914.md`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** One resolved archive read: ids plus which source served them. */
|
|
21
|
+
export interface SwitchArchiveRead {
|
|
22
|
+
ids: readonly string[]
|
|
23
|
+
source: 'registry' | 'storage-file' | 'none'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The workspaceRegistry mirror face (getter only). */
|
|
27
|
+
export interface SwitchRegistryFace {
|
|
28
|
+
readonly archivedSessionIds: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Diagnostics describing how the archive set is being resolved. */
|
|
32
|
+
export interface SwitchArchiveDiagnostics {
|
|
33
|
+
source: 'registry' | 'storage-file' | 'none'
|
|
34
|
+
ids: number
|
|
35
|
+
error?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** DSH storage hub candidates for the workspace domain (json backend). */
|
|
39
|
+
function storageFileCandidates(): string[] {
|
|
40
|
+
return [
|
|
41
|
+
join(homedir(), '.dsh', 'storages', 'workspace.json'),
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse the storage hub file's global.archivedSessionIds; throw on malformed content. */
|
|
46
|
+
function readStorageFile(path: string): readonly string[] {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
48
|
+
global?: { archivedSessionIds?: unknown }
|
|
49
|
+
}
|
|
50
|
+
const ids = parsed.global?.archivedSessionIds
|
|
51
|
+
if (!Array.isArray(ids)) throw new Error(`storage hub "${path}" holds no global.archivedSessionIds array`)
|
|
52
|
+
return ids.filter((id): id is string => typeof id === 'string')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the official archive set once.
|
|
57
|
+
* @param registry - lazy workspaceRegistry face (may be absent or throw).
|
|
58
|
+
* @returns the archive ids plus which source served them.
|
|
59
|
+
*/
|
|
60
|
+
export function readArchiveSet(registry?: SwitchRegistryFace): SwitchArchiveRead {
|
|
61
|
+
if (registry !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
const ids = registry.archivedSessionIds
|
|
64
|
+
if (Array.isArray(ids)) return { ids, source: 'registry' }
|
|
65
|
+
} catch { /* registry not started yet — fall through to the file */ }
|
|
66
|
+
}
|
|
67
|
+
for (const path of storageFileCandidates()) {
|
|
68
|
+
if (!existsSync(path)) continue
|
|
69
|
+
try {
|
|
70
|
+
return { ids: readStorageFile(path), source: 'storage-file' }
|
|
71
|
+
} catch { /* malformed file — try the next candidate */ }
|
|
72
|
+
}
|
|
73
|
+
return { ids: [], source: 'none' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the lazy source face the syncer expects, with diagnostics capture. */
|
|
77
|
+
export function createArchiveSource(getRegistry: () => SwitchRegistryFace | undefined): {
|
|
78
|
+
read: () => SwitchArchiveRead
|
|
79
|
+
diagnostics: () => SwitchArchiveDiagnostics
|
|
80
|
+
} {
|
|
81
|
+
let last: SwitchArchiveDiagnostics = { source: 'none', ids: 0 }
|
|
82
|
+
return {
|
|
83
|
+
read: () => {
|
|
84
|
+
const read = readArchiveSet(getRegistry())
|
|
85
|
+
last = { source: read.source, ids: read.ids.length }
|
|
86
|
+
return read
|
|
87
|
+
},
|
|
88
|
+
diagnostics: () => ({ ...last }),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,571 @@
|
|
|
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
|
+
}): void {
|
|
208
|
+
const db = this.requireDb()
|
|
209
|
+
const documents = buildIndexDocuments(input.sessionId, input.events)
|
|
210
|
+
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
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write an archived session's header row without any document content.
|
|
248
|
+
*
|
|
249
|
+
* @deprecated 旧"归档即软删"语义的遗物,合并包 R1 已改为归档保留 docs;仅剩
|
|
250
|
+
* 旧快照兼容路径可落 header 行。新代码一律 upsertSession + setArchived。
|
|
251
|
+
*/
|
|
252
|
+
upsertArchivedHeader(input: {
|
|
253
|
+
sessionId: string
|
|
254
|
+
version: number
|
|
255
|
+
title?: string
|
|
256
|
+
cwd?: string
|
|
257
|
+
updatedAt?: number
|
|
258
|
+
}): void {
|
|
259
|
+
const db = this.requireDb()
|
|
260
|
+
this.withWriteTx(() => {
|
|
261
|
+
this.deleteSessionFts(db, input.sessionId)
|
|
262
|
+
db.prepare('DELETE FROM docs WHERE session_id = ?').run(input.sessionId)
|
|
263
|
+
db.prepare(`
|
|
264
|
+
INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
|
|
265
|
+
VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
266
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
267
|
+
version = excluded.version,
|
|
268
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
269
|
+
cwd = excluded.cwd,
|
|
270
|
+
updated_at = excluded.updated_at,
|
|
271
|
+
indexed_at = excluded.indexed_at,
|
|
272
|
+
archived = 1
|
|
273
|
+
`).run(
|
|
274
|
+
input.sessionId,
|
|
275
|
+
input.version,
|
|
276
|
+
input.title ?? '',
|
|
277
|
+
input.cwd ?? '',
|
|
278
|
+
input.updatedAt ?? 0,
|
|
279
|
+
Date.now(),
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Apply the official archive set: flip the archived flag both ways.
|
|
286
|
+
*
|
|
287
|
+
* 合表复用语义(R1):归档**保留全部 docs 与 FTS**——正文在归档态不可变
|
|
288
|
+
* (归档门只拒新回合),翻转只是检索域成员资格的变化;恢复也不再需要重灌
|
|
289
|
+
* (旧实现的 version = -1 强制重读随"归档即删 docs"一并废除)。
|
|
290
|
+
*/
|
|
291
|
+
setArchived(archivedIds: ReadonlySet<string>): void {
|
|
292
|
+
const db = this.requireDb()
|
|
293
|
+
this.withWriteTx(() => {
|
|
294
|
+
const rows = db.prepare('SELECT session_id, archived FROM sessions').all() as
|
|
295
|
+
{ session_id: string; archived: number }[]
|
|
296
|
+
for (const row of rows) {
|
|
297
|
+
const shouldBe = archivedIds.has(row.session_id) ? 1 : 0
|
|
298
|
+
if (row.archived !== shouldBe) {
|
|
299
|
+
db.prepare('UPDATE sessions SET archived = ? WHERE session_id = ?').run(shouldBe, row.session_id)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Flip one session's archived flag (steward write-side linkage). No-op when
|
|
307
|
+
* the row does not exist — flag state on an unindexed session is meaningless.
|
|
308
|
+
*/
|
|
309
|
+
setArchivedOne(sessionId: string, archived: boolean): void {
|
|
310
|
+
const db = this.requireDb()
|
|
311
|
+
this.withWriteTx(() => {
|
|
312
|
+
db.prepare('UPDATE sessions SET archived = ? WHERE session_id = ?')
|
|
313
|
+
.run(archived ? 1 : 0, sessionId)
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One session's stored documents, ascending seq (snapshot export face). */
|
|
318
|
+
exportSessionDocs(sessionId: string): {
|
|
319
|
+
seq: number
|
|
320
|
+
type: string
|
|
321
|
+
surface: string
|
|
322
|
+
time: number
|
|
323
|
+
text: string
|
|
324
|
+
}[] {
|
|
325
|
+
const db = this.requireDb()
|
|
326
|
+
return db.prepare(`
|
|
327
|
+
SELECT seq, type, surface, time, text FROM docs WHERE session_id = ? ORDER BY seq
|
|
328
|
+
`).all(sessionId) as { seq: number; type: string; surface: string; time: number; text: string }[]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Insert or replace one session from already-extracted documents
|
|
333
|
+
* (snapshot import face; no re-extraction, what was exported is restored;
|
|
334
|
+
* segmentation is recomputed for the current index format).
|
|
335
|
+
*/
|
|
336
|
+
importSessionDocs(input: {
|
|
337
|
+
sessionId: string
|
|
338
|
+
version: number
|
|
339
|
+
title?: string
|
|
340
|
+
/** 归档标记随快照往返;旧快照缺省为活跃。 */
|
|
341
|
+
archived?: boolean
|
|
342
|
+
docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
|
|
343
|
+
}): void {
|
|
344
|
+
const db = this.requireDb()
|
|
345
|
+
this.withWriteTx(() => {
|
|
346
|
+
this.deleteSessionFts(db, input.sessionId)
|
|
347
|
+
db.prepare('DELETE FROM docs WHERE session_id = ?').run(input.sessionId)
|
|
348
|
+
const insertDoc = db.prepare(`
|
|
349
|
+
INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
|
|
350
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
351
|
+
`)
|
|
352
|
+
const insertFts = db.prepare('INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)')
|
|
353
|
+
for (const doc of input.docs) {
|
|
354
|
+
const indexText = segmentForIndex(doc.text)
|
|
355
|
+
const result = insertDoc.run(
|
|
356
|
+
input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText,
|
|
357
|
+
)
|
|
358
|
+
insertFts.run(Number(result.lastInsertRowid), indexText)
|
|
359
|
+
}
|
|
360
|
+
db.prepare(`
|
|
361
|
+
INSERT INTO sessions (session_id, version, title, updated_at, indexed_at, archived)
|
|
362
|
+
VALUES (?, ?, ?, 0, ?, ?)
|
|
363
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
364
|
+
version = excluded.version,
|
|
365
|
+
title = excluded.title,
|
|
366
|
+
indexed_at = excluded.indexed_at,
|
|
367
|
+
archived = excluded.archived
|
|
368
|
+
`).run(input.sessionId, input.version, input.title ?? '', Date.now(), input.archived === true ? 1 : 0)
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Remove one session and its documents entirely. */
|
|
373
|
+
removeSession(sessionId: string): void {
|
|
374
|
+
const db = this.requireDb()
|
|
375
|
+
this.withWriteTx(() => {
|
|
376
|
+
this.deleteSessionFts(db, sessionId)
|
|
377
|
+
db.prepare('DELETE FROM docs WHERE session_id = ?').run(sessionId)
|
|
378
|
+
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId)
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Update only a session's header row (title backfill), keeping documents. */
|
|
383
|
+
updateSessionHeader(input: { sessionId: string; title?: string; cwd?: string; updatedAt?: number }): void {
|
|
384
|
+
const db = this.requireDb()
|
|
385
|
+
const current = db.prepare('SELECT version FROM sessions WHERE session_id = ?').get(input.sessionId) as
|
|
386
|
+
| { version: number }
|
|
387
|
+
| undefined
|
|
388
|
+
if (current === undefined) return
|
|
389
|
+
db.prepare(`
|
|
390
|
+
UPDATE sessions SET
|
|
391
|
+
title = CASE WHEN ? != '' THEN ? ELSE title END,
|
|
392
|
+
cwd = CASE WHEN ? != '' THEN ? ELSE cwd END,
|
|
393
|
+
updated_at = CASE WHEN ? > 0 THEN ? ELSE updated_at END
|
|
394
|
+
WHERE session_id = ?
|
|
395
|
+
`).run(
|
|
396
|
+
input.title ?? '', input.title ?? '',
|
|
397
|
+
input.cwd ?? '', input.cwd ?? '',
|
|
398
|
+
input.updatedAt ?? 0, input.updatedAt ?? 0,
|
|
399
|
+
input.sessionId,
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** One indexed session row, or undefined. */
|
|
404
|
+
getSession(sessionId: string): SwitchIndexedSession | undefined {
|
|
405
|
+
const db = this.requireDb()
|
|
406
|
+
const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId) as
|
|
407
|
+
| Record<string, unknown>
|
|
408
|
+
| undefined
|
|
409
|
+
return row === undefined ? undefined : rowToSession(row)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** All indexed sessions (active AND archived), newest first — the title
|
|
413
|
+
* corpus and the manage console both read this; the `archived` flag rides
|
|
414
|
+
* each row so clients filter locally. */
|
|
415
|
+
listIndexedSessions(): SwitchIndexedSession[] {
|
|
416
|
+
const db = this.requireDb()
|
|
417
|
+
const rows = db.prepare('SELECT * FROM sessions ORDER BY updated_at DESC').all() as Record<string, unknown>[]
|
|
418
|
+
return rows.map(rowToSession)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Archived (soft-deleted) sessions, newest first — the archive viewer face. */
|
|
422
|
+
listArchived(): SwitchIndexedSession[] {
|
|
423
|
+
const db = this.requireDb()
|
|
424
|
+
const rows = db.prepare('SELECT * FROM sessions WHERE archived = 1 ORDER BY updated_at DESC').all() as Record<string, unknown>[]
|
|
425
|
+
return rows.map(rowToSession)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Number of indexed sessions (active + archived — the whole corpus). */
|
|
429
|
+
countSessions(): number {
|
|
430
|
+
const db = this.requireDb()
|
|
431
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number | bigint }
|
|
432
|
+
return Number(row.n)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Number of archived (soft-deleted) sessions. */
|
|
436
|
+
countArchived(): number {
|
|
437
|
+
const db = this.requireDb()
|
|
438
|
+
const row = db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE archived = 1').get() as { n: number | bigint }
|
|
439
|
+
return Number(row.n)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Run one session-grouped full-text search.
|
|
444
|
+
*
|
|
445
|
+
* One statement: the FTS match is bounded by rank in a subquery (its rowid
|
|
446
|
+
* aligns with docs.doc_id), then the type/surface filters join in — no
|
|
447
|
+
* second round-trip, no large IN parameter lists.
|
|
448
|
+
* @param request - query text, coarse type filter, page size, ordering.
|
|
449
|
+
* @returns hits ordered by `sortBy` (relevance by default).
|
|
450
|
+
*/
|
|
451
|
+
search(request: {
|
|
452
|
+
query: string
|
|
453
|
+
types?: readonly SwitchIndexContentType[]
|
|
454
|
+
limit?: number
|
|
455
|
+
sortBy?: SwitchSearchSort
|
|
456
|
+
/**
|
|
457
|
+
* 检索域:`'active'` 仅活跃、`'archived'` 仅归档、`'all'`(缺省)两者。
|
|
458
|
+
* 归档正文入索引后的筛选 chip 即此参数。
|
|
459
|
+
*/
|
|
460
|
+
archived?: 'active' | 'archived' | 'all'
|
|
461
|
+
/** 更新时间下界(epoch ms,含)。 */
|
|
462
|
+
from?: number
|
|
463
|
+
/** 更新时间上界(epoch ms,排他)。 */
|
|
464
|
+
to?: number
|
|
465
|
+
}): SwitchSearchHit[] {
|
|
466
|
+
const db = this.requireDb()
|
|
467
|
+
const match = sanitizeFtsQuery(request.query)
|
|
468
|
+
if (match === '') return []
|
|
469
|
+
const limit = Math.min(Math.max(1, request.limit ?? 20), 100)
|
|
470
|
+
const types = resolveTypes(request.types)
|
|
471
|
+
const placeholders = types.map(() => '?').join(', ')
|
|
472
|
+
const archivedClause = request.archived === 'active'
|
|
473
|
+
? 'AND s.archived = 0'
|
|
474
|
+
: request.archived === 'archived'
|
|
475
|
+
? 'AND s.archived = 1'
|
|
476
|
+
: ''
|
|
477
|
+
// 日期范围(R6):s.updated_at 上的半开区间 [from, to)。
|
|
478
|
+
const dateClauses: string[] = []
|
|
479
|
+
const dateParams: number[] = []
|
|
480
|
+
if (typeof request.from === 'number' && Number.isFinite(request.from)) {
|
|
481
|
+
dateClauses.push('AND s.updated_at >= ?')
|
|
482
|
+
dateParams.push(request.from)
|
|
483
|
+
}
|
|
484
|
+
if (typeof request.to === 'number' && Number.isFinite(request.to)) {
|
|
485
|
+
dateClauses.push('AND s.updated_at < ?')
|
|
486
|
+
dateParams.push(request.to)
|
|
487
|
+
}
|
|
488
|
+
const dateClause = dateClauses.join(' ')
|
|
489
|
+
const docs = db.prepare(`
|
|
490
|
+
SELECT d.doc_id AS docId, d.session_id AS sessionId, d.seq, d.type, d.time, d.text,
|
|
491
|
+
s.title, s.updated_at AS updatedAt, f.rank AS ftsRank
|
|
492
|
+
FROM (
|
|
493
|
+
SELECT rowid, rank FROM docs_fts WHERE docs_fts MATCH ? ORDER BY rank LIMIT ?
|
|
494
|
+
) f
|
|
495
|
+
JOIN docs d ON d.doc_id = f.rowid
|
|
496
|
+
JOIN sessions s ON s.session_id = d.session_id
|
|
497
|
+
WHERE d.type IN (${placeholders}) AND d.surface = 'current' ${archivedClause} ${dateClause}
|
|
498
|
+
`).all(match, MATCH_SCAN_LIMIT, ...types, ...dateParams) as {
|
|
499
|
+
docId: number | bigint
|
|
500
|
+
sessionId: string
|
|
501
|
+
seq: number
|
|
502
|
+
type: string
|
|
503
|
+
time: number
|
|
504
|
+
text: string
|
|
505
|
+
title: string
|
|
506
|
+
updatedAt: number
|
|
507
|
+
ftsRank: number
|
|
508
|
+
}[]
|
|
509
|
+
// Group by session; strongest hit = best weighted rank (bm25 rank is
|
|
510
|
+
// negative-better, so weight scales its magnitude).
|
|
511
|
+
const bestBySession = new Map<string, { doc: typeof docs[number]; score: number }>()
|
|
512
|
+
for (const doc of docs) {
|
|
513
|
+
const weight = TYPE_WEIGHT[doc.type] ?? 1
|
|
514
|
+
const score = -Number(doc.ftsRank) * weight
|
|
515
|
+
const best = bestBySession.get(doc.sessionId)
|
|
516
|
+
if (best === undefined || score > best.score) bestBySession.set(doc.sessionId, { doc, score })
|
|
517
|
+
}
|
|
518
|
+
const grouped = [...bestBySession.values()]
|
|
519
|
+
if (request.sortBy === 'time') {
|
|
520
|
+
// Recency is a session-level concern, so it keys on `updatedAt` — not on
|
|
521
|
+
// a hit's `time`, which only says when the best-matching document was
|
|
522
|
+
// written. Relevance is the tie-break so equal timestamps stay sane.
|
|
523
|
+
// Ordering happens before the slice: sorting the page afterwards would
|
|
524
|
+
// only reorder an already relevance-truncated top-N.
|
|
525
|
+
grouped.sort((a, b) => (b.doc.updatedAt - a.doc.updatedAt) || (b.score - a.score))
|
|
526
|
+
} else {
|
|
527
|
+
grouped.sort((a, b) => b.score - a.score)
|
|
528
|
+
}
|
|
529
|
+
return grouped
|
|
530
|
+
.slice(0, limit)
|
|
531
|
+
.map(({ doc }) => ({
|
|
532
|
+
sessionId: doc.sessionId,
|
|
533
|
+
title: doc.title,
|
|
534
|
+
seq: doc.seq,
|
|
535
|
+
type: doc.type,
|
|
536
|
+
time: doc.time,
|
|
537
|
+
updatedAt: Number(doc.updatedAt ?? 0),
|
|
538
|
+
snippet: buildSnippet(doc.text, request.query),
|
|
539
|
+
}))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private requireDb(): DatabaseSync {
|
|
543
|
+
if (this.db === undefined) throw new Error('switch-search: index engine is not open')
|
|
544
|
+
return this.db
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Materialize the coarse filter into raw event types (absent → user+reply). */
|
|
549
|
+
export function resolveTypes(types: readonly SwitchIndexContentType[] | undefined): readonly string[] {
|
|
550
|
+
if (types === undefined || types.length === 0) return ['user/message', 'assistant/message']
|
|
551
|
+
const picked = new Set<Exclude<SwitchIndexContentType, 'all'>>()
|
|
552
|
+
for (const entry of types) {
|
|
553
|
+
if (entry === 'user' || entry === 'reply' || entry === 'tool') picked.add(entry)
|
|
554
|
+
else return ['user/message', 'assistant/message', 'tool/call', 'tool/result']
|
|
555
|
+
}
|
|
556
|
+
if (picked.size === 0) return ['user/message', 'assistant/message']
|
|
557
|
+
return [...picked].flatMap(entry => [...CONTENT_TYPE_GROUPS[entry]])
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Map one raw sessions row onto the public face. */
|
|
561
|
+
function rowToSession(row: Record<string, unknown>): SwitchIndexedSession {
|
|
562
|
+
return {
|
|
563
|
+
sessionId: String(row['session_id']),
|
|
564
|
+
version: Number(row['version']),
|
|
565
|
+
title: String(row['title'] ?? ''),
|
|
566
|
+
cwd: String(row['cwd'] ?? ''),
|
|
567
|
+
updatedAt: Number(row['updated_at'] ?? 0),
|
|
568
|
+
indexedAt: Number(row['indexed_at'] ?? 0),
|
|
569
|
+
archived: Number(row['archived'] ?? 0) === 1,
|
|
570
|
+
}
|
|
571
|
+
}
|