@yeaft/webchat-agent 0.1.645 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.645",
3
+ "version": "0.1.646",
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,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,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
+ }