@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,421 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scoreEvictable, tokenBag } from "./eviction-score.js";
3
+ const MESSAGE_OVERHEAD_TOKENS = 6;
4
+ const RECORD_OVERHEAD_TOKENS = 4;
5
+ const IMAGE_TOKEN_ESTIMATE = 1200;
6
+ export function estimateTextTokens(text) {
7
+ if (!text)
8
+ return 0;
9
+ let ascii = 0;
10
+ let nonAscii = 0;
11
+ for (const char of text) {
12
+ const codePoint = char.codePointAt(0) ?? 0;
13
+ if (codePoint <= 0x7f)
14
+ ascii += 1;
15
+ else
16
+ nonAscii += 1;
17
+ }
18
+ return Math.ceil(ascii / 3) + nonAscii;
19
+ }
20
+ export function estimateLcmRecordTokens(recordOrText) {
21
+ const text = typeof recordOrText === "string" ? recordOrText : recordOrText.text;
22
+ const attachments = typeof recordOrText === "string" ? null : recordOrText.attachments;
23
+ const attachmentText = attachments?.map(renderAttachmentForEstimate).filter(Boolean).join("\n") ?? "";
24
+ const contentTokens = estimateTextTokens([text, attachmentText].filter(Boolean).join("\n"));
25
+ return contentTokens > 0 ? contentTokens + RECORD_OVERHEAD_TOKENS : 0;
26
+ }
27
+ export function estimateAgentMessageTokens(message) {
28
+ const role = message.role;
29
+ switch (role) {
30
+ case "user":
31
+ return estimateUserMessageTokens(message);
32
+ case "assistant":
33
+ return estimateAssistantMessageTokens(message);
34
+ case "toolResult":
35
+ return estimateToolResultMessageTokens(message);
36
+ default:
37
+ return estimateFallbackMessageTokens(message);
38
+ }
39
+ }
40
+ export function createAgentMessageFingerprint(message, _index) {
41
+ const timestamp = message.timestamp;
42
+ const id = message.id;
43
+ const text = messageTextForFingerprint(message);
44
+ const payload = typeof timestamp === "number" && Number.isFinite(timestamp)
45
+ ? { role: message.role ?? null, timestamp, text }
46
+ : typeof id === "string" && id.trim()
47
+ ? { role: message.role ?? null, id, text }
48
+ : { text };
49
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
50
+ }
51
+ export function createRawContextItems(messages) {
52
+ return messages.map((message, index) => ({
53
+ id: createAgentMessageFingerprint(message, index),
54
+ message,
55
+ tokens: estimateAgentMessageTokens(message),
56
+ }));
57
+ }
58
+ export function lcmRecordToAgentMessage(record) {
59
+ const timestamp = Date.parse(record.happenedAt);
60
+ const messageTimestamp = Number.isFinite(timestamp) ? timestamp : Date.now();
61
+ if (record.parts?.length)
62
+ return structuredLcmRecordToAgentMessage(record, messageTimestamp);
63
+ if (record.kind === "assistant")
64
+ return fallbackAssistantMessage(record.text, messageTimestamp);
65
+ if (record.kind === "tool")
66
+ return fallbackAssistantMessage(record.text, messageTimestamp);
67
+ return { role: "user", content: record.text, timestamp: messageTimestamp };
68
+ }
69
+ export function renderLcmRecordPartsForSummary(parts) {
70
+ return parts
71
+ .map((part) => {
72
+ if (part.kind === "text")
73
+ return part.text.trim();
74
+ if (part.kind === "thinking")
75
+ return part.text.trim() ? `<thinking>${part.text.trim()}</thinking>` : "";
76
+ if (part.kind === "tool_call") {
77
+ return `<tool_call name="${escapeXmlAttribute(part.toolName)}">${formatJsonForSummary(part.arguments)}</tool_call>`;
78
+ }
79
+ return `<tool_result name="${escapeXmlAttribute(part.toolName)}">${truncateForSummary(formatJsonForSummary(part.output))}</tool_result>`;
80
+ })
81
+ .filter(Boolean)
82
+ .join("\n");
83
+ }
84
+ export function selectLcmCompactionCandidate(items, config, tokenBudget, additionalContextTokens = 0) {
85
+ return selectLcmCompactionCandidatePromptAware(items, config, tokenBudget, "", additionalContextTokens);
86
+ }
87
+ export function selectLcmCompactionCandidatePromptAware(items, config, tokenBudget, promptText, additionalContextTokens = 0) {
88
+ const freshTailStartIndex = resolveFreshTailStartIndex(items, config);
89
+ const compactable = items.slice(0, freshTailStartIndex);
90
+ const rawTokensOutsideTail = compactable.reduce((total, item) => total + item.tokens, 0);
91
+ const totalTokens = items.reduce((total, item) => total + item.tokens, 0) + Math.max(0, additionalContextTokens);
92
+ const contextThresholdTokens = Math.max(1, Math.floor(config.contextThreshold * tokenBudget));
93
+ const reasons = [];
94
+ if (rawTokensOutsideTail >= config.leafChunkTokens)
95
+ reasons.push("leaf_chunk");
96
+ if (totalTokens > contextThresholdTokens && compactable.length > 0)
97
+ reasons.push("context_threshold");
98
+ const chunk = reasons.length > 0 ? selectLeafChunk(compactable, config.leafChunkTokens, promptText, config) : [];
99
+ const chunkTokens = chunk.reduce((total, item) => total + item.tokens, 0);
100
+ return {
101
+ shouldCompact: chunk.length > 0,
102
+ reasons,
103
+ chunk,
104
+ chunkTokens,
105
+ rawTokensOutsideTail,
106
+ freshTailStartIndex,
107
+ totalTokens,
108
+ contextThresholdTokens,
109
+ };
110
+ }
111
+ function estimateUserMessageTokens(message) {
112
+ return MESSAGE_OVERHEAD_TOKENS + estimateContentTokens(message.content);
113
+ }
114
+ function estimateAssistantMessageTokens(message) {
115
+ let tokens = MESSAGE_OVERHEAD_TOKENS;
116
+ for (const block of message.content) {
117
+ if (block.type === "text")
118
+ tokens += estimateTextTokens(block.text);
119
+ else if (block.type === "thinking")
120
+ tokens += estimateTextTokens(block.thinking);
121
+ else if (block.type === "toolCall") {
122
+ tokens += estimateTextTokens(block.name);
123
+ tokens += estimateJsonTokens(block.arguments);
124
+ }
125
+ }
126
+ return tokens;
127
+ }
128
+ function estimateToolResultMessageTokens(message) {
129
+ return MESSAGE_OVERHEAD_TOKENS + estimateTextTokens(message.toolName) + estimateContentTokens(message.content);
130
+ }
131
+ function estimateFallbackMessageTokens(message) {
132
+ const text = JSON.stringify(message) ?? "";
133
+ return text ? MESSAGE_OVERHEAD_TOKENS + estimateTextTokens(text) : 0;
134
+ }
135
+ export function resolveFreshTailStartIndex(items, config) {
136
+ const protectedByCountStart = Math.max(0, items.length - Math.max(0, config.freshTailCount));
137
+ if (config.freshTailMaxTokens === undefined)
138
+ return protectedByCountStart;
139
+ let tokenStart = items.length;
140
+ let tokens = 0;
141
+ for (let index = items.length - 1; index >= 0; index -= 1) {
142
+ const item = items[index];
143
+ if (!item)
144
+ continue;
145
+ if (tokens + item.tokens > config.freshTailMaxTokens)
146
+ break;
147
+ tokens += item.tokens;
148
+ tokenStart = index;
149
+ }
150
+ return Math.max(protectedByCountStart, tokenStart);
151
+ }
152
+ function selectLeafChunk(items, leafChunkTokens, promptText, config) {
153
+ if (config.promptAwareEvictionEnabled === false || tokenBag(promptText).length === 0) {
154
+ return selectOldestLeafChunk(items, leafChunkTokens);
155
+ }
156
+ const ranges = createValidLeafRanges(items, leafChunkTokens);
157
+ if (ranges.length === 0)
158
+ return [];
159
+ if (!ranges.some((range) => range.tokens >= leafChunkTokens))
160
+ return selectOldestLeafChunk(items, leafChunkTokens);
161
+ const targetRanges = ranges.filter((range) => range.tokens >= leafChunkTokens);
162
+ const records = items.map((item) => item.record).filter((record) => !!record);
163
+ if (records.length === 0)
164
+ return selectOldestLeafChunk(items, leafChunkTokens);
165
+ const scored = targetRanges.map((range) => ({
166
+ ...range,
167
+ score: range.items.reduce((total, item) => total + (item.record ? scoreEvictable(item.record, promptText, records) : 0), 0),
168
+ }));
169
+ scored.sort((a, b) => a.score - b.score || a.startIndex - b.startIndex);
170
+ return scored[0]?.items ?? [];
171
+ }
172
+ function createValidLeafRanges(items, leafChunkTokens) {
173
+ const ranges = [];
174
+ for (let startIndex = 0; startIndex < items.length; startIndex += 1) {
175
+ if (isToolResultContinuingPreviousToolCall(items, startIndex))
176
+ continue;
177
+ const chunk = selectOldestLeafChunk(items.slice(startIndex), leafChunkTokens);
178
+ if (chunk.length === 0)
179
+ continue;
180
+ const chunkTokens = chunk.reduce((total, item) => total + item.tokens, 0);
181
+ const endIndex = startIndex + chunk.length - 1;
182
+ const next = items[endIndex + 1];
183
+ if (chunkTokens < leafChunkTokens && next && chunkTokens + next.tokens <= leafChunkTokens)
184
+ continue;
185
+ if (splitsFollowingToolResult(items, chunk, endIndex))
186
+ continue;
187
+ ranges.push({ startIndex, items: chunk, tokens: chunkTokens });
188
+ }
189
+ return ranges;
190
+ }
191
+ function selectOldestLeafChunk(items, leafChunkTokens) {
192
+ const chunk = [];
193
+ let tokens = 0;
194
+ for (let index = 0; index < items.length; index += 1) {
195
+ const item = items[index];
196
+ if (!item)
197
+ continue;
198
+ if (chunk.length > 0 && tokens + item.tokens > leafChunkTokens && !continuesSelectedToolCall(chunk, item)) {
199
+ break;
200
+ }
201
+ chunk.push(item);
202
+ tokens += item.tokens;
203
+ const next = items[index + 1];
204
+ if (tokens >= leafChunkTokens && (!next || !continuesSelectedToolCall(chunk, next)))
205
+ break;
206
+ }
207
+ return chunk;
208
+ }
209
+ function isToolResultContinuingPreviousToolCall(items, index) {
210
+ const item = items[index];
211
+ if (!item)
212
+ return false;
213
+ const toolCallId = item.message.toolCallId;
214
+ if (item.message.role !== "toolResult" || !toolCallId)
215
+ return false;
216
+ return items.slice(0, index).some((previous) => hasAssistantToolCall(previous.message, toolCallId));
217
+ }
218
+ function splitsFollowingToolResult(items, chunk, endIndex) {
219
+ const next = items[endIndex + 1];
220
+ if (!next)
221
+ return false;
222
+ return continuesSelectedToolCall(chunk, next);
223
+ }
224
+ function continuesSelectedToolCall(chunk, item) {
225
+ const toolCallId = item.message.toolCallId;
226
+ if (item.message.role !== "toolResult" || !toolCallId)
227
+ return false;
228
+ return chunk.some((chunkItem) => hasAssistantToolCall(chunkItem.message, toolCallId));
229
+ }
230
+ function hasAssistantToolCall(message, toolCallId) {
231
+ if (message.role !== "assistant" || !("content" in message))
232
+ return false;
233
+ const content = message.content;
234
+ if (!Array.isArray(content))
235
+ return false;
236
+ return content.some((item) => item.type === "toolCall" && item.id === toolCallId);
237
+ }
238
+ function estimateContentTokens(content) {
239
+ if (typeof content === "string")
240
+ return estimateTextTokens(content);
241
+ let tokens = 0;
242
+ for (const block of content) {
243
+ if (block.type === "text")
244
+ tokens += estimateTextTokens(block.text);
245
+ else if (block.type === "image")
246
+ tokens += IMAGE_TOKEN_ESTIMATE;
247
+ }
248
+ return tokens;
249
+ }
250
+ function estimateJsonTokens(value) {
251
+ return estimateTextTokens(JSON.stringify(value) ?? "");
252
+ }
253
+ function renderAttachmentForEstimate(attachment) {
254
+ return [attachment.name, attachment.kind, attachment.mimeType, attachment.text, attachment.note]
255
+ .filter((part) => typeof part === "string" && part.length > 0)
256
+ .join(" ");
257
+ }
258
+ export function selectRetainedSummaries(summaries) {
259
+ const ready = summaries
260
+ .filter((summary) => summary.status === "ready" && summary.text.trim().length > 0)
261
+ .sort(compareSummaries);
262
+ const covered = new Set();
263
+ const selected = [];
264
+ for (const summary of ready) {
265
+ if (covered.has(summary.id))
266
+ continue;
267
+ selected.push(summary);
268
+ coverSummaryParents(summary, ready, covered);
269
+ }
270
+ return selected;
271
+ }
272
+ function coverSummaryParents(summary, summaries, covered) {
273
+ for (const parentId of summary.parents) {
274
+ if (covered.has(parentId))
275
+ continue;
276
+ covered.add(parentId);
277
+ const parent = summaries.find((candidate) => candidate.id === parentId);
278
+ if (parent)
279
+ coverSummaryParents(parent, summaries, covered);
280
+ }
281
+ }
282
+ function compareSummaries(a, b) {
283
+ return (Number(b.pinned) - Number(a.pinned) ||
284
+ a.segmentId.localeCompare(b.segmentId) ||
285
+ b.depth - a.depth ||
286
+ (a.coversFromRecordId ?? Number.MAX_SAFE_INTEGER) - (b.coversFromRecordId ?? Number.MAX_SAFE_INTEGER) ||
287
+ a.id - b.id);
288
+ }
289
+ function structuredLcmRecordToAgentMessage(record, timestamp) {
290
+ if (record.kind === "tool") {
291
+ const result = record.parts?.find((part) => {
292
+ return part.kind === "tool_result";
293
+ });
294
+ if (!result)
295
+ return fallbackAssistantMessage(record.text, timestamp);
296
+ return {
297
+ role: "toolResult",
298
+ toolCallId: result.toolCallId,
299
+ toolName: result.toolName,
300
+ content: [{ type: "text", text: stringifyPartValue(result.output) }],
301
+ details: result.output,
302
+ isError: result.isError ?? false,
303
+ timestamp,
304
+ };
305
+ }
306
+ if (record.kind === "assistant") {
307
+ return {
308
+ ...fallbackAssistantMessage("", timestamp),
309
+ content: structuredAssistantContent(record.parts ?? []),
310
+ stopReason: record.parts?.some((part) => part.kind === "tool_call") ? "toolUse" : "stop",
311
+ };
312
+ }
313
+ return {
314
+ role: "user",
315
+ content: structuredUserContent(record.parts ?? [], record.text),
316
+ timestamp,
317
+ };
318
+ }
319
+ function structuredAssistantContent(parts) {
320
+ const content = [];
321
+ for (const part of parts) {
322
+ if (part.kind === "text" && part.text)
323
+ content.push({ type: "text", text: part.text });
324
+ else if (part.kind === "thinking" && part.text) {
325
+ content.push({
326
+ type: "thinking",
327
+ thinking: part.text,
328
+ ...(part.signature ? { thinkingSignature: part.signature } : {}),
329
+ });
330
+ }
331
+ else if (part.kind === "tool_call") {
332
+ content.push({
333
+ type: "toolCall",
334
+ id: part.toolCallId,
335
+ name: part.toolName,
336
+ arguments: normalizeToolArguments(part.arguments),
337
+ });
338
+ }
339
+ }
340
+ return content.length ? content : [{ type: "text", text: "" }];
341
+ }
342
+ function structuredUserContent(parts, fallback) {
343
+ const text = parts
344
+ .filter((part) => part.kind === "text")
345
+ .map((part) => part.text)
346
+ .join("\n")
347
+ .trim();
348
+ return text || fallback;
349
+ }
350
+ function fallbackAssistantMessage(text, timestamp) {
351
+ return {
352
+ role: "assistant",
353
+ content: [{ type: "text", text }],
354
+ api: "lcm",
355
+ provider: "familiar",
356
+ model: "lcm-record",
357
+ usage: {
358
+ input: 0,
359
+ output: 0,
360
+ cacheRead: 0,
361
+ cacheWrite: 0,
362
+ totalTokens: 0,
363
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
364
+ },
365
+ stopReason: "stop",
366
+ timestamp,
367
+ };
368
+ }
369
+ function normalizeToolArguments(value) {
370
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
371
+ }
372
+ function stringifyPartValue(value) {
373
+ if (typeof value === "string")
374
+ return value;
375
+ try {
376
+ return JSON.stringify(value);
377
+ }
378
+ catch {
379
+ return String(value);
380
+ }
381
+ }
382
+ function formatJsonForSummary(value) {
383
+ try {
384
+ return JSON.stringify(value, null, 2);
385
+ }
386
+ catch {
387
+ return String(value);
388
+ }
389
+ }
390
+ function truncateForSummary(text, maxLength = 2_000) {
391
+ const trimmed = text.trim();
392
+ if (trimmed.length <= maxLength)
393
+ return trimmed;
394
+ return `${trimmed.slice(0, maxLength)}\n...[truncated]`;
395
+ }
396
+ function escapeXmlAttribute(value) {
397
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
398
+ }
399
+ function messageTextForFingerprint(message) {
400
+ if (!("content" in message))
401
+ return "";
402
+ const content = message.content;
403
+ if (typeof content === "string")
404
+ return content;
405
+ if (!Array.isArray(content))
406
+ return "";
407
+ return content
408
+ .map((item) => {
409
+ if (item.type === "text")
410
+ return item.text;
411
+ if (item.type === "thinking")
412
+ return item.thinking;
413
+ if (item.type === "toolCall")
414
+ return `${item.name}\n${JSON.stringify(item.arguments)}`;
415
+ if (item.type === "image")
416
+ return item.mimeType;
417
+ return "";
418
+ })
419
+ .filter(Boolean)
420
+ .join("\n");
421
+ }
@@ -0,0 +1,38 @@
1
+ export function tokenBag(text) {
2
+ return text
3
+ .toLowerCase()
4
+ .split(/[^a-z0-9]+/)
5
+ .filter((token) => token.length >= 2);
6
+ }
7
+ export function scoreEvictable(record, prompt, allRecords) {
8
+ const promptTerms = tokenBag(prompt);
9
+ if (promptTerms.length === 0)
10
+ return 0;
11
+ const recordTerms = tokenBag(record.text);
12
+ if (recordTerms.length === 0)
13
+ return 0;
14
+ const recordFreq = new Map();
15
+ for (const term of recordTerms)
16
+ recordFreq.set(term, (recordFreq.get(term) ?? 0) + 1);
17
+ const promptUniqueTerms = new Set(promptTerms);
18
+ const documentFrequencies = new Map();
19
+ for (const candidate of allRecords) {
20
+ const candidateTerms = new Set(tokenBag(candidate.text));
21
+ for (const term of promptUniqueTerms) {
22
+ if (candidateTerms.has(term))
23
+ documentFrequencies.set(term, (documentFrequencies.get(term) ?? 0) + 1);
24
+ }
25
+ }
26
+ const candidateCount = Math.max(0, allRecords.length);
27
+ let score = 0;
28
+ for (const term of promptUniqueTerms) {
29
+ const tf = recordFreq.get(term) ?? 0;
30
+ if (tf <= 0)
31
+ continue;
32
+ const normalizedTf = tf / recordTerms.length;
33
+ const df = documentFrequencies.get(term) ?? 0;
34
+ const idf = Math.log((candidateCount + 1) / (df + 1) + 1);
35
+ score += normalizedTf * idf;
36
+ }
37
+ return score;
38
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./condense.js";
2
+ export * from "./indexer.js";
3
+ export * from "./normalize.js";
4
+ export * from "./schema.js";
5
+ export * from "./store.js";
6
+ export * from "./types.js";
@@ -0,0 +1,200 @@
1
+ import { lcmRecordIndexSourceId, lcmSummaryIndexSourceId } from "./store.js";
2
+ export const LCM_RECORD_CORPUS = "lcm_record";
3
+ export const LCM_SUMMARY_CORPUS = "lcm_summary";
4
+ export async function projectNormalizedLcmBatch(options) {
5
+ const segmentIds = [];
6
+ for (const segment of options.batch.segments) {
7
+ segmentIds.push(options.lcmStore.ensureSegment(segment).id);
8
+ }
9
+ const storedRecords = [];
10
+ for (const record of options.batch.records) {
11
+ const id = options.lcmStore.insertRecord(record);
12
+ const stored = options.lcmStore.getRecord(id);
13
+ if (!stored)
14
+ throw new Error(`Failed to read projected LCM record: ${id}`);
15
+ storedRecords.push(stored);
16
+ }
17
+ return {
18
+ segmentIds,
19
+ recordIds: storedRecords.map((record) => record.id),
20
+ recordIndex: await indexLcmRecords({ indexer: options.indexer, records: storedRecords, signal: options.signal }),
21
+ };
22
+ }
23
+ export async function indexLcmRecords(options) {
24
+ return options.indexer.indexChunks(lcmRecordsToIndexInputs(options.records), options.signal);
25
+ }
26
+ export async function indexLcmSummaries(options) {
27
+ return options.indexer.indexChunks(lcmSummariesToIndexInputs(options.summaries), options.signal);
28
+ }
29
+ export function lcmRecordsToIndexInputs(records) {
30
+ return records.map(lcmRecordToIndexInput).filter((input) => input !== null);
31
+ }
32
+ export function lcmSummariesToIndexInputs(summaries) {
33
+ return summaries.map(lcmSummaryToIndexInput).filter((input) => input !== null);
34
+ }
35
+ export function lcmRecordToIndexInput(record) {
36
+ const text = memoryTextForLcmRecord(record);
37
+ if (!text)
38
+ return null;
39
+ return {
40
+ corpus: LCM_RECORD_CORPUS,
41
+ sourceId: lcmRecordIndexSourceId(record.id),
42
+ sourceRef: record.source.sourceRef ?? null,
43
+ chunkIndex: 0,
44
+ text,
45
+ snippet: lcmRecordSnippet(record),
46
+ metadata: {
47
+ id: record.id,
48
+ kind: record.kind,
49
+ segmentId: record.segmentId,
50
+ timestamp: record.happenedAt,
51
+ happenedAt: record.happenedAt,
52
+ sessionId: record.sessionId,
53
+ channelKey: record.channelKey,
54
+ channelId: record.channelId,
55
+ jobId: record.jobId,
56
+ source: record.source,
57
+ },
58
+ };
59
+ }
60
+ export function lcmSummaryToIndexInput(summary) {
61
+ const text = summary.text.trim();
62
+ if (!text || summary.status !== "ready")
63
+ return null;
64
+ return {
65
+ corpus: LCM_SUMMARY_CORPUS,
66
+ sourceId: lcmSummaryIndexSourceId(summary.id),
67
+ sourceRef: summary.source.sourceRef ?? null,
68
+ chunkIndex: 0,
69
+ text,
70
+ snippet: lcmSummarySnippet(summary),
71
+ metadata: {
72
+ id: summary.id,
73
+ segmentId: summary.segmentId,
74
+ depth: summary.depth,
75
+ status: summary.status,
76
+ pinned: summary.pinned,
77
+ coversFromRecordId: summary.coversFromRecordId,
78
+ coversToRecordId: summary.coversToRecordId,
79
+ ...summaryCoverageMetadata(summary),
80
+ source: summary.source,
81
+ },
82
+ };
83
+ }
84
+ function lcmRecordSnippet(record) {
85
+ return `[${record.kind}] ${memoryTextForLcmRecord(record)}`.slice(0, 280);
86
+ }
87
+ function lcmSummarySnippet(summary) {
88
+ return `[d${summary.depth}] ${summary.text}`.slice(0, 280);
89
+ }
90
+ function summaryCoverageMetadata(summary) {
91
+ const existingFrom = metadataString(summary.metadata?.coverageFromHappenedAt);
92
+ const existingTo = metadataString(summary.metadata?.coverageToHappenedAt) ?? metadataString(summary.metadata?.timestamp);
93
+ const from = existingFrom ?? firstSnapshotHappenedAt(summary.snapshot);
94
+ const to = existingTo ?? lastSnapshotHappenedAt(summary.snapshot);
95
+ return {
96
+ ...(from ? { coverageFromHappenedAt: from } : {}),
97
+ ...(to ? { coverageToHappenedAt: to, timestamp: to } : {}),
98
+ };
99
+ }
100
+ function metadataString(value) {
101
+ return typeof value === "string" && Number.isFinite(Date.parse(value)) ? value : null;
102
+ }
103
+ function firstSnapshotHappenedAt(snapshot) {
104
+ if (!Array.isArray(snapshot))
105
+ return null;
106
+ for (const item of snapshot) {
107
+ if (isSnapshotRecord(item) && isIsoTimeString(item.happened_at))
108
+ return item.happened_at;
109
+ const nested = isParentSnapshot(item) ? firstSnapshotHappenedAt(item.snapshot) : null;
110
+ if (nested)
111
+ return nested;
112
+ }
113
+ return null;
114
+ }
115
+ function lastSnapshotHappenedAt(snapshot) {
116
+ if (!Array.isArray(snapshot))
117
+ return null;
118
+ for (let index = snapshot.length - 1; index >= 0; index -= 1) {
119
+ const item = snapshot[index];
120
+ if (isSnapshotRecord(item) && isIsoTimeString(item.happened_at))
121
+ return item.happened_at;
122
+ const nested = isParentSnapshot(item) ? lastSnapshotHappenedAt(item.snapshot) : null;
123
+ if (nested)
124
+ return nested;
125
+ }
126
+ return null;
127
+ }
128
+ function isSnapshotRecord(item) {
129
+ return typeof item === "object" && item !== null && "happened_at" in item;
130
+ }
131
+ function isParentSnapshot(item) {
132
+ return typeof item === "object" && item !== null && "snapshot" in item;
133
+ }
134
+ function isIsoTimeString(value) {
135
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
136
+ }
137
+ function memoryTextForLcmRecord(record) {
138
+ if (record.kind === "user")
139
+ return visibleTextFromRecord(record);
140
+ if (record.kind === "assistant")
141
+ return visibleTextFromRecord(record);
142
+ if (record.kind === "note" || record.kind === "boundary")
143
+ return record.text.trim();
144
+ return "";
145
+ }
146
+ function visibleTextFromRecord(record) {
147
+ if (!record.parts?.length)
148
+ return normalizeVisibleText(record.text, record.kind).trim();
149
+ const text = record.parts
150
+ .filter((part) => part.kind === "text")
151
+ .map((part) => part.text.trim())
152
+ .filter(Boolean)
153
+ .join("\n")
154
+ .trim();
155
+ return normalizeVisibleText(text, record.kind).trim();
156
+ }
157
+ function normalizeVisibleText(text, kind) {
158
+ const visible = kind === "assistant" ? stripNoisyRecordMarkers(text) : text;
159
+ return collapseDuplicatedVisibleText(visible);
160
+ }
161
+ function stripNoisyRecordMarkers(text) {
162
+ return text
163
+ .split(/\r?\n/)
164
+ .filter((line) => !isNoisyRecordMarkerLine(line))
165
+ .join("\n");
166
+ }
167
+ function isNoisyRecordMarkerLine(line) {
168
+ const trimmed = line.trim();
169
+ if (/^\[(?:thinking|tool_call|tool_result)\]$/.test(trimmed))
170
+ return true;
171
+ if (/^\[thinking\]\s*\S/.test(trimmed))
172
+ return true;
173
+ if (/^\[tool_call:\s*[\w.-]+\s*\(.*\)\]$/.test(trimmed))
174
+ return true;
175
+ if (/^\[tool_result:\s*[\w.-]+\s*->\s*.*\]$/.test(trimmed))
176
+ return true;
177
+ return false;
178
+ }
179
+ function collapseDuplicatedVisibleText(text) {
180
+ let normalized = text.trim();
181
+ for (let iteration = 0; iteration < 4; iteration += 1) {
182
+ const collapsed = collapseOnce(normalized);
183
+ if (collapsed === normalized)
184
+ return normalized;
185
+ normalized = collapsed;
186
+ }
187
+ return normalized;
188
+ }
189
+ function collapseOnce(text) {
190
+ for (let split = Math.floor(text.length / 2); split >= 24; split -= 1) {
191
+ const left = text.slice(0, split).trim();
192
+ const right = text.slice(split).trim();
193
+ if (left && normalizeComparableText(left) === normalizeComparableText(right))
194
+ return left;
195
+ }
196
+ return text;
197
+ }
198
+ function normalizeComparableText(text) {
199
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
200
+ }