@qearlyao/familiar 0.1.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.
Files changed (72) hide show
  1. package/.env.example +31 -0
  2. package/HEARTBEAT.md +23 -0
  3. package/LICENSE +21 -0
  4. package/MEMORY.md +1 -0
  5. package/README.md +245 -0
  6. package/SOUL.md +13 -0
  7. package/USER.md +13 -0
  8. package/config.example.toml +221 -0
  9. package/dist/agent-events.js +167 -0
  10. package/dist/agent.js +590 -0
  11. package/dist/browser-tools.js +638 -0
  12. package/dist/chat-log.js +130 -0
  13. package/dist/cli.js +168 -0
  14. package/dist/config.js +804 -0
  15. package/dist/data-retention.js +54 -0
  16. package/dist/discord.js +1203 -0
  17. package/dist/generated-media.js +86 -0
  18. package/dist/image-derivatives.js +102 -0
  19. package/dist/image-gen.js +440 -0
  20. package/dist/inbound-attachments.js +266 -0
  21. package/dist/index.js +10 -0
  22. package/dist/media-understanding.js +120 -0
  23. package/dist/memory/diary/ambient-injector.js +180 -0
  24. package/dist/memory/diary/ambient.js +124 -0
  25. package/dist/memory/diary/chunks.js +231 -0
  26. package/dist/memory/diary/index.js +3 -0
  27. package/dist/memory/diary/indexer.js +93 -0
  28. package/dist/memory/doctor.js +250 -0
  29. package/dist/memory/index/chunk-indexer.js +151 -0
  30. package/dist/memory/index/embedding-provider.js +119 -0
  31. package/dist/memory/index/fts-query.js +18 -0
  32. package/dist/memory/index/retrieval.js +246 -0
  33. package/dist/memory/index/schema.js +157 -0
  34. package/dist/memory/index/store.js +513 -0
  35. package/dist/memory/index/vec.js +72 -0
  36. package/dist/memory/index/vector-codec.js +27 -0
  37. package/dist/memory/lcm/backfill.js +247 -0
  38. package/dist/memory/lcm/condense.js +146 -0
  39. package/dist/memory/lcm/context-transformer.js +662 -0
  40. package/dist/memory/lcm/context.js +421 -0
  41. package/dist/memory/lcm/eviction-score.js +38 -0
  42. package/dist/memory/lcm/index.js +6 -0
  43. package/dist/memory/lcm/indexer.js +200 -0
  44. package/dist/memory/lcm/normalize.js +235 -0
  45. package/dist/memory/lcm/schema.js +188 -0
  46. package/dist/memory/lcm/segment-manager.js +136 -0
  47. package/dist/memory/lcm/store.js +722 -0
  48. package/dist/memory/lcm/summarizer.js +258 -0
  49. package/dist/memory/lcm/types.js +1 -0
  50. package/dist/memory/operator.js +477 -0
  51. package/dist/memory/service.js +202 -0
  52. package/dist/memory/tools.js +205 -0
  53. package/dist/models.js +165 -0
  54. package/dist/persona.js +54 -0
  55. package/dist/runtime.js +493 -0
  56. package/dist/scheduler.js +200 -0
  57. package/dist/settings.js +116 -0
  58. package/dist/skills.js +38 -0
  59. package/dist/tts.js +143 -0
  60. package/dist/web-auth.js +105 -0
  61. package/dist/web-events.js +114 -0
  62. package/dist/web-http.js +29 -0
  63. package/dist/web-static.js +106 -0
  64. package/dist/web-tools.js +940 -0
  65. package/dist/web-types.js +2 -0
  66. package/dist/web.js +844 -0
  67. package/package.json +60 -0
  68. package/web/dist/assets/index-ClgkMgaq.css +2 -0
  69. package/web/dist/assets/index-Cu2QquuR.js +59 -0
  70. package/web/dist/favicon.svg +1 -0
  71. package/web/dist/icons.svg +24 -0
  72. package/web/dist/index.html +20 -0
@@ -0,0 +1,722 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { normalizeFtsMatchQuery } from "../index/fts-query.js";
6
+ import { readMeta, runLcmMigrations } from "./schema.js";
7
+ export class LcmStore {
8
+ db;
9
+ ownsDb;
10
+ constructor(options) {
11
+ if (!options.db && !options.path)
12
+ throw new Error("LcmStore requires a db or path");
13
+ if (options.db) {
14
+ this.db = options.db;
15
+ this.ownsDb = false;
16
+ }
17
+ else {
18
+ const path = options.path;
19
+ mkdirSync(dirname(path), { recursive: true });
20
+ this.db = new Database(path);
21
+ this.ownsDb = true;
22
+ }
23
+ runLcmMigrations(this.db);
24
+ this.db.pragma("foreign_keys = ON");
25
+ }
26
+ static open(config, path = resolve(config.memory.lcmDir, "lcm.sqlite")) {
27
+ return new LcmStore({ path });
28
+ }
29
+ close() {
30
+ if (this.ownsDb)
31
+ this.db.close();
32
+ }
33
+ schemaVersion() {
34
+ const raw = readMeta(this.db, "schema_version");
35
+ return raw ? Number(raw) : null;
36
+ }
37
+ ensureSegment(input) {
38
+ const startedAt = input.startedAt ?? new Date().toISOString();
39
+ this.db
40
+ .prepare(`INSERT INTO lcm_segments (
41
+ id, session_id, channel_key, started_at, boundary_source_json, metadata_json
42
+ ) VALUES (?, ?, ?, ?, ?, ?)
43
+ ON CONFLICT(id) DO UPDATE SET
44
+ session_id = COALESCE(excluded.session_id, lcm_segments.session_id),
45
+ channel_key = COALESCE(excluded.channel_key, lcm_segments.channel_key),
46
+ boundary_source_json = COALESCE(excluded.boundary_source_json, lcm_segments.boundary_source_json),
47
+ metadata_json = COALESCE(excluded.metadata_json, lcm_segments.metadata_json),
48
+ updated_at = unixepoch()`)
49
+ .run(input.id, input.sessionId ?? null, input.channelKey ?? null, startedAt, jsonOrNull(input.boundarySource ?? null), jsonOrNull(input.metadata ?? null));
50
+ const segment = this.getSegment(input.id);
51
+ if (!segment)
52
+ throw new Error(`Failed to create LCM segment: ${input.id}`);
53
+ return segment;
54
+ }
55
+ closeSegment(id, closedAt = new Date().toISOString()) {
56
+ this.db
57
+ .prepare(`UPDATE lcm_segments
58
+ SET status = 'closed', closed_at = ?, updated_at = unixepoch()
59
+ WHERE id = ?`)
60
+ .run(closedAt, id);
61
+ }
62
+ getSegment(id) {
63
+ const row = this.db.prepare("SELECT * FROM lcm_segments WHERE id = ?").get(id);
64
+ return row ? segmentFromRow(row) : null;
65
+ }
66
+ listSegments() {
67
+ const rows = this.db.prepare("SELECT * FROM lcm_segments ORDER BY started_at, id").all();
68
+ return rows.map(segmentFromRow);
69
+ }
70
+ insertRecord(input) {
71
+ const normalized = normalizeRecordInput(input);
72
+ const runInsert = () => {
73
+ this.ensureSegment({
74
+ id: normalized.segmentId,
75
+ sessionId: normalized.sessionId,
76
+ channelKey: normalized.channelKey,
77
+ startedAt: normalized.happenedAt,
78
+ });
79
+ const existing = this.db
80
+ .prepare("SELECT id FROM lcm_records WHERE record_key = ?")
81
+ .get(normalized.recordKey);
82
+ if (existing)
83
+ return existing.id;
84
+ return insertRecordPrepared(this.db, normalized);
85
+ };
86
+ return this.db.inTransaction ? runInsert() : this.db.transaction(runInsert).immediate();
87
+ }
88
+ getRecord(id) {
89
+ const row = this.db.prepare("SELECT * FROM lcm_records WHERE id = ?").get(id);
90
+ return row ? recordFromRow(row) : null;
91
+ }
92
+ listRecords(segmentId) {
93
+ const rows = (segmentId
94
+ ? this.db.prepare("SELECT * FROM lcm_records WHERE segment_id = ? ORDER BY happened_at, id").all(segmentId)
95
+ : this.db.prepare("SELECT * FROM lcm_records ORDER BY happened_at, id").all());
96
+ return rows.map(recordFromRow);
97
+ }
98
+ searchRecordsLexical(query, limit = 10) {
99
+ const matchQuery = normalizeFtsMatchQuery(query);
100
+ if (!matchQuery)
101
+ return [];
102
+ const rows = this.db
103
+ .prepare(`SELECT r.*
104
+ FROM lcm_records_fts f
105
+ JOIN lcm_records r ON r.id = f.rowid
106
+ WHERE lcm_records_fts MATCH ?
107
+ ORDER BY f.rank
108
+ LIMIT ?`)
109
+ .all(matchQuery, limit);
110
+ return rows.map(recordFromRow);
111
+ }
112
+ searchSummariesLexical(query, limit = 10) {
113
+ const matchQuery = normalizeFtsMatchQuery(query);
114
+ if (!matchQuery)
115
+ return [];
116
+ const rows = this.db
117
+ .prepare(`SELECT s.*
118
+ FROM lcm_summaries_fts f
119
+ JOIN lcm_summaries s ON s.id = f.rowid
120
+ WHERE lcm_summaries_fts MATCH ?
121
+ ORDER BY f.rank
122
+ LIMIT ?`)
123
+ .all(matchQuery, limit);
124
+ return rows.map((row) => summaryFromRow(row));
125
+ }
126
+ insertSummary(input) {
127
+ if (!Number.isInteger(input.depth) || input.depth < 0) {
128
+ throw new Error("LCM summary depth must be an integer >= 0");
129
+ }
130
+ const normalized = normalizeSummaryInput(input);
131
+ const runInsert = () => runSummaryInsertTransaction(this, normalized, (id, sources, parents) => {
132
+ this.insertSummarySources(id, sources);
133
+ this.insertSummaryParents(id, parents);
134
+ });
135
+ const result = this.db.inTransaction ? runInsert() : this.db.transaction(runInsert).immediate();
136
+ return result;
137
+ }
138
+ getSummary(id) {
139
+ const row = this.db.prepare("SELECT * FROM lcm_summaries WHERE id = ?").get(id);
140
+ return row ? summaryFromRow(row, this.getSummaryParents(row.id)) : null;
141
+ }
142
+ listSummaries(segmentId) {
143
+ const rows = (segmentId
144
+ ? this.db.prepare("SELECT * FROM lcm_summaries WHERE segment_id = ? ORDER BY depth, id").all(segmentId)
145
+ : this.db.prepare("SELECT * FROM lcm_summaries ORDER BY segment_id, depth, id").all());
146
+ const parentMap = this.summaryParentMap(rows.map((row) => row.id));
147
+ return rows.map((row) => summaryFromRow(row, parentMap.get(row.id) ?? []));
148
+ }
149
+ getSummarySources(summaryId) {
150
+ const rows = this.db
151
+ .prepare("SELECT * FROM lcm_summary_sources WHERE summary_id = ? ORDER BY ord")
152
+ .all(summaryId);
153
+ return rows.map(summarySourceFromRow);
154
+ }
155
+ getSummaryParents(summaryId) {
156
+ const rows = this.db
157
+ .prepare("SELECT parent_summary_id FROM lcm_summary_parents WHERE summary_id = ? ORDER BY ord, parent_summary_id")
158
+ .all(summaryId);
159
+ return rows.map((row) => row.parent_summary_id);
160
+ }
161
+ getSummaryChildren(summaryId) {
162
+ const rows = this.db
163
+ .prepare("SELECT summary_id FROM lcm_summary_parents WHERE parent_summary_id = ? ORDER BY ord, summary_id")
164
+ .all(summaryId);
165
+ return rows.map((row) => row.summary_id);
166
+ }
167
+ replaceContextItems(sessionKey, items) {
168
+ const run = () => {
169
+ this.db.prepare("DELETE FROM lcm_context_items WHERE session_key = ?").run(sessionKey);
170
+ const insert = this.db.prepare(`INSERT INTO lcm_context_items (
171
+ session_key, ordinal, item_type, record_id, summary_id, fingerprint, happened_at
172
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
173
+ for (const [ordinal, item] of items.entries()) {
174
+ insert.run(sessionKey, ordinal, item.type, item.type === "raw" ? item.recordId : null, item.type === "summary" ? item.summaryId : null, item.fingerprint, item.happenedAt);
175
+ }
176
+ };
177
+ if (this.db.inTransaction)
178
+ run();
179
+ else
180
+ this.db.transaction(run).immediate();
181
+ }
182
+ listContextItems(sessionKey) {
183
+ const rows = this.db
184
+ .prepare(`SELECT session_key, ordinal, item_type, record_id, summary_id, fingerprint, happened_at, updated_at
185
+ FROM lcm_context_items
186
+ WHERE session_key = ?
187
+ ORDER BY ordinal ASC`)
188
+ .all(sessionKey);
189
+ return rows.map(contextItemFromRow);
190
+ }
191
+ clearContextItems(sessionKey) {
192
+ this.db.prepare("DELETE FROM lcm_context_items WHERE session_key = ?").run(sessionKey);
193
+ }
194
+ getSessionState(sessionKey) {
195
+ const row = this.db
196
+ .prepare("SELECT session_key, compaction_debt, cache_touched_at, updated_at FROM lcm_session_state WHERE session_key = ?")
197
+ .get(sessionKey);
198
+ return row ? sessionStateFromRow(row) : null;
199
+ }
200
+ upsertSessionState(input) {
201
+ const compactionDebt = Math.max(0, Math.floor(input.compactionDebt));
202
+ this.db
203
+ .prepare(`INSERT INTO lcm_session_state (session_key, compaction_debt, cache_touched_at, updated_at)
204
+ VALUES (?, ?, ?, ?)
205
+ ON CONFLICT(session_key) DO UPDATE SET
206
+ compaction_debt = excluded.compaction_debt,
207
+ cache_touched_at = excluded.cache_touched_at,
208
+ updated_at = excluded.updated_at`)
209
+ .run(input.sessionKey, compactionDebt, input.cacheTouchedAt, input.updatedAt ?? input.cacheTouchedAt ?? Date.now());
210
+ }
211
+ clearSessionState(sessionKey) {
212
+ this.db.prepare("DELETE FROM lcm_session_state WHERE session_key = ?").run(sessionKey);
213
+ }
214
+ applyNewSessionRetention(options) {
215
+ const retainDepth = options.newSessionRetainDepth;
216
+ if (!Number.isInteger(retainDepth) || retainDepth < -1) {
217
+ throw new Error("newSessionRetainDepth must be an integer >= -1");
218
+ }
219
+ const report = {
220
+ retainDepth,
221
+ affectedSegments: [],
222
+ rawRecordsDeleted: 0,
223
+ summariesDeleted: 0,
224
+ recordFtsRowsDeleted: 0,
225
+ summaryFtsRowsDeleted: 0,
226
+ indexDeletes: [],
227
+ };
228
+ if (retainDepth === -1)
229
+ return report;
230
+ const runRetention = () => {
231
+ const segmentRows = this.db
232
+ .prepare(`SELECT id FROM lcm_segments
233
+ WHERE status = 'closed'
234
+ ${options.activeSegmentId ? "AND id != ?" : ""}
235
+ ORDER BY started_at, id`)
236
+ .all(...(options.activeSegmentId ? [options.activeSegmentId] : []));
237
+ const segmentIds = segmentRows.map((row) => row.id);
238
+ report.affectedSegments = segmentIds;
239
+ if (segmentIds.length === 0)
240
+ return;
241
+ for (const segmentId of segmentIds) {
242
+ if (retainDepth > 0) {
243
+ this.snapshotSummariesForPrunedChildren(segmentId, retainDepth);
244
+ const summaries = this.db
245
+ .prepare("SELECT id FROM lcm_summaries WHERE segment_id = ? AND pinned = 0 AND depth < ?")
246
+ .all(segmentId, retainDepth);
247
+ for (const summary of summaries) {
248
+ report.indexDeletes.push({ corpus: "lcm_summary", sourceId: lcmSummaryIndexSourceId(summary.id) });
249
+ report.summaryFtsRowsDeleted += this.deleteSummaryFtsRow(summary.id);
250
+ }
251
+ report.summariesDeleted += this.db
252
+ .prepare("DELETE FROM lcm_summaries WHERE segment_id = ? AND pinned = 0 AND depth < ?")
253
+ .run(segmentId, retainDepth).changes;
254
+ // lcm_summary_parents rows for pruned summaries are removed by ON DELETE CASCADE.
255
+ }
256
+ this.snapshotSummariesForPrunedRecords(segmentId);
257
+ const records = this.db.prepare("SELECT id FROM lcm_records WHERE segment_id = ?").all(segmentId);
258
+ for (const record of records) {
259
+ report.indexDeletes.push({ corpus: "lcm_record", sourceId: lcmRecordIndexSourceId(record.id) });
260
+ report.recordFtsRowsDeleted += this.deleteRecordFtsRow(record.id);
261
+ }
262
+ report.rawRecordsDeleted += this.db
263
+ .prepare("DELETE FROM lcm_records WHERE segment_id = ?")
264
+ .run(segmentId).changes;
265
+ this.db
266
+ .prepare("UPDATE lcm_segments SET raw_pruned_at = ?, updated_at = unixepoch() WHERE id = ?")
267
+ .run(new Date().toISOString(), segmentId);
268
+ }
269
+ };
270
+ if (this.db.inTransaction)
271
+ runRetention();
272
+ else
273
+ this.db.transaction(runRetention).immediate();
274
+ if (options.vacuum)
275
+ this.db.exec("VACUUM");
276
+ return report;
277
+ }
278
+ insertSummarySources(summaryId, sources) {
279
+ const insert = this.db.prepare(`INSERT INTO lcm_summary_sources (
280
+ summary_id, ord, record_id, source_summary_id, source_ref, snapshot_json
281
+ ) VALUES (?, ?, ?, ?, ?, ?)`);
282
+ for (const [index, source] of sources.entries()) {
283
+ // source_summary_id is legacy advisory lineage only. The canonical
284
+ // parent edge is lcm_summary_parents, and this column is scheduled for removal.
285
+ insert.run(summaryId, index, source.recordId ?? null, null, source.sourceRef ?? null, jsonOrNull(source.snapshot ?? null));
286
+ }
287
+ }
288
+ insertSummaryParents(summaryId, parents) {
289
+ if (parents.length === 0)
290
+ return;
291
+ const uniqueParents = dedupeNumbers(parents);
292
+ const existingRows = this.db
293
+ .prepare(`SELECT id FROM lcm_summaries WHERE id IN (${uniqueParents.map(() => "?").join(",")})`)
294
+ .all(...uniqueParents);
295
+ const existing = new Set(existingRows.map((row) => row.id));
296
+ const missing = uniqueParents.filter((id) => !existing.has(id));
297
+ if (missing.length > 0)
298
+ throw new Error(`LCM summary parent does not exist: ${missing.join(", ")}`);
299
+ const insert = this.db.prepare(`INSERT INTO lcm_summary_parents (summary_id, parent_summary_id, ord)
300
+ VALUES (?, ?, ?)`);
301
+ for (const [index, parentId] of uniqueParents.entries())
302
+ insert.run(summaryId, parentId, index);
303
+ }
304
+ summaryParentMap(summaryIds) {
305
+ const map = new Map();
306
+ if (summaryIds.length === 0)
307
+ return map;
308
+ const rows = this.db
309
+ .prepare(`SELECT summary_id, parent_summary_id
310
+ FROM lcm_summary_parents
311
+ WHERE summary_id IN (${summaryIds.map(() => "?").join(",")})
312
+ ORDER BY summary_id, ord, parent_summary_id`)
313
+ .all(...summaryIds);
314
+ for (const row of rows) {
315
+ const parents = map.get(row.summary_id) ?? [];
316
+ parents.push(row.parent_summary_id);
317
+ map.set(row.summary_id, parents);
318
+ }
319
+ return map;
320
+ }
321
+ deleteRecordFtsRow(id) {
322
+ const row = this.db.prepare("SELECT text_full FROM lcm_records WHERE id = ?").get(id);
323
+ if (!row)
324
+ return 0;
325
+ this.db.prepare("DELETE FROM lcm_records_fts WHERE rowid = ?").run(id);
326
+ return 1;
327
+ }
328
+ deleteSummaryFtsRow(id) {
329
+ const row = this.db.prepare("SELECT text_full FROM lcm_summaries WHERE id = ?").get(id);
330
+ if (!row)
331
+ return 0;
332
+ this.db.prepare("DELETE FROM lcm_summaries_fts WHERE rowid = ?").run(id);
333
+ return 1;
334
+ }
335
+ snapshotSummariesForPrunedRecords(segmentId) {
336
+ const summaries = this.db
337
+ .prepare(`SELECT * FROM lcm_summaries
338
+ WHERE segment_id = ?
339
+ AND covers_from_record_id IS NOT NULL
340
+ AND covers_to_record_id IS NOT NULL
341
+ AND snapshot_json IS NULL
342
+ AND EXISTS (
343
+ SELECT 1 FROM lcm_records r
344
+ WHERE r.segment_id = lcm_summaries.segment_id
345
+ AND r.id BETWEEN lcm_summaries.covers_from_record_id AND lcm_summaries.covers_to_record_id
346
+ )
347
+ ORDER BY depth, id`)
348
+ .all(segmentId);
349
+ const update = this.db.prepare("UPDATE lcm_summaries SET snapshot_json = ?, updated_at = unixepoch() WHERE id = ?");
350
+ for (const summary of summaries) {
351
+ update.run(jsonOrNull(buildSummarySnapshot(this.db, summary)), summary.id);
352
+ }
353
+ }
354
+ snapshotSummariesForPrunedChildren(segmentId, retainDepth) {
355
+ const rows = this.db
356
+ .prepare(`SELECT * FROM lcm_summaries
357
+ WHERE segment_id = ?
358
+ AND pinned = 0
359
+ AND depth >= ?
360
+ ORDER BY depth DESC, id`)
361
+ .all(segmentId, retainDepth);
362
+ const update = this.db.prepare("UPDATE lcm_summaries SET snapshot_json = ?, updated_at = unixepoch() WHERE id = ?");
363
+ for (const row of rows) {
364
+ const snapshot = buildSummaryParentSnapshot(this.db, row.id, new Set());
365
+ if (snapshot.parents.length === 0)
366
+ continue;
367
+ update.run(jsonOrNull(snapshot.parents), row.id);
368
+ }
369
+ }
370
+ }
371
+ export function lcmRecordIndexSourceId(id) {
372
+ return `lcm_record:${id}`;
373
+ }
374
+ export function lcmSummaryIndexSourceId(id) {
375
+ return `lcm_summary:${id}`;
376
+ }
377
+ export function computeLcmRecordKey(input) {
378
+ const text = (input.text ?? "").trim();
379
+ if (!text && input.kind !== "boundary")
380
+ throw new Error("LCM record text must not be empty");
381
+ const parts = input.parts?.length ? input.parts : null;
382
+ return stableHash({
383
+ segmentId: input.segmentId,
384
+ kind: input.kind,
385
+ text: text || "Session boundary",
386
+ parts,
387
+ happenedAt: input.happenedAt ?? new Date().toISOString(),
388
+ source: normalizeSource(input.source),
389
+ });
390
+ }
391
+ function normalizeRecordInput(input) {
392
+ const text = (input.text ?? "").trim();
393
+ if (!text && input.kind !== "boundary")
394
+ throw new Error("LCM record text must not be empty");
395
+ const source = normalizeSource(input.source);
396
+ const happenedAt = input.happenedAt ?? new Date().toISOString();
397
+ const parts = input.parts?.length ? input.parts : null;
398
+ const normalizedText = text || "Session boundary";
399
+ return {
400
+ segmentId: input.segmentId,
401
+ kind: input.kind,
402
+ text: normalizedText,
403
+ parts,
404
+ happenedAt,
405
+ sessionId: input.sessionId ?? null,
406
+ channelKey: input.channelKey ?? null,
407
+ channelId: input.channelId ?? null,
408
+ jobId: input.jobId ?? null,
409
+ source,
410
+ attachments: input.attachments?.length ? input.attachments : null,
411
+ metadata: input.metadata ?? null,
412
+ recordKey: computeLcmRecordKey({ ...input, happenedAt }),
413
+ };
414
+ }
415
+ function insertRecordPrepared(db, normalized) {
416
+ const inserted = db
417
+ .prepare(`INSERT INTO lcm_records (
418
+ record_key, segment_id, kind, text_full, happened_at, session_id, channel_key,
419
+ channel_id, job_id, source_type, source_path, source_line, source_record_id,
420
+ source_message_id, source_ref, attachments_json, metadata_json, parts_json
421
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
422
+ .run(normalized.recordKey, normalized.segmentId, normalized.kind, normalized.text, normalized.happenedAt, normalized.sessionId, normalized.channelKey, normalized.channelId, normalized.jobId, normalized.source.sourceType, normalized.source.sourcePath ?? null, normalized.source.sourceLine ?? null, sourceRecordIdToString(normalized.source.sourceRecordId), normalized.source.sourceMessageId ?? null, normalized.source.sourceRef ?? null, jsonOrNull(normalized.attachments), jsonOrNull(normalized.metadata), jsonOrNull(normalized.parts));
423
+ const id = Number(inserted.lastInsertRowid);
424
+ if (normalized.kind !== "boundary") {
425
+ db.prepare("INSERT INTO lcm_records_fts(rowid, text_full) VALUES (?, ?)").run(id, normalized.text);
426
+ }
427
+ return id;
428
+ }
429
+ function normalizeSummaryInput(input) {
430
+ const text = (input.text ?? "").trim();
431
+ const source = normalizeSource(input.source);
432
+ const status = input.status ?? (text ? "ready" : "placeholder");
433
+ const normalizedText = text || "";
434
+ return {
435
+ segmentId: input.segmentId,
436
+ depth: input.depth,
437
+ status,
438
+ text: normalizedText,
439
+ pinned: input.pinned ?? false,
440
+ coversFromRecordId: input.coversFromRecordId ?? null,
441
+ coversToRecordId: input.coversToRecordId ?? null,
442
+ source,
443
+ sourceItems: input.sourceItems ?? [],
444
+ parents: input.parents ?? [],
445
+ metadata: input.metadata ?? null,
446
+ summaryKey: stableHash({
447
+ segmentId: input.segmentId,
448
+ depth: input.depth,
449
+ status,
450
+ text: normalizedText,
451
+ coversFromRecordId: input.coversFromRecordId ?? null,
452
+ coversToRecordId: input.coversToRecordId ?? null,
453
+ source,
454
+ parents: input.parents ?? [],
455
+ }),
456
+ };
457
+ }
458
+ function insertSummaryPrepared(db, normalized, insertEdges) {
459
+ const inserted = db
460
+ .prepare(`INSERT INTO lcm_summaries (
461
+ summary_key, segment_id, depth, status, text_full, pinned,
462
+ covers_from_record_id, covers_to_record_id, snapshot_json, source_type, source_path,
463
+ source_line, source_record_id, source_message_id, source_ref, metadata_json
464
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
465
+ .run(normalized.summaryKey, normalized.segmentId, normalized.depth, normalized.status, normalized.text, normalized.pinned ? 1 : 0, normalized.coversFromRecordId, normalized.coversToRecordId, null, normalized.source.sourceType, normalized.source.sourcePath ?? null, normalized.source.sourceLine ?? null, sourceRecordIdToString(normalized.source.sourceRecordId), normalized.source.sourceMessageId ?? null, normalized.source.sourceRef ?? null, jsonOrNull(normalized.metadata));
466
+ const id = Number(inserted.lastInsertRowid);
467
+ db.prepare("INSERT INTO lcm_summaries_fts(rowid, text_full) VALUES (?, ?)").run(id, normalized.text);
468
+ insertEdges(id, normalized.sourceItems, normalized.parents);
469
+ return id;
470
+ }
471
+ function runSummaryInsertTransaction(store, normalized, insertEdges) {
472
+ store.ensureSegment({ id: normalized.segmentId });
473
+ const existing = store.db.prepare("SELECT id FROM lcm_summaries WHERE summary_key = ?").get(normalized.summaryKey);
474
+ if (existing)
475
+ return existing.id;
476
+ return insertSummaryPrepared(store.db, normalized, insertEdges);
477
+ }
478
+ function dedupeNumbers(values) {
479
+ const seen = new Set();
480
+ const result = [];
481
+ for (const value of values) {
482
+ if (!Number.isInteger(value) || value <= 0)
483
+ throw new Error("LCM summary parents must be positive integer ids");
484
+ if (seen.has(value))
485
+ continue;
486
+ seen.add(value);
487
+ result.push(value);
488
+ }
489
+ return result;
490
+ }
491
+ function normalizeSource(source) {
492
+ return {
493
+ sourceType: source.sourceType,
494
+ sourcePath: source.sourcePath ?? null,
495
+ sourceLine: source.sourceLine ?? null,
496
+ sourceRecordId: source.sourceRecordId ?? null,
497
+ sourceMessageId: source.sourceMessageId ?? null,
498
+ sourceRef: source.sourceRef ?? null,
499
+ };
500
+ }
501
+ function stableHash(value) {
502
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
503
+ }
504
+ function sourceRecordIdToString(value) {
505
+ if (value === null || value === undefined)
506
+ return null;
507
+ return String(value);
508
+ }
509
+ function jsonOrNull(value) {
510
+ if (value === null || value === undefined)
511
+ return null;
512
+ return JSON.stringify(value);
513
+ }
514
+ function parseJsonObject(value) {
515
+ if (!value)
516
+ return null;
517
+ try {
518
+ const parsed = JSON.parse(value);
519
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
520
+ ? parsed
521
+ : null;
522
+ }
523
+ catch {
524
+ return null;
525
+ }
526
+ }
527
+ function parseJsonArray(value) {
528
+ if (!value)
529
+ return null;
530
+ try {
531
+ const parsed = JSON.parse(value);
532
+ return Array.isArray(parsed) ? parsed : null;
533
+ }
534
+ catch {
535
+ return null;
536
+ }
537
+ }
538
+ function sourceFromRow(row) {
539
+ return {
540
+ sourceType: row.source_type,
541
+ sourcePath: row.source_path,
542
+ sourceLine: row.source_line,
543
+ sourceRecordId: row.source_record_id,
544
+ sourceMessageId: row.source_message_id,
545
+ sourceRef: row.source_ref,
546
+ };
547
+ }
548
+ function segmentFromRow(row) {
549
+ return {
550
+ id: row.id,
551
+ status: row.status,
552
+ sessionId: row.session_id,
553
+ channelKey: row.channel_key,
554
+ startedAt: row.started_at,
555
+ closedAt: row.closed_at,
556
+ rawPrunedAt: row.raw_pruned_at,
557
+ boundarySource: parseJsonObject(row.boundary_source_json),
558
+ metadata: parseJsonObject(row.metadata_json),
559
+ createdAt: row.created_at,
560
+ updatedAt: row.updated_at,
561
+ };
562
+ }
563
+ function recordFromRow(row) {
564
+ return {
565
+ id: row.id,
566
+ recordKey: row.record_key,
567
+ segmentId: row.segment_id,
568
+ kind: row.kind,
569
+ text: row.text_full,
570
+ parts: parseJsonArray(row.parts_json),
571
+ happenedAt: row.happened_at,
572
+ sessionId: row.session_id,
573
+ channelKey: row.channel_key,
574
+ channelId: row.channel_id,
575
+ jobId: row.job_id,
576
+ source: sourceFromRow(row),
577
+ attachments: parseJsonArray(row.attachments_json),
578
+ metadata: parseJsonObject(row.metadata_json),
579
+ createdAt: row.created_at,
580
+ updatedAt: row.updated_at,
581
+ };
582
+ }
583
+ function summaryFromRow(row, parents = []) {
584
+ return {
585
+ id: row.id,
586
+ summaryKey: row.summary_key,
587
+ segmentId: row.segment_id,
588
+ depth: row.depth,
589
+ status: row.status,
590
+ text: row.text_full,
591
+ pinned: row.pinned === 1,
592
+ coversFromRecordId: row.covers_from_record_id,
593
+ coversToRecordId: row.covers_to_record_id,
594
+ source: sourceFromRow(row),
595
+ metadata: parseJsonObject(row.metadata_json),
596
+ snapshot: parseJsonArray(row.snapshot_json),
597
+ parents,
598
+ createdAt: row.created_at,
599
+ updatedAt: row.updated_at,
600
+ };
601
+ }
602
+ function summarySourceFromRow(row) {
603
+ return {
604
+ summaryId: row.summary_id,
605
+ ord: row.ord,
606
+ recordId: row.record_id,
607
+ sourceSummaryId: row.source_summary_id,
608
+ sourceRef: row.source_ref,
609
+ snapshot: parseJsonObject(row.snapshot_json),
610
+ };
611
+ }
612
+ function contextItemFromRow(row) {
613
+ if (row.item_type === "raw") {
614
+ if (row.record_id === null)
615
+ throw new Error(`Invalid raw LCM context item at ordinal ${row.ordinal}`);
616
+ return {
617
+ sessionKey: row.session_key,
618
+ ordinal: row.ordinal,
619
+ type: "raw",
620
+ recordId: row.record_id,
621
+ summaryId: null,
622
+ fingerprint: row.fingerprint,
623
+ happenedAt: row.happened_at,
624
+ updatedAt: row.updated_at,
625
+ };
626
+ }
627
+ if (row.item_type === "summary") {
628
+ if (row.summary_id === null)
629
+ throw new Error(`Invalid summary LCM context item at ordinal ${row.ordinal}`);
630
+ return {
631
+ sessionKey: row.session_key,
632
+ ordinal: row.ordinal,
633
+ type: "summary",
634
+ recordId: null,
635
+ summaryId: row.summary_id,
636
+ fingerprint: row.fingerprint,
637
+ happenedAt: row.happened_at,
638
+ updatedAt: row.updated_at,
639
+ };
640
+ }
641
+ throw new Error(`Unknown LCM context item type: ${row.item_type}`);
642
+ }
643
+ function sessionStateFromRow(row) {
644
+ return {
645
+ sessionKey: row.session_key,
646
+ compactionDebt: row.compaction_debt,
647
+ cacheTouchedAt: row.cache_touched_at,
648
+ updatedAt: row.updated_at,
649
+ };
650
+ }
651
+ const SUMMARY_SNAPSHOT_TEXT_LIMIT = 4 * 1024;
652
+ const SUMMARY_SNAPSHOT_TRUNCATED_SUFFIX = "…[truncated]";
653
+ function buildSummarySnapshot(db, summary) {
654
+ const rows = db
655
+ .prepare(`SELECT * FROM lcm_records
656
+ WHERE segment_id = ?
657
+ AND id BETWEEN ? AND ?
658
+ ORDER BY happened_at, id`)
659
+ .all(summary.segment_id, summary.covers_from_record_id, summary.covers_to_record_id);
660
+ return rows.map(snapshotRecordFromRow);
661
+ }
662
+ function buildSummaryParentSnapshot(db, summaryId, visiting) {
663
+ if (visiting.has(summaryId))
664
+ throw new Error(`Cycle detected in LCM summary parents at ${summaryId}`);
665
+ visiting.add(summaryId);
666
+ const row = db.prepare("SELECT * FROM lcm_summaries WHERE id = ?").get(summaryId);
667
+ if (!row)
668
+ throw new Error(`LCM summary does not exist: ${summaryId}`);
669
+ let snapshot = parseJsonArray(row.snapshot_json);
670
+ if (!snapshot &&
671
+ row.covers_from_record_id !== null &&
672
+ row.covers_to_record_id !== null &&
673
+ db
674
+ .prepare("SELECT 1 FROM lcm_records WHERE segment_id = ? AND id BETWEEN ? AND ? LIMIT 1")
675
+ .get(row.segment_id, row.covers_from_record_id, row.covers_to_record_id)) {
676
+ snapshot = buildSummarySnapshot(db, row);
677
+ }
678
+ const parentRows = db
679
+ .prepare(`SELECT parent_summary_id
680
+ FROM lcm_summary_parents
681
+ WHERE summary_id = ?
682
+ ORDER BY ord, parent_summary_id`)
683
+ .all(summaryId);
684
+ const parents = parentRows.map((parent) => buildSummaryParentSnapshot(db, parent.parent_summary_id, visiting));
685
+ visiting.delete(summaryId);
686
+ return {
687
+ summaryId: row.id,
688
+ depth: row.depth,
689
+ text: row.text_full,
690
+ coversFromRecordId: row.covers_from_record_id,
691
+ coversToRecordId: row.covers_to_record_id,
692
+ snapshot,
693
+ parents,
694
+ };
695
+ }
696
+ function snapshotRecordFromRow(row) {
697
+ const metadata = parseJsonObject(row.metadata_json);
698
+ return {
699
+ id: row.id,
700
+ kind: row.kind,
701
+ happened_at: row.happened_at,
702
+ role: snapshotRole(row.kind, metadata),
703
+ text: truncateSummarySnapshotText(row.text_full),
704
+ parts: parseJsonArray(row.parts_json),
705
+ attachments: parseJsonArray(row.attachments_json),
706
+ };
707
+ }
708
+ function snapshotRole(kind, metadata) {
709
+ if (typeof metadata?.role === "string" && metadata.role.trim())
710
+ return metadata.role;
711
+ if (kind === "user" || kind === "assistant")
712
+ return kind;
713
+ if (kind === "tool")
714
+ return "tool";
715
+ return null;
716
+ }
717
+ function truncateSummarySnapshotText(text) {
718
+ if (text.length <= SUMMARY_SNAPSHOT_TEXT_LIMIT)
719
+ return text;
720
+ const retainedLength = Math.max(0, SUMMARY_SNAPSHOT_TEXT_LIMIT - SUMMARY_SNAPSHOT_TRUNCATED_SUFFIX.length);
721
+ return `${text.slice(0, retainedLength)}${SUMMARY_SNAPSHOT_TRUNCATED_SUFFIX}`;
722
+ }