@yeaft/webchat-agent 0.1.643 → 0.1.646
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/ams.js +197 -0
- package/unify/memory/budget.js +111 -0
- 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/unify/memory/summary-store.js +63 -0
package/package.json
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/ams.js — DESIGN-H2-AMS §5. Active Memory Set.
|
|
3
|
+
*
|
|
4
|
+
* Per-group session state. Three layers:
|
|
5
|
+
*
|
|
6
|
+
* resident summaries of all relevant scopes — always-on, high precision
|
|
7
|
+
* recent LRU of segments touched in last N turns — warm cache
|
|
8
|
+
* onDemand segments the pre-flow FTS pulled in this turn — hot recall
|
|
9
|
+
*
|
|
10
|
+
* AMS is in-memory; it's rebuilt at session start. The disk source of
|
|
11
|
+
* truth is `<scope>/memory.md` + `<scope>/summary.md`. AMS itself
|
|
12
|
+
* doesn't write to disk — that's Dream's job.
|
|
13
|
+
*
|
|
14
|
+
* Privacy (DESIGN-v2 §2.2): `vp/<other>` scopes are ALWAYS filtered out
|
|
15
|
+
* for any worker that isn't `<other>`. The owning code passes its own
|
|
16
|
+
* vpId at construction.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { approxTokens, packWithinBudget } from './budget.js';
|
|
20
|
+
|
|
21
|
+
const RECENT_DEFAULT_CAPACITY = 64;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} AmsLayers
|
|
25
|
+
* @property {Map<string, string>} resident
|
|
26
|
+
* @property {Array<{ id: string, seg: import('./segment.js').Segment, ts: number }>} recent
|
|
27
|
+
* @property {Map<string, import('./segment.js').Segment>} onDemand
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} AmsSnapshot
|
|
32
|
+
* @property {Array<{ scope: string, summary: string }>} resident
|
|
33
|
+
* @property {import('./segment.js').Segment[]} recent
|
|
34
|
+
* @property {import('./segment.js').Segment[]} onDemand
|
|
35
|
+
* @property {{ resident: number, recent: number, onDemand: number, total: number }} usage
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export class ActiveMemorySet {
|
|
39
|
+
/**
|
|
40
|
+
* @param {{
|
|
41
|
+
* ownVpId?: string | null,
|
|
42
|
+
* budget: import('./budget.js').BudgetSplit,
|
|
43
|
+
* recentCapacity?: number,
|
|
44
|
+
* }} opts
|
|
45
|
+
*/
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
if (!opts || !opts.budget) throw new Error('ActiveMemorySet: budget required');
|
|
48
|
+
this.ownVpId = opts.ownVpId || null;
|
|
49
|
+
this.budget = opts.budget;
|
|
50
|
+
this.recentCapacity = opts.recentCapacity || RECENT_DEFAULT_CAPACITY;
|
|
51
|
+
/** @type {Map<string, string>} */
|
|
52
|
+
this._resident = new Map(); // scope → summaryText
|
|
53
|
+
/** @type {Map<string, { seg: import('./segment.js').Segment, ts: number }>} */
|
|
54
|
+
this._recent = new Map(); // segId → entry (insertion-order is LRU order)
|
|
55
|
+
/** @type {Map<string, import('./segment.js').Segment>} */
|
|
56
|
+
this._onDemand = new Map(); // segId → segment
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ────────────────────────── resident ──────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace the resident layer with a fresh set of scope→summary
|
|
63
|
+
* pairs. Foreign VP scopes are silently dropped.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{ scope: string, summary: string }>} entries
|
|
66
|
+
*/
|
|
67
|
+
setResident(entries) {
|
|
68
|
+
this._resident.clear();
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (this._isForeignVp(e.scope)) continue;
|
|
71
|
+
if (!e.summary) continue;
|
|
72
|
+
this._resident.set(e.scope, e.summary);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ────────────────────────── recent ──────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Touch a segment as "used this turn". LRU semantics: most-recent at
|
|
80
|
+
* the end. Trims to capacity automatically.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('./segment.js').Segment} seg
|
|
83
|
+
*/
|
|
84
|
+
touchRecent(seg) {
|
|
85
|
+
if (!seg || !seg.id) return;
|
|
86
|
+
if (this._isForeignVp(seg.scope)) return;
|
|
87
|
+
if (this._recent.has(seg.id)) this._recent.delete(seg.id);
|
|
88
|
+
this._recent.set(seg.id, { seg, ts: Date.now() });
|
|
89
|
+
while (this._recent.size > this.recentCapacity) {
|
|
90
|
+
const firstKey = this._recent.keys().next().value;
|
|
91
|
+
this._recent.delete(firstKey);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ────────────────────────── onDemand ──────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replace the onDemand layer with this turn's FTS hits.
|
|
99
|
+
*
|
|
100
|
+
* @param {import('./segment.js').Segment[]} segments
|
|
101
|
+
*/
|
|
102
|
+
setOnDemand(segments) {
|
|
103
|
+
this._onDemand.clear();
|
|
104
|
+
for (const seg of segments) {
|
|
105
|
+
if (this._isForeignVp(seg.scope)) continue;
|
|
106
|
+
this._onDemand.set(seg.id, seg);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add segments to onDemand without clearing (used by adjustMemory).
|
|
112
|
+
*
|
|
113
|
+
* @param {import('./segment.js').Segment[]} segments
|
|
114
|
+
*/
|
|
115
|
+
addOnDemand(segments) {
|
|
116
|
+
for (const seg of segments) {
|
|
117
|
+
if (this._isForeignVp(seg.scope)) continue;
|
|
118
|
+
this._onDemand.set(seg.id, seg);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove segment ids from onDemand (used by adjustMemory eviction).
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} ids
|
|
126
|
+
*/
|
|
127
|
+
removeOnDemand(ids) {
|
|
128
|
+
for (const id of ids) this._onDemand.delete(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ────────────────────────── snapshot ──────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Produce a budget-aware snapshot that can be injected into the
|
|
135
|
+
* system prompt. Each layer is greedily packed within its budget;
|
|
136
|
+
* overflow is dropped from this turn but not from disk.
|
|
137
|
+
*
|
|
138
|
+
* @returns {AmsSnapshot}
|
|
139
|
+
*/
|
|
140
|
+
snapshot() {
|
|
141
|
+
// Resident: pack scopes by priority order (caller provides via insert
|
|
142
|
+
// order — current group's own vp first, then user, etc.).
|
|
143
|
+
const resEntries = [...this._resident.entries()].map(([scope, summary]) => ({
|
|
144
|
+
scope, summary,
|
|
145
|
+
}));
|
|
146
|
+
const { picked: resPicked, cost: resCost } = packWithinBudget(
|
|
147
|
+
resEntries, this.budget.resident,
|
|
148
|
+
e => approxTokens(e.summary),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Recent: insertion order is oldest-first; we want newest first.
|
|
152
|
+
const recentArr = [...this._recent.values()]
|
|
153
|
+
.reverse()
|
|
154
|
+
.map(e => e.seg);
|
|
155
|
+
const { picked: recPicked, cost: recCost } = packWithinBudget(
|
|
156
|
+
recentArr, this.budget.recent,
|
|
157
|
+
seg => approxTokens(seg.body),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// OnDemand: insertion order from caller (already FTS-ranked).
|
|
161
|
+
const odArr = [...this._onDemand.values()];
|
|
162
|
+
const { picked: odPicked, cost: odCost } = packWithinBudget(
|
|
163
|
+
odArr, this.budget.onDemand,
|
|
164
|
+
seg => approxTokens(seg.body),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
resident: resPicked,
|
|
169
|
+
recent: recPicked,
|
|
170
|
+
onDemand: odPicked,
|
|
171
|
+
usage: {
|
|
172
|
+
resident: resCost,
|
|
173
|
+
recent: recCost,
|
|
174
|
+
onDemand: odCost,
|
|
175
|
+
total: resCost + recCost + odCost,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read-only inspectors (for tests / observability / adjustMemory input).
|
|
182
|
+
*/
|
|
183
|
+
residentScopes() { return [...this._resident.keys()]; }
|
|
184
|
+
recentIds() { return [...this._recent.keys()]; }
|
|
185
|
+
onDemandIds() { return [...this._onDemand.keys()]; }
|
|
186
|
+
onDemandSegments() { return [...this._onDemand.values()]; }
|
|
187
|
+
size() { return this._resident.size + this._recent.size + this._onDemand.size; }
|
|
188
|
+
|
|
189
|
+
// ────────────────────────── privacy ──────────────────────────
|
|
190
|
+
|
|
191
|
+
_isForeignVp(scope) {
|
|
192
|
+
if (!scope || !scope.startsWith('vp/')) return false;
|
|
193
|
+
if (!this.ownVpId) return false; // no own id → no filtering
|
|
194
|
+
const other = scope.slice(3).split('/')[0];
|
|
195
|
+
return other !== this.ownVpId;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/budget.js — DESIGN-H2-AMS §5.2.
|
|
3
|
+
*
|
|
4
|
+
* Memory budget = `min(50_000, modelMaxContext * 0.10)`.
|
|
5
|
+
*
|
|
6
|
+
* Then split across the three AMS layers (resident / recent / onDemand)
|
|
7
|
+
* with a configurable ratio. The defaults are tuned for ~200k context
|
|
8
|
+
* models (Claude / GPT-5):
|
|
9
|
+
*
|
|
10
|
+
* resident 40% → 20k (all relevant scope summaries)
|
|
11
|
+
* recent 25% → 12.5k (LRU of recently-used segments)
|
|
12
|
+
* onDemand 35% → 17.5k (this turn's FTS recall)
|
|
13
|
+
*
|
|
14
|
+
* Token counting here is approximate (chars / 4) — accurate enough for
|
|
15
|
+
* budget enforcement. The engine has a real tokenizer for prompt
|
|
16
|
+
* assembly; budget here is a guard rail, not the source of truth.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const ABSOLUTE_CAP = 50_000;
|
|
20
|
+
export const MODEL_FRACTION = 0.10;
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_RATIO = {
|
|
23
|
+
resident: 0.40,
|
|
24
|
+
recent: 0.25,
|
|
25
|
+
onDemand: 0.35,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} BudgetSplit
|
|
30
|
+
* @property {number} total
|
|
31
|
+
* @property {number} resident
|
|
32
|
+
* @property {number} recent
|
|
33
|
+
* @property {number} onDemand
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {number} modelMaxContext tokens of the model's full context window
|
|
38
|
+
* @param {Partial<typeof DEFAULT_RATIO>} [ratio]
|
|
39
|
+
* @returns {BudgetSplit}
|
|
40
|
+
*/
|
|
41
|
+
export function computeBudget(modelMaxContext, ratio = {}) {
|
|
42
|
+
const ctx = Number.isFinite(modelMaxContext) && modelMaxContext > 0
|
|
43
|
+
? modelMaxContext : 200_000;
|
|
44
|
+
const total = Math.min(ABSOLUTE_CAP, Math.floor(ctx * MODEL_FRACTION));
|
|
45
|
+
|
|
46
|
+
const r = { ...DEFAULT_RATIO, ...ratio };
|
|
47
|
+
// Normalise so ratios sum to 1 (defensive).
|
|
48
|
+
const sum = r.resident + r.recent + r.onDemand;
|
|
49
|
+
const norm = sum > 0 ? sum : 1;
|
|
50
|
+
return {
|
|
51
|
+
total,
|
|
52
|
+
resident: Math.floor(total * (r.resident / norm)),
|
|
53
|
+
recent: Math.floor(total * (r.recent / norm)),
|
|
54
|
+
onDemand: Math.floor(total * (r.onDemand / norm)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Approximate token count of a string. Avg English ≈ 4 chars / token,
|
|
60
|
+
* Chinese ≈ 1 char / token. We use a conservative blended estimate.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
export function approxTokens(text) {
|
|
66
|
+
if (!text) return 0;
|
|
67
|
+
// Count CJK chars as ~1 token each, the rest as char/4.
|
|
68
|
+
let cjk = 0;
|
|
69
|
+
let other = 0;
|
|
70
|
+
for (const ch of text) {
|
|
71
|
+
const c = ch.codePointAt(0) || 0;
|
|
72
|
+
if (
|
|
73
|
+
(c >= 0x4e00 && c <= 0x9fff) ||
|
|
74
|
+
(c >= 0x3040 && c <= 0x309f) ||
|
|
75
|
+
(c >= 0x30a0 && c <= 0x30ff) ||
|
|
76
|
+
(c >= 0xac00 && c <= 0xd7af)
|
|
77
|
+
) {
|
|
78
|
+
cjk += 1;
|
|
79
|
+
} else {
|
|
80
|
+
other += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Math.ceil(cjk + other / 4);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Greedy pack: pick items in order until adding the next would exceed
|
|
88
|
+
* the budget. Returns the picked list and the total cost. Does NOT
|
|
89
|
+
* sort — caller decides ordering.
|
|
90
|
+
*
|
|
91
|
+
* @template T
|
|
92
|
+
* @param {T[]} items
|
|
93
|
+
* @param {number} budget
|
|
94
|
+
* @param {(item: T) => number} costFn
|
|
95
|
+
* @returns {{ picked: T[], cost: number, dropped: T[] }}
|
|
96
|
+
*/
|
|
97
|
+
export function packWithinBudget(items, budget, costFn) {
|
|
98
|
+
const picked = [];
|
|
99
|
+
const dropped = [];
|
|
100
|
+
let cost = 0;
|
|
101
|
+
for (const it of items) {
|
|
102
|
+
const c = costFn(it);
|
|
103
|
+
if (cost + c <= budget) {
|
|
104
|
+
picked.push(it);
|
|
105
|
+
cost += c;
|
|
106
|
+
} else {
|
|
107
|
+
dropped.push(it);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { picked, cost, dropped };
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/summary-store.js — DESIGN-H2-AMS §3.
|
|
3
|
+
*
|
|
4
|
+
* `summary.md` is a bounded, per-scope prose digest derived from all
|
|
5
|
+
* segments in that scope. Resident AMS layer = concatenation of all
|
|
6
|
+
* relevant scope summaries. Regenerated by Dream after segments change.
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
* ~/.yeaft/memory/<scope>/summary.md
|
|
10
|
+
*
|
|
11
|
+
* Format: plain prose, optionally prefixed with a one-line metadata
|
|
12
|
+
* header `<!-- updatedAt: ISO -->`. No required schema beyond non-empty
|
|
13
|
+
* text.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, renameSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} memoryRoot
|
|
23
|
+
* @param {string} scope
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function summaryPath(memoryRoot, scope) {
|
|
27
|
+
return join(memoryRoot, scope, 'summary.md');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read summary text for a scope (empty string if missing).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} memoryRoot
|
|
34
|
+
* @param {string} scope
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function readSummary(memoryRoot, scope) {
|
|
38
|
+
const p = summaryPath(memoryRoot, scope);
|
|
39
|
+
if (!existsSync(p)) return '';
|
|
40
|
+
return stripHeader(readFileSync(p, 'utf8')).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Atomic write summary text. Empty string = wipe content but keep file.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} memoryRoot
|
|
47
|
+
* @param {string} scope
|
|
48
|
+
* @param {string} text
|
|
49
|
+
*/
|
|
50
|
+
export function writeSummary(memoryRoot, scope, text) {
|
|
51
|
+
const p = summaryPath(memoryRoot, scope);
|
|
52
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
53
|
+
const header = `<!-- updatedAt: ${new Date().toISOString()} -->\n`;
|
|
54
|
+
const body = (text || '').trim();
|
|
55
|
+
const final = body ? `${header}${body}\n` : header;
|
|
56
|
+
const tmp = `${p}.tmp`;
|
|
57
|
+
writeFileSync(tmp, final, 'utf8');
|
|
58
|
+
renameSync(tmp, p);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripHeader(text) {
|
|
62
|
+
return text.replace(/^<!--[^>]*-->\s*/m, '');
|
|
63
|
+
}
|