@jonathangu/openclawbrain 0.3.0
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/LICENSE +21 -0
- package/README.md +412 -0
- package/bin/openclawbrain.js +15 -0
- package/docs/END_STATE.md +244 -0
- package/docs/EVIDENCE.md +128 -0
- package/docs/RELEASE_CONTRACT.md +91 -0
- package/docs/agent-tools.md +106 -0
- package/docs/architecture.md +224 -0
- package/docs/configuration.md +178 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/status.json +87 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/summary.md +16 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/trace.json +273 -0
- package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/validation-report.json +652 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/channels-status.txt +31 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/config-snapshot.json +66 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/doctor.json +14 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-probe.txt +34 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-status.txt +41 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/logs.txt +428 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status-all.txt +60 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status.json +223 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/summary.md +13 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/trace.json +4 -0
- package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/validation-report.json +334 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/channels-status.txt +25 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/config-snapshot.json +91 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/doctor.json +14 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-probe.txt +36 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-status.txt +44 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/logs.txt +428 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-doctor.json +10 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-sdk-probe.json +11 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-setup-only.json +12 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/summary.md +30 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/validation-report.json +72 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status-all.txt +63 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status.json +200 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/summary.md +13 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/trace.json +4 -0
- package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/validation-report.json +311 -0
- package/docs/evidence/README.md +16 -0
- package/docs/fts5.md +161 -0
- package/docs/tui.md +506 -0
- package/index.ts +1372 -0
- package/openclaw.plugin.json +136 -0
- package/package.json +66 -0
- package/src/assembler.ts +804 -0
- package/src/brain-cli.ts +316 -0
- package/src/brain-core/decay.ts +35 -0
- package/src/brain-core/episode.ts +82 -0
- package/src/brain-core/graph.ts +321 -0
- package/src/brain-core/health.ts +116 -0
- package/src/brain-core/mutator.ts +281 -0
- package/src/brain-core/pack.ts +117 -0
- package/src/brain-core/policy.ts +153 -0
- package/src/brain-core/replay.ts +1 -0
- package/src/brain-core/teacher.ts +105 -0
- package/src/brain-core/trace.ts +40 -0
- package/src/brain-core/traverse.ts +230 -0
- package/src/brain-core/types.ts +405 -0
- package/src/brain-core/update.ts +123 -0
- package/src/brain-harvest/human.ts +46 -0
- package/src/brain-harvest/scanner.ts +98 -0
- package/src/brain-harvest/self.ts +147 -0
- package/src/brain-runtime/assembler-extension.ts +230 -0
- package/src/brain-runtime/evidence-detectors.ts +68 -0
- package/src/brain-runtime/graph-io.ts +72 -0
- package/src/brain-runtime/harvester-extension.ts +98 -0
- package/src/brain-runtime/service.ts +659 -0
- package/src/brain-runtime/tools.ts +109 -0
- package/src/brain-runtime/worker-state.ts +106 -0
- package/src/brain-runtime/worker-supervisor.ts +169 -0
- package/src/brain-store/embedding.ts +179 -0
- package/src/brain-store/init.ts +347 -0
- package/src/brain-store/migrations.ts +188 -0
- package/src/brain-store/store.ts +816 -0
- package/src/brain-worker/child-runner.ts +321 -0
- package/src/brain-worker/jobs.ts +12 -0
- package/src/brain-worker/mutation-job.ts +5 -0
- package/src/brain-worker/promotion-job.ts +5 -0
- package/src/brain-worker/protocol.ts +79 -0
- package/src/brain-worker/teacher-job.ts +5 -0
- package/src/brain-worker/update-job.ts +5 -0
- package/src/brain-worker/worker.ts +422 -0
- package/src/compaction.ts +1332 -0
- package/src/db/config.ts +265 -0
- package/src/db/connection.ts +72 -0
- package/src/db/features.ts +42 -0
- package/src/db/migration.ts +561 -0
- package/src/engine.ts +1995 -0
- package/src/expansion-auth.ts +351 -0
- package/src/expansion-policy.ts +303 -0
- package/src/expansion.ts +383 -0
- package/src/integrity.ts +600 -0
- package/src/large-files.ts +527 -0
- package/src/openclaw-bridge.ts +22 -0
- package/src/retrieval.ts +357 -0
- package/src/store/conversation-store.ts +748 -0
- package/src/store/fts5-sanitize.ts +29 -0
- package/src/store/full-text-fallback.ts +74 -0
- package/src/store/index.ts +29 -0
- package/src/store/summary-store.ts +918 -0
- package/src/summarize.ts +847 -0
- package/src/tools/common.ts +53 -0
- package/src/tools/lcm-conversation-scope.ts +76 -0
- package/src/tools/lcm-describe-tool.ts +234 -0
- package/src/tools/lcm-expand-query-tool.ts +594 -0
- package/src/tools/lcm-expand-tool.delegation.ts +556 -0
- package/src/tools/lcm-expand-tool.ts +448 -0
- package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
- package/src/tools/lcm-grep-tool.ts +200 -0
- package/src/transcript-repair.ts +301 -0
- package/src/types.ts +149 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { sanitizeFts5Query } from "./fts5-sanitize.js";
|
|
3
|
+
import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
|
|
4
|
+
|
|
5
|
+
export type SummaryKind = "leaf" | "condensed";
|
|
6
|
+
export type ContextItemType = "message" | "summary";
|
|
7
|
+
|
|
8
|
+
export type CreateSummaryInput = {
|
|
9
|
+
summaryId: string;
|
|
10
|
+
conversationId: number;
|
|
11
|
+
kind: SummaryKind;
|
|
12
|
+
depth?: number;
|
|
13
|
+
content: string;
|
|
14
|
+
tokenCount: number;
|
|
15
|
+
fileIds?: string[];
|
|
16
|
+
earliestAt?: Date;
|
|
17
|
+
latestAt?: Date;
|
|
18
|
+
descendantCount?: number;
|
|
19
|
+
descendantTokenCount?: number;
|
|
20
|
+
sourceMessageTokenCount?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SummaryRecord = {
|
|
24
|
+
summaryId: string;
|
|
25
|
+
conversationId: number;
|
|
26
|
+
kind: SummaryKind;
|
|
27
|
+
depth: number;
|
|
28
|
+
content: string;
|
|
29
|
+
tokenCount: number;
|
|
30
|
+
fileIds: string[];
|
|
31
|
+
earliestAt: Date | null;
|
|
32
|
+
latestAt: Date | null;
|
|
33
|
+
descendantCount: number;
|
|
34
|
+
descendantTokenCount: number;
|
|
35
|
+
sourceMessageTokenCount: number;
|
|
36
|
+
createdAt: Date;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SummarySubtreeNodeRecord = SummaryRecord & {
|
|
40
|
+
depthFromRoot: number;
|
|
41
|
+
parentSummaryId: string | null;
|
|
42
|
+
path: string;
|
|
43
|
+
childCount: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ContextItemRecord = {
|
|
47
|
+
conversationId: number;
|
|
48
|
+
ordinal: number;
|
|
49
|
+
itemType: ContextItemType;
|
|
50
|
+
messageId: number | null;
|
|
51
|
+
summaryId: string | null;
|
|
52
|
+
createdAt: Date;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SummarySearchInput = {
|
|
56
|
+
conversationId?: number;
|
|
57
|
+
query: string;
|
|
58
|
+
mode: "regex" | "full_text";
|
|
59
|
+
since?: Date;
|
|
60
|
+
before?: Date;
|
|
61
|
+
limit?: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type SummarySearchResult = {
|
|
65
|
+
summaryId: string;
|
|
66
|
+
conversationId: number;
|
|
67
|
+
kind: SummaryKind;
|
|
68
|
+
snippet: string;
|
|
69
|
+
createdAt: Date;
|
|
70
|
+
rank?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type CreateLargeFileInput = {
|
|
74
|
+
fileId: string;
|
|
75
|
+
conversationId: number;
|
|
76
|
+
fileName?: string;
|
|
77
|
+
mimeType?: string;
|
|
78
|
+
byteSize?: number;
|
|
79
|
+
storageUri: string;
|
|
80
|
+
explorationSummary?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type LargeFileRecord = {
|
|
84
|
+
fileId: string;
|
|
85
|
+
conversationId: number;
|
|
86
|
+
fileName: string | null;
|
|
87
|
+
mimeType: string | null;
|
|
88
|
+
byteSize: number | null;
|
|
89
|
+
storageUri: string;
|
|
90
|
+
explorationSummary: string | null;
|
|
91
|
+
createdAt: Date;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ── DB row shapes (snake_case) ────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
interface SummaryRow {
|
|
97
|
+
summary_id: string;
|
|
98
|
+
conversation_id: number;
|
|
99
|
+
kind: SummaryKind;
|
|
100
|
+
depth: number;
|
|
101
|
+
content: string;
|
|
102
|
+
token_count: number;
|
|
103
|
+
file_ids: string;
|
|
104
|
+
earliest_at: string | null;
|
|
105
|
+
latest_at: string | null;
|
|
106
|
+
descendant_count: number | null;
|
|
107
|
+
descendant_token_count: number | null;
|
|
108
|
+
source_message_token_count: number | null;
|
|
109
|
+
created_at: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface SummarySubtreeRow extends SummaryRow {
|
|
113
|
+
depth_from_root: number;
|
|
114
|
+
parent_summary_id: string | null;
|
|
115
|
+
path: string;
|
|
116
|
+
child_count: number | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface ContextItemRow {
|
|
120
|
+
conversation_id: number;
|
|
121
|
+
ordinal: number;
|
|
122
|
+
item_type: ContextItemType;
|
|
123
|
+
message_id: number | null;
|
|
124
|
+
summary_id: string | null;
|
|
125
|
+
created_at: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface SummarySearchRow {
|
|
129
|
+
summary_id: string;
|
|
130
|
+
conversation_id: number;
|
|
131
|
+
kind: SummaryKind;
|
|
132
|
+
snippet: string;
|
|
133
|
+
rank: number;
|
|
134
|
+
created_at: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface MaxOrdinalRow {
|
|
138
|
+
max_ordinal: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface DistinctDepthRow {
|
|
142
|
+
depth: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface TokenSumRow {
|
|
146
|
+
total: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface MessageIdRow {
|
|
150
|
+
message_id: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface LargeFileRow {
|
|
154
|
+
file_id: string;
|
|
155
|
+
conversation_id: number;
|
|
156
|
+
file_name: string | null;
|
|
157
|
+
mime_type: string | null;
|
|
158
|
+
byte_size: number | null;
|
|
159
|
+
storage_uri: string;
|
|
160
|
+
exploration_summary: string | null;
|
|
161
|
+
created_at: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Row mappers ───────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function toSummaryRecord(row: SummaryRow): SummaryRecord {
|
|
167
|
+
let fileIds: string[] = [];
|
|
168
|
+
try {
|
|
169
|
+
fileIds = JSON.parse(row.file_ids);
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore malformed JSON
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
summaryId: row.summary_id,
|
|
175
|
+
conversationId: row.conversation_id,
|
|
176
|
+
kind: row.kind,
|
|
177
|
+
depth: row.depth,
|
|
178
|
+
content: row.content,
|
|
179
|
+
tokenCount: row.token_count,
|
|
180
|
+
fileIds,
|
|
181
|
+
earliestAt: row.earliest_at ? new Date(row.earliest_at) : null,
|
|
182
|
+
latestAt: row.latest_at ? new Date(row.latest_at) : null,
|
|
183
|
+
descendantCount:
|
|
184
|
+
typeof row.descendant_count === "number" &&
|
|
185
|
+
Number.isFinite(row.descendant_count) &&
|
|
186
|
+
row.descendant_count >= 0
|
|
187
|
+
? Math.floor(row.descendant_count)
|
|
188
|
+
: 0,
|
|
189
|
+
descendantTokenCount:
|
|
190
|
+
typeof row.descendant_token_count === "number" &&
|
|
191
|
+
Number.isFinite(row.descendant_token_count) &&
|
|
192
|
+
row.descendant_token_count >= 0
|
|
193
|
+
? Math.floor(row.descendant_token_count)
|
|
194
|
+
: 0,
|
|
195
|
+
sourceMessageTokenCount:
|
|
196
|
+
typeof row.source_message_token_count === "number" &&
|
|
197
|
+
Number.isFinite(row.source_message_token_count) &&
|
|
198
|
+
row.source_message_token_count >= 0
|
|
199
|
+
? Math.floor(row.source_message_token_count)
|
|
200
|
+
: 0,
|
|
201
|
+
createdAt: new Date(row.created_at),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toContextItemRecord(row: ContextItemRow): ContextItemRecord {
|
|
206
|
+
return {
|
|
207
|
+
conversationId: row.conversation_id,
|
|
208
|
+
ordinal: row.ordinal,
|
|
209
|
+
itemType: row.item_type,
|
|
210
|
+
messageId: row.message_id,
|
|
211
|
+
summaryId: row.summary_id,
|
|
212
|
+
createdAt: new Date(row.created_at),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function toSearchResult(row: SummarySearchRow): SummarySearchResult {
|
|
217
|
+
return {
|
|
218
|
+
summaryId: row.summary_id,
|
|
219
|
+
conversationId: row.conversation_id,
|
|
220
|
+
kind: row.kind,
|
|
221
|
+
snippet: row.snippet,
|
|
222
|
+
createdAt: new Date(row.created_at),
|
|
223
|
+
rank: row.rank,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function toLargeFileRecord(row: LargeFileRow): LargeFileRecord {
|
|
228
|
+
return {
|
|
229
|
+
fileId: row.file_id,
|
|
230
|
+
conversationId: row.conversation_id,
|
|
231
|
+
fileName: row.file_name,
|
|
232
|
+
mimeType: row.mime_type,
|
|
233
|
+
byteSize: row.byte_size,
|
|
234
|
+
storageUri: row.storage_uri,
|
|
235
|
+
explorationSummary: row.exploration_summary,
|
|
236
|
+
createdAt: new Date(row.created_at),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── SummaryStore ──────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export class SummaryStore {
|
|
243
|
+
private readonly fts5Available: boolean;
|
|
244
|
+
|
|
245
|
+
constructor(
|
|
246
|
+
private db: DatabaseSync,
|
|
247
|
+
options?: { fts5Available?: boolean },
|
|
248
|
+
) {
|
|
249
|
+
this.fts5Available = options?.fts5Available ?? true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Summary CRUD ──────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
async insertSummary(input: CreateSummaryInput): Promise<SummaryRecord> {
|
|
255
|
+
const fileIds = JSON.stringify(input.fileIds ?? []);
|
|
256
|
+
const earliestAt = input.earliestAt instanceof Date ? input.earliestAt.toISOString() : null;
|
|
257
|
+
const latestAt = input.latestAt instanceof Date ? input.latestAt.toISOString() : null;
|
|
258
|
+
const descendantCount =
|
|
259
|
+
typeof input.descendantCount === "number" &&
|
|
260
|
+
Number.isFinite(input.descendantCount) &&
|
|
261
|
+
input.descendantCount >= 0
|
|
262
|
+
? Math.floor(input.descendantCount)
|
|
263
|
+
: 0;
|
|
264
|
+
const descendantTokenCount =
|
|
265
|
+
typeof input.descendantTokenCount === "number" &&
|
|
266
|
+
Number.isFinite(input.descendantTokenCount) &&
|
|
267
|
+
input.descendantTokenCount >= 0
|
|
268
|
+
? Math.floor(input.descendantTokenCount)
|
|
269
|
+
: 0;
|
|
270
|
+
const sourceMessageTokenCount =
|
|
271
|
+
typeof input.sourceMessageTokenCount === "number" &&
|
|
272
|
+
Number.isFinite(input.sourceMessageTokenCount) &&
|
|
273
|
+
input.sourceMessageTokenCount >= 0
|
|
274
|
+
? Math.floor(input.sourceMessageTokenCount)
|
|
275
|
+
: 0;
|
|
276
|
+
const depth =
|
|
277
|
+
typeof input.depth === "number" && Number.isFinite(input.depth) && input.depth >= 0
|
|
278
|
+
? Math.floor(input.depth)
|
|
279
|
+
: input.kind === "leaf"
|
|
280
|
+
? 0
|
|
281
|
+
: 1;
|
|
282
|
+
|
|
283
|
+
this.db
|
|
284
|
+
.prepare(
|
|
285
|
+
`INSERT INTO summaries (
|
|
286
|
+
summary_id,
|
|
287
|
+
conversation_id,
|
|
288
|
+
kind,
|
|
289
|
+
depth,
|
|
290
|
+
content,
|
|
291
|
+
token_count,
|
|
292
|
+
file_ids,
|
|
293
|
+
earliest_at,
|
|
294
|
+
latest_at,
|
|
295
|
+
descendant_count,
|
|
296
|
+
descendant_token_count,
|
|
297
|
+
source_message_token_count
|
|
298
|
+
)
|
|
299
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
300
|
+
)
|
|
301
|
+
.run(
|
|
302
|
+
input.summaryId,
|
|
303
|
+
input.conversationId,
|
|
304
|
+
input.kind,
|
|
305
|
+
depth,
|
|
306
|
+
input.content,
|
|
307
|
+
input.tokenCount,
|
|
308
|
+
fileIds,
|
|
309
|
+
earliestAt,
|
|
310
|
+
latestAt,
|
|
311
|
+
descendantCount,
|
|
312
|
+
descendantTokenCount,
|
|
313
|
+
sourceMessageTokenCount,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const row = this.db
|
|
317
|
+
.prepare(
|
|
318
|
+
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
319
|
+
earliest_at, latest_at, descendant_count, created_at
|
|
320
|
+
, descendant_token_count, source_message_token_count
|
|
321
|
+
FROM summaries WHERE summary_id = ?`,
|
|
322
|
+
)
|
|
323
|
+
.get(input.summaryId) as unknown as SummaryRow;
|
|
324
|
+
|
|
325
|
+
// Index in FTS5 as best-effort; compaction flow must continue even if
|
|
326
|
+
// FTS indexing fails for any reason.
|
|
327
|
+
if (!this.fts5Available) {
|
|
328
|
+
return toSummaryRecord(row);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
this.db
|
|
333
|
+
.prepare(`INSERT INTO summaries_fts(summary_id, content) VALUES (?, ?)`)
|
|
334
|
+
.run(input.summaryId, input.content);
|
|
335
|
+
} catch {
|
|
336
|
+
// FTS indexing failed — search won't find this summary but
|
|
337
|
+
// compaction and assembly will still work correctly.
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return toSummaryRecord(row);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async getSummary(summaryId: string): Promise<SummaryRecord | null> {
|
|
344
|
+
const row = this.db
|
|
345
|
+
.prepare(
|
|
346
|
+
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
347
|
+
earliest_at, latest_at, descendant_count, created_at
|
|
348
|
+
, descendant_token_count, source_message_token_count
|
|
349
|
+
FROM summaries WHERE summary_id = ?`,
|
|
350
|
+
)
|
|
351
|
+
.get(summaryId) as unknown as SummaryRow | undefined;
|
|
352
|
+
return row ? toSummaryRecord(row) : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async getSummariesByConversation(conversationId: number): Promise<SummaryRecord[]> {
|
|
356
|
+
const rows = this.db
|
|
357
|
+
.prepare(
|
|
358
|
+
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
359
|
+
earliest_at, latest_at, descendant_count, created_at
|
|
360
|
+
, descendant_token_count, source_message_token_count
|
|
361
|
+
FROM summaries
|
|
362
|
+
WHERE conversation_id = ?
|
|
363
|
+
ORDER BY created_at`,
|
|
364
|
+
)
|
|
365
|
+
.all(conversationId) as unknown as SummaryRow[];
|
|
366
|
+
return rows.map(toSummaryRecord);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Lineage ───────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
async linkSummaryToMessages(summaryId: string, messageIds: number[]): Promise<void> {
|
|
372
|
+
if (messageIds.length === 0) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const stmt = this.db.prepare(
|
|
377
|
+
`INSERT INTO summary_messages (summary_id, message_id, ordinal)
|
|
378
|
+
VALUES (?, ?, ?)
|
|
379
|
+
ON CONFLICT (summary_id, message_id) DO NOTHING`,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
for (let idx = 0; idx < messageIds.length; idx++) {
|
|
383
|
+
stmt.run(summaryId, messageIds[idx], idx);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async linkSummaryToParents(summaryId: string, parentSummaryIds: string[]): Promise<void> {
|
|
388
|
+
if (parentSummaryIds.length === 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const stmt = this.db.prepare(
|
|
393
|
+
`INSERT INTO summary_parents (summary_id, parent_summary_id, ordinal)
|
|
394
|
+
VALUES (?, ?, ?)
|
|
395
|
+
ON CONFLICT (summary_id, parent_summary_id) DO NOTHING`,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
for (let idx = 0; idx < parentSummaryIds.length; idx++) {
|
|
399
|
+
stmt.run(summaryId, parentSummaryIds[idx], idx);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async getSummaryMessages(summaryId: string): Promise<number[]> {
|
|
404
|
+
const rows = this.db
|
|
405
|
+
.prepare(
|
|
406
|
+
`SELECT message_id FROM summary_messages
|
|
407
|
+
WHERE summary_id = ?
|
|
408
|
+
ORDER BY ordinal`,
|
|
409
|
+
)
|
|
410
|
+
.all(summaryId) as unknown as MessageIdRow[];
|
|
411
|
+
return rows.map((r) => r.message_id);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async getSummaryChildren(parentSummaryId: string): Promise<SummaryRecord[]> {
|
|
415
|
+
const rows = this.db
|
|
416
|
+
.prepare(
|
|
417
|
+
`SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
|
|
418
|
+
s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
|
|
419
|
+
, s.descendant_token_count, s.source_message_token_count
|
|
420
|
+
FROM summaries s
|
|
421
|
+
JOIN summary_parents sp ON sp.summary_id = s.summary_id
|
|
422
|
+
WHERE sp.parent_summary_id = ?
|
|
423
|
+
ORDER BY sp.ordinal`,
|
|
424
|
+
)
|
|
425
|
+
.all(parentSummaryId) as unknown as SummaryRow[];
|
|
426
|
+
return rows.map(toSummaryRecord);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async getSummaryParents(summaryId: string): Promise<SummaryRecord[]> {
|
|
430
|
+
const rows = this.db
|
|
431
|
+
.prepare(
|
|
432
|
+
`SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
|
|
433
|
+
s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
|
|
434
|
+
, s.descendant_token_count, s.source_message_token_count
|
|
435
|
+
FROM summaries s
|
|
436
|
+
JOIN summary_parents sp ON sp.parent_summary_id = s.summary_id
|
|
437
|
+
WHERE sp.summary_id = ?
|
|
438
|
+
ORDER BY sp.ordinal`,
|
|
439
|
+
)
|
|
440
|
+
.all(summaryId) as unknown as SummaryRow[];
|
|
441
|
+
return rows.map(toSummaryRecord);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async getSummarySubtree(summaryId: string): Promise<SummarySubtreeNodeRecord[]> {
|
|
445
|
+
const rows = this.db
|
|
446
|
+
.prepare(
|
|
447
|
+
`WITH RECURSIVE subtree(summary_id, parent_summary_id, depth_from_root, path) AS (
|
|
448
|
+
SELECT ?, NULL, 0, ''
|
|
449
|
+
UNION ALL
|
|
450
|
+
SELECT
|
|
451
|
+
sp.summary_id,
|
|
452
|
+
sp.parent_summary_id,
|
|
453
|
+
subtree.depth_from_root + 1,
|
|
454
|
+
CASE
|
|
455
|
+
WHEN subtree.path = '' THEN printf('%04d', sp.ordinal)
|
|
456
|
+
ELSE subtree.path || '.' || printf('%04d', sp.ordinal)
|
|
457
|
+
END
|
|
458
|
+
FROM summary_parents sp
|
|
459
|
+
JOIN subtree ON sp.parent_summary_id = subtree.summary_id
|
|
460
|
+
)
|
|
461
|
+
SELECT
|
|
462
|
+
s.summary_id,
|
|
463
|
+
s.conversation_id,
|
|
464
|
+
s.kind,
|
|
465
|
+
s.depth,
|
|
466
|
+
s.content,
|
|
467
|
+
s.token_count,
|
|
468
|
+
s.file_ids,
|
|
469
|
+
s.earliest_at,
|
|
470
|
+
s.latest_at,
|
|
471
|
+
s.descendant_count,
|
|
472
|
+
s.descendant_token_count,
|
|
473
|
+
s.source_message_token_count,
|
|
474
|
+
s.created_at,
|
|
475
|
+
subtree.depth_from_root,
|
|
476
|
+
subtree.parent_summary_id,
|
|
477
|
+
subtree.path,
|
|
478
|
+
(
|
|
479
|
+
SELECT COUNT(*) FROM summary_parents sp2
|
|
480
|
+
WHERE sp2.parent_summary_id = s.summary_id
|
|
481
|
+
) AS child_count
|
|
482
|
+
FROM subtree
|
|
483
|
+
JOIN summaries s ON s.summary_id = subtree.summary_id
|
|
484
|
+
ORDER BY subtree.depth_from_root ASC, subtree.path ASC, s.created_at ASC`,
|
|
485
|
+
)
|
|
486
|
+
.all(summaryId) as unknown as SummarySubtreeRow[];
|
|
487
|
+
|
|
488
|
+
const seen = new Set<string>();
|
|
489
|
+
const output: SummarySubtreeNodeRecord[] = [];
|
|
490
|
+
for (const row of rows) {
|
|
491
|
+
if (seen.has(row.summary_id)) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
seen.add(row.summary_id);
|
|
495
|
+
output.push({
|
|
496
|
+
...toSummaryRecord(row),
|
|
497
|
+
depthFromRoot: Math.max(0, Math.floor(row.depth_from_root ?? 0)),
|
|
498
|
+
parentSummaryId: row.parent_summary_id ?? null,
|
|
499
|
+
path: typeof row.path === "string" ? row.path : "",
|
|
500
|
+
childCount:
|
|
501
|
+
typeof row.child_count === "number" && Number.isFinite(row.child_count)
|
|
502
|
+
? Math.max(0, Math.floor(row.child_count))
|
|
503
|
+
: 0,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return output;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Context items ─────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
async getContextItems(conversationId: number): Promise<ContextItemRecord[]> {
|
|
512
|
+
const rows = this.db
|
|
513
|
+
.prepare(
|
|
514
|
+
`SELECT conversation_id, ordinal, item_type, message_id, summary_id, created_at
|
|
515
|
+
FROM context_items
|
|
516
|
+
WHERE conversation_id = ?
|
|
517
|
+
ORDER BY ordinal`,
|
|
518
|
+
)
|
|
519
|
+
.all(conversationId) as unknown as ContextItemRow[];
|
|
520
|
+
return rows.map(toContextItemRecord);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async getDistinctDepthsInContext(
|
|
524
|
+
conversationId: number,
|
|
525
|
+
options?: { maxOrdinalExclusive?: number },
|
|
526
|
+
): Promise<number[]> {
|
|
527
|
+
const maxOrdinalExclusive = options?.maxOrdinalExclusive;
|
|
528
|
+
const useOrdinalBound =
|
|
529
|
+
typeof maxOrdinalExclusive === "number" &&
|
|
530
|
+
Number.isFinite(maxOrdinalExclusive) &&
|
|
531
|
+
maxOrdinalExclusive !== Infinity;
|
|
532
|
+
|
|
533
|
+
const sql = useOrdinalBound
|
|
534
|
+
? `SELECT DISTINCT s.depth
|
|
535
|
+
FROM context_items ci
|
|
536
|
+
JOIN summaries s ON s.summary_id = ci.summary_id
|
|
537
|
+
WHERE ci.conversation_id = ?
|
|
538
|
+
AND ci.item_type = 'summary'
|
|
539
|
+
AND ci.ordinal < ?
|
|
540
|
+
ORDER BY s.depth ASC`
|
|
541
|
+
: `SELECT DISTINCT s.depth
|
|
542
|
+
FROM context_items ci
|
|
543
|
+
JOIN summaries s ON s.summary_id = ci.summary_id
|
|
544
|
+
WHERE ci.conversation_id = ?
|
|
545
|
+
AND ci.item_type = 'summary'
|
|
546
|
+
ORDER BY s.depth ASC`;
|
|
547
|
+
|
|
548
|
+
const rows = useOrdinalBound
|
|
549
|
+
? (this.db
|
|
550
|
+
.prepare(sql)
|
|
551
|
+
.all(conversationId, Math.floor(maxOrdinalExclusive)) as unknown as DistinctDepthRow[])
|
|
552
|
+
: (this.db.prepare(sql).all(conversationId) as unknown as DistinctDepthRow[]);
|
|
553
|
+
|
|
554
|
+
return rows.map((row) => row.depth);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async appendContextMessage(conversationId: number, messageId: number): Promise<void> {
|
|
558
|
+
const row = this.db
|
|
559
|
+
.prepare(
|
|
560
|
+
`SELECT COALESCE(MAX(ordinal), -1) AS max_ordinal
|
|
561
|
+
FROM context_items WHERE conversation_id = ?`,
|
|
562
|
+
)
|
|
563
|
+
.get(conversationId) as unknown as MaxOrdinalRow;
|
|
564
|
+
|
|
565
|
+
this.db
|
|
566
|
+
.prepare(
|
|
567
|
+
`INSERT INTO context_items (conversation_id, ordinal, item_type, message_id)
|
|
568
|
+
VALUES (?, ?, 'message', ?)`,
|
|
569
|
+
)
|
|
570
|
+
.run(conversationId, row.max_ordinal + 1, messageId);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async appendContextMessages(conversationId: number, messageIds: number[]): Promise<void> {
|
|
574
|
+
if (messageIds.length === 0) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const row = this.db
|
|
579
|
+
.prepare(
|
|
580
|
+
`SELECT COALESCE(MAX(ordinal), -1) AS max_ordinal
|
|
581
|
+
FROM context_items WHERE conversation_id = ?`,
|
|
582
|
+
)
|
|
583
|
+
.get(conversationId) as unknown as MaxOrdinalRow;
|
|
584
|
+
const baseOrdinal = row.max_ordinal + 1;
|
|
585
|
+
|
|
586
|
+
const stmt = this.db.prepare(
|
|
587
|
+
`INSERT INTO context_items (conversation_id, ordinal, item_type, message_id)
|
|
588
|
+
VALUES (?, ?, 'message', ?)`,
|
|
589
|
+
);
|
|
590
|
+
for (let idx = 0; idx < messageIds.length; idx++) {
|
|
591
|
+
stmt.run(conversationId, baseOrdinal + idx, messageIds[idx]);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async appendContextSummary(conversationId: number, summaryId: string): Promise<void> {
|
|
596
|
+
const row = this.db
|
|
597
|
+
.prepare(
|
|
598
|
+
`SELECT COALESCE(MAX(ordinal), -1) AS max_ordinal
|
|
599
|
+
FROM context_items WHERE conversation_id = ?`,
|
|
600
|
+
)
|
|
601
|
+
.get(conversationId) as unknown as MaxOrdinalRow;
|
|
602
|
+
|
|
603
|
+
this.db
|
|
604
|
+
.prepare(
|
|
605
|
+
`INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id)
|
|
606
|
+
VALUES (?, ?, 'summary', ?)`,
|
|
607
|
+
)
|
|
608
|
+
.run(conversationId, row.max_ordinal + 1, summaryId);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async replaceContextRangeWithSummary(input: {
|
|
612
|
+
conversationId: number;
|
|
613
|
+
startOrdinal: number;
|
|
614
|
+
endOrdinal: number;
|
|
615
|
+
summaryId: string;
|
|
616
|
+
}): Promise<void> {
|
|
617
|
+
const { conversationId, startOrdinal, endOrdinal, summaryId } = input;
|
|
618
|
+
|
|
619
|
+
this.db.exec("BEGIN");
|
|
620
|
+
try {
|
|
621
|
+
// 1. Delete context items in the range [startOrdinal, endOrdinal]
|
|
622
|
+
this.db
|
|
623
|
+
.prepare(
|
|
624
|
+
`DELETE FROM context_items
|
|
625
|
+
WHERE conversation_id = ?
|
|
626
|
+
AND ordinal >= ?
|
|
627
|
+
AND ordinal <= ?`,
|
|
628
|
+
)
|
|
629
|
+
.run(conversationId, startOrdinal, endOrdinal);
|
|
630
|
+
|
|
631
|
+
// 2. Insert the replacement summary item at startOrdinal
|
|
632
|
+
this.db
|
|
633
|
+
.prepare(
|
|
634
|
+
`INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id)
|
|
635
|
+
VALUES (?, ?, 'summary', ?)`,
|
|
636
|
+
)
|
|
637
|
+
.run(conversationId, startOrdinal, summaryId);
|
|
638
|
+
|
|
639
|
+
// 3. Resequence all ordinals to maintain contiguity (no gaps).
|
|
640
|
+
// Fetch current items, then update ordinals in order.
|
|
641
|
+
const items = this.db
|
|
642
|
+
.prepare(
|
|
643
|
+
`SELECT ordinal FROM context_items
|
|
644
|
+
WHERE conversation_id = ?
|
|
645
|
+
ORDER BY ordinal`,
|
|
646
|
+
)
|
|
647
|
+
.all(conversationId) as unknown as { ordinal: number }[];
|
|
648
|
+
|
|
649
|
+
const updateStmt = this.db.prepare(
|
|
650
|
+
`UPDATE context_items
|
|
651
|
+
SET ordinal = ?
|
|
652
|
+
WHERE conversation_id = ? AND ordinal = ?`,
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// Use negative temp ordinals first to avoid unique constraint conflicts
|
|
656
|
+
for (let i = 0; i < items.length; i++) {
|
|
657
|
+
updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
|
|
658
|
+
}
|
|
659
|
+
for (let i = 0; i < items.length; i++) {
|
|
660
|
+
updateStmt.run(i, conversationId, -(i + 1));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
this.db.exec("COMMIT");
|
|
664
|
+
} catch (err) {
|
|
665
|
+
this.db.exec("ROLLBACK");
|
|
666
|
+
throw err;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async getContextTokenCount(conversationId: number): Promise<number> {
|
|
671
|
+
const row = this.db
|
|
672
|
+
.prepare(
|
|
673
|
+
`SELECT COALESCE(SUM(token_count), 0) AS total
|
|
674
|
+
FROM (
|
|
675
|
+
SELECT m.token_count
|
|
676
|
+
FROM context_items ci
|
|
677
|
+
JOIN messages m ON m.message_id = ci.message_id
|
|
678
|
+
WHERE ci.conversation_id = ?
|
|
679
|
+
AND ci.item_type = 'message'
|
|
680
|
+
|
|
681
|
+
UNION ALL
|
|
682
|
+
|
|
683
|
+
SELECT s.token_count
|
|
684
|
+
FROM context_items ci
|
|
685
|
+
JOIN summaries s ON s.summary_id = ci.summary_id
|
|
686
|
+
WHERE ci.conversation_id = ?
|
|
687
|
+
AND ci.item_type = 'summary'
|
|
688
|
+
) sub`,
|
|
689
|
+
)
|
|
690
|
+
.get(conversationId, conversationId) as unknown as TokenSumRow;
|
|
691
|
+
return row?.total ?? 0;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Search ────────────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
async searchSummaries(input: SummarySearchInput): Promise<SummarySearchResult[]> {
|
|
697
|
+
const limit = input.limit ?? 50;
|
|
698
|
+
|
|
699
|
+
if (input.mode === "full_text") {
|
|
700
|
+
if (this.fts5Available) {
|
|
701
|
+
try {
|
|
702
|
+
return this.searchFullText(
|
|
703
|
+
input.query,
|
|
704
|
+
limit,
|
|
705
|
+
input.conversationId,
|
|
706
|
+
input.since,
|
|
707
|
+
input.before,
|
|
708
|
+
);
|
|
709
|
+
} catch {
|
|
710
|
+
return this.searchLike(
|
|
711
|
+
input.query,
|
|
712
|
+
limit,
|
|
713
|
+
input.conversationId,
|
|
714
|
+
input.since,
|
|
715
|
+
input.before,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return this.searchLike(input.query, limit, input.conversationId, input.since, input.before);
|
|
720
|
+
}
|
|
721
|
+
return this.searchRegex(input.query, limit, input.conversationId, input.since, input.before);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private searchFullText(
|
|
725
|
+
query: string,
|
|
726
|
+
limit: number,
|
|
727
|
+
conversationId?: number,
|
|
728
|
+
since?: Date,
|
|
729
|
+
before?: Date,
|
|
730
|
+
): SummarySearchResult[] {
|
|
731
|
+
const where: string[] = ["summaries_fts MATCH ?"];
|
|
732
|
+
const args: Array<string | number> = [sanitizeFts5Query(query)];
|
|
733
|
+
if (conversationId != null) {
|
|
734
|
+
where.push("s.conversation_id = ?");
|
|
735
|
+
args.push(conversationId);
|
|
736
|
+
}
|
|
737
|
+
if (since) {
|
|
738
|
+
where.push("julianday(s.created_at) >= julianday(?)");
|
|
739
|
+
args.push(since.toISOString());
|
|
740
|
+
}
|
|
741
|
+
if (before) {
|
|
742
|
+
where.push("julianday(s.created_at) < julianday(?)");
|
|
743
|
+
args.push(before.toISOString());
|
|
744
|
+
}
|
|
745
|
+
args.push(limit);
|
|
746
|
+
|
|
747
|
+
const sql = `SELECT
|
|
748
|
+
summaries_fts.summary_id,
|
|
749
|
+
s.conversation_id,
|
|
750
|
+
s.kind,
|
|
751
|
+
snippet(summaries_fts, 1, '', '', '...', 32) AS snippet,
|
|
752
|
+
rank,
|
|
753
|
+
s.created_at
|
|
754
|
+
FROM summaries_fts
|
|
755
|
+
JOIN summaries s ON s.summary_id = summaries_fts.summary_id
|
|
756
|
+
WHERE ${where.join(" AND ")}
|
|
757
|
+
ORDER BY s.created_at DESC
|
|
758
|
+
LIMIT ?`;
|
|
759
|
+
const rows = this.db.prepare(sql).all(...args) as unknown as SummarySearchRow[];
|
|
760
|
+
return rows.map(toSearchResult);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private searchLike(
|
|
764
|
+
query: string,
|
|
765
|
+
limit: number,
|
|
766
|
+
conversationId?: number,
|
|
767
|
+
since?: Date,
|
|
768
|
+
before?: Date,
|
|
769
|
+
): SummarySearchResult[] {
|
|
770
|
+
const plan = buildLikeSearchPlan("content", query);
|
|
771
|
+
if (plan.terms.length === 0) {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const where: string[] = [...plan.where];
|
|
776
|
+
const args: Array<string | number> = [...plan.args];
|
|
777
|
+
if (conversationId != null) {
|
|
778
|
+
where.push("conversation_id = ?");
|
|
779
|
+
args.push(conversationId);
|
|
780
|
+
}
|
|
781
|
+
if (since) {
|
|
782
|
+
where.push("julianday(created_at) >= julianday(?)");
|
|
783
|
+
args.push(since.toISOString());
|
|
784
|
+
}
|
|
785
|
+
if (before) {
|
|
786
|
+
where.push("julianday(created_at) < julianday(?)");
|
|
787
|
+
args.push(before.toISOString());
|
|
788
|
+
}
|
|
789
|
+
args.push(limit);
|
|
790
|
+
|
|
791
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
792
|
+
const rows = this.db
|
|
793
|
+
.prepare(
|
|
794
|
+
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
795
|
+
earliest_at, latest_at, descendant_count, descendant_token_count,
|
|
796
|
+
source_message_token_count, created_at
|
|
797
|
+
FROM summaries
|
|
798
|
+
${whereClause}
|
|
799
|
+
ORDER BY created_at DESC
|
|
800
|
+
LIMIT ?`,
|
|
801
|
+
)
|
|
802
|
+
.all(...args) as unknown as SummaryRow[];
|
|
803
|
+
|
|
804
|
+
return rows.map((row) => ({
|
|
805
|
+
summaryId: row.summary_id,
|
|
806
|
+
conversationId: row.conversation_id,
|
|
807
|
+
kind: row.kind,
|
|
808
|
+
snippet: createFallbackSnippet(row.content, plan.terms),
|
|
809
|
+
createdAt: new Date(row.created_at),
|
|
810
|
+
rank: 0,
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private searchRegex(
|
|
815
|
+
pattern: string,
|
|
816
|
+
limit: number,
|
|
817
|
+
conversationId?: number,
|
|
818
|
+
since?: Date,
|
|
819
|
+
before?: Date,
|
|
820
|
+
): SummarySearchResult[] {
|
|
821
|
+
const re = new RegExp(pattern);
|
|
822
|
+
|
|
823
|
+
const where: string[] = [];
|
|
824
|
+
const args: Array<string | number> = [];
|
|
825
|
+
if (conversationId != null) {
|
|
826
|
+
where.push("conversation_id = ?");
|
|
827
|
+
args.push(conversationId);
|
|
828
|
+
}
|
|
829
|
+
if (since) {
|
|
830
|
+
where.push("julianday(created_at) >= julianday(?)");
|
|
831
|
+
args.push(since.toISOString());
|
|
832
|
+
}
|
|
833
|
+
if (before) {
|
|
834
|
+
where.push("julianday(created_at) < julianday(?)");
|
|
835
|
+
args.push(before.toISOString());
|
|
836
|
+
}
|
|
837
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
838
|
+
const rows = this.db
|
|
839
|
+
.prepare(
|
|
840
|
+
`SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
|
|
841
|
+
earliest_at, latest_at, descendant_count, descendant_token_count,
|
|
842
|
+
source_message_token_count, created_at
|
|
843
|
+
FROM summaries
|
|
844
|
+
${whereClause}
|
|
845
|
+
ORDER BY created_at DESC`,
|
|
846
|
+
)
|
|
847
|
+
.all(...args) as unknown as SummaryRow[];
|
|
848
|
+
|
|
849
|
+
const results: SummarySearchResult[] = [];
|
|
850
|
+
for (const row of rows) {
|
|
851
|
+
if (results.length >= limit) {
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
const match = re.exec(row.content);
|
|
855
|
+
if (match) {
|
|
856
|
+
results.push({
|
|
857
|
+
summaryId: row.summary_id,
|
|
858
|
+
conversationId: row.conversation_id,
|
|
859
|
+
kind: row.kind,
|
|
860
|
+
snippet: match[0],
|
|
861
|
+
createdAt: new Date(row.created_at),
|
|
862
|
+
rank: 0,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return results;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ── Large files ───────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
async insertLargeFile(input: CreateLargeFileInput): Promise<LargeFileRecord> {
|
|
872
|
+
this.db
|
|
873
|
+
.prepare(
|
|
874
|
+
`INSERT INTO large_files (file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary)
|
|
875
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
876
|
+
)
|
|
877
|
+
.run(
|
|
878
|
+
input.fileId,
|
|
879
|
+
input.conversationId,
|
|
880
|
+
input.fileName ?? null,
|
|
881
|
+
input.mimeType ?? null,
|
|
882
|
+
input.byteSize ?? null,
|
|
883
|
+
input.storageUri,
|
|
884
|
+
input.explorationSummary ?? null,
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
const row = this.db
|
|
888
|
+
.prepare(
|
|
889
|
+
`SELECT file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary, created_at
|
|
890
|
+
FROM large_files WHERE file_id = ?`,
|
|
891
|
+
)
|
|
892
|
+
.get(input.fileId) as unknown as LargeFileRow;
|
|
893
|
+
|
|
894
|
+
return toLargeFileRecord(row);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async getLargeFile(fileId: string): Promise<LargeFileRecord | null> {
|
|
898
|
+
const row = this.db
|
|
899
|
+
.prepare(
|
|
900
|
+
`SELECT file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary, created_at
|
|
901
|
+
FROM large_files WHERE file_id = ?`,
|
|
902
|
+
)
|
|
903
|
+
.get(fileId) as unknown as LargeFileRow | undefined;
|
|
904
|
+
return row ? toLargeFileRecord(row) : null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async getLargeFilesByConversation(conversationId: number): Promise<LargeFileRecord[]> {
|
|
908
|
+
const rows = this.db
|
|
909
|
+
.prepare(
|
|
910
|
+
`SELECT file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary, created_at
|
|
911
|
+
FROM large_files
|
|
912
|
+
WHERE conversation_id = ?
|
|
913
|
+
ORDER BY created_at`,
|
|
914
|
+
)
|
|
915
|
+
.all(conversationId) as unknown as LargeFileRow[];
|
|
916
|
+
return rows.map(toLargeFileRecord);
|
|
917
|
+
}
|
|
918
|
+
}
|