@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.642",
3
+ "version": "0.1.645",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
+ }