@yeaft/webchat-agent 0.1.628 → 0.1.630
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/dream-v2/apply.js +271 -0
- package/unify/dream-v2/limits.js +46 -0
- package/unify/dream-v2/merge.js +87 -0
- package/unify/dream-v2/runner.js +267 -0
- package/unify/dream-v2/schedule.js +73 -0
- package/unify/dream-v2/segment.js +191 -0
- package/unify/dream-v2/snapshot.js +80 -0
- package/unify/dream-v2/state.js +177 -0
- package/unify/dream-v2/triage.js +287 -0
- package/unify/memory/migrate-r6-to-v2.js +462 -0
- package/unify/memory/store-v2.js +402 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/schedule.js — DESIGN-v2 §10.2.
|
|
3
|
+
*
|
|
4
|
+
* Two trigger paths:
|
|
5
|
+
*
|
|
6
|
+
* 1. 12-hour interval timer.
|
|
7
|
+
* 2. Manual trigger (UI button or `/dream` command), routed in via
|
|
8
|
+
* `triggerNow()` — sets `manual: true` so the per-group threshold
|
|
9
|
+
* is bypassed.
|
|
10
|
+
*
|
|
11
|
+
* The scheduler is a thin wrapper around `runDream()` that prevents
|
|
12
|
+
* concurrent passes (a second tick while the previous is still running
|
|
13
|
+
* is dropped, not queued — DESIGN-v2 §10.1: "slow is OK, doesn't
|
|
14
|
+
* compete with user latency").
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { DREAM_INTERVAL_HOURS } from './limits.js';
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_INTERVAL_MS = DREAM_INTERVAL_HOURS * 60 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a scheduler around `runDream()`. The runner closure captures
|
|
23
|
+
* everything `runDream` needs (memory root, llm, message-store hooks,
|
|
24
|
+
* onProgress sink); the scheduler only knows how to call it.
|
|
25
|
+
*
|
|
26
|
+
* @param {{
|
|
27
|
+
* run: (opts: { manual: boolean, scopeFilter?: string[] }) => Promise<object>,
|
|
28
|
+
* intervalMs?: number,
|
|
29
|
+
* logger?: { info?: (...a:any) => void, warn?: (...a:any) => void, error?: (...a:any) => void },
|
|
30
|
+
* }} args
|
|
31
|
+
*/
|
|
32
|
+
export function createDreamScheduler({ run, intervalMs = DEFAULT_INTERVAL_MS, logger }) {
|
|
33
|
+
if (typeof run !== 'function') throw new Error('createDreamScheduler: run callable required');
|
|
34
|
+
const log = logger || {};
|
|
35
|
+
let timer = null;
|
|
36
|
+
let inflight = null;
|
|
37
|
+
|
|
38
|
+
async function fire(opts) {
|
|
39
|
+
if (inflight) {
|
|
40
|
+
log.warn?.('[dream] tick dropped — previous run still in progress');
|
|
41
|
+
return inflight;
|
|
42
|
+
}
|
|
43
|
+
inflight = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
return await run(opts);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.error?.('[dream] run failed:', err && err.message ? err.message : err);
|
|
48
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
49
|
+
} finally {
|
|
50
|
+
inflight = null;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
return inflight;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
start() {
|
|
58
|
+
if (timer) return;
|
|
59
|
+
timer = setInterval(() => { fire({ manual: false }).catch(() => {}); }, intervalMs);
|
|
60
|
+
// Don't keep the event loop alive solely for the dream ticker.
|
|
61
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
62
|
+
},
|
|
63
|
+
stop() {
|
|
64
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
65
|
+
},
|
|
66
|
+
triggerNow(scopeFilter) {
|
|
67
|
+
return fire({ manual: true, scopeFilter });
|
|
68
|
+
},
|
|
69
|
+
isRunning() { return !!inflight; },
|
|
70
|
+
/** Test hook: fires once without scheduling a timer. */
|
|
71
|
+
_fire: fire,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/segment.js — DESIGN-v2 §17.
|
|
3
|
+
*
|
|
4
|
+
* Three independent length-control concerns, kept pure so they can be
|
|
5
|
+
* unit-tested without touching disk or any LLM:
|
|
6
|
+
*
|
|
7
|
+
* 1. truncateMessage — clamp a single message body to
|
|
8
|
+
* MAX_SINGLE_MESSAGE_CHARS, appending a clear notice. The full
|
|
9
|
+
* body is still preserved in the conversation log; this only
|
|
10
|
+
* affects what dream sees. (§17.3)
|
|
11
|
+
*
|
|
12
|
+
* 2. estimateTokens — rough chars-to-tokens approximation (we use 4
|
|
13
|
+
* chars/token, a stable industry approximation that doesn't drag
|
|
14
|
+
* a tokenizer into this layer; precise counts aren't required for
|
|
15
|
+
* "should we segment?" decisions and a small over-count is the
|
|
16
|
+
* safe direction).
|
|
17
|
+
*
|
|
18
|
+
* 3. segmentDiff — split a long per-group diff into K consecutive
|
|
19
|
+
* slices, each ≤ MAX_DIFF_TOKENS_PER_TRIAGE, with a 3-message
|
|
20
|
+
* overlap between adjacent slices for context continuity. (§17.1)
|
|
21
|
+
*
|
|
22
|
+
* 4. needsBatchedApply / batchSourcesForApply — when an Apply target's
|
|
23
|
+
* memory + summary + sources cumulatively exceed MAX_APPLY_TOKENS,
|
|
24
|
+
* split the sources (one source = one group's contribution) into
|
|
25
|
+
* batches; the LLM is then called once per batch, threading the
|
|
26
|
+
* written-back memory.md as input to the next batch. (§17.2)
|
|
27
|
+
*
|
|
28
|
+
* No side-effects. All functions are deterministic given their inputs.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
MAX_SINGLE_MESSAGE_CHARS,
|
|
33
|
+
MAX_DIFF_TOKENS_PER_TRIAGE,
|
|
34
|
+
MAX_APPLY_TOKENS,
|
|
35
|
+
DREAM_OVERLAP,
|
|
36
|
+
} from './limits.js';
|
|
37
|
+
|
|
38
|
+
const TRUNCATION_NOTICE = '\n\n[message truncated for dream, original preserved in conversation log]';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Truncate a single message body if it exceeds the per-message char cap.
|
|
42
|
+
* Idempotent: passing in an already-truncated body returns it unchanged.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} body
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function truncateMessage(body) {
|
|
48
|
+
const s = String(body || '');
|
|
49
|
+
if (s.length <= MAX_SINGLE_MESSAGE_CHARS) return s;
|
|
50
|
+
if (s.endsWith(TRUNCATION_NOTICE)) return s;
|
|
51
|
+
// Reserve room for the notice without overflowing the cap.
|
|
52
|
+
const room = Math.max(0, MAX_SINGLE_MESSAGE_CHARS - TRUNCATION_NOTICE.length);
|
|
53
|
+
return s.slice(0, room) + TRUNCATION_NOTICE;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Conservative chars-to-tokens approximation. We over-count slightly
|
|
58
|
+
* (1 token ≈ 4 chars) to make MAX_*_TOKENS act as a true upper bound.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} text
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
export function estimateTokens(text) {
|
|
64
|
+
if (!text) return 0;
|
|
65
|
+
return Math.ceil(String(text).length / 4);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Estimate the token cost of an array of messages (header + body for each).
|
|
70
|
+
* @param {Array<{id?: string, role?: string, body?: string}>} msgs
|
|
71
|
+
*/
|
|
72
|
+
export function estimateMessagesTokens(msgs) {
|
|
73
|
+
if (!Array.isArray(msgs)) return 0;
|
|
74
|
+
let n = 0;
|
|
75
|
+
for (const m of msgs) {
|
|
76
|
+
n += estimateTokens(m.role || '');
|
|
77
|
+
n += estimateTokens(m.body || '');
|
|
78
|
+
n += 2; // separator overhead
|
|
79
|
+
}
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Split a contiguous group diff into ≤MAX-token segments, with a
|
|
85
|
+
* DREAM_OVERLAP-message tail/head overlap between consecutive segments.
|
|
86
|
+
*
|
|
87
|
+
* Returns segments in temporal order. Each segment is `{ messages, kind }`
|
|
88
|
+
* where `kind` is 'overlap' for messages that exist only as continuity
|
|
89
|
+
* preamble (because they appeared in a prior segment), and 'new' for
|
|
90
|
+
* the rest. The first segment has no overlap header.
|
|
91
|
+
*
|
|
92
|
+
* Properties:
|
|
93
|
+
* - The union of `kind: 'new'` messages across all segments equals
|
|
94
|
+
* the input diff exactly, in order, with no duplicates.
|
|
95
|
+
* - Each segment's total token estimate ≤ MAX_DIFF_TOKENS_PER_TRIAGE
|
|
96
|
+
* unless a single message alone exceeds the cap, in which case
|
|
97
|
+
* that message gets its own segment (we never split a message).
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<{id?: string, role?: string, body?: string}>} diff
|
|
100
|
+
* @param {number} [maxTokens=MAX_DIFF_TOKENS_PER_TRIAGE]
|
|
101
|
+
* @param {number} [overlap=DREAM_OVERLAP]
|
|
102
|
+
* @returns {Array<{ messages: Array<object>, overlapCount: number, newCount: number }>}
|
|
103
|
+
*/
|
|
104
|
+
export function segmentDiff(diff, maxTokens = MAX_DIFF_TOKENS_PER_TRIAGE, overlap = DREAM_OVERLAP) {
|
|
105
|
+
const msgs = Array.isArray(diff) ? diff : [];
|
|
106
|
+
if (msgs.length === 0) return [];
|
|
107
|
+
|
|
108
|
+
// Fast path: whole diff fits in one segment.
|
|
109
|
+
if (estimateMessagesTokens(msgs) <= maxTokens) {
|
|
110
|
+
return [{ messages: msgs, overlapCount: 0, newCount: msgs.length }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const segments = [];
|
|
114
|
+
let cursor = 0;
|
|
115
|
+
while (cursor < msgs.length) {
|
|
116
|
+
const overlapHead = segments.length > 0
|
|
117
|
+
? msgs.slice(Math.max(0, cursor - overlap), cursor)
|
|
118
|
+
: [];
|
|
119
|
+
let used = estimateMessagesTokens(overlapHead);
|
|
120
|
+
let end = cursor;
|
|
121
|
+
while (end < msgs.length) {
|
|
122
|
+
const cost = estimateTokens(msgs[end].body || '') + estimateTokens(msgs[end].role || '') + 2;
|
|
123
|
+
if (used + cost > maxTokens && end > cursor) break;
|
|
124
|
+
used += cost;
|
|
125
|
+
end += 1;
|
|
126
|
+
}
|
|
127
|
+
// If we made no progress (single oversized message), advance by 1.
|
|
128
|
+
if (end === cursor) end = cursor + 1;
|
|
129
|
+
segments.push({
|
|
130
|
+
messages: [...overlapHead, ...msgs.slice(cursor, end)],
|
|
131
|
+
overlapCount: overlapHead.length,
|
|
132
|
+
newCount: end - cursor,
|
|
133
|
+
});
|
|
134
|
+
cursor = end;
|
|
135
|
+
}
|
|
136
|
+
return segments;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── apply batching ───────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Decide whether a merged apply target needs to be split into batches.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ memoryMd?: string, summaryMd?: string, sources: Array<{ groupId: string, diff: any }> }} merged
|
|
145
|
+
* @param {number} [maxTokens=MAX_APPLY_TOKENS]
|
|
146
|
+
*/
|
|
147
|
+
export function needsBatchedApply(merged, maxTokens = MAX_APPLY_TOKENS) {
|
|
148
|
+
return totalApplyTokens(merged) > maxTokens;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function totalApplyTokens(merged) {
|
|
152
|
+
let n = estimateTokens(merged.memoryMd || '') + estimateTokens(merged.summaryMd || '');
|
|
153
|
+
for (const src of merged.sources || []) n += estimateMessagesTokens(src.diff || []);
|
|
154
|
+
return n;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pack `merged.sources` into ordered batches such that each batch's
|
|
159
|
+
* (memoryMd + summaryMd + that batch's sources) ≤ maxTokens. The first
|
|
160
|
+
* batch uses the original memoryMd; subsequent batches assume the LLM's
|
|
161
|
+
* previous-batch output replaces memoryMd, so we account for the same
|
|
162
|
+
* baseline cost in each batch.
|
|
163
|
+
*
|
|
164
|
+
* If a single source (one group's diff) alone would overflow, it still
|
|
165
|
+
* goes into its own batch — we never split a source diff here (segment
|
|
166
|
+
* happens earlier, in triage).
|
|
167
|
+
*
|
|
168
|
+
* @param {{ memoryMd?: string, summaryMd?: string, sources: Array<{ groupId: string, diff: any }> }} merged
|
|
169
|
+
* @param {number} [maxTokens=MAX_APPLY_TOKENS]
|
|
170
|
+
* @returns {Array<{ groupId: string, diff: any }[]>}
|
|
171
|
+
*/
|
|
172
|
+
export function batchSourcesForApply(merged, maxTokens = MAX_APPLY_TOKENS) {
|
|
173
|
+
const sources = Array.isArray(merged.sources) ? merged.sources : [];
|
|
174
|
+
if (sources.length === 0) return [];
|
|
175
|
+
const baseline = estimateTokens(merged.memoryMd || '') + estimateTokens(merged.summaryMd || '');
|
|
176
|
+
const batches = [];
|
|
177
|
+
let cur = [];
|
|
178
|
+
let used = baseline;
|
|
179
|
+
for (const src of sources) {
|
|
180
|
+
const cost = estimateMessagesTokens(src.diff || []);
|
|
181
|
+
if (cur.length > 0 && used + cost > maxTokens) {
|
|
182
|
+
batches.push(cur);
|
|
183
|
+
cur = [];
|
|
184
|
+
used = baseline;
|
|
185
|
+
}
|
|
186
|
+
cur.push(src);
|
|
187
|
+
used += cost;
|
|
188
|
+
}
|
|
189
|
+
if (cur.length > 0) batches.push(cur);
|
|
190
|
+
return batches;
|
|
191
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/snapshot.js — DESIGN-v2 §16.3 + §10.
|
|
3
|
+
*
|
|
4
|
+
* Pre-Apply backup of memory.md + summary.md to
|
|
5
|
+
* `~/.yeaft/memory/.dream-bak/<ts>/<scope-path>/`. The runner takes a
|
|
6
|
+
* snapshot once per merged target before Apply mutates it; that snapshot
|
|
7
|
+
* is the unit of rollback in case of LLM error or write failure.
|
|
8
|
+
*
|
|
9
|
+
* `pruneOldSnapshots()` keeps the most recent DREAM_BACKUP_KEEP
|
|
10
|
+
* timestamp directories under `.dream-bak/` and rm-rf's the rest.
|
|
11
|
+
*
|
|
12
|
+
* Pure I/O. No LLM. No control-flow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { promises as fsp, existsSync } from 'fs';
|
|
16
|
+
import { join, dirname } from 'path';
|
|
17
|
+
|
|
18
|
+
import { DREAM_BACKUP_KEEP } from './limits.js';
|
|
19
|
+
|
|
20
|
+
/** Folder name where snapshots live, relative to memory root. */
|
|
21
|
+
export const BACKUP_DIRNAME = '.dream-bak';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a stable filesystem-safe ISO timestamp string. Same shape that
|
|
25
|
+
* the migration script uses (`migrate-r6-to-v2.js`).
|
|
26
|
+
*/
|
|
27
|
+
export function tsForBackup(d = new Date()) {
|
|
28
|
+
return d.toISOString().replace(/[:.]/g, '-');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Snapshot a single scope's memory.md + summary.md into
|
|
33
|
+
* `<root>/.dream-bak/<ts>/<scopeRelDir>/`. Missing source files are
|
|
34
|
+
* skipped silently; the destination dir is always created so that an
|
|
35
|
+
* absent snapshot is still distinguishable from "didn't run".
|
|
36
|
+
*
|
|
37
|
+
* @param {string} root — memory root
|
|
38
|
+
* @param {string} ts — timestamp folder name (re-use across all
|
|
39
|
+
* scopes in one dream pass)
|
|
40
|
+
* @param {string} scopeRelDir — e.g. 'user', 'group/g-eng', 'topic/sci/phys'
|
|
41
|
+
* @returns {Promise<{ backupDir: string, copied: string[] }>}
|
|
42
|
+
*/
|
|
43
|
+
export async function snapshotScope(root, ts, scopeRelDir) {
|
|
44
|
+
const srcDir = join(root, scopeRelDir);
|
|
45
|
+
const dstDir = join(root, BACKUP_DIRNAME, ts, scopeRelDir);
|
|
46
|
+
await fsp.mkdir(dstDir, { recursive: true });
|
|
47
|
+
const copied = [];
|
|
48
|
+
for (const name of ['memory.md', 'summary.md']) {
|
|
49
|
+
const s = join(srcDir, name);
|
|
50
|
+
if (!existsSync(s)) continue;
|
|
51
|
+
const d = join(dstDir, name);
|
|
52
|
+
await fsp.copyFile(s, d);
|
|
53
|
+
copied.push(name);
|
|
54
|
+
}
|
|
55
|
+
return { backupDir: dstDir, copied };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keep the `keep` newest snapshot timestamps, rm-rf the rest.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} root
|
|
62
|
+
* @param {number} [keep=DREAM_BACKUP_KEEP]
|
|
63
|
+
* @returns {Promise<{ kept: string[], removed: string[] }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function pruneOldSnapshots(root, keep = DREAM_BACKUP_KEEP) {
|
|
66
|
+
const baseDir = join(root, BACKUP_DIRNAME);
|
|
67
|
+
if (!existsSync(baseDir)) return { kept: [], removed: [] };
|
|
68
|
+
let entries;
|
|
69
|
+
try { entries = await fsp.readdir(baseDir, { withFileTypes: true }); }
|
|
70
|
+
catch (err) { if (err && err.code === 'ENOENT') return { kept: [], removed: [] }; throw err; }
|
|
71
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
72
|
+
// sort() of ISO-with-dashes timestamps is chronological.
|
|
73
|
+
const cutoff = Math.max(0, dirs.length - keep);
|
|
74
|
+
const removed = dirs.slice(0, cutoff);
|
|
75
|
+
const kept = dirs.slice(cutoff);
|
|
76
|
+
for (const name of removed) {
|
|
77
|
+
await fsp.rm(join(baseDir, name), { recursive: true, force: true }).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
return { kept, removed };
|
|
80
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/state.js — DESIGN-v2 §11.
|
|
3
|
+
*
|
|
4
|
+
* Two pieces of state, tracked separately:
|
|
5
|
+
*
|
|
6
|
+
* 1. Per-group control state (used to decide whether a group enters
|
|
7
|
+
* triage and how far to advance the cursor):
|
|
8
|
+
*
|
|
9
|
+
* ~/.yeaft/memory/group/<id>/.dream-state
|
|
10
|
+
*
|
|
11
|
+
* A 3-line text file:
|
|
12
|
+
*
|
|
13
|
+
* lastDreamMessageId: m-1024
|
|
14
|
+
* lastDreamAt: 2026-04-28T03:07:00Z
|
|
15
|
+
* messageCount: 491
|
|
16
|
+
*
|
|
17
|
+
* Fields are independent of each other; missing fields default to
|
|
18
|
+
* empty / null / 0. The file is rewritten atomically every dream.
|
|
19
|
+
*
|
|
20
|
+
* The virtual `_no-group/` group lives at the same path layout
|
|
21
|
+
* (`group/_no-group/.dream-state`) and uses the same accessor.
|
|
22
|
+
*
|
|
23
|
+
* 2. Per-scope observability marker, embedded inside the scope's
|
|
24
|
+
* `memory.md` between two HTML comments at the file's tail:
|
|
25
|
+
*
|
|
26
|
+
* <!-- dream-state -->
|
|
27
|
+
* lastDreamAt: 2026-04-28T03:07:00Z
|
|
28
|
+
* <!-- /dream-state -->
|
|
29
|
+
*
|
|
30
|
+
* Read for the debug panel only; it does NOT participate in any
|
|
31
|
+
* control-flow decision. We update it by replacing the existing
|
|
32
|
+
* block (if any) or appending a new one to the end of the file.
|
|
33
|
+
*
|
|
34
|
+
* Both helpers are pure I/O; no LLM, no logic beyond parsing.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { promises as fsp, existsSync } from 'fs';
|
|
38
|
+
import { join, dirname } from 'path';
|
|
39
|
+
|
|
40
|
+
const STATE_FILE = '.dream-state';
|
|
41
|
+
const DREAM_BLOCK_OPEN = '<!-- dream-state -->';
|
|
42
|
+
const DREAM_BLOCK_CLOSE = '<!-- /dream-state -->';
|
|
43
|
+
|
|
44
|
+
// ─── per-group ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read a group's .dream-state. Missing file → defaults.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} root — memory root, e.g. ~/.yeaft/memory
|
|
50
|
+
* @param {string} groupId
|
|
51
|
+
* @returns {Promise<{ lastDreamMessageId: string|null, lastDreamAt: string|null, messageCount: number }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function readGroupState(root, groupId) {
|
|
54
|
+
const abs = join(root, 'group', groupId, STATE_FILE);
|
|
55
|
+
const empty = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
|
|
56
|
+
let raw;
|
|
57
|
+
try { raw = await fsp.readFile(abs, 'utf8'); }
|
|
58
|
+
catch (err) { if (err && err.code === 'ENOENT') return empty; throw err; }
|
|
59
|
+
return parseGroupState(raw);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Atomically rewrite a group's .dream-state. Creates the group dir if
|
|
64
|
+
* absent. Unknown fields are ignored.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} root
|
|
67
|
+
* @param {string} groupId
|
|
68
|
+
* @param {{ lastDreamMessageId?: string|null, lastDreamAt?: string|null, messageCount?: number }} state
|
|
69
|
+
*/
|
|
70
|
+
export async function writeGroupState(root, groupId, state) {
|
|
71
|
+
const dir = join(root, 'group', groupId);
|
|
72
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
73
|
+
const abs = join(dir, STATE_FILE);
|
|
74
|
+
const body =
|
|
75
|
+
`lastDreamMessageId: ${state.lastDreamMessageId == null ? '' : state.lastDreamMessageId}\n` +
|
|
76
|
+
`lastDreamAt: ${state.lastDreamAt == null ? '' : state.lastDreamAt}\n` +
|
|
77
|
+
`messageCount: ${Number.isFinite(state.messageCount) ? state.messageCount : 0}\n`;
|
|
78
|
+
await atomicWrite(abs, body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse the 3-line key:value format. Tolerant of stray whitespace and
|
|
83
|
+
* empty values.
|
|
84
|
+
* @param {string} raw
|
|
85
|
+
*/
|
|
86
|
+
function parseGroupState(raw) {
|
|
87
|
+
const out = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
|
|
88
|
+
const lines = String(raw || '').split(/\r?\n/);
|
|
89
|
+
for (const ln of lines) {
|
|
90
|
+
const m = /^(\w[\w-]*)\s*:\s*(.*)$/.exec(ln);
|
|
91
|
+
if (!m) continue;
|
|
92
|
+
const k = m[1];
|
|
93
|
+
const v = m[2].trim();
|
|
94
|
+
if (k === 'lastDreamMessageId') out.lastDreamMessageId = v || null;
|
|
95
|
+
else if (k === 'lastDreamAt') out.lastDreamAt = v || null;
|
|
96
|
+
else if (k === 'messageCount') {
|
|
97
|
+
const n = Number(v);
|
|
98
|
+
out.messageCount = Number.isFinite(n) ? n : 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── per-scope marker (memory.md tail block) ───────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the lastDreamAt timestamp from a scope's memory.md, or null if
|
|
108
|
+
* the file or the dream-state block is absent.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} memoryMdAbsPath
|
|
111
|
+
* @returns {Promise<string|null>}
|
|
112
|
+
*/
|
|
113
|
+
export async function readScopeDreamMarker(memoryMdAbsPath) {
|
|
114
|
+
let raw;
|
|
115
|
+
try { raw = await fsp.readFile(memoryMdAbsPath, 'utf8'); }
|
|
116
|
+
catch (err) { if (err && err.code === 'ENOENT') return null; throw err; }
|
|
117
|
+
const block = extractDreamBlock(raw);
|
|
118
|
+
if (!block) return null;
|
|
119
|
+
const m = /^lastDreamAt:\s*(.*)$/m.exec(block);
|
|
120
|
+
return m ? (m[1].trim() || null) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Replace or append the per-scope dream-state block in memory.md.
|
|
125
|
+
* Returns the new file body (caller decides how to persist).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} memoryMd — current full file content
|
|
128
|
+
* @param {{ lastDreamAt: string }} fields
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function withDreamMarker(memoryMd, fields) {
|
|
132
|
+
const block = renderDreamBlock(fields);
|
|
133
|
+
const body = String(memoryMd || '');
|
|
134
|
+
if (body.includes(DREAM_BLOCK_OPEN) && body.includes(DREAM_BLOCK_CLOSE)) {
|
|
135
|
+
// Replace existing block.
|
|
136
|
+
return body.replace(
|
|
137
|
+
new RegExp(`${escapeRe(DREAM_BLOCK_OPEN)}[\\s\\S]*?${escapeRe(DREAM_BLOCK_CLOSE)}`),
|
|
138
|
+
block,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
// Append. Ensure exactly one newline before the block.
|
|
142
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
143
|
+
const sep = trimmed.length === 0 ? '' : '\n\n';
|
|
144
|
+
return `${trimmed}${sep}${block}\n`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extract the contents of the dream-state block (between the two HTML
|
|
149
|
+
* comments). Returns null if the block isn't present.
|
|
150
|
+
* @param {string} body
|
|
151
|
+
*/
|
|
152
|
+
function extractDreamBlock(body) {
|
|
153
|
+
const re = new RegExp(`${escapeRe(DREAM_BLOCK_OPEN)}([\\s\\S]*?)${escapeRe(DREAM_BLOCK_CLOSE)}`);
|
|
154
|
+
const m = re.exec(String(body || ''));
|
|
155
|
+
return m ? m[1].trim() : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderDreamBlock(fields) {
|
|
159
|
+
const lines = [DREAM_BLOCK_OPEN];
|
|
160
|
+
if (fields.lastDreamAt) lines.push(`lastDreamAt: ${fields.lastDreamAt}`);
|
|
161
|
+
lines.push(DREAM_BLOCK_CLOSE);
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
166
|
+
|
|
167
|
+
// ─── shared atomic writer ─────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
async function atomicWrite(absPath, content) {
|
|
170
|
+
await fsp.mkdir(dirname(absPath), { recursive: true });
|
|
171
|
+
const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
172
|
+
await fsp.writeFile(tmp, content, 'utf8');
|
|
173
|
+
await fsp.rename(tmp, absPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// re-exported for tests
|
|
177
|
+
export const _internals = { parseGroupState, extractDreamBlock };
|