@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.
@@ -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 };