@yeaft/webchat-agent 0.1.642 → 0.1.645
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/unify/memory/index-db.js +269 -0
- package/unify/memory/segment-store.js +91 -0
- package/unify/memory/segment-sync.js +110 -0
- package/unify/memory/segment.js +268 -0
package/package.json
CHANGED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/index-db.js — DESIGN-H2-AMS §4. SQLite + FTS5 segment index.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: on-disk `~/.yeaft/memory/<scope>/memory.md` files.
|
|
5
|
+
* SQLite is a derived index — rebuildable from disk at any time.
|
|
6
|
+
*
|
|
7
|
+
* Schema is created idempotently; opening an older DB without the
|
|
8
|
+
* required tables triggers a fresh CREATE. A version PRAGMA guards
|
|
9
|
+
* against silent schema drift.
|
|
10
|
+
*
|
|
11
|
+
* Uses node:sqlite (Node 22.5+ built-in). Synchronous API — fine for
|
|
12
|
+
* our workload (10k segments, single-process agent).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import { mkdirSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
const DDL = [
|
|
22
|
+
`PRAGMA journal_mode = WAL;`,
|
|
23
|
+
`PRAGMA synchronous = NORMAL;`,
|
|
24
|
+
`PRAGMA foreign_keys = ON;`,
|
|
25
|
+
|
|
26
|
+
`CREATE TABLE IF NOT EXISTS schema_meta (
|
|
27
|
+
key TEXT PRIMARY KEY,
|
|
28
|
+
value TEXT NOT NULL
|
|
29
|
+
);`,
|
|
30
|
+
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS memory_segments (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
scope TEXT NOT NULL,
|
|
34
|
+
kind TEXT NOT NULL,
|
|
35
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
36
|
+
body TEXT NOT NULL,
|
|
37
|
+
source_msgs TEXT NOT NULL DEFAULT '[]',
|
|
38
|
+
created_at INTEGER NOT NULL,
|
|
39
|
+
updated_at INTEGER NOT NULL
|
|
40
|
+
);`,
|
|
41
|
+
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_segments_scope
|
|
43
|
+
ON memory_segments(scope);`,
|
|
44
|
+
|
|
45
|
+
`CREATE INDEX IF NOT EXISTS idx_segments_updated
|
|
46
|
+
ON memory_segments(updated_at DESC);`,
|
|
47
|
+
|
|
48
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
49
|
+
body, tags, scope,
|
|
50
|
+
content='memory_segments',
|
|
51
|
+
content_rowid='rowid',
|
|
52
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
53
|
+
);`,
|
|
54
|
+
|
|
55
|
+
`CREATE TRIGGER IF NOT EXISTS seg_ai AFTER INSERT ON memory_segments BEGIN
|
|
56
|
+
INSERT INTO memory_fts(rowid, body, tags, scope)
|
|
57
|
+
VALUES (new.rowid, new.body, new.tags, new.scope);
|
|
58
|
+
END;`,
|
|
59
|
+
|
|
60
|
+
`CREATE TRIGGER IF NOT EXISTS seg_au AFTER UPDATE ON memory_segments BEGIN
|
|
61
|
+
INSERT INTO memory_fts(memory_fts, rowid, body, tags, scope)
|
|
62
|
+
VALUES('delete', old.rowid, old.body, old.tags, old.scope);
|
|
63
|
+
INSERT INTO memory_fts(rowid, body, tags, scope)
|
|
64
|
+
VALUES (new.rowid, new.body, new.tags, new.scope);
|
|
65
|
+
END;`,
|
|
66
|
+
|
|
67
|
+
`CREATE TRIGGER IF NOT EXISTS seg_ad AFTER DELETE ON memory_segments BEGIN
|
|
68
|
+
INSERT INTO memory_fts(memory_fts, rowid, body, tags, scope)
|
|
69
|
+
VALUES('delete', old.rowid, old.body, old.tags, old.scope);
|
|
70
|
+
END;`,
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Open (or create) the segment index DB. Returns a thin handle with
|
|
75
|
+
* the operations the rest of the memory layer needs. The handle owns
|
|
76
|
+
* the underlying DatabaseSync; call `.close()` when done.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} dbPath Absolute path to the .db file.
|
|
79
|
+
* @returns {SegmentIndex}
|
|
80
|
+
*/
|
|
81
|
+
export function openSegmentIndex(dbPath) {
|
|
82
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
83
|
+
const db = new DatabaseSync(dbPath);
|
|
84
|
+
for (const stmt of DDL) db.exec(stmt);
|
|
85
|
+
|
|
86
|
+
// Version check / set
|
|
87
|
+
const cur = db.prepare('SELECT value FROM schema_meta WHERE key=?').get('schema_version');
|
|
88
|
+
if (!cur) {
|
|
89
|
+
db.prepare('INSERT INTO schema_meta(key,value) VALUES(?,?)')
|
|
90
|
+
.run('schema_version', String(SCHEMA_VERSION));
|
|
91
|
+
} else if (cur.value !== String(SCHEMA_VERSION)) {
|
|
92
|
+
// For now, simple drop-and-recreate. v2 may add migrations.
|
|
93
|
+
db.exec('DROP TABLE IF EXISTS memory_fts');
|
|
94
|
+
db.exec('DROP TABLE IF EXISTS memory_segments');
|
|
95
|
+
db.exec('DELETE FROM schema_meta');
|
|
96
|
+
for (const stmt of DDL) db.exec(stmt);
|
|
97
|
+
db.prepare('INSERT INTO schema_meta(key,value) VALUES(?,?)')
|
|
98
|
+
.run('schema_version', String(SCHEMA_VERSION));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return makeHandle(db);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {object} SegmentIndex
|
|
106
|
+
* @property {(seg: import('./segment.js').Segment) => void} upsert
|
|
107
|
+
* @property {(ids: string[]) => void} deleteMany
|
|
108
|
+
* @property {(scope: string) => void} deleteScope
|
|
109
|
+
* @property {(id: string) => import('./segment.js').Segment | null} get
|
|
110
|
+
* @property {(scope: string) => import('./segment.js').Segment[]} listByScope
|
|
111
|
+
* @property {(opts: SearchOpts) => SearchHit[]} search
|
|
112
|
+
* @property {() => number} count
|
|
113
|
+
* @property {() => void} close
|
|
114
|
+
* @property {DatabaseSync} _db exposed for tests / advanced ops
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @typedef {object} SearchOpts
|
|
119
|
+
* @property {string} query FTS5 MATCH expression
|
|
120
|
+
* @property {string[]=} scopeFilter if set, restrict to these scopes
|
|
121
|
+
* @property {number=} limit default 50
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {object} SearchHit
|
|
126
|
+
* @property {string} id
|
|
127
|
+
* @property {string} scope
|
|
128
|
+
* @property {string} kind
|
|
129
|
+
* @property {string[]} tags
|
|
130
|
+
* @property {string} body
|
|
131
|
+
* @property {string[]} sourceMessages
|
|
132
|
+
* @property {number} rank bm25 (lower = better)
|
|
133
|
+
* @property {number} createdAt
|
|
134
|
+
* @property {number} updatedAt
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
function makeHandle(db) {
|
|
138
|
+
const stmtUpsert = db.prepare(`
|
|
139
|
+
INSERT INTO memory_segments(id, scope, kind, tags, body, source_msgs, created_at, updated_at)
|
|
140
|
+
VALUES (@id, @scope, @kind, @tags, @body, @sourceMsgs, @createdAt, @updatedAt)
|
|
141
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
142
|
+
scope=excluded.scope,
|
|
143
|
+
kind=excluded.kind,
|
|
144
|
+
tags=excluded.tags,
|
|
145
|
+
body=excluded.body,
|
|
146
|
+
source_msgs=excluded.source_msgs,
|
|
147
|
+
updated_at=excluded.updated_at
|
|
148
|
+
`);
|
|
149
|
+
const stmtDeleteOne = db.prepare('DELETE FROM memory_segments WHERE id=?');
|
|
150
|
+
const stmtDeleteScope = db.prepare('DELETE FROM memory_segments WHERE scope=?');
|
|
151
|
+
const stmtGet = db.prepare('SELECT * FROM memory_segments WHERE id=?');
|
|
152
|
+
const stmtListByScope = db.prepare(
|
|
153
|
+
'SELECT * FROM memory_segments WHERE scope=? ORDER BY created_at ASC',
|
|
154
|
+
);
|
|
155
|
+
const stmtCount = db.prepare('SELECT COUNT(*) AS n FROM memory_segments');
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
_db: db,
|
|
159
|
+
|
|
160
|
+
upsert(seg) {
|
|
161
|
+
stmtUpsert.run({
|
|
162
|
+
id: seg.id,
|
|
163
|
+
scope: seg.scope,
|
|
164
|
+
kind: seg.kind,
|
|
165
|
+
tags: JSON.stringify(seg.tags || []),
|
|
166
|
+
body: seg.body,
|
|
167
|
+
sourceMsgs: JSON.stringify(seg.sourceMessages || []),
|
|
168
|
+
createdAt: toEpochMs(seg.createdAt),
|
|
169
|
+
updatedAt: toEpochMs(seg.updatedAt),
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
deleteMany(ids) {
|
|
174
|
+
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
175
|
+
const tx = db.prepare('BEGIN');
|
|
176
|
+
tx.run();
|
|
177
|
+
try {
|
|
178
|
+
for (const id of ids) stmtDeleteOne.run(id);
|
|
179
|
+
db.prepare('COMMIT').run();
|
|
180
|
+
} catch (err) {
|
|
181
|
+
db.prepare('ROLLBACK').run();
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
deleteScope(scope) { stmtDeleteScope.run(scope); },
|
|
187
|
+
|
|
188
|
+
get(id) {
|
|
189
|
+
const row = stmtGet.get(id);
|
|
190
|
+
return row ? rowToSegment(row) : null;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
listByScope(scope) {
|
|
194
|
+
return stmtListByScope.all(scope).map(rowToSegment);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {SearchOpts} opts
|
|
199
|
+
* @returns {SearchHit[]}
|
|
200
|
+
*/
|
|
201
|
+
search(opts) {
|
|
202
|
+
const limit = Number.isFinite(opts.limit) && opts.limit > 0
|
|
203
|
+
? Math.min(500, Math.floor(opts.limit)) : 50;
|
|
204
|
+
const scopeFilter = Array.isArray(opts.scopeFilter) && opts.scopeFilter.length > 0
|
|
205
|
+
? opts.scopeFilter : null;
|
|
206
|
+
|
|
207
|
+
// Build the SQL dynamically — scope IN (...) needs N placeholders.
|
|
208
|
+
let sql = `
|
|
209
|
+
SELECT s.*, bm25(memory_fts) AS rank
|
|
210
|
+
FROM memory_fts
|
|
211
|
+
JOIN memory_segments s ON s.rowid = memory_fts.rowid
|
|
212
|
+
WHERE memory_fts MATCH ?`;
|
|
213
|
+
const params = [opts.query];
|
|
214
|
+
if (scopeFilter) {
|
|
215
|
+
sql += ` AND s.scope IN (${scopeFilter.map(() => '?').join(',')})`;
|
|
216
|
+
params.push(...scopeFilter);
|
|
217
|
+
}
|
|
218
|
+
sql += ` ORDER BY rank LIMIT ?`;
|
|
219
|
+
params.push(limit);
|
|
220
|
+
|
|
221
|
+
const rows = db.prepare(sql).all(...params);
|
|
222
|
+
return rows.map(r => ({
|
|
223
|
+
...rowToSegment(r),
|
|
224
|
+
rank: r.rank,
|
|
225
|
+
}));
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
count() {
|
|
229
|
+
return stmtCount.get().n;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
close() {
|
|
233
|
+
db.close();
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function rowToSegment(row) {
|
|
239
|
+
return {
|
|
240
|
+
id: row.id,
|
|
241
|
+
scope: row.scope,
|
|
242
|
+
kind: row.kind,
|
|
243
|
+
tags: safeJsonArr(row.tags),
|
|
244
|
+
body: row.body,
|
|
245
|
+
sourceMessages: safeJsonArr(row.source_msgs),
|
|
246
|
+
createdAt: fromEpochMs(row.created_at),
|
|
247
|
+
updatedAt: fromEpochMs(row.updated_at),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function safeJsonArr(s) {
|
|
252
|
+
if (!s) return [];
|
|
253
|
+
try {
|
|
254
|
+
const v = JSON.parse(s);
|
|
255
|
+
return Array.isArray(v) ? v : [];
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function toEpochMs(iso) {
|
|
262
|
+
if (typeof iso === 'number') return iso;
|
|
263
|
+
const t = Date.parse(iso || '');
|
|
264
|
+
return Number.isFinite(t) ? t : Date.now();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function fromEpochMs(ms) {
|
|
268
|
+
return new Date(Number(ms) || Date.now()).toISOString();
|
|
269
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/segment-store.js — disk I/O for segment-formatted memory.md.
|
|
3
|
+
*
|
|
4
|
+
* Bridges between the on-disk format (memory.md per scope, multiple
|
|
5
|
+
* segment blocks) and the SQLite segment index. This layer handles
|
|
6
|
+
* scope <-> file path mapping; the index layer is scope-agnostic.
|
|
7
|
+
*
|
|
8
|
+
* Path conventions (DESIGN-v2 §5):
|
|
9
|
+
* ~/.yeaft/memory/user/memory.md
|
|
10
|
+
* ~/.yeaft/memory/vp/<id>/memory.md
|
|
11
|
+
* ~/.yeaft/memory/group/<id>/memory.md
|
|
12
|
+
* ~/.yeaft/memory/feature/<id>/memory.md
|
|
13
|
+
* ~/.yeaft/memory/topic/<l1>/memory.md
|
|
14
|
+
* ~/.yeaft/memory/topic/<l1>/<l2>/memory.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
19
|
+
readdirSync, statSync, renameSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { join, dirname, relative, sep } from 'node:path';
|
|
22
|
+
import { parseSegments, serializeSegments } from './segment.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read all segments for a given scope from disk.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} memoryRoot e.g. ~/.yeaft/memory
|
|
28
|
+
* @param {string} scope
|
|
29
|
+
* @returns {import('./segment.js').Segment[]}
|
|
30
|
+
*/
|
|
31
|
+
export function readScope(memoryRoot, scope) {
|
|
32
|
+
const path = scopeFilePath(memoryRoot, scope);
|
|
33
|
+
if (!existsSync(path)) return [];
|
|
34
|
+
const text = readFileSync(path, 'utf8');
|
|
35
|
+
return parseSegments(text, { defaultScope: scope });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Atomically write segments for a scope. Creates the directory if
|
|
40
|
+
* needed. Empty array → empties the file (we keep the file so absence
|
|
41
|
+
* means "scope never existed").
|
|
42
|
+
*
|
|
43
|
+
* @param {string} memoryRoot
|
|
44
|
+
* @param {string} scope
|
|
45
|
+
* @param {import('./segment.js').Segment[]} segments
|
|
46
|
+
*/
|
|
47
|
+
export function writeScope(memoryRoot, scope, segments) {
|
|
48
|
+
const path = scopeFilePath(memoryRoot, scope);
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
50
|
+
const text = serializeSegments(segments);
|
|
51
|
+
// atomic-ish write: tmp file + rename
|
|
52
|
+
const tmp = `${path}.tmp`;
|
|
53
|
+
writeFileSync(tmp, text, 'utf8');
|
|
54
|
+
renameSync(tmp, path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Walk the memory root and return all scopes that have a memory.md.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} memoryRoot
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function listScopes(memoryRoot) {
|
|
64
|
+
if (!existsSync(memoryRoot)) return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
walk(memoryRoot, memoryRoot, out);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function walk(root, dir, out) {
|
|
71
|
+
for (const entry of readdirSync(dir)) {
|
|
72
|
+
const full = join(dir, entry);
|
|
73
|
+
let st;
|
|
74
|
+
try { st = statSync(full); } catch { continue; }
|
|
75
|
+
if (st.isDirectory()) {
|
|
76
|
+
walk(root, full, out);
|
|
77
|
+
} else if (entry === 'memory.md') {
|
|
78
|
+
const rel = relative(root, dir).split(sep).join('/');
|
|
79
|
+
if (rel) out.push(rel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} memoryRoot
|
|
86
|
+
* @param {string} scope
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function scopeFilePath(memoryRoot, scope) {
|
|
90
|
+
return join(memoryRoot, scope, 'memory.md');
|
|
91
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/segment-sync.js — disk → SQLite reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth is on-disk memory.md per scope. SQLite is a derived
|
|
5
|
+
* index. This module reads disk, diffs against SQLite, and emits
|
|
6
|
+
* upsert / delete operations.
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
* - For each scope on disk: read all segments → upsert into index.
|
|
10
|
+
* - For ids that exist in index for that scope but no longer on
|
|
11
|
+
* disk: delete.
|
|
12
|
+
* - For scopes that exist in index but no longer on disk: deleteScope.
|
|
13
|
+
*
|
|
14
|
+
* Cost: O(N) read + O(N) upsert per call. Fine for boot-time and
|
|
15
|
+
* post-Dream sync. For high-frequency syncs caller can pass a single
|
|
16
|
+
* scope to limit work (`syncScope`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { listScopes, readScope } from './segment-store.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Full sync: walk disk, reconcile every scope into the index. Returns
|
|
23
|
+
* counts for telemetry.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} memoryRoot
|
|
26
|
+
* @param {import('./index-db.js').SegmentIndex} index
|
|
27
|
+
* @returns {{ scopes: number, upserted: number, deleted: number }}
|
|
28
|
+
*/
|
|
29
|
+
export function syncAll(memoryRoot, index) {
|
|
30
|
+
const diskScopes = new Set(listScopes(memoryRoot));
|
|
31
|
+
const indexScopes = new Set(allScopesFromIndex(index));
|
|
32
|
+
|
|
33
|
+
let upserted = 0;
|
|
34
|
+
let deleted = 0;
|
|
35
|
+
|
|
36
|
+
for (const scope of diskScopes) {
|
|
37
|
+
const r = syncScope(memoryRoot, index, scope);
|
|
38
|
+
upserted += r.upserted;
|
|
39
|
+
deleted += r.deleted;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scopes in index but not on disk → drop entirely.
|
|
43
|
+
for (const scope of indexScopes) {
|
|
44
|
+
if (!diskScopes.has(scope)) {
|
|
45
|
+
const before = index.listByScope(scope).length;
|
|
46
|
+
index.deleteScope(scope);
|
|
47
|
+
deleted += before;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { scopes: diskScopes.size, upserted, deleted };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sync one scope. Reads disk, compares against index, applies upsert /
|
|
56
|
+
* delete. Returns counts.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} memoryRoot
|
|
59
|
+
* @param {import('./index-db.js').SegmentIndex} index
|
|
60
|
+
* @param {string} scope
|
|
61
|
+
* @returns {{ upserted: number, deleted: number }}
|
|
62
|
+
*/
|
|
63
|
+
export function syncScope(memoryRoot, index, scope) {
|
|
64
|
+
const onDisk = readScope(memoryRoot, scope);
|
|
65
|
+
const onDiskIds = new Set(onDisk.map(s => s.id));
|
|
66
|
+
const inIndex = index.listByScope(scope);
|
|
67
|
+
const inIndexIds = new Set(inIndex.map(s => s.id));
|
|
68
|
+
|
|
69
|
+
let upserted = 0;
|
|
70
|
+
let deleted = 0;
|
|
71
|
+
|
|
72
|
+
for (const seg of onDisk) {
|
|
73
|
+
const existing = inIndex.find(e => e.id === seg.id);
|
|
74
|
+
if (!existing
|
|
75
|
+
|| existing.body !== seg.body
|
|
76
|
+
|| existing.kind !== seg.kind
|
|
77
|
+
|| existing.updatedAt !== seg.updatedAt
|
|
78
|
+
|| !sameArr(existing.tags, seg.tags)
|
|
79
|
+
|| !sameArr(existing.sourceMessages, seg.sourceMessages)) {
|
|
80
|
+
index.upsert(seg);
|
|
81
|
+
upserted += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const toDelete = [];
|
|
86
|
+
for (const id of inIndexIds) {
|
|
87
|
+
if (!onDiskIds.has(id)) toDelete.push(id);
|
|
88
|
+
}
|
|
89
|
+
if (toDelete.length > 0) {
|
|
90
|
+
index.deleteMany(toDelete);
|
|
91
|
+
deleted = toDelete.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { upserted, deleted };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function allScopesFromIndex(index) {
|
|
98
|
+
// Cheap unique-scope query via the underlying db handle.
|
|
99
|
+
const rows = index._db
|
|
100
|
+
.prepare('SELECT DISTINCT scope FROM memory_segments')
|
|
101
|
+
.all();
|
|
102
|
+
return rows.map(r => r.scope);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sameArr(a, b) {
|
|
106
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
107
|
+
if (a.length !== b.length) return false;
|
|
108
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/segment.js — DESIGN-H2-AMS §1.
|
|
3
|
+
*
|
|
4
|
+
* A Memory Segment is Dream LLM's secondary processing of raw messages:
|
|
5
|
+
* a self-contained semantic chunk (one segment per topic). NOT a copy
|
|
6
|
+
* of messages — messages already live in conversation/messages/.
|
|
7
|
+
*
|
|
8
|
+
* Physical layout: each scope's `memory.md` is multiple segments
|
|
9
|
+
* concatenated, each with a YAML frontmatter block and a body.
|
|
10
|
+
*
|
|
11
|
+
* ---
|
|
12
|
+
* id: seg_<8hex>
|
|
13
|
+
* scope: feature/auth
|
|
14
|
+
* kind: decision # fact|preference|decision|lesson|relation|goal|context
|
|
15
|
+
* tags: [auth, jwt]
|
|
16
|
+
* sourceMessages: [m_142, m_143]
|
|
17
|
+
* createdAt: 2026-04-29T10:11:12Z
|
|
18
|
+
* updatedAt: 2026-04-29T10:11:12Z
|
|
19
|
+
* ---
|
|
20
|
+
* <body — natural language, multi-sentence, detail preserved>
|
|
21
|
+
*
|
|
22
|
+
* Robustness rules:
|
|
23
|
+
* - Frontmatter is OPTIONAL. Body-only blocks (no `---`) are treated
|
|
24
|
+
* as one anonymous segment; missing fields are filled with defaults.
|
|
25
|
+
* - Partial frontmatter is OK: only `id` is auto-computed when
|
|
26
|
+
* absent; `kind` defaults to "context"; tags/sourceMessages default
|
|
27
|
+
* to []; timestamps default to now.
|
|
28
|
+
* - `scope` may be absent in frontmatter — the parser falls back to
|
|
29
|
+
* the caller-supplied `defaultScope` (typically derived from the
|
|
30
|
+
* memory.md file path).
|
|
31
|
+
*
|
|
32
|
+
* Round-trip: serializeSegments → parseSegments yields equivalent
|
|
33
|
+
* segments (modulo whitespace).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} Segment
|
|
40
|
+
* @property {string} id seg_<8hex>
|
|
41
|
+
* @property {string} scope
|
|
42
|
+
* @property {string} kind
|
|
43
|
+
* @property {string[]} tags
|
|
44
|
+
* @property {string[]} sourceMessages
|
|
45
|
+
* @property {string} createdAt
|
|
46
|
+
* @property {string} updatedAt
|
|
47
|
+
* @property {string} body
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export const KIND_VALUES = new Set([
|
|
51
|
+
'fact', 'preference', 'decision', 'lesson', 'relation', 'goal', 'context',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const SCOPE_RE = /^(user|vp\/[\w-]+|group\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?)$/;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute a stable id from segment content. Same body + scope + kind →
|
|
58
|
+
* same id, even across rewrites of unchanged content.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ scope: string, kind: string, body: string }} parts
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function computeSegmentId({ scope, kind, body }) {
|
|
64
|
+
const h = createHash('sha256')
|
|
65
|
+
.update(`${scope}\0${kind}\0${body.trim()}`)
|
|
66
|
+
.digest('hex')
|
|
67
|
+
.slice(0, 8);
|
|
68
|
+
return `seg_${h}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate + normalize raw segment data. Throws only on truly missing
|
|
73
|
+
* essentials (scope + body). Everything else gets a default.
|
|
74
|
+
*
|
|
75
|
+
* @param {Partial<Segment>} raw
|
|
76
|
+
* @returns {Segment}
|
|
77
|
+
*/
|
|
78
|
+
export function makeSegment(raw) {
|
|
79
|
+
if (!raw || typeof raw !== 'object') {
|
|
80
|
+
throw new Error('makeSegment: object required');
|
|
81
|
+
}
|
|
82
|
+
const scope = String(raw.scope || '').trim();
|
|
83
|
+
if (!SCOPE_RE.test(scope)) {
|
|
84
|
+
throw new Error(`makeSegment: invalid or missing scope "${scope}"`);
|
|
85
|
+
}
|
|
86
|
+
const body = String(raw.body || '').trim();
|
|
87
|
+
if (!body) throw new Error('makeSegment: body required');
|
|
88
|
+
|
|
89
|
+
let kind = String(raw.kind || 'context').trim();
|
|
90
|
+
if (!KIND_VALUES.has(kind)) kind = 'context';
|
|
91
|
+
|
|
92
|
+
const tags = Array.isArray(raw.tags)
|
|
93
|
+
? raw.tags.map(t => String(t).trim()).filter(Boolean)
|
|
94
|
+
: [];
|
|
95
|
+
const sourceMessages = Array.isArray(raw.sourceMessages)
|
|
96
|
+
? raw.sourceMessages.map(t => String(t).trim()).filter(Boolean)
|
|
97
|
+
: [];
|
|
98
|
+
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
const createdAt = raw.createdAt || now;
|
|
101
|
+
const updatedAt = raw.updatedAt || createdAt;
|
|
102
|
+
const id = raw.id || computeSegmentId({ scope, kind, body });
|
|
103
|
+
|
|
104
|
+
return { id, scope, kind, tags, sourceMessages, createdAt, updatedAt, body };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse a memory.md text into segments. Tolerant by design:
|
|
109
|
+
* - Empty / whitespace input → [].
|
|
110
|
+
* - Body-only (no frontmatter) → one anonymous segment using
|
|
111
|
+
* `defaultScope`.
|
|
112
|
+
* - Multi-block input → split on `---` boundaries; each block may
|
|
113
|
+
* have full, partial, or no frontmatter.
|
|
114
|
+
*
|
|
115
|
+
* Blocks that would fail validation (e.g. no scope and no defaultScope)
|
|
116
|
+
* are silently dropped — the writer can always re-emit canonical form.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} text
|
|
119
|
+
* @param {{ defaultScope?: string }} [opts]
|
|
120
|
+
* @returns {Segment[]}
|
|
121
|
+
*/
|
|
122
|
+
export function parseSegments(text, opts = {}) {
|
|
123
|
+
if (!text || typeof text !== 'string') return [];
|
|
124
|
+
const trimmed = text.replace(/^/, '').trim();
|
|
125
|
+
if (!trimmed) return [];
|
|
126
|
+
const defaultScope = opts.defaultScope || '';
|
|
127
|
+
|
|
128
|
+
// Case 1: no `---` at all → single anonymous block.
|
|
129
|
+
if (!/^---\s*$/m.test(trimmed)) {
|
|
130
|
+
return tryMake({ body: trimmed }, defaultScope);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Case 2: split into blocks. A block starts at a line that is exactly
|
|
134
|
+
// `---`, optionally preceded by blank line(s). We walk line-by-line.
|
|
135
|
+
const lines = trimmed.split('\n');
|
|
136
|
+
const blocks = [];
|
|
137
|
+
let i = 0;
|
|
138
|
+
// Skip leading blanks
|
|
139
|
+
while (i < lines.length && !lines[i].trim()) i += 1;
|
|
140
|
+
// If the first non-blank line is not `---`, treat the prefix up to
|
|
141
|
+
// the first `---` as a body-only segment.
|
|
142
|
+
if (lines[i] !== undefined && lines[i].trim() !== '---') {
|
|
143
|
+
const start = i;
|
|
144
|
+
while (i < lines.length && lines[i].trim() !== '---') i += 1;
|
|
145
|
+
const prefix = lines.slice(start, i).join('\n').trim();
|
|
146
|
+
if (prefix) blocks.push({ frontmatter: '', body: prefix });
|
|
147
|
+
}
|
|
148
|
+
// Now i points at `---` or end. Each iteration: `---` ... `---` ... body
|
|
149
|
+
while (i < lines.length) {
|
|
150
|
+
if (lines[i].trim() !== '---') { i += 1; continue; }
|
|
151
|
+
// Found opening `---`. Find closing `---`.
|
|
152
|
+
const fmStart = i + 1;
|
|
153
|
+
let j = fmStart;
|
|
154
|
+
while (j < lines.length && lines[j].trim() !== '---') j += 1;
|
|
155
|
+
if (j >= lines.length) {
|
|
156
|
+
// Unterminated frontmatter — treat the rest as body.
|
|
157
|
+
const body = lines.slice(fmStart).join('\n').trim();
|
|
158
|
+
if (body) blocks.push({ frontmatter: '', body });
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
const fm = lines.slice(fmStart, j).join('\n');
|
|
162
|
+
// Body runs from j+1 until the next `---` line (or end).
|
|
163
|
+
let k = j + 1;
|
|
164
|
+
while (k < lines.length && lines[k].trim() !== '---') k += 1;
|
|
165
|
+
const body = lines.slice(j + 1, k).join('\n').trim();
|
|
166
|
+
blocks.push({ frontmatter: fm, body });
|
|
167
|
+
i = k;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const blk of blocks) {
|
|
172
|
+
if (!blk.body) continue;
|
|
173
|
+
const fm = blk.frontmatter ? parseFrontmatter(blk.frontmatter) : {};
|
|
174
|
+
out.push(...tryMake({ ...fm, body: blk.body }, defaultScope));
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tryMake(raw, defaultScope) {
|
|
180
|
+
const scope = raw.scope || defaultScope;
|
|
181
|
+
if (!scope) return [];
|
|
182
|
+
try {
|
|
183
|
+
return [makeSegment({ ...raw, scope })];
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Serialize a list of segments back to memory.md text. Round-trips with
|
|
191
|
+
* parseSegments.
|
|
192
|
+
*
|
|
193
|
+
* @param {Segment[]} segments
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
export function serializeSegments(segments) {
|
|
197
|
+
if (!Array.isArray(segments) || segments.length === 0) return '';
|
|
198
|
+
return segments.map(serializeOne).join('\n\n') + '\n';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function serializeOne(seg) {
|
|
202
|
+
const fm = [
|
|
203
|
+
`id: ${seg.id}`,
|
|
204
|
+
`scope: ${seg.scope}`,
|
|
205
|
+
`kind: ${seg.kind}`,
|
|
206
|
+
`tags: [${seg.tags.map(yamlInlineString).join(', ')}]`,
|
|
207
|
+
`sourceMessages: [${seg.sourceMessages.map(yamlInlineString).join(', ')}]`,
|
|
208
|
+
`createdAt: ${seg.createdAt}`,
|
|
209
|
+
`updatedAt: ${seg.updatedAt}`,
|
|
210
|
+
].join('\n');
|
|
211
|
+
return `---\n${fm}\n---\n${seg.body}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function yamlInlineString(s) {
|
|
215
|
+
if (/^[\w./-]+$/.test(s)) return s;
|
|
216
|
+
return JSON.stringify(s);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Tiny YAML-frontmatter parser sized for our schema. Handles:
|
|
221
|
+
* key: value
|
|
222
|
+
* key: [a, b, "c d"]
|
|
223
|
+
* key: 2026-04-29T10:11:12Z (returned as raw string)
|
|
224
|
+
* Unknown lines are ignored.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} text
|
|
227
|
+
* @returns {Record<string, any>}
|
|
228
|
+
*/
|
|
229
|
+
function parseFrontmatter(text) {
|
|
230
|
+
const out = {};
|
|
231
|
+
for (const line of text.split('\n')) {
|
|
232
|
+
const m = /^([A-Za-z_]\w*):\s*(.*)$/.exec(line);
|
|
233
|
+
if (!m) continue;
|
|
234
|
+
out[m[1]] = parseYamlScalarOrArray(m[2].trim());
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseYamlScalarOrArray(raw) {
|
|
240
|
+
if (!raw) return '';
|
|
241
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
242
|
+
const inner = raw.slice(1, -1).trim();
|
|
243
|
+
if (!inner) return [];
|
|
244
|
+
return splitTopLevelCommas(inner).map(stripYamlString);
|
|
245
|
+
}
|
|
246
|
+
return stripYamlString(raw);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function splitTopLevelCommas(s) {
|
|
250
|
+
const parts = [];
|
|
251
|
+
let cur = '';
|
|
252
|
+
let inStr = false;
|
|
253
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
254
|
+
const c = s[i];
|
|
255
|
+
if (c === '"' && s[i - 1] !== '\\') inStr = !inStr;
|
|
256
|
+
if (c === ',' && !inStr) { parts.push(cur.trim()); cur = ''; }
|
|
257
|
+
else cur += c;
|
|
258
|
+
}
|
|
259
|
+
if (cur.trim()) parts.push(cur.trim());
|
|
260
|
+
return parts;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function stripYamlString(s) {
|
|
264
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
265
|
+
try { return JSON.parse(s); } catch { return s.slice(1, -1); }
|
|
266
|
+
}
|
|
267
|
+
return s;
|
|
268
|
+
}
|