dsh-tdai-memory 0.2.13 → 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.
Files changed (72) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +15 -13
  3. package/README.zh.md +9 -7
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +21 -4
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +36 -14
  8. package/package.json +77 -78
  9. package/recall-inject.js +86 -86
  10. package/vendor/tdai/adapters/index.js +15 -15
  11. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  12. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  13. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  14. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  15. package/vendor/tdai/adapters/standalone/index.js +5 -5
  16. package/vendor/tdai/adapters/standalone/llm-runner.js +34 -6
  17. package/vendor/tdai/config.js +3 -0
  18. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  19. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  20. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  21. package/vendor/tdai/core/index.js +8 -8
  22. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  23. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  24. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  25. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  26. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  27. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  28. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  29. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  30. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  31. package/vendor/tdai/core/record/l1-reader.js +178 -178
  32. package/vendor/tdai/core/record/l1-writer.js +141 -141
  33. package/vendor/tdai/core/report/reporter.js +86 -86
  34. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  35. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  36. package/vendor/tdai/core/scene/scene-format.js +53 -53
  37. package/vendor/tdai/core/scene/scene-index.js +82 -82
  38. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  39. package/vendor/tdai/core/seed/input.js +404 -404
  40. package/vendor/tdai/core/seed/seed-runtime.js +4 -0
  41. package/vendor/tdai/core/seed/types.js +9 -9
  42. package/vendor/tdai/core/store/bm25-client.js +117 -117
  43. package/vendor/tdai/core/store/bm25-local.js +68 -68
  44. package/vendor/tdai/core/store/embedding.js +632 -632
  45. package/vendor/tdai/core/store/factory.js +95 -95
  46. package/vendor/tdai/core/store/search-utils.js +54 -54
  47. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  48. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  49. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  50. package/vendor/tdai/core/store/types.js +17 -17
  51. package/vendor/tdai/core/tdai-core.js +4 -0
  52. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  53. package/vendor/tdai/core/tools/memory-search.js +207 -207
  54. package/vendor/tdai/core/types.js +12 -12
  55. package/vendor/tdai/utils/backup.js +189 -189
  56. package/vendor/tdai/utils/checkpoint.js +360 -360
  57. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  58. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  59. package/vendor/tdai/utils/env.js +13 -13
  60. package/vendor/tdai/utils/managed-timer.js +127 -127
  61. package/vendor/tdai/utils/manifest.js +91 -91
  62. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  63. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  64. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  65. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  66. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  67. package/vendor/tdai/utils/sanitize.js +360 -360
  68. package/vendor/tdai/utils/serial-queue.js +103 -103
  69. package/vendor/tdai/utils/session-filter.js +88 -88
  70. package/vendor/tdai/utils/session-header-fetch.js +88 -0
  71. package/vendor/tdai/utils/text-utils.js +29 -29
  72. package/vendor/tdai/utils/time.js +252 -252
@@ -1,178 +1,178 @@
1
- /**
2
- * L1 Memory Reader: reads persisted L1 memory records.
3
- *
4
- * Provides two data paths:
5
- *
6
- * 1. **SQLite** (preferred): `queryMemoryRecords()` — uses VectorStore's `queryL1Records()`
7
- * with composite indexes on (session_key, updated_time) and (session_id, updated_time)
8
- * for efficient session-scoped and time-range queries.
9
- *
10
- * 2. **JSONL** (fallback): `readMemoryRecords()` / `readAllMemoryRecords()` — reads from
11
- * `records/YYYY-MM-DD.jsonl` files. Used when VectorStore is unavailable or degraded.
12
- */
13
- import fs from "node:fs/promises";
14
- import path from "node:path";
15
- const TAG = "[memory-tdai] [l1-reader]";
16
- // ============================
17
- // SQLite-based queries (preferred)
18
- // ============================
19
- /**
20
- * Query L1 memory records from SQLite via VectorStore.
21
- *
22
- * This is the **preferred** read path — it uses the composite index
23
- * `idx_l1_session_updated(session_id, updated_time)` for efficient
24
- * session-scoped and time-range queries.
25
- *
26
- * All timestamps are UTC ISO 8601 (as stored by l1-writer's dual-write).
27
- *
28
- * Falls back to empty array if VectorStore is null or degraded.
29
- */
30
- export async function queryMemoryRecords(vectorStore, filter, logger) {
31
- if (!vectorStore) {
32
- logger?.warn(`${TAG} queryMemoryRecords: no VectorStore available, returning empty`);
33
- return [];
34
- }
35
- const rows = await vectorStore.queryL1Records(filter);
36
- return rows.map(rowToMemoryRecord);
37
- }
38
- /**
39
- * Convert a raw SQLite L1RecordRow to a MemoryRecord (same shape as JSONL records).
40
- */
41
- function rowToMemoryRecord(row) {
42
- let metadata = {};
43
- try {
44
- metadata = JSON.parse(row.metadata_json);
45
- }
46
- catch {
47
- // malformed JSON — use empty object
48
- }
49
- // Reconstruct timestamps array from timestamp_start / timestamp_end
50
- const timestamps = [];
51
- if (row.timestamp_str)
52
- timestamps.push(row.timestamp_str);
53
- if (row.timestamp_start && row.timestamp_start !== row.timestamp_str)
54
- timestamps.push(row.timestamp_start);
55
- if (row.timestamp_end && row.timestamp_end !== row.timestamp_str && row.timestamp_end !== row.timestamp_start) {
56
- timestamps.push(row.timestamp_end);
57
- }
58
- return {
59
- id: row.record_id,
60
- content: row.content,
61
- type: row.type,
62
- priority: row.priority,
63
- scene_name: row.scene_name,
64
- source_message_ids: [], // not stored in SQLite (vector search doesn't need them)
65
- metadata,
66
- timestamps,
67
- createdAt: row.created_time,
68
- updatedAt: row.updated_time,
69
- sessionKey: row.session_key,
70
- sessionId: row.session_id,
71
- };
72
- }
73
- // ============================
74
- // JSONL-based reads (fallback)
75
- // ============================
76
- /**
77
- * Read all memory records for a session from JSONL files.
78
- *
79
- * Current naming mode:
80
- * - Daily merged file: records/YYYY-MM-DD.jsonl (all sessions in one file)
81
- */
82
- export async function readMemoryRecords(sessionKey, baseDir, logger) {
83
- const recordsDir = path.join(baseDir, "records");
84
- const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
85
- let entries;
86
- try {
87
- entries = await fs.readdir(recordsDir, { withFileTypes: true });
88
- }
89
- catch {
90
- // Directory doesn't exist yet
91
- return [];
92
- }
93
- const targetFiles = entries
94
- .filter((entry) => entry.isFile() && dateFilePattern.test(entry.name))
95
- .map((entry) => entry.name)
96
- .sort();
97
- if (targetFiles.length === 0) {
98
- return [];
99
- }
100
- const records = [];
101
- for (const fileName of targetFiles) {
102
- const filePath = path.join(recordsDir, fileName);
103
- let raw;
104
- try {
105
- raw = await fs.readFile(filePath, "utf-8");
106
- }
107
- catch {
108
- logger?.warn?.(`${TAG} Failed to read L1 file: ${filePath}`);
109
- continue;
110
- }
111
- const lines = raw.split("\n").filter((line) => line.trim());
112
- for (let i = 0; i < lines.length; i++) {
113
- const line = lines[i];
114
- try {
115
- const parsed = JSON.parse(line);
116
- if (parsed.sessionKey !== sessionKey) {
117
- continue;
118
- }
119
- records.push(parsed);
120
- }
121
- catch {
122
- logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
123
- }
124
- }
125
- }
126
- records.sort((a, b) => {
127
- const ta = a.updatedAt || a.createdAt || "";
128
- const tb = b.updatedAt || b.createdAt || "";
129
- return ta.localeCompare(tb);
130
- });
131
- return records;
132
- }
133
- /**
134
- * Read ALL memory records across all session JSONL files.
135
- */
136
- export async function readAllMemoryRecords(baseDir, logger) {
137
- const recordsDir = path.join(baseDir, "records");
138
- try {
139
- const files = await fs.readdir(recordsDir);
140
- const allRecords = [];
141
- for (const file of files) {
142
- if (!file.endsWith(".jsonl"))
143
- continue;
144
- const filePath = path.join(recordsDir, file);
145
- try {
146
- const raw = await fs.readFile(filePath, "utf-8");
147
- const lines = raw.split("\n").filter((line) => line.trim());
148
- for (const line of lines) {
149
- try {
150
- allRecords.push(JSON.parse(line));
151
- }
152
- catch {
153
- logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${file}`);
154
- }
155
- }
156
- }
157
- catch {
158
- logger?.warn?.(`${TAG} Failed to read ${file}`);
159
- }
160
- }
161
- allRecords.sort((a, b) => {
162
- const ta = a.updatedAt || a.createdAt || "";
163
- const tb = b.updatedAt || b.createdAt || "";
164
- return ta.localeCompare(tb);
165
- });
166
- return allRecords;
167
- }
168
- catch {
169
- // records/ directory doesn't exist yet
170
- return [];
171
- }
172
- }
173
- // ============================
174
- // Helpers
175
- // ============================
176
- function sanitizeFilename(name) {
177
- return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
178
- }
1
+ /**
2
+ * L1 Memory Reader: reads persisted L1 memory records.
3
+ *
4
+ * Provides two data paths:
5
+ *
6
+ * 1. **SQLite** (preferred): `queryMemoryRecords()` — uses VectorStore's `queryL1Records()`
7
+ * with composite indexes on (session_key, updated_time) and (session_id, updated_time)
8
+ * for efficient session-scoped and time-range queries.
9
+ *
10
+ * 2. **JSONL** (fallback): `readMemoryRecords()` / `readAllMemoryRecords()` — reads from
11
+ * `records/YYYY-MM-DD.jsonl` files. Used when VectorStore is unavailable or degraded.
12
+ */
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ const TAG = "[memory-tdai] [l1-reader]";
16
+ // ============================
17
+ // SQLite-based queries (preferred)
18
+ // ============================
19
+ /**
20
+ * Query L1 memory records from SQLite via VectorStore.
21
+ *
22
+ * This is the **preferred** read path — it uses the composite index
23
+ * `idx_l1_session_updated(session_id, updated_time)` for efficient
24
+ * session-scoped and time-range queries.
25
+ *
26
+ * All timestamps are UTC ISO 8601 (as stored by l1-writer's dual-write).
27
+ *
28
+ * Falls back to empty array if VectorStore is null or degraded.
29
+ */
30
+ export async function queryMemoryRecords(vectorStore, filter, logger) {
31
+ if (!vectorStore) {
32
+ logger?.warn(`${TAG} queryMemoryRecords: no VectorStore available, returning empty`);
33
+ return [];
34
+ }
35
+ const rows = await vectorStore.queryL1Records(filter);
36
+ return rows.map(rowToMemoryRecord);
37
+ }
38
+ /**
39
+ * Convert a raw SQLite L1RecordRow to a MemoryRecord (same shape as JSONL records).
40
+ */
41
+ function rowToMemoryRecord(row) {
42
+ let metadata = {};
43
+ try {
44
+ metadata = JSON.parse(row.metadata_json);
45
+ }
46
+ catch {
47
+ // malformed JSON — use empty object
48
+ }
49
+ // Reconstruct timestamps array from timestamp_start / timestamp_end
50
+ const timestamps = [];
51
+ if (row.timestamp_str)
52
+ timestamps.push(row.timestamp_str);
53
+ if (row.timestamp_start && row.timestamp_start !== row.timestamp_str)
54
+ timestamps.push(row.timestamp_start);
55
+ if (row.timestamp_end && row.timestamp_end !== row.timestamp_str && row.timestamp_end !== row.timestamp_start) {
56
+ timestamps.push(row.timestamp_end);
57
+ }
58
+ return {
59
+ id: row.record_id,
60
+ content: row.content,
61
+ type: row.type,
62
+ priority: row.priority,
63
+ scene_name: row.scene_name,
64
+ source_message_ids: [], // not stored in SQLite (vector search doesn't need them)
65
+ metadata,
66
+ timestamps,
67
+ createdAt: row.created_time,
68
+ updatedAt: row.updated_time,
69
+ sessionKey: row.session_key,
70
+ sessionId: row.session_id,
71
+ };
72
+ }
73
+ // ============================
74
+ // JSONL-based reads (fallback)
75
+ // ============================
76
+ /**
77
+ * Read all memory records for a session from JSONL files.
78
+ *
79
+ * Current naming mode:
80
+ * - Daily merged file: records/YYYY-MM-DD.jsonl (all sessions in one file)
81
+ */
82
+ export async function readMemoryRecords(sessionKey, baseDir, logger) {
83
+ const recordsDir = path.join(baseDir, "records");
84
+ const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
85
+ let entries;
86
+ try {
87
+ entries = await fs.readdir(recordsDir, { withFileTypes: true });
88
+ }
89
+ catch {
90
+ // Directory doesn't exist yet
91
+ return [];
92
+ }
93
+ const targetFiles = entries
94
+ .filter((entry) => entry.isFile() && dateFilePattern.test(entry.name))
95
+ .map((entry) => entry.name)
96
+ .sort();
97
+ if (targetFiles.length === 0) {
98
+ return [];
99
+ }
100
+ const records = [];
101
+ for (const fileName of targetFiles) {
102
+ const filePath = path.join(recordsDir, fileName);
103
+ let raw;
104
+ try {
105
+ raw = await fs.readFile(filePath, "utf-8");
106
+ }
107
+ catch {
108
+ logger?.warn?.(`${TAG} Failed to read L1 file: ${filePath}`);
109
+ continue;
110
+ }
111
+ const lines = raw.split("\n").filter((line) => line.trim());
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i];
114
+ try {
115
+ const parsed = JSON.parse(line);
116
+ if (parsed.sessionKey !== sessionKey) {
117
+ continue;
118
+ }
119
+ records.push(parsed);
120
+ }
121
+ catch {
122
+ logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
123
+ }
124
+ }
125
+ }
126
+ records.sort((a, b) => {
127
+ const ta = a.updatedAt || a.createdAt || "";
128
+ const tb = b.updatedAt || b.createdAt || "";
129
+ return ta.localeCompare(tb);
130
+ });
131
+ return records;
132
+ }
133
+ /**
134
+ * Read ALL memory records across all session JSONL files.
135
+ */
136
+ export async function readAllMemoryRecords(baseDir, logger) {
137
+ const recordsDir = path.join(baseDir, "records");
138
+ try {
139
+ const files = await fs.readdir(recordsDir);
140
+ const allRecords = [];
141
+ for (const file of files) {
142
+ if (!file.endsWith(".jsonl"))
143
+ continue;
144
+ const filePath = path.join(recordsDir, file);
145
+ try {
146
+ const raw = await fs.readFile(filePath, "utf-8");
147
+ const lines = raw.split("\n").filter((line) => line.trim());
148
+ for (const line of lines) {
149
+ try {
150
+ allRecords.push(JSON.parse(line));
151
+ }
152
+ catch {
153
+ logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${file}`);
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ logger?.warn?.(`${TAG} Failed to read ${file}`);
159
+ }
160
+ }
161
+ allRecords.sort((a, b) => {
162
+ const ta = a.updatedAt || a.createdAt || "";
163
+ const tb = b.updatedAt || b.createdAt || "";
164
+ return ta.localeCompare(tb);
165
+ });
166
+ return allRecords;
167
+ }
168
+ catch {
169
+ // records/ directory doesn't exist yet
170
+ return [];
171
+ }
172
+ }
173
+ // ============================
174
+ // Helpers
175
+ // ============================
176
+ function sanitizeFilename(name) {
177
+ return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
178
+ }
@@ -1,141 +1,141 @@
1
- /**
2
- * L1 Memory Writer: writes extracted memories to JSONL files.
3
- *
4
- * File naming: records/YYYY-MM-DD.jsonl (daily shards, all sessions merged).
5
- * Each record includes sessionKey for traceability.
6
- *
7
- * Write strategy:
8
- * - JSONL is the append-only persistent store (source of truth for backup/recovery).
9
- * - VectorStore (SQLite) is the primary retrieval engine.
10
- * - On update/merge, old records are deleted from VectorStore in real-time;
11
- * JSONL is append-only and cleaned up periodically by memory-cleaner.
12
- *
13
- * Supports store (append), update, merge, and skip operations.
14
- *
15
- * v3: Aligned with Kenty's prompt output format — 3 memory types (persona/episodic/instruction),
16
- * numeric priority, scene_name, source_message_ids, metadata, timestamps.
17
- */
18
- import fs from "node:fs/promises";
19
- import path from "node:path";
20
- import crypto from "node:crypto";
21
- import { formatLocalDate } from "../../utils/time.js";
22
- const TAG = "[memory-tdai][l1-writer]";
23
- // ============================
24
- // Core functions
25
- // ============================
26
- /**
27
- * Generate a unique memory ID.
28
- */
29
- export function generateMemoryId() {
30
- return `m_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
31
- }
32
- /**
33
- * Write a memory record according to the dedup decision.
34
- *
35
- * - store: append new record
36
- * - update: remove target records + append updated record
37
- * - merge: remove target records + append merged record
38
- * - skip: do nothing
39
- *
40
- * v3: supports multi-target removal for update/merge.
41
- * v3.1: optional VectorStore + EmbeddingService for dual-write (JSONL + vector).
42
- */
43
- export async function writeMemory(params) {
44
- const { memory, decision, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
45
- if (decision.action === "skip") {
46
- logger?.debug?.(`${TAG} Skipping memory: ${memory.content.slice(0, 50)}...`);
47
- return null;
48
- }
49
- const now = new Date().toISOString();
50
- // Determine final content, type, priority based on action
51
- let finalContent;
52
- let finalType;
53
- let finalPriority;
54
- let finalTimestamps;
55
- if (decision.action === "merge" || decision.action === "update") {
56
- finalContent = decision.merged_content ?? memory.content;
57
- finalType = decision.merged_type ?? memory.type;
58
- finalPriority = decision.merged_priority ?? memory.priority;
59
- finalTimestamps = decision.merged_timestamps ?? [now];
60
- }
61
- else {
62
- // store
63
- finalContent = memory.content;
64
- finalType = memory.type;
65
- finalPriority = memory.priority;
66
- finalTimestamps = [now];
67
- }
68
- const record = {
69
- id: decision.record_id || generateMemoryId(),
70
- content: finalContent,
71
- type: finalType,
72
- priority: finalPriority,
73
- scene_name: memory.scene_name,
74
- source_message_ids: memory.source_message_ids,
75
- metadata: memory.metadata,
76
- timestamps: finalTimestamps,
77
- createdAt: now,
78
- updatedAt: now,
79
- sessionKey,
80
- sessionId: sessionId || "",
81
- };
82
- const recordsDir = path.join(baseDir, "records");
83
- await fs.mkdir(recordsDir, { recursive: true });
84
- const shardDate = formatLocalDate(new Date());
85
- const filePath = path.join(recordsDir, `${shardDate}.jsonl`);
86
- if ((decision.action === "update" || decision.action === "merge") && decision.target_ids.length > 0) {
87
- // Remove target records from VectorStore (real-time deletion for retrieval accuracy).
88
- // JSONL is append-only — old records remain in files and are cleaned up periodically
89
- // by memory-cleaner (which reconciles against VectorStore as source of truth).
90
- if (vectorStore) {
91
- try {
92
- await vectorStore.deleteL1Batch(decision.target_ids);
93
- logger?.debug?.(`${TAG} VectorStore: deleted ${decision.target_ids.length} target record(s) for ${decision.action}`);
94
- }
95
- catch (err) {
96
- logger?.warn?.(`${TAG} VectorStore delete failed for ${decision.action}: ${err instanceof Error ? err.message : String(err)}`);
97
- }
98
- }
99
- await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
100
- logger?.debug?.(`${TAG} ${decision.action} memory: removed [${decision.target_ids.join(",")}] from VectorStore → ${record.id}: ${finalContent.slice(0, 80)}...`);
101
- }
102
- else {
103
- // store: append a new line
104
- await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
105
- logger?.debug?.(`${TAG} Stored memory ${record.id}: ${finalContent.slice(0, 80)}...`);
106
- }
107
- // === Vector Store dual-write ===
108
- if (vectorStore) {
109
- try {
110
- logger?.debug?.(`${TAG} [vec-dual-write] START id=${record.id}, contentLen=${record.content.length}, ` +
111
- `content="${record.content.slice(0, 80)}..."`);
112
- let embedding;
113
- if (embeddingService) {
114
- try {
115
- embedding = await embeddingService.embed(record.content);
116
- logger?.debug?.(`${TAG} [vec-dual-write] Embedding OK: dims=${embedding.length}, ` +
117
- `norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`);
118
- }
119
- catch (embedErr) {
120
- // Embedding failed — pass undefined to upsert() which writes
121
- // metadata + FTS only, skipping the vec0 table.
122
- logger?.warn(`${TAG} [vec-dual-write] Embedding FAILED for id=${record.id}, ` +
123
- `will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`);
124
- }
125
- }
126
- const upsertOk = await vectorStore.upsertL1(record, embedding);
127
- logger?.debug?.(`${TAG} [vec-dual-write] upsert result=${upsertOk} id=${record.id}`);
128
- }
129
- catch (err) {
130
- // Vector write failure should NOT block the main JSONL write
131
- logger?.warn?.(`${TAG} [vec-dual-write] FAILED (JSONL already written) id=${record.id}: ${err instanceof Error ? err.message : String(err)}`);
132
- }
133
- }
134
- else {
135
- logger?.debug?.(`${TAG} [vec-dual-write] SKIPPED id=${record.id}: vectorStore=${!!vectorStore}`);
136
- }
137
- return record;
138
- }
139
- // ============================
140
- // Helpers
141
- // ============================
1
+ /**
2
+ * L1 Memory Writer: writes extracted memories to JSONL files.
3
+ *
4
+ * File naming: records/YYYY-MM-DD.jsonl (daily shards, all sessions merged).
5
+ * Each record includes sessionKey for traceability.
6
+ *
7
+ * Write strategy:
8
+ * - JSONL is the append-only persistent store (source of truth for backup/recovery).
9
+ * - VectorStore (SQLite) is the primary retrieval engine.
10
+ * - On update/merge, old records are deleted from VectorStore in real-time;
11
+ * JSONL is append-only and cleaned up periodically by memory-cleaner.
12
+ *
13
+ * Supports store (append), update, merge, and skip operations.
14
+ *
15
+ * v3: Aligned with Kenty's prompt output format — 3 memory types (persona/episodic/instruction),
16
+ * numeric priority, scene_name, source_message_ids, metadata, timestamps.
17
+ */
18
+ import fs from "node:fs/promises";
19
+ import path from "node:path";
20
+ import crypto from "node:crypto";
21
+ import { formatLocalDate } from "../../utils/time.js";
22
+ const TAG = "[memory-tdai][l1-writer]";
23
+ // ============================
24
+ // Core functions
25
+ // ============================
26
+ /**
27
+ * Generate a unique memory ID.
28
+ */
29
+ export function generateMemoryId() {
30
+ return `m_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
31
+ }
32
+ /**
33
+ * Write a memory record according to the dedup decision.
34
+ *
35
+ * - store: append new record
36
+ * - update: remove target records + append updated record
37
+ * - merge: remove target records + append merged record
38
+ * - skip: do nothing
39
+ *
40
+ * v3: supports multi-target removal for update/merge.
41
+ * v3.1: optional VectorStore + EmbeddingService for dual-write (JSONL + vector).
42
+ */
43
+ export async function writeMemory(params) {
44
+ const { memory, decision, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
45
+ if (decision.action === "skip") {
46
+ logger?.debug?.(`${TAG} Skipping memory: ${memory.content.slice(0, 50)}...`);
47
+ return null;
48
+ }
49
+ const now = new Date().toISOString();
50
+ // Determine final content, type, priority based on action
51
+ let finalContent;
52
+ let finalType;
53
+ let finalPriority;
54
+ let finalTimestamps;
55
+ if (decision.action === "merge" || decision.action === "update") {
56
+ finalContent = decision.merged_content ?? memory.content;
57
+ finalType = decision.merged_type ?? memory.type;
58
+ finalPriority = decision.merged_priority ?? memory.priority;
59
+ finalTimestamps = decision.merged_timestamps ?? [now];
60
+ }
61
+ else {
62
+ // store
63
+ finalContent = memory.content;
64
+ finalType = memory.type;
65
+ finalPriority = memory.priority;
66
+ finalTimestamps = [now];
67
+ }
68
+ const record = {
69
+ id: decision.record_id || generateMemoryId(),
70
+ content: finalContent,
71
+ type: finalType,
72
+ priority: finalPriority,
73
+ scene_name: memory.scene_name,
74
+ source_message_ids: memory.source_message_ids,
75
+ metadata: memory.metadata,
76
+ timestamps: finalTimestamps,
77
+ createdAt: now,
78
+ updatedAt: now,
79
+ sessionKey,
80
+ sessionId: sessionId || "",
81
+ };
82
+ const recordsDir = path.join(baseDir, "records");
83
+ await fs.mkdir(recordsDir, { recursive: true });
84
+ const shardDate = formatLocalDate(new Date());
85
+ const filePath = path.join(recordsDir, `${shardDate}.jsonl`);
86
+ if ((decision.action === "update" || decision.action === "merge") && decision.target_ids.length > 0) {
87
+ // Remove target records from VectorStore (real-time deletion for retrieval accuracy).
88
+ // JSONL is append-only — old records remain in files and are cleaned up periodically
89
+ // by memory-cleaner (which reconciles against VectorStore as source of truth).
90
+ if (vectorStore) {
91
+ try {
92
+ await vectorStore.deleteL1Batch(decision.target_ids);
93
+ logger?.debug?.(`${TAG} VectorStore: deleted ${decision.target_ids.length} target record(s) for ${decision.action}`);
94
+ }
95
+ catch (err) {
96
+ logger?.warn?.(`${TAG} VectorStore delete failed for ${decision.action}: ${err instanceof Error ? err.message : String(err)}`);
97
+ }
98
+ }
99
+ await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
100
+ logger?.debug?.(`${TAG} ${decision.action} memory: removed [${decision.target_ids.join(",")}] from VectorStore → ${record.id}: ${finalContent.slice(0, 80)}...`);
101
+ }
102
+ else {
103
+ // store: append a new line
104
+ await fs.appendFile(filePath, JSON.stringify(record) + "\n", "utf-8");
105
+ logger?.debug?.(`${TAG} Stored memory ${record.id}: ${finalContent.slice(0, 80)}...`);
106
+ }
107
+ // === Vector Store dual-write ===
108
+ if (vectorStore) {
109
+ try {
110
+ logger?.debug?.(`${TAG} [vec-dual-write] START id=${record.id}, contentLen=${record.content.length}, ` +
111
+ `content="${record.content.slice(0, 80)}..."`);
112
+ let embedding;
113
+ if (embeddingService) {
114
+ try {
115
+ embedding = await embeddingService.embed(record.content);
116
+ logger?.debug?.(`${TAG} [vec-dual-write] Embedding OK: dims=${embedding.length}, ` +
117
+ `norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`);
118
+ }
119
+ catch (embedErr) {
120
+ // Embedding failed — pass undefined to upsert() which writes
121
+ // metadata + FTS only, skipping the vec0 table.
122
+ logger?.warn(`${TAG} [vec-dual-write] Embedding FAILED for id=${record.id}, ` +
123
+ `will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`);
124
+ }
125
+ }
126
+ const upsertOk = await vectorStore.upsertL1(record, embedding);
127
+ logger?.debug?.(`${TAG} [vec-dual-write] upsert result=${upsertOk} id=${record.id}`);
128
+ }
129
+ catch (err) {
130
+ // Vector write failure should NOT block the main JSONL write
131
+ logger?.warn?.(`${TAG} [vec-dual-write] FAILED (JSONL already written) id=${record.id}: ${err instanceof Error ? err.message : String(err)}`);
132
+ }
133
+ }
134
+ else {
135
+ logger?.debug?.(`${TAG} [vec-dual-write] SKIPPED id=${record.id}: vectorStore=${!!vectorStore}`);
136
+ }
137
+ return record;
138
+ }
139
+ // ============================
140
+ // Helpers
141
+ // ============================