@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,235 @@
1
+ export function normalizeChatRecords(chatRecords, options) {
2
+ const segments = new Map();
3
+ const records = [];
4
+ const sortedRecords = [...chatRecords].sort((a, b) => a.recordId - b.recordId);
5
+ const assistantEventsByJob = collectAssistantEvents(sortedRecords);
6
+ for (const record of sortedRecords) {
7
+ const segmentId = options.segmentId;
8
+ if (!segments.has(segmentId)) {
9
+ segments.set(segmentId, {
10
+ id: segmentId,
11
+ sessionId: options.sessionId ?? null,
12
+ channelKey: options.channelKey ?? null,
13
+ startedAt: record.ts,
14
+ });
15
+ }
16
+ const source = chatRecordSource(record, options.sourcePath);
17
+ const common = {
18
+ segmentId,
19
+ happenedAt: record.ts,
20
+ sessionId: options.sessionId ?? null,
21
+ channelKey: options.channelKey ?? null,
22
+ channelId: record.channelId,
23
+ source,
24
+ };
25
+ if (record.type === "inbound") {
26
+ const text = withAttachmentNotes(record.text, attachmentNotes(record.attachments));
27
+ if (text) {
28
+ records.push({
29
+ ...common,
30
+ kind: "user",
31
+ text,
32
+ attachments: attachmentNotes(record.attachments),
33
+ metadata: { authorId: record.authorId, authorName: record.authorName ?? null },
34
+ });
35
+ }
36
+ continue;
37
+ }
38
+ if (record.type === "outbound" && !record.silent) {
39
+ const notes = attachmentNotes(record.attachments ?? []);
40
+ const parts = assistantParts(record, assistantEventsByJob.get(record.jobId ?? ""));
41
+ appendAssistantFinalText(parts, withAttachmentNotes(record.text, notes));
42
+ const text = flattenLcmRecordParts(parts);
43
+ if (text) {
44
+ records.push({
45
+ ...common,
46
+ kind: "assistant",
47
+ text,
48
+ parts: parts.length ? parts : undefined,
49
+ jobId: record.jobId ?? null,
50
+ attachments: notes,
51
+ metadata: { messageIds: record.messageIds, replyToMessageId: record.replyToMessageId ?? null },
52
+ });
53
+ }
54
+ continue;
55
+ }
56
+ if (record.type === "agent_event" && record.event.type === "tool_execution_end") {
57
+ const parts = [
58
+ {
59
+ kind: "tool_result",
60
+ toolCallId: record.event.toolCallId,
61
+ toolName: record.event.toolName,
62
+ output: record.event.result,
63
+ ...(record.event.isError ? { isError: true } : {}),
64
+ },
65
+ ];
66
+ records.push({
67
+ ...common,
68
+ kind: "tool",
69
+ text: flattenLcmRecordParts(parts),
70
+ parts,
71
+ jobId: record.jobId,
72
+ metadata: { messageId: record.messageId },
73
+ });
74
+ continue;
75
+ }
76
+ if (record.type === "control" && record.command === "new") {
77
+ records.push({
78
+ ...common,
79
+ kind: "boundary",
80
+ text: record.text || "/new",
81
+ metadata: { command: record.command, args: record.args ?? null, authorId: record.authorId },
82
+ });
83
+ continue;
84
+ }
85
+ if (record.type === "runtime" && record.event === "reset") {
86
+ records.push({
87
+ ...common,
88
+ kind: "boundary",
89
+ text: record.detail || "runtime reset",
90
+ metadata: { event: record.event },
91
+ });
92
+ }
93
+ }
94
+ return { segments: [...segments.values()], records };
95
+ }
96
+ export function flattenLcmRecordParts(parts) {
97
+ return parts
98
+ .map((part) => {
99
+ if (part.kind === "text")
100
+ return part.text.trim();
101
+ if (part.kind === "thinking")
102
+ return part.text.trim() ? `[thinking] ${part.text.trim()}` : "";
103
+ if (part.kind === "tool_call")
104
+ return `[tool_call: ${part.toolName}(${briefJson(part.arguments)})]`;
105
+ return `[tool_result: ${part.toolName} -> ${briefJson(part.output)}]`;
106
+ })
107
+ .filter(Boolean)
108
+ .join("\n");
109
+ }
110
+ function collectAssistantEvents(records) {
111
+ const byJob = new Map();
112
+ for (const record of records) {
113
+ if (record.type !== "agent_event")
114
+ continue;
115
+ const events = byJob.get(record.jobId) ?? [];
116
+ events.push(record.event);
117
+ byJob.set(record.jobId, events);
118
+ }
119
+ return byJob;
120
+ }
121
+ function assistantParts(record, events) {
122
+ const parts = [];
123
+ if (record.thinking?.trim())
124
+ appendThinkingPart(parts, record.thinking);
125
+ for (const event of events ?? []) {
126
+ if (event.type !== "message_update")
127
+ continue;
128
+ const assistantEvent = event.assistantMessageEvent;
129
+ if (assistantEvent.type === "text_delta") {
130
+ appendTextPart(parts, assistantEvent.delta);
131
+ }
132
+ else if (assistantEvent.type === "thinking_delta") {
133
+ appendThinkingPart(parts, assistantEvent.delta);
134
+ }
135
+ else if (assistantEvent.type === "toolcall_end") {
136
+ parts.push({
137
+ kind: "tool_call",
138
+ toolCallId: assistantEvent.toolCall.id,
139
+ toolName: assistantEvent.toolCall.name,
140
+ arguments: assistantEvent.toolCall.arguments,
141
+ });
142
+ }
143
+ }
144
+ return parts;
145
+ }
146
+ function appendTextPart(parts, text) {
147
+ if (!text)
148
+ return;
149
+ const previous = parts.at(-1);
150
+ if (previous?.kind === "text") {
151
+ previous.text += text;
152
+ return;
153
+ }
154
+ parts.push({ kind: "text", text });
155
+ }
156
+ function appendAssistantFinalText(parts, text) {
157
+ const normalized = text.trim();
158
+ if (!normalized)
159
+ return;
160
+ const existingText = parts
161
+ .filter((part) => part.kind === "text")
162
+ .map((part) => part.text)
163
+ .join("")
164
+ .trim();
165
+ if (existingText === normalized)
166
+ return;
167
+ appendTextPart(parts, text);
168
+ }
169
+ function appendThinkingPart(parts, text) {
170
+ if (!text)
171
+ return;
172
+ const previous = parts.at(-1);
173
+ if (previous?.kind === "thinking" && !previous.signature) {
174
+ previous.text += text;
175
+ return;
176
+ }
177
+ parts.push({ kind: "thinking", text });
178
+ }
179
+ function briefJson(value, maxLength = 160) {
180
+ let text;
181
+ if (typeof value === "string")
182
+ text = value;
183
+ else {
184
+ try {
185
+ text = JSON.stringify(value);
186
+ }
187
+ catch {
188
+ text = String(value);
189
+ }
190
+ }
191
+ const compact = text.replace(/\s+/g, " ").trim();
192
+ return compact.length > maxLength ? `${compact.slice(0, maxLength - 1)}...` : compact;
193
+ }
194
+ function chatRecordSource(record, sourcePath) {
195
+ const messageId = "messageId" in record && typeof record.messageId === "string"
196
+ ? record.messageId
197
+ : "messageIds" in record
198
+ ? record.messageIds[0]
199
+ : undefined;
200
+ return {
201
+ sourceType: "chat",
202
+ sourcePath: sourcePath ?? null,
203
+ sourceRecordId: record.recordId,
204
+ sourceMessageId: messageId ?? null,
205
+ sourceRef: sourcePath ? `${sourcePath}#${record.recordId}` : `chat:${record.recordId}`,
206
+ };
207
+ }
208
+ function attachmentNotes(attachments) {
209
+ const notes = attachments
210
+ .map((attachment) => {
211
+ const derivedText = attachment.derived?.text?.text.trim();
212
+ const imageNote = attachment.derived?.image?.note?.trim();
213
+ const note = {
214
+ id: attachment.id,
215
+ name: attachment.name,
216
+ kind: attachment.kind,
217
+ mimeType: attachment.mimeType,
218
+ text: derivedText || undefined,
219
+ note: imageNote || undefined,
220
+ sourceRef: attachment.localPath ?? attachment.remoteUrl ?? attachment.sourceUrl,
221
+ };
222
+ return note.text || note.note || note.name ? note : null;
223
+ })
224
+ .filter((item) => item !== null);
225
+ return notes.length ? notes : null;
226
+ }
227
+ function withAttachmentNotes(text, notes) {
228
+ const trimmed = text.trim();
229
+ const usefulNotes = (notes ?? [])
230
+ .map((note) => note.text || note.note || (note.name ? `Attachment: ${note.name}` : ""))
231
+ .filter((note) => note.trim());
232
+ if (usefulNotes.length === 0)
233
+ return trimmed;
234
+ return [trimmed, ...usefulNotes].filter(Boolean).join("\n");
235
+ }
@@ -0,0 +1,188 @@
1
+ const SCHEMA_VERSION = 7;
2
+ export function runLcmMigrations(db) {
3
+ db.pragma("journal_mode = WAL");
4
+ db.pragma("foreign_keys = ON");
5
+ const run = () => {
6
+ db.exec(`
7
+ CREATE TABLE IF NOT EXISTS lcm_meta (
8
+ k TEXT PRIMARY KEY,
9
+ v TEXT NOT NULL
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS lcm_segments (
13
+ id TEXT PRIMARY KEY,
14
+ status TEXT NOT NULL DEFAULT 'active',
15
+ session_id TEXT,
16
+ channel_key TEXT,
17
+ started_at TEXT NOT NULL,
18
+ closed_at TEXT,
19
+ raw_pruned_at TEXT,
20
+ boundary_source_json TEXT,
21
+ metadata_json TEXT,
22
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
23
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS lcm_records (
27
+ id INTEGER PRIMARY KEY,
28
+ record_key TEXT NOT NULL UNIQUE,
29
+ segment_id TEXT NOT NULL REFERENCES lcm_segments(id) ON DELETE CASCADE,
30
+ kind TEXT NOT NULL,
31
+ text_full TEXT NOT NULL,
32
+ parts_json TEXT,
33
+ happened_at TEXT NOT NULL,
34
+ session_id TEXT,
35
+ channel_key TEXT,
36
+ channel_id TEXT,
37
+ job_id TEXT,
38
+ source_type TEXT NOT NULL,
39
+ source_path TEXT,
40
+ source_line INTEGER,
41
+ source_record_id TEXT,
42
+ source_message_id TEXT,
43
+ source_ref TEXT,
44
+ attachments_json TEXT,
45
+ metadata_json TEXT,
46
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
47
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
48
+ );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_lcm_records_segment ON lcm_records(segment_id, happened_at, id);
51
+ CREATE INDEX IF NOT EXISTS idx_lcm_records_source ON lcm_records(source_type, source_path, source_record_id);
52
+ CREATE INDEX IF NOT EXISTS idx_lcm_records_session ON lcm_records(session_id, channel_key, happened_at);
53
+
54
+ CREATE VIRTUAL TABLE IF NOT EXISTS lcm_records_fts USING fts5(
55
+ text_full,
56
+ content='',
57
+ contentless_delete=1
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS lcm_summaries (
61
+ id INTEGER PRIMARY KEY,
62
+ summary_key TEXT NOT NULL UNIQUE,
63
+ segment_id TEXT NOT NULL REFERENCES lcm_segments(id) ON DELETE CASCADE,
64
+ depth INTEGER NOT NULL,
65
+ status TEXT NOT NULL DEFAULT 'placeholder',
66
+ text_full TEXT NOT NULL,
67
+ pinned INTEGER NOT NULL DEFAULT 0,
68
+ covers_from_record_id INTEGER REFERENCES lcm_records(id) ON DELETE SET NULL,
69
+ covers_to_record_id INTEGER REFERENCES lcm_records(id) ON DELETE SET NULL,
70
+ snapshot_json TEXT,
71
+ source_type TEXT NOT NULL,
72
+ source_path TEXT,
73
+ source_line INTEGER,
74
+ source_record_id TEXT,
75
+ source_message_id TEXT,
76
+ source_ref TEXT,
77
+ metadata_json TEXT,
78
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
79
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_lcm_summaries_segment ON lcm_summaries(segment_id, depth, id);
83
+ CREATE INDEX IF NOT EXISTS idx_lcm_summaries_status ON lcm_summaries(status, pinned);
84
+
85
+ CREATE VIRTUAL TABLE IF NOT EXISTS lcm_summaries_fts USING fts5(
86
+ text_full,
87
+ content='',
88
+ contentless_delete=1
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS lcm_summary_sources (
92
+ summary_id INTEGER NOT NULL REFERENCES lcm_summaries(id) ON DELETE CASCADE,
93
+ ord INTEGER NOT NULL,
94
+ record_id INTEGER REFERENCES lcm_records(id) ON DELETE SET NULL,
95
+ -- Advisory legacy edge only. New condensation code must write summary-to-summary
96
+ -- lineage to lcm_summary_parents so parent coverage survives source-row drift.
97
+ source_summary_id INTEGER REFERENCES lcm_summaries(id) ON DELETE SET NULL,
98
+ source_ref TEXT,
99
+ snapshot_json TEXT,
100
+ PRIMARY KEY(summary_id, ord)
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS lcm_summary_parents (
104
+ summary_id INTEGER NOT NULL REFERENCES lcm_summaries(id) ON DELETE CASCADE,
105
+ parent_summary_id INTEGER NOT NULL REFERENCES lcm_summaries(id) ON DELETE CASCADE,
106
+ ord INTEGER NOT NULL,
107
+ PRIMARY KEY(summary_id, parent_summary_id)
108
+ );
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_summary_parents_parent ON lcm_summary_parents(parent_summary_id);
111
+ CREATE INDEX IF NOT EXISTS idx_summary_parents_child ON lcm_summary_parents(summary_id);
112
+
113
+ CREATE TABLE IF NOT EXISTS lcm_context_items (
114
+ session_key TEXT NOT NULL,
115
+ ordinal INTEGER NOT NULL,
116
+ item_type TEXT NOT NULL CHECK(item_type IN ('raw', 'summary')),
117
+ record_id INTEGER REFERENCES lcm_records(id) ON DELETE CASCADE,
118
+ summary_id INTEGER REFERENCES lcm_summaries(id) ON DELETE CASCADE,
119
+ fingerprint TEXT NOT NULL,
120
+ happened_at TEXT,
121
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
122
+ PRIMARY KEY(session_key, ordinal),
123
+ CHECK ((item_type = 'raw' AND record_id IS NOT NULL AND summary_id IS NULL)
124
+ OR (item_type = 'summary' AND summary_id IS NOT NULL AND record_id IS NULL))
125
+ );
126
+
127
+ CREATE INDEX IF NOT EXISTS idx_lcm_context_items_session ON lcm_context_items(session_key);
128
+ CREATE INDEX IF NOT EXISTS idx_lcm_context_items_record ON lcm_context_items(record_id);
129
+ CREATE INDEX IF NOT EXISTS idx_lcm_context_items_summary ON lcm_context_items(summary_id);
130
+
131
+ CREATE TABLE IF NOT EXISTS lcm_session_state (
132
+ session_key TEXT PRIMARY KEY,
133
+ compaction_debt INTEGER NOT NULL DEFAULT 0,
134
+ cache_touched_at INTEGER,
135
+ updated_at INTEGER
136
+ );
137
+ `);
138
+ migrateRecordPartsColumn(db);
139
+ migrateSummarySnapshotColumn(db);
140
+ migrateContentlessFts(db);
141
+ writeMeta(db, "schema_version", String(SCHEMA_VERSION));
142
+ };
143
+ if (db.inTransaction)
144
+ run();
145
+ else
146
+ db.transaction(run).immediate();
147
+ }
148
+ export function readMeta(db, key) {
149
+ const row = db.prepare("SELECT v FROM lcm_meta WHERE k = ?").get(key);
150
+ return row?.v ?? null;
151
+ }
152
+ export function writeMeta(db, key, value) {
153
+ db.prepare(`INSERT INTO lcm_meta(k, v) VALUES (?, ?)
154
+ ON CONFLICT(k) DO UPDATE SET v = excluded.v`).run(key, value);
155
+ }
156
+ function migrateContentlessFts(db) {
157
+ migrateOneFts(db, "lcm_records_fts", "lcm_records", "text_full");
158
+ migrateOneFts(db, "lcm_summaries_fts", "lcm_summaries", "text_full");
159
+ }
160
+ function migrateRecordPartsColumn(db) {
161
+ const columns = db.prepare("PRAGMA table_info(lcm_records)").all();
162
+ if (columns.some((column) => column.name === "parts_json"))
163
+ return;
164
+ db.prepare("ALTER TABLE lcm_records ADD COLUMN parts_json TEXT").run();
165
+ }
166
+ function migrateSummarySnapshotColumn(db) {
167
+ const columns = db.prepare("PRAGMA table_info(lcm_summaries)").all();
168
+ if (columns.some((column) => column.name === "snapshot_json"))
169
+ return;
170
+ db.prepare("ALTER TABLE lcm_summaries ADD COLUMN snapshot_json TEXT").run();
171
+ }
172
+ function migrateOneFts(db, ftsTable, sourceTable, textColumn) {
173
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(ftsTable);
174
+ if (row?.sql.includes("contentless_delete=1"))
175
+ return;
176
+ const run = () => {
177
+ db.prepare(`DROP TABLE ${ftsTable}`).run();
178
+ db.prepare(`CREATE VIRTUAL TABLE ${ftsTable} USING fts5(${textColumn}, content='', contentless_delete=1)`).run();
179
+ const rows = db.prepare(`SELECT id, ${textColumn} FROM ${sourceTable}`).all();
180
+ const insert = db.prepare(`INSERT INTO ${ftsTable}(rowid, ${textColumn}) VALUES (?, ?)`);
181
+ for (const source of rows)
182
+ insert.run(source.id, source[textColumn]);
183
+ };
184
+ if (db.inTransaction)
185
+ run();
186
+ else
187
+ db.transaction(run).immediate();
188
+ }
@@ -0,0 +1,136 @@
1
+ import { projectNormalizedLcmBatch } from "./indexer.js";
2
+ import { normalizeChatRecords } from "./normalize.js";
3
+ export class LcmSegmentManager {
4
+ lcmStore;
5
+ memoryStore;
6
+ indexer;
7
+ newSessionRetainDepth;
8
+ onRotate;
9
+ activeSegments = new Map();
10
+ segmentCounters = new Map();
11
+ projectionQueue = Promise.resolve();
12
+ projectionFailures = 0;
13
+ constructor(options) {
14
+ this.lcmStore = options.lcmStore;
15
+ this.memoryStore = options.memoryStore;
16
+ this.indexer = options.indexer;
17
+ this.newSessionRetainDepth = options.newSessionRetainDepth;
18
+ this.onRotate = options.onRotate;
19
+ }
20
+ subscribeRuntime(runtime, sessionId) {
21
+ const unsubscribe = runtime.subscribe((record) => {
22
+ this.projectionQueue = this.projectionQueue.then(() => this.projectRuntimeRecord(runtime, record, sessionId), () => this.projectRuntimeRecord(runtime, record, sessionId));
23
+ void this.projectionQueue.catch((error) => {
24
+ this.projectionFailures += 1;
25
+ console.error(`memory projection failed for ${runtime.channelKey}`, error);
26
+ });
27
+ });
28
+ return unsubscribe;
29
+ }
30
+ async flush() {
31
+ await this.projectionQueue.catch(() => undefined);
32
+ }
33
+ stats() {
34
+ return { projectionFailures: this.projectionFailures };
35
+ }
36
+ activeSegmentId(channelKey) {
37
+ const existing = this.activeSegments.get(channelKey);
38
+ if (existing)
39
+ return existing;
40
+ const persisted = this.findPersistedActiveSegmentId(channelKey);
41
+ if (persisted) {
42
+ this.activeSegments.set(channelKey, persisted);
43
+ this.seedSegmentCounter(channelKey);
44
+ return persisted;
45
+ }
46
+ this.seedSegmentCounter(channelKey);
47
+ const next = this.nextSegmentId(channelKey);
48
+ this.activeSegments.set(channelKey, next);
49
+ return next;
50
+ }
51
+ async projectRuntimeRecord(runtime, record, sessionId) {
52
+ if (record.type === "runtime" && record.event === "reset") {
53
+ this.rotateRuntimeSegment(runtime, record);
54
+ return;
55
+ }
56
+ const segmentId = this.activeSegmentId(runtime.channelKey);
57
+ const batch = normalizeChatRecords([record], {
58
+ segmentId,
59
+ sessionId: sessionId ?? null,
60
+ channelKey: runtime.channelKey,
61
+ });
62
+ if (batch.records.length === 0 && batch.segments.length === 0)
63
+ return;
64
+ await projectNormalizedLcmBatch({ batch, lcmStore: this.lcmStore, indexer: this.indexer });
65
+ }
66
+ rotateRuntimeSegment(runtime, record) {
67
+ const previousSegmentId = this.activeSegmentId(runtime.channelKey);
68
+ const nextSegmentId = this.nextSegmentId(runtime.channelKey);
69
+ let indexDeletes = [];
70
+ // LCM rotation commits as one source-of-truth transaction. Shared-index deletes are
71
+ // applied after commit; startup reconciliation repairs crashes between the two DBs.
72
+ this.lcmStore.db
73
+ .transaction(() => {
74
+ const batch = normalizeChatRecords([record], {
75
+ segmentId: previousSegmentId,
76
+ channelKey: runtime.channelKey,
77
+ });
78
+ for (const segment of batch.segments)
79
+ this.lcmStore.ensureSegment(segment);
80
+ for (const normalizedRecord of batch.records)
81
+ this.lcmStore.insertRecord(normalizedRecord);
82
+ this.lcmStore.closeSegment(previousSegmentId, record.ts);
83
+ this.lcmStore.ensureSegment({
84
+ id: nextSegmentId,
85
+ channelKey: runtime.channelKey,
86
+ startedAt: record.ts,
87
+ boundarySource: {
88
+ sourceType: "chat",
89
+ sourceRecordId: record.recordId,
90
+ sourceRef: `chat:${record.recordId}`,
91
+ },
92
+ });
93
+ this.lcmStore.clearContextItems(runtime.channelKey);
94
+ this.lcmStore.clearSessionState(runtime.channelKey);
95
+ const retention = this.lcmStore.applyNewSessionRetention({
96
+ newSessionRetainDepth: this.newSessionRetainDepth,
97
+ activeSegmentId: nextSegmentId,
98
+ });
99
+ indexDeletes = retention.indexDeletes;
100
+ })
101
+ .immediate();
102
+ this.activeSegments.set(runtime.channelKey, nextSegmentId);
103
+ this.onRotate?.(runtime.channelKey);
104
+ this.memoryStore.db
105
+ .transaction((deletes) => {
106
+ for (const deleted of deletes)
107
+ this.memoryStore.deleteBySourceUnsafe(deleted.corpus, deleted.sourceId);
108
+ })
109
+ .immediate(indexDeletes);
110
+ }
111
+ nextSegmentId(channelKey) {
112
+ const next = (this.segmentCounters.get(channelKey) ?? 0) + 1;
113
+ this.segmentCounters.set(channelKey, next);
114
+ return `${channelKey}:seg-${next}`;
115
+ }
116
+ findPersistedActiveSegmentId(channelKey) {
117
+ const segments = this.lcmStore
118
+ .listSegments()
119
+ .filter((segment) => segment.channelKey === channelKey && segment.status === "active")
120
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt) || b.id.localeCompare(a.id));
121
+ return segments[0]?.id ?? null;
122
+ }
123
+ seedSegmentCounter(channelKey) {
124
+ const existing = this.segmentCounters.get(channelKey) ?? 0;
125
+ let max = existing;
126
+ for (const segment of this.lcmStore.listSegments()) {
127
+ if (segment.channelKey !== channelKey)
128
+ continue;
129
+ const match = segment.id.match(/:seg-(\d+)$/);
130
+ if (!match)
131
+ continue;
132
+ max = Math.max(max, Number(match[1]));
133
+ }
134
+ this.segmentCounters.set(channelKey, max);
135
+ }
136
+ }