@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,748 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { sanitizeFts5Query } from "./fts5-sanitize.js";
|
|
4
|
+
import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
|
|
5
|
+
|
|
6
|
+
export type ConversationId = number;
|
|
7
|
+
export type MessageId = number;
|
|
8
|
+
export type SummaryId = string;
|
|
9
|
+
export type MessageRole = "system" | "user" | "assistant" | "tool";
|
|
10
|
+
export type MessagePartType =
|
|
11
|
+
| "text"
|
|
12
|
+
| "reasoning"
|
|
13
|
+
| "tool"
|
|
14
|
+
| "patch"
|
|
15
|
+
| "file"
|
|
16
|
+
| "subtask"
|
|
17
|
+
| "compaction"
|
|
18
|
+
| "step_start"
|
|
19
|
+
| "step_finish"
|
|
20
|
+
| "snapshot"
|
|
21
|
+
| "agent"
|
|
22
|
+
| "retry";
|
|
23
|
+
|
|
24
|
+
export type CreateMessageInput = {
|
|
25
|
+
conversationId: ConversationId;
|
|
26
|
+
seq: number;
|
|
27
|
+
role: MessageRole;
|
|
28
|
+
content: string;
|
|
29
|
+
tokenCount: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type MessageRecord = {
|
|
33
|
+
messageId: MessageId;
|
|
34
|
+
conversationId: ConversationId;
|
|
35
|
+
seq: number;
|
|
36
|
+
role: MessageRole;
|
|
37
|
+
content: string;
|
|
38
|
+
tokenCount: number;
|
|
39
|
+
createdAt: Date;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type CreateMessagePartInput = {
|
|
43
|
+
sessionId: string;
|
|
44
|
+
partType: MessagePartType;
|
|
45
|
+
ordinal: number;
|
|
46
|
+
textContent?: string | null;
|
|
47
|
+
toolCallId?: string | null;
|
|
48
|
+
toolName?: string | null;
|
|
49
|
+
toolInput?: string | null;
|
|
50
|
+
toolOutput?: string | null;
|
|
51
|
+
metadata?: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type MessagePartRecord = {
|
|
55
|
+
partId: string;
|
|
56
|
+
messageId: MessageId;
|
|
57
|
+
sessionId: string;
|
|
58
|
+
partType: MessagePartType;
|
|
59
|
+
ordinal: number;
|
|
60
|
+
textContent: string | null;
|
|
61
|
+
toolCallId: string | null;
|
|
62
|
+
toolName: string | null;
|
|
63
|
+
toolInput: string | null;
|
|
64
|
+
toolOutput: string | null;
|
|
65
|
+
metadata: string | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CreateConversationInput = {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
title?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ConversationRecord = {
|
|
74
|
+
conversationId: ConversationId;
|
|
75
|
+
sessionId: string;
|
|
76
|
+
title: string | null;
|
|
77
|
+
bootstrappedAt: Date | null;
|
|
78
|
+
createdAt: Date;
|
|
79
|
+
updatedAt: Date;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type MessageSearchInput = {
|
|
83
|
+
conversationId?: ConversationId;
|
|
84
|
+
query: string;
|
|
85
|
+
mode: "regex" | "full_text";
|
|
86
|
+
since?: Date;
|
|
87
|
+
before?: Date;
|
|
88
|
+
limit?: number;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type MessageSearchResult = {
|
|
92
|
+
messageId: MessageId;
|
|
93
|
+
conversationId: ConversationId;
|
|
94
|
+
role: MessageRole;
|
|
95
|
+
snippet: string;
|
|
96
|
+
createdAt: Date;
|
|
97
|
+
rank?: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ── DB row shapes (snake_case) ────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
interface ConversationRow {
|
|
103
|
+
conversation_id: number;
|
|
104
|
+
session_id: string;
|
|
105
|
+
title: string | null;
|
|
106
|
+
bootstrapped_at: string | null;
|
|
107
|
+
created_at: string;
|
|
108
|
+
updated_at: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface MessageRow {
|
|
112
|
+
message_id: number;
|
|
113
|
+
conversation_id: number;
|
|
114
|
+
seq: number;
|
|
115
|
+
role: MessageRole;
|
|
116
|
+
content: string;
|
|
117
|
+
token_count: number;
|
|
118
|
+
created_at: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface MessageSearchRow {
|
|
122
|
+
message_id: number;
|
|
123
|
+
conversation_id: number;
|
|
124
|
+
role: MessageRole;
|
|
125
|
+
snippet: string;
|
|
126
|
+
rank: number;
|
|
127
|
+
created_at: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface MessagePartRow {
|
|
131
|
+
part_id: string;
|
|
132
|
+
message_id: number;
|
|
133
|
+
session_id: string;
|
|
134
|
+
part_type: MessagePartType;
|
|
135
|
+
ordinal: number;
|
|
136
|
+
text_content: string | null;
|
|
137
|
+
tool_call_id: string | null;
|
|
138
|
+
tool_name: string | null;
|
|
139
|
+
tool_input: string | null;
|
|
140
|
+
tool_output: string | null;
|
|
141
|
+
metadata: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface CountRow {
|
|
145
|
+
count: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface MaxSeqRow {
|
|
149
|
+
max_seq: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Row mappers ───────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function toConversationRecord(row: ConversationRow): ConversationRecord {
|
|
155
|
+
return {
|
|
156
|
+
conversationId: row.conversation_id,
|
|
157
|
+
sessionId: row.session_id,
|
|
158
|
+
title: row.title,
|
|
159
|
+
bootstrappedAt: row.bootstrapped_at ? new Date(row.bootstrapped_at) : null,
|
|
160
|
+
createdAt: new Date(row.created_at),
|
|
161
|
+
updatedAt: new Date(row.updated_at),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toMessageRecord(row: MessageRow): MessageRecord {
|
|
166
|
+
return {
|
|
167
|
+
messageId: row.message_id,
|
|
168
|
+
conversationId: row.conversation_id,
|
|
169
|
+
seq: row.seq,
|
|
170
|
+
role: row.role,
|
|
171
|
+
content: row.content,
|
|
172
|
+
tokenCount: row.token_count,
|
|
173
|
+
createdAt: new Date(row.created_at),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function toSearchResult(row: MessageSearchRow): MessageSearchResult {
|
|
178
|
+
return {
|
|
179
|
+
messageId: row.message_id,
|
|
180
|
+
conversationId: row.conversation_id,
|
|
181
|
+
role: row.role,
|
|
182
|
+
snippet: row.snippet,
|
|
183
|
+
createdAt: new Date(row.created_at),
|
|
184
|
+
rank: row.rank,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function toMessagePartRecord(row: MessagePartRow): MessagePartRecord {
|
|
189
|
+
return {
|
|
190
|
+
partId: row.part_id,
|
|
191
|
+
messageId: row.message_id,
|
|
192
|
+
sessionId: row.session_id,
|
|
193
|
+
partType: row.part_type,
|
|
194
|
+
ordinal: row.ordinal,
|
|
195
|
+
textContent: row.text_content,
|
|
196
|
+
toolCallId: row.tool_call_id,
|
|
197
|
+
toolName: row.tool_name,
|
|
198
|
+
toolInput: row.tool_input,
|
|
199
|
+
toolOutput: row.tool_output,
|
|
200
|
+
metadata: row.metadata,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── ConversationStore ─────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
export class ConversationStore {
|
|
207
|
+
private readonly fts5Available: boolean;
|
|
208
|
+
|
|
209
|
+
constructor(
|
|
210
|
+
private db: DatabaseSync,
|
|
211
|
+
options?: { fts5Available?: boolean },
|
|
212
|
+
) {
|
|
213
|
+
this.fts5Available = options?.fts5Available ?? true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Transaction helpers ──────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
async withTransaction<T>(operation: () => Promise<T> | T): Promise<T> {
|
|
219
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
220
|
+
try {
|
|
221
|
+
const result = await operation();
|
|
222
|
+
this.db.exec("COMMIT");
|
|
223
|
+
return result;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.db.exec("ROLLBACK");
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Conversation operations ───────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
async createConversation(input: CreateConversationInput): Promise<ConversationRecord> {
|
|
233
|
+
const result = this.db
|
|
234
|
+
.prepare(`INSERT INTO conversations (session_id, title) VALUES (?, ?)`)
|
|
235
|
+
.run(input.sessionId, input.title ?? null);
|
|
236
|
+
|
|
237
|
+
const row = this.db
|
|
238
|
+
.prepare(
|
|
239
|
+
`SELECT conversation_id, session_id, title, bootstrapped_at, created_at, updated_at
|
|
240
|
+
FROM conversations WHERE conversation_id = ?`,
|
|
241
|
+
)
|
|
242
|
+
.get(Number(result.lastInsertRowid)) as unknown as ConversationRow;
|
|
243
|
+
|
|
244
|
+
return toConversationRecord(row);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async getConversation(conversationId: ConversationId): Promise<ConversationRecord | null> {
|
|
248
|
+
const row = this.db
|
|
249
|
+
.prepare(
|
|
250
|
+
`SELECT conversation_id, session_id, title, bootstrapped_at, created_at, updated_at
|
|
251
|
+
FROM conversations WHERE conversation_id = ?`,
|
|
252
|
+
)
|
|
253
|
+
.get(conversationId) as unknown as ConversationRow | undefined;
|
|
254
|
+
|
|
255
|
+
return row ? toConversationRecord(row) : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async getConversationBySessionId(sessionId: string): Promise<ConversationRecord | null> {
|
|
259
|
+
const row = this.db
|
|
260
|
+
.prepare(
|
|
261
|
+
`SELECT conversation_id, session_id, title, bootstrapped_at, created_at, updated_at
|
|
262
|
+
FROM conversations
|
|
263
|
+
WHERE session_id = ?
|
|
264
|
+
ORDER BY created_at DESC
|
|
265
|
+
LIMIT 1`,
|
|
266
|
+
)
|
|
267
|
+
.get(sessionId) as unknown as ConversationRow | undefined;
|
|
268
|
+
|
|
269
|
+
return row ? toConversationRecord(row) : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async getOrCreateConversation(sessionId: string, title?: string): Promise<ConversationRecord> {
|
|
273
|
+
const existing = await this.getConversationBySessionId(sessionId);
|
|
274
|
+
if (existing) {
|
|
275
|
+
return existing;
|
|
276
|
+
}
|
|
277
|
+
return this.createConversation({ sessionId, title });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async markConversationBootstrapped(conversationId: ConversationId): Promise<void> {
|
|
281
|
+
this.db
|
|
282
|
+
.prepare(
|
|
283
|
+
`UPDATE conversations
|
|
284
|
+
SET bootstrapped_at = COALESCE(bootstrapped_at, datetime('now')),
|
|
285
|
+
updated_at = datetime('now')
|
|
286
|
+
WHERE conversation_id = ?`,
|
|
287
|
+
)
|
|
288
|
+
.run(conversationId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Message operations ────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async createMessage(input: CreateMessageInput): Promise<MessageRecord> {
|
|
294
|
+
const result = this.db
|
|
295
|
+
.prepare(
|
|
296
|
+
`INSERT INTO messages (conversation_id, seq, role, content, token_count)
|
|
297
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
298
|
+
)
|
|
299
|
+
.run(input.conversationId, input.seq, input.role, input.content, input.tokenCount);
|
|
300
|
+
|
|
301
|
+
const messageId = Number(result.lastInsertRowid);
|
|
302
|
+
|
|
303
|
+
this.indexMessageForFullText(messageId, input.content);
|
|
304
|
+
|
|
305
|
+
const row = this.db
|
|
306
|
+
.prepare(
|
|
307
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
308
|
+
FROM messages WHERE message_id = ?`,
|
|
309
|
+
)
|
|
310
|
+
.get(messageId) as unknown as MessageRow;
|
|
311
|
+
|
|
312
|
+
return toMessageRecord(row);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async createMessagesBulk(inputs: CreateMessageInput[]): Promise<MessageRecord[]> {
|
|
316
|
+
if (inputs.length === 0) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const insertStmt = this.db.prepare(
|
|
320
|
+
`INSERT INTO messages (conversation_id, seq, role, content, token_count)
|
|
321
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
322
|
+
);
|
|
323
|
+
const selectStmt = this.db.prepare(
|
|
324
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
325
|
+
FROM messages WHERE message_id = ?`,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const records: MessageRecord[] = [];
|
|
329
|
+
for (const input of inputs) {
|
|
330
|
+
const result = insertStmt.run(
|
|
331
|
+
input.conversationId,
|
|
332
|
+
input.seq,
|
|
333
|
+
input.role,
|
|
334
|
+
input.content,
|
|
335
|
+
input.tokenCount,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const messageId = Number(result.lastInsertRowid);
|
|
339
|
+
this.indexMessageForFullText(messageId, input.content);
|
|
340
|
+
const row = selectStmt.get(messageId) as unknown as MessageRow;
|
|
341
|
+
records.push(toMessageRecord(row));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return records;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async getMessages(
|
|
348
|
+
conversationId: ConversationId,
|
|
349
|
+
opts?: { afterSeq?: number; limit?: number },
|
|
350
|
+
): Promise<MessageRecord[]> {
|
|
351
|
+
const afterSeq = opts?.afterSeq ?? -1;
|
|
352
|
+
const limit = opts?.limit;
|
|
353
|
+
|
|
354
|
+
if (limit != null) {
|
|
355
|
+
const rows = this.db
|
|
356
|
+
.prepare(
|
|
357
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
358
|
+
FROM messages
|
|
359
|
+
WHERE conversation_id = ? AND seq > ?
|
|
360
|
+
ORDER BY seq
|
|
361
|
+
LIMIT ?`,
|
|
362
|
+
)
|
|
363
|
+
.all(conversationId, afterSeq, limit) as unknown as MessageRow[];
|
|
364
|
+
return rows.map(toMessageRecord);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const rows = this.db
|
|
368
|
+
.prepare(
|
|
369
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
370
|
+
FROM messages
|
|
371
|
+
WHERE conversation_id = ? AND seq > ?
|
|
372
|
+
ORDER BY seq`,
|
|
373
|
+
)
|
|
374
|
+
.all(conversationId, afterSeq) as unknown as MessageRow[];
|
|
375
|
+
return rows.map(toMessageRecord);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async getLastMessage(conversationId: ConversationId): Promise<MessageRecord | null> {
|
|
379
|
+
const row = this.db
|
|
380
|
+
.prepare(
|
|
381
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
382
|
+
FROM messages
|
|
383
|
+
WHERE conversation_id = ?
|
|
384
|
+
ORDER BY seq DESC
|
|
385
|
+
LIMIT 1`,
|
|
386
|
+
)
|
|
387
|
+
.get(conversationId) as unknown as MessageRow | undefined;
|
|
388
|
+
|
|
389
|
+
return row ? toMessageRecord(row) : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async hasMessage(
|
|
393
|
+
conversationId: ConversationId,
|
|
394
|
+
role: MessageRole,
|
|
395
|
+
content: string,
|
|
396
|
+
): Promise<boolean> {
|
|
397
|
+
const row = this.db
|
|
398
|
+
.prepare(
|
|
399
|
+
`SELECT 1 AS count
|
|
400
|
+
FROM messages
|
|
401
|
+
WHERE conversation_id = ? AND role = ? AND content = ?
|
|
402
|
+
LIMIT 1`,
|
|
403
|
+
)
|
|
404
|
+
.get(conversationId, role, content) as unknown as CountRow | undefined;
|
|
405
|
+
|
|
406
|
+
return row?.count === 1;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async countMessagesByIdentity(
|
|
410
|
+
conversationId: ConversationId,
|
|
411
|
+
role: MessageRole,
|
|
412
|
+
content: string,
|
|
413
|
+
): Promise<number> {
|
|
414
|
+
const row = this.db
|
|
415
|
+
.prepare(
|
|
416
|
+
`SELECT COUNT(*) AS count
|
|
417
|
+
FROM messages
|
|
418
|
+
WHERE conversation_id = ? AND role = ? AND content = ?`,
|
|
419
|
+
)
|
|
420
|
+
.get(conversationId, role, content) as unknown as CountRow | undefined;
|
|
421
|
+
|
|
422
|
+
return row?.count ?? 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async getMessageById(messageId: MessageId): Promise<MessageRecord | null> {
|
|
426
|
+
const row = this.db
|
|
427
|
+
.prepare(
|
|
428
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
429
|
+
FROM messages WHERE message_id = ?`,
|
|
430
|
+
)
|
|
431
|
+
.get(messageId) as unknown as MessageRow | undefined;
|
|
432
|
+
return row ? toMessageRecord(row) : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async createMessageParts(messageId: MessageId, parts: CreateMessagePartInput[]): Promise<void> {
|
|
436
|
+
if (parts.length === 0) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const stmt = this.db.prepare(
|
|
441
|
+
`INSERT INTO message_parts (
|
|
442
|
+
part_id,
|
|
443
|
+
message_id,
|
|
444
|
+
session_id,
|
|
445
|
+
part_type,
|
|
446
|
+
ordinal,
|
|
447
|
+
text_content,
|
|
448
|
+
tool_call_id,
|
|
449
|
+
tool_name,
|
|
450
|
+
tool_input,
|
|
451
|
+
tool_output,
|
|
452
|
+
metadata
|
|
453
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
for (const part of parts) {
|
|
457
|
+
stmt.run(
|
|
458
|
+
randomUUID(),
|
|
459
|
+
messageId,
|
|
460
|
+
part.sessionId,
|
|
461
|
+
part.partType,
|
|
462
|
+
part.ordinal,
|
|
463
|
+
part.textContent ?? null,
|
|
464
|
+
part.toolCallId ?? null,
|
|
465
|
+
part.toolName ?? null,
|
|
466
|
+
part.toolInput ?? null,
|
|
467
|
+
part.toolOutput ?? null,
|
|
468
|
+
part.metadata ?? null,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async getMessageParts(messageId: MessageId): Promise<MessagePartRecord[]> {
|
|
474
|
+
const rows = this.db
|
|
475
|
+
.prepare(
|
|
476
|
+
`SELECT
|
|
477
|
+
part_id,
|
|
478
|
+
message_id,
|
|
479
|
+
session_id,
|
|
480
|
+
part_type,
|
|
481
|
+
ordinal,
|
|
482
|
+
text_content,
|
|
483
|
+
tool_call_id,
|
|
484
|
+
tool_name,
|
|
485
|
+
tool_input,
|
|
486
|
+
tool_output,
|
|
487
|
+
metadata
|
|
488
|
+
FROM message_parts
|
|
489
|
+
WHERE message_id = ?
|
|
490
|
+
ORDER BY ordinal`,
|
|
491
|
+
)
|
|
492
|
+
.all(messageId) as unknown as MessagePartRow[];
|
|
493
|
+
|
|
494
|
+
return rows.map(toMessagePartRecord);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async getMessageCount(conversationId: ConversationId): Promise<number> {
|
|
498
|
+
const row = this.db
|
|
499
|
+
.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`)
|
|
500
|
+
.get(conversationId) as unknown as CountRow;
|
|
501
|
+
return row?.count ?? 0;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async getMaxSeq(conversationId: ConversationId): Promise<number> {
|
|
505
|
+
const row = this.db
|
|
506
|
+
.prepare(
|
|
507
|
+
`SELECT COALESCE(MAX(seq), 0) AS max_seq
|
|
508
|
+
FROM messages WHERE conversation_id = ?`,
|
|
509
|
+
)
|
|
510
|
+
.get(conversationId) as unknown as MaxSeqRow;
|
|
511
|
+
return row?.max_seq ?? 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Deletion ──────────────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Delete messages and their associated records (context_items, FTS, message_parts).
|
|
518
|
+
*
|
|
519
|
+
* Skips messages referenced in summary_messages (already compacted) to avoid
|
|
520
|
+
* breaking the summary DAG. Returns the count of actually deleted messages.
|
|
521
|
+
*/
|
|
522
|
+
async deleteMessages(messageIds: MessageId[]): Promise<number> {
|
|
523
|
+
if (messageIds.length === 0) {
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let deleted = 0;
|
|
528
|
+
for (const messageId of messageIds) {
|
|
529
|
+
// Skip if referenced by a summary (ON DELETE RESTRICT would fail anyway)
|
|
530
|
+
const refRow = this.db
|
|
531
|
+
.prepare(`SELECT 1 AS found FROM summary_messages WHERE message_id = ? LIMIT 1`)
|
|
532
|
+
.get(messageId) as unknown as { found: number } | undefined;
|
|
533
|
+
if (refRow) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Remove from context_items first (RESTRICT constraint)
|
|
538
|
+
this.db
|
|
539
|
+
.prepare(`DELETE FROM context_items WHERE item_type = 'message' AND message_id = ?`)
|
|
540
|
+
.run(messageId);
|
|
541
|
+
|
|
542
|
+
this.deleteMessageFromFullText(messageId);
|
|
543
|
+
|
|
544
|
+
// Delete the message (message_parts cascade via ON DELETE CASCADE)
|
|
545
|
+
this.db.prepare(`DELETE FROM messages WHERE message_id = ?`).run(messageId);
|
|
546
|
+
|
|
547
|
+
deleted += 1;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return deleted;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── Search ────────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
async searchMessages(input: MessageSearchInput): Promise<MessageSearchResult[]> {
|
|
556
|
+
const limit = input.limit ?? 50;
|
|
557
|
+
|
|
558
|
+
if (input.mode === "full_text") {
|
|
559
|
+
if (this.fts5Available) {
|
|
560
|
+
try {
|
|
561
|
+
return this.searchFullText(
|
|
562
|
+
input.query,
|
|
563
|
+
limit,
|
|
564
|
+
input.conversationId,
|
|
565
|
+
input.since,
|
|
566
|
+
input.before,
|
|
567
|
+
);
|
|
568
|
+
} catch {
|
|
569
|
+
return this.searchLike(
|
|
570
|
+
input.query,
|
|
571
|
+
limit,
|
|
572
|
+
input.conversationId,
|
|
573
|
+
input.since,
|
|
574
|
+
input.before,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return this.searchLike(input.query, limit, input.conversationId, input.since, input.before);
|
|
579
|
+
}
|
|
580
|
+
return this.searchRegex(input.query, limit, input.conversationId, input.since, input.before);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private indexMessageForFullText(messageId: MessageId, content: string): void {
|
|
584
|
+
if (!this.fts5Available) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
this.db
|
|
589
|
+
.prepare(`INSERT INTO messages_fts(rowid, content) VALUES (?, ?)`)
|
|
590
|
+
.run(messageId, content);
|
|
591
|
+
} catch {
|
|
592
|
+
// Full-text indexing is optional. Message persistence must still succeed.
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private deleteMessageFromFullText(messageId: MessageId): void {
|
|
597
|
+
if (!this.fts5Available) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
this.db.prepare(`DELETE FROM messages_fts WHERE rowid = ?`).run(messageId);
|
|
602
|
+
} catch {
|
|
603
|
+
// Ignore FTS cleanup failures; the source row deletion is authoritative.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private searchFullText(
|
|
608
|
+
query: string,
|
|
609
|
+
limit: number,
|
|
610
|
+
conversationId?: ConversationId,
|
|
611
|
+
since?: Date,
|
|
612
|
+
before?: Date,
|
|
613
|
+
): MessageSearchResult[] {
|
|
614
|
+
const where: string[] = ["messages_fts MATCH ?"];
|
|
615
|
+
const args: Array<string | number> = [sanitizeFts5Query(query)];
|
|
616
|
+
if (conversationId != null) {
|
|
617
|
+
where.push("m.conversation_id = ?");
|
|
618
|
+
args.push(conversationId);
|
|
619
|
+
}
|
|
620
|
+
if (since) {
|
|
621
|
+
where.push("julianday(m.created_at) >= julianday(?)");
|
|
622
|
+
args.push(since.toISOString());
|
|
623
|
+
}
|
|
624
|
+
if (before) {
|
|
625
|
+
where.push("julianday(m.created_at) < julianday(?)");
|
|
626
|
+
args.push(before.toISOString());
|
|
627
|
+
}
|
|
628
|
+
args.push(limit);
|
|
629
|
+
|
|
630
|
+
const sql = `SELECT
|
|
631
|
+
m.message_id,
|
|
632
|
+
m.conversation_id,
|
|
633
|
+
m.role,
|
|
634
|
+
snippet(messages_fts, 0, '', '', '...', 32) AS snippet,
|
|
635
|
+
rank,
|
|
636
|
+
m.created_at
|
|
637
|
+
FROM messages_fts
|
|
638
|
+
JOIN messages m ON m.message_id = messages_fts.rowid
|
|
639
|
+
WHERE ${where.join(" AND ")}
|
|
640
|
+
ORDER BY m.created_at DESC
|
|
641
|
+
LIMIT ?`;
|
|
642
|
+
const rows = this.db.prepare(sql).all(...args) as unknown as MessageSearchRow[];
|
|
643
|
+
return rows.map(toSearchResult);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private searchLike(
|
|
647
|
+
query: string,
|
|
648
|
+
limit: number,
|
|
649
|
+
conversationId?: ConversationId,
|
|
650
|
+
since?: Date,
|
|
651
|
+
before?: Date,
|
|
652
|
+
): MessageSearchResult[] {
|
|
653
|
+
const plan = buildLikeSearchPlan("content", query);
|
|
654
|
+
if (plan.terms.length === 0) {
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const where: string[] = [...plan.where];
|
|
659
|
+
const args: Array<string | number> = [...plan.args];
|
|
660
|
+
if (conversationId != null) {
|
|
661
|
+
where.push("conversation_id = ?");
|
|
662
|
+
args.push(conversationId);
|
|
663
|
+
}
|
|
664
|
+
if (since) {
|
|
665
|
+
where.push("julianday(created_at) >= julianday(?)");
|
|
666
|
+
args.push(since.toISOString());
|
|
667
|
+
}
|
|
668
|
+
if (before) {
|
|
669
|
+
where.push("julianday(created_at) < julianday(?)");
|
|
670
|
+
args.push(before.toISOString());
|
|
671
|
+
}
|
|
672
|
+
args.push(limit);
|
|
673
|
+
|
|
674
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
675
|
+
const rows = this.db
|
|
676
|
+
.prepare(
|
|
677
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
678
|
+
FROM messages
|
|
679
|
+
${whereClause}
|
|
680
|
+
ORDER BY created_at DESC
|
|
681
|
+
LIMIT ?`,
|
|
682
|
+
)
|
|
683
|
+
.all(...args) as unknown as MessageRow[];
|
|
684
|
+
|
|
685
|
+
return rows.map((row) => ({
|
|
686
|
+
messageId: row.message_id,
|
|
687
|
+
conversationId: row.conversation_id,
|
|
688
|
+
role: row.role,
|
|
689
|
+
snippet: createFallbackSnippet(row.content, plan.terms),
|
|
690
|
+
createdAt: new Date(row.created_at),
|
|
691
|
+
rank: 0,
|
|
692
|
+
}));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private searchRegex(
|
|
696
|
+
pattern: string,
|
|
697
|
+
limit: number,
|
|
698
|
+
conversationId?: ConversationId,
|
|
699
|
+
since?: Date,
|
|
700
|
+
before?: Date,
|
|
701
|
+
): MessageSearchResult[] {
|
|
702
|
+
// SQLite has no native POSIX regex; fetch candidates and filter in JS
|
|
703
|
+
const re = new RegExp(pattern);
|
|
704
|
+
|
|
705
|
+
const where: string[] = [];
|
|
706
|
+
const args: Array<string | number> = [];
|
|
707
|
+
if (conversationId != null) {
|
|
708
|
+
where.push("conversation_id = ?");
|
|
709
|
+
args.push(conversationId);
|
|
710
|
+
}
|
|
711
|
+
if (since) {
|
|
712
|
+
where.push("julianday(created_at) >= julianday(?)");
|
|
713
|
+
args.push(since.toISOString());
|
|
714
|
+
}
|
|
715
|
+
if (before) {
|
|
716
|
+
where.push("julianday(created_at) < julianday(?)");
|
|
717
|
+
args.push(before.toISOString());
|
|
718
|
+
}
|
|
719
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
720
|
+
const rows = this.db
|
|
721
|
+
.prepare(
|
|
722
|
+
`SELECT message_id, conversation_id, seq, role, content, token_count, created_at
|
|
723
|
+
FROM messages
|
|
724
|
+
${whereClause}
|
|
725
|
+
ORDER BY created_at DESC`,
|
|
726
|
+
)
|
|
727
|
+
.all(...args) as unknown as MessageRow[];
|
|
728
|
+
|
|
729
|
+
const results: MessageSearchResult[] = [];
|
|
730
|
+
for (const row of rows) {
|
|
731
|
+
if (results.length >= limit) {
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
const match = re.exec(row.content);
|
|
735
|
+
if (match) {
|
|
736
|
+
results.push({
|
|
737
|
+
messageId: row.message_id,
|
|
738
|
+
conversationId: row.conversation_id,
|
|
739
|
+
role: row.role,
|
|
740
|
+
snippet: match[0],
|
|
741
|
+
createdAt: new Date(row.created_at),
|
|
742
|
+
rank: 0,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return results;
|
|
747
|
+
}
|
|
748
|
+
}
|