@yeaft/webchat-agent 0.1.704 → 0.1.706
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/archive/tool-results.js +9 -1
- package/unify/engine.js +133 -3
- package/unify/groups/group-crud.js +63 -2
- package/unify/groups/seed-default.js +50 -2
- package/unify/history-compact.js +112 -11
- package/unify/memory/seed-backfill.js +177 -0
- package/unify/memory/store-v2.js +75 -0
- package/unify/models.js +37 -0
- package/unify/session.js +17 -1
- package/unify/tools/registry.js +78 -1
- package/unify/tools/types.js +3 -0
- package/unify/vp/vp-crud.js +73 -1
- package/unify/web-bridge.js +20 -6
package/package.json
CHANGED
|
@@ -151,7 +151,15 @@ export async function archiveToolResults({
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Format a byte count as a short human-readable string ("1.5KB", "2.0MB").
|
|
156
|
+
* Exported so other size-aware helpers (tool-result truncation in the
|
|
157
|
+
* registry) format identically.
|
|
158
|
+
*
|
|
159
|
+
* @param {number} bytes
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function formatSize(bytes) {
|
|
155
163
|
if (bytes < 1024) return `${bytes}B`;
|
|
156
164
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
157
165
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
package/unify/engine.js
CHANGED
|
@@ -34,9 +34,11 @@ import { runStopHooks } from './stop-hooks.js';
|
|
|
34
34
|
// the constant 'main'.
|
|
35
35
|
const MAIN_THREAD_ID = 'main';
|
|
36
36
|
import { pickEffort, parseEffortPrefix } from './effort.js';
|
|
37
|
-
import { normalizeEffort } from './models.js';
|
|
37
|
+
import { normalizeEffort, resolveContextWindow } from './models.js';
|
|
38
38
|
import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
|
|
39
39
|
import { resolveThinking } from './router/thinking.js';
|
|
40
|
+
import { approxTokens } from './memory/budget.js';
|
|
41
|
+
import { truncateToolResultIfNeeded } from './tools/registry.js';
|
|
40
42
|
import {
|
|
41
43
|
TOOL_BATCH_SIZE,
|
|
42
44
|
TURN_SUMMARY_THRESHOLD,
|
|
@@ -100,6 +102,64 @@ export function mapDebugMessage(m) {
|
|
|
100
102
|
return out;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* task-704b — estimate the total token cost of a system prompt + a
|
|
107
|
+
* messages array. Used by the pre-flight guard before adapter.stream()
|
|
108
|
+
* to decide whether to run an emergency archive sweep.
|
|
109
|
+
*
|
|
110
|
+
* Why estimate, not exact: a real tokenizer (tiktoken, claude-tokenizer)
|
|
111
|
+
* adds a heavy dep + per-turn cost for what is fundamentally a guard
|
|
112
|
+
* rail. `approxTokens` (char/4 with CJK weighting) is the same
|
|
113
|
+
* estimator the AMS budget code uses; it is monotonic in payload size
|
|
114
|
+
* and that is the only property the guard rail needs. False positives
|
|
115
|
+
* cost an unnecessary archive sweep (cheap); false negatives let a
|
|
116
|
+
* runaway request through (expensive — that is exactly the bug we are
|
|
117
|
+
* fixing).
|
|
118
|
+
*
|
|
119
|
+
* Multi-modal messages: `content` may be an array of content parts
|
|
120
|
+
* (Anthropic / OpenAI Responses shape). Text parts use approxTokens;
|
|
121
|
+
* image parts get a fixed 1024-token estimate — a rough average across
|
|
122
|
+
* vision pricing models. Exact pricing isn't the goal; "this is roughly
|
|
123
|
+
* how much of the window the message will consume" is.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} system
|
|
126
|
+
* @param {Array<{role:string, content?:any, toolCalls?:Array}>} messages
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
export function estimateMessagesTokens(system, messages) {
|
|
130
|
+
let total = approxTokens(typeof system === 'string' ? system : '');
|
|
131
|
+
if (!Array.isArray(messages)) return total;
|
|
132
|
+
for (const m of messages) {
|
|
133
|
+
if (!m) continue;
|
|
134
|
+
const c = m.content;
|
|
135
|
+
if (typeof c === 'string') {
|
|
136
|
+
total += approxTokens(c);
|
|
137
|
+
} else if (Array.isArray(c)) {
|
|
138
|
+
for (const part of c) {
|
|
139
|
+
if (!part) continue;
|
|
140
|
+
if (part.type === 'text') {
|
|
141
|
+
// Coerce defensively — a non-string `text` (number, Buffer,
|
|
142
|
+
// object) would otherwise throw inside approxTokens and abort
|
|
143
|
+
// the pre-flight estimate, defeating the guard rail.
|
|
144
|
+
total += approxTokens(typeof part.text === 'string' ? part.text : '');
|
|
145
|
+
} else if (part.type === 'image') {
|
|
146
|
+
total += 1024;
|
|
147
|
+
}
|
|
148
|
+
// Other multi-modal parts (audio etc.) — skip; not produced today.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(m.toolCalls)) {
|
|
152
|
+
for (const tc of m.toolCalls) {
|
|
153
|
+
try {
|
|
154
|
+
total += approxTokens(JSON.stringify(tc.input || {}));
|
|
155
|
+
} catch { /* circular — ignore */ }
|
|
156
|
+
total += approxTokens(typeof tc.name === 'string' ? tc.name : '');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return total;
|
|
161
|
+
}
|
|
162
|
+
|
|
103
163
|
// ─── Engine Events (superset of adapter events) ──────────────────
|
|
104
164
|
|
|
105
165
|
/**
|
|
@@ -603,6 +663,11 @@ export class Engine {
|
|
|
603
663
|
conversationStore: this.#conversationStore,
|
|
604
664
|
adapter: this.#adapter,
|
|
605
665
|
config: this.#config,
|
|
666
|
+
// task-704b: per-tool-result hard cap derives from this. Threaded
|
|
667
|
+
// from the live model (resolveModel(currentModel)) every turn so
|
|
668
|
+
// fallbackModel switches see the new window. Falls back to
|
|
669
|
+
// config.maxContextTokens, then 200K, in registry.js.
|
|
670
|
+
contextWindow: vpCtx?.contextWindow,
|
|
606
671
|
// ViewImage (task-333b PR-B rev-3 P1-A): expose size cap + allowlist
|
|
607
672
|
// via tool ctx so hosts can override via ~/.yeaft/config.json without
|
|
608
673
|
// touching the tool impl.
|
|
@@ -1152,6 +1217,17 @@ export class Engine {
|
|
|
1152
1217
|
if (exchange?.rawResponse) rawResponse = exchange.rawResponse;
|
|
1153
1218
|
};
|
|
1154
1219
|
|
|
1220
|
+
// task-704b: resolve the live model's context window for this turn.
|
|
1221
|
+
// Used by the per-tool-result cap (passed via toolCtx) and the
|
|
1222
|
+
// pre-flight total-token guard inside the try-block. Hoisted out of
|
|
1223
|
+
// the try so toolCtx (built after the adapter stream) can see it.
|
|
1224
|
+
// Re-resolved every turn because fallbackModel switches change
|
|
1225
|
+
// `currentModel` mid query() — the cap MUST track the model we're
|
|
1226
|
+
// actually about to call. Single resolver in models.js owns the
|
|
1227
|
+
// fallback ladder (registry → config → default) so engine.js and
|
|
1228
|
+
// tools/registry.js can never disagree.
|
|
1229
|
+
const currentContextWindow = resolveContextWindow(currentModel, this.#config);
|
|
1230
|
+
|
|
1155
1231
|
yield { type: 'turn_start', turnNumber };
|
|
1156
1232
|
|
|
1157
1233
|
try {
|
|
@@ -1203,6 +1279,7 @@ export class Engine {
|
|
|
1203
1279
|
// message_trace can fetch it on demand. The stub keeps the
|
|
1204
1280
|
// OpenAI/Anthropic toolCallId pairing intact.
|
|
1205
1281
|
let wireMessages = stripMetaForWire([...conversationMessages]);
|
|
1282
|
+
|
|
1206
1283
|
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
1207
1284
|
try {
|
|
1208
1285
|
const swept = await archiveToolResults({
|
|
@@ -1224,6 +1301,52 @@ export class Engine {
|
|
|
1224
1301
|
} catch { /* best-effort */ }
|
|
1225
1302
|
}
|
|
1226
1303
|
|
|
1304
|
+
// task-704b: pre-flight total-token guard. Even with the per-tool
|
|
1305
|
+
// cap (registry.js: 10% of contextWindow per result), N tool
|
|
1306
|
+
// results plus history can still breach the wire limit before we
|
|
1307
|
+
// ever call adapter.stream(). Estimate the total token cost; if
|
|
1308
|
+
// it exceeds PREFLIGHT_RATIO of the live context window, run an
|
|
1309
|
+
// emergency archive sweep with `turnAgeMin: 0` so even
|
|
1310
|
+
// current-turn-but-not-this-call bulky results get stubbed. The
|
|
1311
|
+
// normal sweep above only stubs results older than 5 user turns
|
|
1312
|
+
// — that's the wrong cadence when the *current* turn already has
|
|
1313
|
+
// 4 large grep results.
|
|
1314
|
+
//
|
|
1315
|
+
// PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
|
|
1316
|
+
// own output tokens + tools metadata + light future history.
|
|
1317
|
+
// The estimator (`estimateMessagesTokens`) is approxTokens
|
|
1318
|
+
// (char/4 with CJK weighting) — good enough for a guard rail; a
|
|
1319
|
+
// real tokenizer would be exact but adds a heavy dep.
|
|
1320
|
+
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
1321
|
+
const PREFLIGHT_RATIO = 0.85;
|
|
1322
|
+
const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
|
|
1323
|
+
const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
|
|
1324
|
+
if (estimate > threshold) {
|
|
1325
|
+
try {
|
|
1326
|
+
const sweep = await archiveToolResults({
|
|
1327
|
+
root: `${this.#yeaftDir}/memory`,
|
|
1328
|
+
scopeDir: 'user',
|
|
1329
|
+
messages: wireMessages,
|
|
1330
|
+
turnAgeMin: 0,
|
|
1331
|
+
lengthMin: this.#config?.archive?.lengthMin ?? 2000,
|
|
1332
|
+
});
|
|
1333
|
+
wireMessages = sweep.nextMessages;
|
|
1334
|
+
if (sweep.archivedCount > 0) {
|
|
1335
|
+
for (let i = 0; i < conversationMessages.length; i += 1) {
|
|
1336
|
+
conversationMessages[i] = wireMessages[i];
|
|
1337
|
+
}
|
|
1338
|
+
this.#trace.log?.('preflight_sweep', {
|
|
1339
|
+
archivedCount: sweep.archivedCount,
|
|
1340
|
+
archivedBytes: sweep.archivedBytes,
|
|
1341
|
+
estimateBefore: estimate,
|
|
1342
|
+
threshold,
|
|
1343
|
+
contextWindow: currentContextWindow,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
} catch { /* best-effort */ }
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1227
1350
|
// Stream from adapter
|
|
1228
1351
|
for await (const event of this.#adapter.stream({
|
|
1229
1352
|
model: currentModel,
|
|
@@ -1555,7 +1678,7 @@ export class Engine {
|
|
|
1555
1678
|
}
|
|
1556
1679
|
|
|
1557
1680
|
// Execute tool calls and feed results back
|
|
1558
|
-
const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona });
|
|
1681
|
+
const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona, contextWindow: currentContextWindow });
|
|
1559
1682
|
|
|
1560
1683
|
// task-325a: track whether we aborted mid tool-loop so we can
|
|
1561
1684
|
// break out of the outer while-loop cleanly once the current
|
|
@@ -1624,7 +1747,14 @@ export class Engine {
|
|
|
1624
1747
|
output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
|
|
1625
1748
|
} else {
|
|
1626
1749
|
const tool = this.#tools.get(tc.name);
|
|
1627
|
-
|
|
1750
|
+
const rawOutput = await tool.execute(tc.input, { signal });
|
|
1751
|
+
// task-704b: legacy #tools branch must apply the same per-tool
|
|
1752
|
+
// cap as ToolRegistry.execute. Otherwise a deployment using
|
|
1753
|
+
// the legacy registration path bypasses the defense entirely.
|
|
1754
|
+
output = truncateToolResultIfNeeded(rawOutput, {
|
|
1755
|
+
contextWindow: currentContextWindow,
|
|
1756
|
+
toolName: tc.name,
|
|
1757
|
+
});
|
|
1628
1758
|
}
|
|
1629
1759
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
|
|
1630
1760
|
} catch (err) {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { existsSync, renameSync, rmSync, readdirSync, statSync } from 'fs';
|
|
36
36
|
import { randomBytes } from 'crypto';
|
|
37
|
+
import { homedir } from 'os';
|
|
37
38
|
import { join } from 'path';
|
|
38
39
|
import {
|
|
39
40
|
openGroup, createGroup, listGroups, loadGroupMeta,
|
|
@@ -42,6 +43,40 @@ import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './ro
|
|
|
42
43
|
import { seedDefaultGroup, DEFAULT_GROUP_ID } from './seed-default.js';
|
|
43
44
|
import { nextGroupId, validateVpId, isReservedVpId } from './ids.js';
|
|
44
45
|
import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
|
|
46
|
+
import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
50
|
+
* See `vp/vp-crud.js` for the same default; production code threads
|
|
51
|
+
* `<yeaftDir>/memory` through to keep test/prod isolation honest.
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the group seed summary body. Uses the group display name + roster
|
|
57
|
+
* so even an empty conversation has SOMETHING for engine.#prepareAms to
|
|
58
|
+
* pull into the Layer-A resident summary on the very first turn.
|
|
59
|
+
*
|
|
60
|
+
* Format is intentionally short: Dream-v2 will rewrite it in full once
|
|
61
|
+
* meaningful diffs accumulate.
|
|
62
|
+
*
|
|
63
|
+
* @param {{name:string, roster?:string[], defaultVpId?:string|null}} spec
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function buildGroupSeedSummary(spec) {
|
|
67
|
+
const name = String(spec?.name || '').trim();
|
|
68
|
+
const roster = Array.isArray(spec?.roster) ? spec.roster : [];
|
|
69
|
+
const lines = [];
|
|
70
|
+
if (name) lines.push(`# ${name}`);
|
|
71
|
+
lines.push('', `Group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
|
|
72
|
+
if (roster.length > 0) {
|
|
73
|
+
lines.push('', `**Members:** ${roster.join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
if (spec?.defaultVpId) {
|
|
76
|
+
lines.push('', `**Default VP:** ${spec.defaultVpId}`);
|
|
77
|
+
}
|
|
78
|
+
return lines.join('\n').trim();
|
|
79
|
+
}
|
|
45
80
|
|
|
46
81
|
export class GroupCrudError extends Error {
|
|
47
82
|
constructor(code, groupId, message) {
|
|
@@ -78,6 +113,7 @@ export function makeGroupId(name) {
|
|
|
78
113
|
*/
|
|
79
114
|
export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
|
|
80
115
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
116
|
+
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
81
117
|
const existing = listGroups(groupsRoot(yeaftDir));
|
|
82
118
|
if (existing.length > 0) {
|
|
83
119
|
return { seeded: false, groupId: existing[0].id };
|
|
@@ -95,6 +131,7 @@ export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
|
|
|
95
131
|
name: options.name || 'Default',
|
|
96
132
|
roster: vps,
|
|
97
133
|
defaultVpId,
|
|
134
|
+
memoryRoot,
|
|
98
135
|
});
|
|
99
136
|
return {
|
|
100
137
|
seeded: created,
|
|
@@ -112,7 +149,8 @@ export function ensureDefaultGroupIfEmpty(yeaftDir, options = {}) {
|
|
|
112
149
|
* @param {{name:string, roster?:string[], defaultVpId?:string|null}} spec
|
|
113
150
|
* @returns {{id:string, name:string, roster:string[], defaultVpId:string|null}}
|
|
114
151
|
*/
|
|
115
|
-
export function createGroupFromSpec(yeaftDir, spec) {
|
|
152
|
+
export function createGroupFromSpec(yeaftDir, spec, options = {}) {
|
|
153
|
+
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
116
154
|
const name = String(spec && spec.name || '').trim();
|
|
117
155
|
if (!name) throw new GroupCrudError('invalid_name', null, 'group name required');
|
|
118
156
|
|
|
@@ -145,6 +183,20 @@ export function createGroupFromSpec(yeaftDir, spec) {
|
|
|
145
183
|
const handle = createGroup(root, { id, name, roster, defaultVpId });
|
|
146
184
|
const meta = handle.getMeta();
|
|
147
185
|
handle.close();
|
|
186
|
+
|
|
187
|
+
// Seed Layer-A resident summary so the first session has memory content
|
|
188
|
+
// even before Dream-v2 has run. No-op if a summary.md already exists.
|
|
189
|
+
// Best-effort: a memory-root permission failure must NOT break group create.
|
|
190
|
+
try {
|
|
191
|
+
seedSummaryIfMissingSync(
|
|
192
|
+
{ kind: 'group', id },
|
|
193
|
+
buildGroupSeedSummary({ name, roster, defaultVpId }),
|
|
194
|
+
{ root: memoryRoot },
|
|
195
|
+
);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.warn(`[group-crud] failed to seed summary.md for ${id}:`, err?.message || err);
|
|
198
|
+
}
|
|
199
|
+
|
|
148
200
|
return meta;
|
|
149
201
|
}
|
|
150
202
|
|
|
@@ -216,7 +268,8 @@ export function archiveGroup(yeaftDir, groupId) {
|
|
|
216
268
|
* behind by the previous soft-archive implementation, so a single
|
|
217
269
|
* delete cleans up legacy state too.
|
|
218
270
|
*/
|
|
219
|
-
export function deleteGroup(yeaftDir, groupId) {
|
|
271
|
+
export function deleteGroup(yeaftDir, groupId, options = {}) {
|
|
272
|
+
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
220
273
|
const root = groupsRoot(yeaftDir);
|
|
221
274
|
const srcDir = join(root, groupId);
|
|
222
275
|
const liveExists = existsSync(srcDir) && !!loadGroupMeta(srcDir);
|
|
@@ -246,6 +299,14 @@ export function deleteGroup(yeaftDir, groupId) {
|
|
|
246
299
|
rmSync(dir, { recursive: true, force: true });
|
|
247
300
|
}
|
|
248
301
|
|
|
302
|
+
// Cascade: drop the group's memory scope so a recreate with the same id
|
|
303
|
+
// starts clean. Best-effort — never let memory cleanup fail the CRUD op.
|
|
304
|
+
try {
|
|
305
|
+
removeScopeDirSync({ kind: 'group', id: groupId }, { root: memoryRoot });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.warn(`[group-crud] failed to remove memory dir for ${groupId}:`, err?.message || err);
|
|
308
|
+
}
|
|
309
|
+
|
|
249
310
|
return { groupId, deleted: true, legacyCleanedUp: legacyDirs.length };
|
|
250
311
|
}
|
|
251
312
|
|
|
@@ -14,16 +14,47 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, mkdirSync } from 'fs';
|
|
16
16
|
import { join } from 'path';
|
|
17
|
+
import { homedir } from 'os';
|
|
17
18
|
import { openGroup, createGroup, loadGroupMeta } from './group-store.js';
|
|
19
|
+
import { seedSummaryIfMissingSync } from '../memory/store-v2.js';
|
|
18
20
|
|
|
19
21
|
export const DEFAULT_GROUP_ID = 'grp_default';
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
25
|
+
* See `groups/group-crud.js` and `vp/vp-crud.js` for the same default;
|
|
26
|
+
* production code threads `<yeaftDir>/memory` through to keep test/prod
|
|
27
|
+
* isolation honest.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the default-group seed summary body. Pulled into a helper so
|
|
33
|
+
* tests can pin the exact format. Mirrors `buildGroupSeedSummary` in
|
|
34
|
+
* `group-crud.js` shape, with the "Default group" wording reserved for
|
|
35
|
+
* the bootstrap path.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ name?: string, roster?: string[], defaultVpId?: string|null }} spec
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function buildDefaultGroupSeedSummary(spec) {
|
|
41
|
+
const name = String(spec?.name || 'Default').trim();
|
|
42
|
+
const roster = Array.isArray(spec?.roster) ? spec.roster : [];
|
|
43
|
+
const defaultVpId = spec?.defaultVpId || null;
|
|
44
|
+
const lines = [`# ${name}`, ''];
|
|
45
|
+
lines.push(`Default group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
|
|
46
|
+
if (roster.length > 0) lines.push('', `**Members:** ${roster.join(', ')}`);
|
|
47
|
+
if (defaultVpId) lines.push('', `**Default VP:** ${defaultVpId}`);
|
|
48
|
+
return lines.join('\n').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
/**
|
|
22
52
|
* @param {string} yeaftDir
|
|
23
|
-
* @param {{ defaultVpId?: string|null, roster?: string[], name?: string }} [spec]
|
|
53
|
+
* @param {{ defaultVpId?: string|null, roster?: string[], name?: string, memoryRoot?: string }} [spec]
|
|
24
54
|
* @returns {{ group: import('./group-store.js').GroupHandle, created: boolean }}
|
|
25
55
|
*/
|
|
26
56
|
export function seedDefaultGroup(yeaftDir, spec = {}) {
|
|
57
|
+
const memoryRoot = spec.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
27
58
|
const groupsRoot = join(yeaftDir, 'groups');
|
|
28
59
|
if (!existsSync(groupsRoot)) mkdirSync(groupsRoot, { recursive: true });
|
|
29
60
|
|
|
@@ -36,12 +67,29 @@ export function seedDefaultGroup(yeaftDir, spec = {}) {
|
|
|
36
67
|
? spec.roster.slice()
|
|
37
68
|
: (spec.defaultVpId ? [spec.defaultVpId] : []);
|
|
38
69
|
const defaultVpId = spec.defaultVpId || roster[0] || null;
|
|
70
|
+
const name = spec.name || 'Default';
|
|
39
71
|
|
|
40
72
|
const group = createGroup(groupsRoot, {
|
|
41
73
|
id: DEFAULT_GROUP_ID,
|
|
42
|
-
name
|
|
74
|
+
name,
|
|
43
75
|
roster,
|
|
44
76
|
defaultVpId,
|
|
45
77
|
});
|
|
78
|
+
|
|
79
|
+
// Seed Layer-A resident summary so the very first session — even on a
|
|
80
|
+
// brand-new install where only `grp_default` exists — renders a non-
|
|
81
|
+
// empty memory section in the system prompt. No-op once Dream-v2 (or
|
|
82
|
+
// createGroupFromSpec) has already written one. Best-effort: a memory-
|
|
83
|
+
// root permission failure must NOT break the bootstrap flow.
|
|
84
|
+
try {
|
|
85
|
+
seedSummaryIfMissingSync(
|
|
86
|
+
{ kind: 'group', id: DEFAULT_GROUP_ID },
|
|
87
|
+
buildDefaultGroupSeedSummary({ name, roster, defaultVpId }),
|
|
88
|
+
{ root: memoryRoot },
|
|
89
|
+
);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.warn(`[seed-default] failed to seed summary.md for ${DEFAULT_GROUP_ID}:`, err?.message || err);
|
|
92
|
+
}
|
|
93
|
+
|
|
46
94
|
return { group, created: true };
|
|
47
95
|
}
|
package/unify/history-compact.js
CHANGED
|
@@ -55,6 +55,7 @@ import { pairSanitize } from './pair-sanitize.js';
|
|
|
55
55
|
import {
|
|
56
56
|
countTurns as countTurnsImpl,
|
|
57
57
|
indexOfNthTurnFromEnd,
|
|
58
|
+
sliceLastNTurns,
|
|
58
59
|
} from './turn-utils.js';
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -65,27 +66,32 @@ import {
|
|
|
65
66
|
export const countTurns = countTurnsImpl;
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
|
-
* Default trigger thresholds (2026-05-
|
|
69
|
-
* - never compact while total tokens <
|
|
69
|
+
* Default trigger thresholds (2026-05-02 policy update):
|
|
70
|
+
* - never compact while total tokens < 12K (soft floor — most short
|
|
70
71
|
* conversations under that aren't worth paying the summarizer
|
|
71
72
|
* cost; the LLM hasn't started feeling the context yet either),
|
|
72
73
|
* - otherwise compact if ANY of:
|
|
74
|
+
* turnCount > 30 (back-stop for chats with many small turns)
|
|
73
75
|
* tokens > 40 % of `maxContextTokens` (default 200K → 80K)
|
|
74
76
|
* tokens > 200K hard ceiling
|
|
75
77
|
*
|
|
76
|
-
*
|
|
77
|
-
* the
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
78
|
+
* Lowered from 30K → 12K and re-enabled a turn-count back-stop because
|
|
79
|
+
* the previous "soft floor of 30K, no turn cap" combination is dead in
|
|
80
|
+
* the multi-VP fan-out path: hundreds of small turns happily stay below
|
|
81
|
+
* 30K and never trigger compact, then `runVpTurn` feeds the whole 720+
|
|
82
|
+
* message snapshot to the LLM and trips the provider's context window.
|
|
83
|
+
* The snapshot trim in `web-bridge.js#trimSnapshotForBudget` is the
|
|
84
|
+
* primary defense; this is the second-line trigger that compresses
|
|
85
|
+
* the on-array form so subsequent turns also stay bounded.
|
|
86
|
+
*
|
|
87
|
+
* `turnLimit` and the `turn_count` reason code are still overridable
|
|
88
|
+
* for tests / future config.
|
|
83
89
|
*
|
|
84
90
|
* Token thresholds are derived from `maxContextTokens` at evaluation
|
|
85
91
|
* time so the policy auto-adjusts to the user's configured context.
|
|
86
92
|
*/
|
|
87
|
-
export const DEFAULT_TURN_LIMIT =
|
|
88
|
-
export const DEFAULT_MIN_TOKEN_FLOOR =
|
|
93
|
+
export const DEFAULT_TURN_LIMIT = 30;
|
|
94
|
+
export const DEFAULT_MIN_TOKEN_FLOOR = 12_000;
|
|
89
95
|
export const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
|
|
90
96
|
export const DEFAULT_TOKEN_FRACTION = 0.4;
|
|
91
97
|
export const DEFAULT_HARD_TOKEN_CEILING = 200_000;
|
|
@@ -106,6 +112,35 @@ export const DEFAULT_TOKEN_LIMIT = Math.min(
|
|
|
106
112
|
*/
|
|
107
113
|
export const DEFAULT_KEEP_RECENT_TURNS = 2;
|
|
108
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Default cap on the number of turns kept in the per-call snapshot fed
|
|
117
|
+
* to `engine.query` (see `trimSnapshotForBudget` below). A turn here is
|
|
118
|
+
* one user-side prompt — multi-VP `@vp-X` variants of the same prompt
|
|
119
|
+
* collapse into one turn (see `turn-utils.js#countTurns`).
|
|
120
|
+
*
|
|
121
|
+
* Sized in conjunction with `DEFAULT_TURN_LIMIT` (30, the compact-trigger
|
|
122
|
+
* back-stop): trim to 25 leaves a 5-turn buffer below the compact trigger
|
|
123
|
+
* so a typical chat sees its history compacted before the trim starts
|
|
124
|
+
* dropping turns silently. That ordering matters — compact preserves the
|
|
125
|
+
* tail's lossless 2 turns AND a summary of everything older, whereas trim
|
|
126
|
+
* just discards anything beyond the cap.
|
|
127
|
+
*
|
|
128
|
+
* 25 turns at ~5 messages each (user + assistant + a couple tool steps)
|
|
129
|
+
* is roughly 100–125 messages — well under the LLM context window for
|
|
130
|
+
* any reasonable model, and large enough to preserve "what we've been
|
|
131
|
+
* talking about" context for the model. The hard token-budget cap inside
|
|
132
|
+
* `trimSnapshotForBudget` tightens this further when individual turns
|
|
133
|
+
* are large.
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_RECENT_TURN_CAP = 25;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Default per-query token budget for the snapshot (separate from the
|
|
139
|
+
* `tokenLimit` used by compact triggers). Mirrors the historical default
|
|
140
|
+
* carried in `~/.yeaft/config.json`'s `messageTokenBudget` field.
|
|
141
|
+
*/
|
|
142
|
+
export const DEFAULT_MESSAGE_TOKEN_BUDGET = 8192;
|
|
143
|
+
|
|
109
144
|
/**
|
|
110
145
|
* Estimate the token weight of a single message including role overhead
|
|
111
146
|
* and any tool-call structure. Mirrors `dream-v2/segment.js` approach: a
|
|
@@ -489,3 +524,69 @@ export async function compactHistory(messages, options) {
|
|
|
489
524
|
afterTokens: after.tokenCount,
|
|
490
525
|
};
|
|
491
526
|
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Trim a snapshot of conversation messages so the per-call array fed
|
|
530
|
+
* to `engine.query` stays bounded.
|
|
531
|
+
*
|
|
532
|
+
* Two-stage policy:
|
|
533
|
+
* 1. **Turn cap** — keep at most `recentTurnCap` turns (default 25)
|
|
534
|
+
* via `sliceLastNTurns`. This always cuts at a user-message
|
|
535
|
+
* boundary and walks forward through `@vp-X` variants of the
|
|
536
|
+
* cut turn so the slice is pair-safe.
|
|
537
|
+
* 2. **Token budget** — if the trimmed slice still exceeds
|
|
538
|
+
* `messageTokenBudget` tokens (default 8192 from
|
|
539
|
+
* `~/.yeaft/config.json`), iteratively drop the oldest turn until
|
|
540
|
+
* we're under budget. We never drop below 1 turn — even a single
|
|
541
|
+
* huge turn is preferable to no context.
|
|
542
|
+
*
|
|
543
|
+
* Then run `pairSanitize` as belt-and-suspenders to drop any orphan
|
|
544
|
+
* tool_use/tool_result that survived the cuts. The transform is
|
|
545
|
+
* idempotent and never mutates the input.
|
|
546
|
+
*
|
|
547
|
+
* Why this exists:
|
|
548
|
+
* `runVpTurn` previously fed the entire `conversationMessages` array
|
|
549
|
+
* into `engine.query` for every fan-out. With multi-VP turns the
|
|
550
|
+
* array grows ~5–8 messages per user prompt, so after a few hundred
|
|
551
|
+
* prompts the per-call payload exceeds 100 KB and routinely OOMs the
|
|
552
|
+
* provider's context window. `compactHistory` only fires above its
|
|
553
|
+
* token soft floor — small chats with many turns stay below that
|
|
554
|
+
* floor but still bloat the messages array. This trim is the second-
|
|
555
|
+
* line defense: it ALWAYS runs, before every query, regardless of
|
|
556
|
+
* compact state.
|
|
557
|
+
*
|
|
558
|
+
* Lives in `history-compact.js` alongside `compactHistory` because
|
|
559
|
+
* both functions are part of the same "bound the messages array fed
|
|
560
|
+
* to the LLM" surface — keeping them together makes the relationship
|
|
561
|
+
* between trim (per-call) and compact (global) explicit.
|
|
562
|
+
*
|
|
563
|
+
* @param {Array<object>} snapshot
|
|
564
|
+
* @param {{ messageTokenBudget?: number, recentTurnCap?: number }} [opts]
|
|
565
|
+
* @returns {Array<object>}
|
|
566
|
+
*/
|
|
567
|
+
export function trimSnapshotForBudget(snapshot, opts = {}) {
|
|
568
|
+
if (!Array.isArray(snapshot) || snapshot.length === 0) return [];
|
|
569
|
+
|
|
570
|
+
const recentTurnCap = Number.isFinite(opts.recentTurnCap) && opts.recentTurnCap > 0
|
|
571
|
+
? opts.recentTurnCap
|
|
572
|
+
: DEFAULT_RECENT_TURN_CAP;
|
|
573
|
+
const messageTokenBudget = Number.isFinite(opts.messageTokenBudget) && opts.messageTokenBudget > 0
|
|
574
|
+
? opts.messageTokenBudget
|
|
575
|
+
: DEFAULT_MESSAGE_TOKEN_BUDGET;
|
|
576
|
+
|
|
577
|
+
// Stage 1: cap by turn count.
|
|
578
|
+
let trimmed = sliceLastNTurns(snapshot, recentTurnCap);
|
|
579
|
+
|
|
580
|
+
// Stage 2: cap by token budget. Drop oldest turn iteratively.
|
|
581
|
+
// We never drop below ~1 turn — pick a safety floor of 1.
|
|
582
|
+
let remainingTurnCap = recentTurnCap;
|
|
583
|
+
let tokens = estimateMessagesTokens(trimmed);
|
|
584
|
+
while (tokens > messageTokenBudget && remainingTurnCap > 1) {
|
|
585
|
+
remainingTurnCap--;
|
|
586
|
+
trimmed = sliceLastNTurns(trimmed, remainingTurnCap);
|
|
587
|
+
tokens = estimateMessagesTokens(trimmed);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Stage 3: pair-sanitize to drop orphan tool_use/tool_result.
|
|
591
|
+
return pairSanitize(trimmed);
|
|
592
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/seed-backfill.js — Run-once backfill of `summary.md` for VPs and
|
|
3
|
+
* groups that were created BEFORE the create-time seed was added (PR
|
|
4
|
+
* "fix-unify-context-and-memory"). Without backfill, an existing user's
|
|
5
|
+
* `grp_claude` group and `steve` VP never get a Layer-A resident summary —
|
|
6
|
+
* `engine.#prepareAms` then renders an empty memory section every turn,
|
|
7
|
+
* which is the user-visible Bug #2.
|
|
8
|
+
*
|
|
9
|
+
* Idempotency:
|
|
10
|
+
* - Reads `<root>/<scopeDir>/summary.md`. If it already has any non-
|
|
11
|
+
* empty content, the backfill is a no-op for that scope.
|
|
12
|
+
* - Only seeds when the file is missing OR empty.
|
|
13
|
+
*
|
|
14
|
+
* This runs sync at session boot. Failures are logged and swallowed —
|
|
15
|
+
* a permission error must NEVER prevent the session from loading.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { homedir } from 'os';
|
|
21
|
+
import { parseRoleMd } from '../vp/vp-store.js';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
24
|
+
|
|
25
|
+
function readIfPresent(path) {
|
|
26
|
+
try {
|
|
27
|
+
if (!existsSync(path)) return '';
|
|
28
|
+
return readFileSync(path, 'utf-8').trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeAtomicSync(path, body) {
|
|
35
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
36
|
+
writeFileSync(path, (body || '').trim() + '\n', 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a synthetic VP summary from the on-disk role.md.
|
|
41
|
+
*
|
|
42
|
+
* Delegates frontmatter parsing to `vp-store.js#parseRoleMd` so the
|
|
43
|
+
* backfill stays in sync with the production loader. The earlier hand-
|
|
44
|
+
* rolled regex parser silently dropped quoted multi-line scalars and
|
|
45
|
+
* list-shaped fields — `parseRoleMd` covers both.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} libDir
|
|
48
|
+
* @param {string} vpId
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
function readVpRoleSummary(libDir, vpId) {
|
|
52
|
+
const rolePath = join(libDir, vpId, 'role.md');
|
|
53
|
+
if (!existsSync(rolePath)) return null;
|
|
54
|
+
let raw = '';
|
|
55
|
+
try { raw = readFileSync(rolePath, 'utf-8'); } catch { return null; }
|
|
56
|
+
|
|
57
|
+
const { meta, body } = parseRoleMd(raw);
|
|
58
|
+
const name = String(meta.name || vpId).trim() || vpId;
|
|
59
|
+
const role = typeof meta.role === 'string' ? meta.role.trim() : '';
|
|
60
|
+
|
|
61
|
+
const persona = typeof body === 'string' ? body.trim() : '';
|
|
62
|
+
const lines = [`# ${name}`];
|
|
63
|
+
if (role) lines.push('', `**Role:** ${role}`);
|
|
64
|
+
if (persona) {
|
|
65
|
+
const truncated = persona.length > 800 ? persona.slice(0, 800).trim() + '…' : persona;
|
|
66
|
+
lines.push('', '**Persona:**', '', truncated);
|
|
67
|
+
}
|
|
68
|
+
return lines.join('\n').trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Walk the VP library and seed `summary.md` for every VP without one.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ libDir: string, root?: string }} opts
|
|
75
|
+
* @returns {{seeded: number, scanned: number}}
|
|
76
|
+
*/
|
|
77
|
+
export function backfillVpSummaries({ libDir, root = DEFAULT_MEMORY_ROOT }) {
|
|
78
|
+
let scanned = 0;
|
|
79
|
+
let seeded = 0;
|
|
80
|
+
if (!existsSync(libDir)) return { scanned, seeded };
|
|
81
|
+
let entries;
|
|
82
|
+
try { entries = readdirSync(libDir); } catch { return { scanned, seeded }; }
|
|
83
|
+
for (const name of entries) {
|
|
84
|
+
const vpDir = join(libDir, name);
|
|
85
|
+
let isDir = false;
|
|
86
|
+
try { isDir = statSync(vpDir).isDirectory(); } catch { /* skip */ }
|
|
87
|
+
if (!isDir) continue;
|
|
88
|
+
if (name.startsWith('.')) continue;
|
|
89
|
+
scanned++;
|
|
90
|
+
const summaryPath = join(root, 'vp', name, 'summary.md');
|
|
91
|
+
if (readIfPresent(summaryPath)) continue;
|
|
92
|
+
const body = readVpRoleSummary(libDir, name);
|
|
93
|
+
if (!body) continue;
|
|
94
|
+
try {
|
|
95
|
+
writeAtomicSync(summaryPath, body);
|
|
96
|
+
seeded++;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.warn(`[seed-backfill] vp ${name}: ${err?.message || err}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { scanned, seeded };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a synthetic group summary from group.json on disk.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} groupDir
|
|
108
|
+
* @returns {string|null}
|
|
109
|
+
*/
|
|
110
|
+
function readGroupSummaryBody(groupDir) {
|
|
111
|
+
const metaPath = join(groupDir, 'group.json');
|
|
112
|
+
if (!existsSync(metaPath)) return null;
|
|
113
|
+
let meta;
|
|
114
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf-8')); } catch { return null; }
|
|
115
|
+
const name = (meta?.name || '').trim();
|
|
116
|
+
const roster = Array.isArray(meta?.roster) ? meta.roster : [];
|
|
117
|
+
const defaultVpId = meta?.defaultVpId || null;
|
|
118
|
+
const lines = [];
|
|
119
|
+
if (name) lines.push(`# ${name}`);
|
|
120
|
+
lines.push('', `Group with ${roster.length} member${roster.length === 1 ? '' : 's'}.`);
|
|
121
|
+
if (roster.length > 0) lines.push('', `**Members:** ${roster.join(', ')}`);
|
|
122
|
+
if (defaultVpId) lines.push('', `**Default VP:** ${defaultVpId}`);
|
|
123
|
+
return lines.join('\n').trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walk groups/ and seed `summary.md` for every group without one.
|
|
128
|
+
*
|
|
129
|
+
* @param {{ yeaftDir: string, root?: string }} opts
|
|
130
|
+
* @returns {{seeded: number, scanned: number}}
|
|
131
|
+
*/
|
|
132
|
+
export function backfillGroupSummaries({ yeaftDir, root = DEFAULT_MEMORY_ROOT }) {
|
|
133
|
+
let scanned = 0;
|
|
134
|
+
let seeded = 0;
|
|
135
|
+
const groupsRoot = join(yeaftDir, 'groups');
|
|
136
|
+
if (!existsSync(groupsRoot)) return { scanned, seeded };
|
|
137
|
+
let entries;
|
|
138
|
+
try { entries = readdirSync(groupsRoot); } catch { return { scanned, seeded }; }
|
|
139
|
+
for (const name of entries) {
|
|
140
|
+
if (name.startsWith('.')) continue;
|
|
141
|
+
const groupDir = join(groupsRoot, name);
|
|
142
|
+
let isDir = false;
|
|
143
|
+
try { isDir = statSync(groupDir).isDirectory(); } catch { /* skip */ }
|
|
144
|
+
if (!isDir) continue;
|
|
145
|
+
scanned++;
|
|
146
|
+
const summaryPath = join(root, 'group', name, 'summary.md');
|
|
147
|
+
if (readIfPresent(summaryPath)) continue;
|
|
148
|
+
const body = readGroupSummaryBody(groupDir);
|
|
149
|
+
if (!body) continue;
|
|
150
|
+
try {
|
|
151
|
+
writeAtomicSync(summaryPath, body);
|
|
152
|
+
seeded++;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.warn(`[seed-backfill] group ${name}: ${err?.message || err}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { scanned, seeded };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run all backfills sequentially. Best-effort — any per-step error is
|
|
162
|
+
* logged and the next step still runs.
|
|
163
|
+
*
|
|
164
|
+
* @param {{ yeaftDir: string, libDir: string, root?: string }} opts
|
|
165
|
+
* @returns {{ vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
|
|
166
|
+
*/
|
|
167
|
+
export function runSummaryBackfill({ yeaftDir, libDir, root = DEFAULT_MEMORY_ROOT }) {
|
|
168
|
+
let vp = { scanned: 0, seeded: 0 };
|
|
169
|
+
let group = { scanned: 0, seeded: 0 };
|
|
170
|
+
try { vp = backfillVpSummaries({ libDir, root }); } catch (err) {
|
|
171
|
+
console.warn('[seed-backfill] vp pass failed:', err?.message || err);
|
|
172
|
+
}
|
|
173
|
+
try { group = backfillGroupSummaries({ yeaftDir, root }); } catch (err) {
|
|
174
|
+
console.warn('[seed-backfill] group pass failed:', err?.message || err);
|
|
175
|
+
}
|
|
176
|
+
return { vp, group };
|
|
177
|
+
}
|
package/unify/memory/store-v2.js
CHANGED
|
@@ -44,6 +44,9 @@ import {
|
|
|
44
44
|
promises as fsp,
|
|
45
45
|
existsSync,
|
|
46
46
|
mkdirSync,
|
|
47
|
+
readFileSync,
|
|
48
|
+
writeFileSync,
|
|
49
|
+
rmSync,
|
|
47
50
|
} from 'fs';
|
|
48
51
|
import { join, dirname } from 'path';
|
|
49
52
|
import { homedir } from 'os';
|
|
@@ -285,6 +288,78 @@ export async function writeSummary(scope, body, opts = {}) {
|
|
|
285
288
|
await atomicWrite(abs, `${(body || '').trim()}\n`);
|
|
286
289
|
}
|
|
287
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Seed a scope's summary.md if (and only if) it is missing or empty. Used
|
|
293
|
+
* at create-time for VPs and groups so a fresh session has SOMETHING for
|
|
294
|
+
* `engine.#prepareAms` to pull into the Layer-A resident summary — the
|
|
295
|
+
* earlier behavior of "no summary.md until Dream-v2 runs" left the memory
|
|
296
|
+
* section empty for the entire first session.
|
|
297
|
+
*
|
|
298
|
+
* Intentionally a no-op if a non-empty summary.md already exists, so this
|
|
299
|
+
* is safe to call from any place that creates the scope (VP create, group
|
|
300
|
+
* create, first-session bootstrap) without clobbering Dream-v2's writes.
|
|
301
|
+
*
|
|
302
|
+
* @param {Scope} scope
|
|
303
|
+
* @param {string} body
|
|
304
|
+
* @param {{ root?: string, currentVpId?: string }} [opts]
|
|
305
|
+
* @returns {Promise<boolean>} true if seeded, false if a non-empty summary already existed
|
|
306
|
+
*/
|
|
307
|
+
export async function seedSummaryIfMissing(scope, body, opts = {}) {
|
|
308
|
+
const existing = await readSummary(scope, opts);
|
|
309
|
+
if (existing && existing.trim().length > 0) return false;
|
|
310
|
+
await writeSummary(scope, body, opts);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sync variant of `seedSummaryIfMissing` for synchronous CRUD entry points
|
|
316
|
+
* (vp-crud.js / group-crud.js / seed-default.js). Same idempotency contract:
|
|
317
|
+
* a non-empty existing `summary.md` blocks the seed; missing or empty
|
|
318
|
+
* triggers an atomic write. Failures are converted into thrown errors so
|
|
319
|
+
* the caller can decide whether to swallow (best-effort seed) or surface.
|
|
320
|
+
*
|
|
321
|
+
* NOTE on `opts.root`: callers MUST pass the configured memory root
|
|
322
|
+
* (typically `<yeaftDir>/memory`) so a non-default `yeaftDir` doesn't end
|
|
323
|
+
* up writing under `~/.yeaft/memory/`. The default is provided only for
|
|
324
|
+
* top-of-tree convenience; production code paths thread the root through.
|
|
325
|
+
*
|
|
326
|
+
* @param {Scope} scope
|
|
327
|
+
* @param {string} body
|
|
328
|
+
* @param {{ root?: string }} [opts]
|
|
329
|
+
* @returns {boolean} true if seeded, false if a non-empty summary already existed
|
|
330
|
+
*/
|
|
331
|
+
export function seedSummaryIfMissingSync(scope, body, opts = {}) {
|
|
332
|
+
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
333
|
+
const rel = `${scopeDir(scope)}/summary.md`;
|
|
334
|
+
const abs = join(root, rel);
|
|
335
|
+
let existing = '';
|
|
336
|
+
if (existsSync(abs)) {
|
|
337
|
+
try { existing = readFileSync(abs, 'utf8').trim(); }
|
|
338
|
+
catch { /* read race — fall through to seed */ }
|
|
339
|
+
}
|
|
340
|
+
if (existing) return false;
|
|
341
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
342
|
+
writeFileSync(abs, `${(body || '').trim()}\n`, 'utf8');
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Synchronously remove a scope's directory under the memory root. Used by
|
|
348
|
+
* `deleteVp` / `deleteGroup` to cascade memory cleanup so a recreate of the
|
|
349
|
+
* same id doesn't see stale `summary.md` / `memory.md` / `segments/` files.
|
|
350
|
+
*
|
|
351
|
+
* Idempotent — missing directory is a no-op.
|
|
352
|
+
*
|
|
353
|
+
* @param {Scope} scope
|
|
354
|
+
* @param {{ root?: string }} [opts]
|
|
355
|
+
*/
|
|
356
|
+
export function removeScopeDirSync(scope, opts = {}) {
|
|
357
|
+
const { root = DEFAULT_MEMORY_ROOT } = opts;
|
|
358
|
+
const abs = join(root, scopeDir(scope));
|
|
359
|
+
if (!existsSync(abs)) return;
|
|
360
|
+
rmSync(abs, { recursive: true, force: true });
|
|
361
|
+
}
|
|
362
|
+
|
|
288
363
|
// ─── scope discovery ───────────────────────────────────────────
|
|
289
364
|
|
|
290
365
|
/**
|
package/unify/models.js
CHANGED
|
@@ -213,6 +213,43 @@ export function resolveModel(modelName) {
|
|
|
213
213
|
return info ? { ...info } : null;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Default context window when neither the model registry nor the engine
|
|
218
|
+
* config has a value. 200K is a conservative middle-ground — most modern
|
|
219
|
+
* production models (Claude, GPT-5, Gemini) have ≥ 128K.
|
|
220
|
+
*
|
|
221
|
+
* Single source of truth: callers (engine.js pre-flight guard,
|
|
222
|
+
* tools/registry.js per-result cap) MUST use this constant or
|
|
223
|
+
* {@link resolveContextWindow} instead of hardcoding 200_000.
|
|
224
|
+
*/
|
|
225
|
+
export const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve the live context window for a model, with an explicit fallback
|
|
229
|
+
* ladder:
|
|
230
|
+
* 1. MODEL_REGISTRY entry's `contextWindow` (most accurate)
|
|
231
|
+
* 2. caller-supplied config override (e.g. `config.maxContextTokens`)
|
|
232
|
+
* 3. {@link DEFAULT_CONTEXT_WINDOW}
|
|
233
|
+
*
|
|
234
|
+
* Used by the per-tool-result cap and the pre-flight token guard so the
|
|
235
|
+
* defense layers always see the same number, regardless of which seam
|
|
236
|
+
* resolves it first.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} modelName
|
|
239
|
+
* @param {{ maxContextTokens?: number }} [config]
|
|
240
|
+
* @returns {number}
|
|
241
|
+
*/
|
|
242
|
+
export function resolveContextWindow(modelName, config) {
|
|
243
|
+
const info = resolveModel(modelName);
|
|
244
|
+
if (info && Number.isFinite(info.contextWindow) && info.contextWindow > 0) {
|
|
245
|
+
return info.contextWindow;
|
|
246
|
+
}
|
|
247
|
+
const cfg = config && Number.isFinite(config.maxContextTokens) && config.maxContextTokens > 0
|
|
248
|
+
? config.maxContextTokens : null;
|
|
249
|
+
if (cfg !== null) return cfg;
|
|
250
|
+
return DEFAULT_CONTEXT_WINDOW;
|
|
251
|
+
}
|
|
252
|
+
|
|
216
253
|
/**
|
|
217
254
|
* List all known models.
|
|
218
255
|
*
|
package/unify/session.js
CHANGED
|
@@ -41,6 +41,7 @@ import { Engine } from './engine.js';
|
|
|
41
41
|
// AMS each turn and to run `memory/adjust.js` post-turn.
|
|
42
42
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
43
43
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
44
|
+
import { runSummaryBackfill } from './memory/seed-backfill.js';
|
|
44
45
|
import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
|
|
45
46
|
import { openSegmentIndex } from './memory/index-db.js';
|
|
46
47
|
import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
|
|
@@ -236,10 +237,25 @@ export async function loadSession(options = {}) {
|
|
|
236
237
|
console.warn(`[Yeaft] seedDefaultVps failed: ${err?.message || err}`);
|
|
237
238
|
}
|
|
238
239
|
try {
|
|
239
|
-
ensureDefaultGroupIfEmpty(yeaftDir);
|
|
240
|
+
ensureDefaultGroupIfEmpty(yeaftDir, { memoryRoot: join(yeaftDir, 'memory') });
|
|
240
241
|
} catch (err) {
|
|
241
242
|
console.warn(`[Yeaft] ensureDefaultGroupIfEmpty failed: ${err?.message || err}`);
|
|
242
243
|
}
|
|
244
|
+
|
|
245
|
+
// task-fix-memory-load: backfill summary.md for VPs / groups created
|
|
246
|
+
// before the create-time seed was added. Without this, an existing
|
|
247
|
+
// user's `grp_claude` and `steve` VP have an empty Layer-A resident
|
|
248
|
+
// summary every turn (memory section in the system prompt is just
|
|
249
|
+
// the `active_scope` header). Idempotent — only writes when missing.
|
|
250
|
+
try {
|
|
251
|
+
runSummaryBackfill({
|
|
252
|
+
yeaftDir,
|
|
253
|
+
libDir: join(yeaftDir, 'virtual-persons'),
|
|
254
|
+
root: join(yeaftDir, 'memory'),
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.warn(`[Yeaft] runSummaryBackfill failed: ${err?.message || err}`);
|
|
258
|
+
}
|
|
243
259
|
}
|
|
244
260
|
|
|
245
261
|
// ─── 6. Load skills ────────────────────────────────────
|
package/unify/tools/registry.js
CHANGED
|
@@ -9,6 +9,73 @@
|
|
|
9
9
|
* still carry a `modes` field, but the registry ignores it.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { formatSize } from '../archive/tool-results.js';
|
|
13
|
+
import { DEFAULT_CONTEXT_WINDOW } from '../models.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-tool-result hard cap.
|
|
17
|
+
*
|
|
18
|
+
* A single tool can return megabytes (a grep over a large repo, a large
|
|
19
|
+
* file read, a paginated web fetch). If we forward that verbatim into the
|
|
20
|
+
* next LLM request the model returns LLMContextError ("context_length_exceeded")
|
|
21
|
+
* and the user sees a hard failure mid-turn. Cap at a fraction of the
|
|
22
|
+
* model's context window so even a runaway tool can't kill the request.
|
|
23
|
+
*
|
|
24
|
+
* Cap = max(MIN_CAP, floor(contextWindow * RATIO))
|
|
25
|
+
* - RATIO = 10%: leaves 90% of context for system prompt, history, the
|
|
26
|
+
* model's own output, and other tools called this turn. Generous in
|
|
27
|
+
* absolute terms (25.6 KB on a 256 K window, ~20 K on a 200 K window),
|
|
28
|
+
* plenty for a real tool result; small enough that 5 such results
|
|
29
|
+
* still fit alongside everything else.
|
|
30
|
+
* - MIN_CAP = 8 KB: sanity floor for tiny test fixtures (4 K context
|
|
31
|
+
* models in tests would otherwise cap at 409 chars, defeating the
|
|
32
|
+
* test's purpose). Real production models all have ≥ 32 K context, so
|
|
33
|
+
* the floor never bites in practice.
|
|
34
|
+
*
|
|
35
|
+
* The truncation lands HERE (not in engine.js when pushing tool results
|
|
36
|
+
* into messages) so the UI's `tool_end` event, the exec log, AND the
|
|
37
|
+
* model all see the same truncated content. Truncating later would mean
|
|
38
|
+
* the user sees the full 2 MB output but the model gets a stub —
|
|
39
|
+
* confusing.
|
|
40
|
+
*/
|
|
41
|
+
const TOOL_RESULT_CAP_RATIO = 0.10;
|
|
42
|
+
const TOOL_RESULT_MIN_CAP = 8 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Truncate a tool result if it exceeds the per-result cap. Non-string
|
|
46
|
+
* outputs are JSON-stringified first (matching what engine.js eventually
|
|
47
|
+
* pushes into `content`), then capped.
|
|
48
|
+
*
|
|
49
|
+
* Edge cases handled:
|
|
50
|
+
* - `undefined` → `JSON.stringify(undefined)` returns `undefined`, not
|
|
51
|
+
* a string, so we coerce to `'undefined'` and let the cap apply.
|
|
52
|
+
* - circular refs / `JSON.stringify` throws → fall back to `String(...)`.
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} output
|
|
55
|
+
* @param {{ contextWindow?: number, toolName: string }} opts
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function truncateToolResultIfNeeded(output, { contextWindow, toolName }) {
|
|
59
|
+
let text;
|
|
60
|
+
if (typeof output === 'string') {
|
|
61
|
+
text = output;
|
|
62
|
+
} else {
|
|
63
|
+
try {
|
|
64
|
+
const json = JSON.stringify(output);
|
|
65
|
+
text = typeof json === 'string' ? json : String(output);
|
|
66
|
+
} catch {
|
|
67
|
+
text = String(output);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const ctx = Number.isFinite(contextWindow) && contextWindow > 0
|
|
71
|
+
? contextWindow : DEFAULT_CONTEXT_WINDOW;
|
|
72
|
+
const cap = Math.max(TOOL_RESULT_MIN_CAP, Math.floor(ctx * TOOL_RESULT_CAP_RATIO));
|
|
73
|
+
if (text.length <= cap) return text;
|
|
74
|
+
const head = text.slice(0, cap);
|
|
75
|
+
const marker = `\n\n[truncated: ${toolName} returned ${formatSize(text.length)}, capped at ${formatSize(cap)}; the model will not see the rest of this output]`;
|
|
76
|
+
return head + marker;
|
|
77
|
+
}
|
|
78
|
+
|
|
12
79
|
export class ToolRegistry {
|
|
13
80
|
/** @type {Map<string, import('./types.js').ToolDef>} */
|
|
14
81
|
#tools = new Map();
|
|
@@ -93,6 +160,12 @@ export class ToolRegistry {
|
|
|
93
160
|
|
|
94
161
|
/**
|
|
95
162
|
* Execute a tool by name.
|
|
163
|
+
*
|
|
164
|
+
* The result is passed through {@link truncateToolResultIfNeeded} so that
|
|
165
|
+
* a single tool can never blow the context window. The cap derives from
|
|
166
|
+
* `ctx.contextWindow` (the live model's window, threaded by engine.js);
|
|
167
|
+
* fall back to a 200K default for callers that don't supply it.
|
|
168
|
+
*
|
|
96
169
|
* @param {string} name
|
|
97
170
|
* @param {object} input
|
|
98
171
|
* @param {import('./types.js').ToolContext} [ctx={}]
|
|
@@ -101,7 +174,11 @@ export class ToolRegistry {
|
|
|
101
174
|
async execute(name, input, ctx = {}) {
|
|
102
175
|
const tool = this.#tools.get(name);
|
|
103
176
|
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
104
|
-
|
|
177
|
+
const output = await tool.execute(input, ctx);
|
|
178
|
+
return truncateToolResultIfNeeded(output, {
|
|
179
|
+
contextWindow: ctx.contextWindow,
|
|
180
|
+
toolName: name,
|
|
181
|
+
});
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
/** Number of registered tools. */
|
package/unify/tools/types.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* @property {(groupId: string) => string[]|null} [getGroupRoster]
|
|
25
25
|
* — R6: resolve a group's roster (used by TaskCreate / route_forward to
|
|
26
26
|
* validate `members` ⊆ roster without importing group-store directly).
|
|
27
|
+
* @property {number} [contextWindow] — current model's context window in
|
|
28
|
+
* tokens (used by ToolRegistry.execute to cap a single tool result at a
|
|
29
|
+
* fraction of the window so one runaway grep can't blow the wire).
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
/**
|
package/unify/vp/vp-crud.js
CHANGED
|
@@ -19,8 +19,49 @@
|
|
|
19
19
|
|
|
20
20
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
21
21
|
import { join } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
22
23
|
import { validateVpId } from '../groups/ids.js';
|
|
23
24
|
import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
|
|
25
|
+
import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
29
|
+
* Memory lives at `<root>/vp/<id>/{summary.md,memory.md,segments/…}` —
|
|
30
|
+
* see `store-v2.scopeDir`. Production sites should thread the configured
|
|
31
|
+
* `<yeaftDir>/memory` through `options.memoryRoot` so a non-default yeaft
|
|
32
|
+
* directory (e.g. tests, sandboxed CI) doesn't write under `~/.yeaft/`.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the seed summary body for a freshly-created VP. Pulled into a
|
|
38
|
+
* helper so tests can pin the exact format.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} payload same shape as createVp
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function buildVpSeedSummary(payload) {
|
|
44
|
+
const id = String(payload?.vpId || '').trim();
|
|
45
|
+
const name = (payload?.displayName != null ? String(payload.displayName) : id).trim();
|
|
46
|
+
const role = (payload?.role != null ? String(payload.role) : '').trim();
|
|
47
|
+
const persona = (typeof payload?.persona === 'string' ? payload.persona : '').trim();
|
|
48
|
+
const traits = Array.isArray(payload?.traits)
|
|
49
|
+
? payload.traits.map(t => String(t)).filter(Boolean)
|
|
50
|
+
: [];
|
|
51
|
+
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(`# ${name}`);
|
|
54
|
+
if (role) lines.push('', `**Role:** ${role}`);
|
|
55
|
+
if (traits.length > 0) lines.push('', `**Traits:** ${traits.join(', ')}`);
|
|
56
|
+
if (persona) {
|
|
57
|
+
// Keep the persona body terse — first 800 chars is plenty for an
|
|
58
|
+
// initial Layer-A resident summary; Dream-v2 will rewrite it as
|
|
59
|
+
// memory accumulates.
|
|
60
|
+
const truncated = persona.length > 800 ? persona.slice(0, 800).trim() + '…' : persona;
|
|
61
|
+
lines.push('', '**Persona:**', '', truncated);
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n').trim();
|
|
64
|
+
}
|
|
24
65
|
|
|
25
66
|
/**
|
|
26
67
|
* Error thrown by CRUD entry points. Has stable `.code` so callers can map
|
|
@@ -106,6 +147,7 @@ function yamlScalar(v) {
|
|
|
106
147
|
*/
|
|
107
148
|
export function createVp(payload, options = {}) {
|
|
108
149
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
150
|
+
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
109
151
|
const vpId = payload && payload.vpId;
|
|
110
152
|
|
|
111
153
|
const v = validateVpId(vpId);
|
|
@@ -123,6 +165,24 @@ export function createVp(payload, options = {}) {
|
|
|
123
165
|
mkdirSync(dir, { recursive: true });
|
|
124
166
|
mkdirSync(join(dir, 'memory'), { recursive: true });
|
|
125
167
|
writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
|
|
168
|
+
|
|
169
|
+
// Seed the VP's Layer-A resident summary so the first session has SOMETHING
|
|
170
|
+
// for engine.#loadLayerASummaries to read. Without this, fresh VPs have
|
|
171
|
+
// an empty memory section in the system prompt until Dream-v2 runs (which
|
|
172
|
+
// requires a non-empty diff stream — i.e. several turns of activity).
|
|
173
|
+
// We only seed when the file is missing/empty: this is safe to re-run and
|
|
174
|
+
// never clobbers Dream-v2 writes. Failures are best-effort: a memory-root
|
|
175
|
+
// permission failure must NOT break VP creation.
|
|
176
|
+
try {
|
|
177
|
+
seedSummaryIfMissingSync(
|
|
178
|
+
{ kind: 'vp', id: vpId },
|
|
179
|
+
buildVpSeedSummary({ ...payload, vpId }),
|
|
180
|
+
{ root: memoryRoot },
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.warn(`[vp-crud] failed to seed summary.md for ${vpId}:`, err?.message || err);
|
|
184
|
+
}
|
|
185
|
+
|
|
126
186
|
return { vpId, dir };
|
|
127
187
|
}
|
|
128
188
|
|
|
@@ -150,7 +210,9 @@ export function updateVp(payload, options = {}) {
|
|
|
150
210
|
}
|
|
151
211
|
|
|
152
212
|
/**
|
|
153
|
-
* Delete a VP — removes the entire VP dir (role.md + memory/)
|
|
213
|
+
* Delete a VP — removes the entire VP dir (role.md + memory/) AND the
|
|
214
|
+
* shared memory root's `<root>/vp/<id>/` so a recreate of the same id
|
|
215
|
+
* doesn't see stale `summary.md` / segments / index entries.
|
|
154
216
|
*
|
|
155
217
|
* Hard constraint: `memory/` contents are scoped to this VP; removing them
|
|
156
218
|
* with the role is the intended CRUD semantic (UX rule is the confirm
|
|
@@ -158,10 +220,13 @@ export function updateVp(payload, options = {}) {
|
|
|
158
220
|
*
|
|
159
221
|
* @param {string} vpId
|
|
160
222
|
* @param {object} [options]
|
|
223
|
+
* @param {string} [options.libDir]
|
|
224
|
+
* @param {string} [options.memoryRoot]
|
|
161
225
|
* @returns {{vpId:string}}
|
|
162
226
|
*/
|
|
163
227
|
export function deleteVp(vpId, options = {}) {
|
|
164
228
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
229
|
+
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
165
230
|
// We do NOT run validateVpId here — deleting an already-legacy bad id is
|
|
166
231
|
// legitimate cleanup. But we DO refuse obviously unsafe inputs.
|
|
167
232
|
if (!vpId || typeof vpId !== 'string' || vpId.includes('/') || vpId.includes('\\') || vpId === '..' || vpId === '.') {
|
|
@@ -172,6 +237,13 @@ export function deleteVp(vpId, options = {}) {
|
|
|
172
237
|
throw new VpCrudError('not_found', vpId);
|
|
173
238
|
}
|
|
174
239
|
rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
// Cascade: drop the VP's memory scope so a recreate with the same id
|
|
241
|
+
// starts clean. Best-effort — never let memory cleanup fail the CRUD op.
|
|
242
|
+
try {
|
|
243
|
+
removeScopeDirSync({ kind: 'vp', id: vpId }, { root: memoryRoot });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.warn(`[vp-crud] failed to remove memory dir for ${vpId}:`, err?.message || err);
|
|
246
|
+
}
|
|
175
247
|
return { vpId };
|
|
176
248
|
}
|
|
177
249
|
|
package/unify/web-bridge.js
CHANGED
|
@@ -48,6 +48,7 @@ import { seedDefaultGroup } from './groups/seed-default.js';
|
|
|
48
48
|
import {
|
|
49
49
|
shouldCompactHistory,
|
|
50
50
|
compactHistory,
|
|
51
|
+
trimSnapshotForBudget,
|
|
51
52
|
} from './history-compact.js';
|
|
52
53
|
|
|
53
54
|
/** @type {import('./session.js').Session | null} */
|
|
@@ -168,7 +169,9 @@ export function handleUnifyVpCreate(msg) {
|
|
|
168
169
|
const requestId = msg && msg.requestId;
|
|
169
170
|
const payload = msg && msg.payload;
|
|
170
171
|
try {
|
|
171
|
-
const
|
|
172
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
173
|
+
const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
|
|
174
|
+
const { vpId } = createVp(payload || {}, memoryRoot ? { memoryRoot } : {});
|
|
172
175
|
sendVpCrudResult({ op: 'create', requestId, ok: true, vpId });
|
|
173
176
|
} catch (err) {
|
|
174
177
|
sendVpCrudResult({
|
|
@@ -208,7 +211,9 @@ export function handleUnifyVpDelete(msg) {
|
|
|
208
211
|
const requestId = msg && msg.requestId;
|
|
209
212
|
const vpId = msg && msg.vpId;
|
|
210
213
|
try {
|
|
211
|
-
|
|
214
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
215
|
+
const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
|
|
216
|
+
deleteVp(vpId, memoryRoot ? { memoryRoot } : {});
|
|
212
217
|
sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
|
|
213
218
|
} catch (err) {
|
|
214
219
|
sendVpCrudResult({
|
|
@@ -297,7 +302,8 @@ export function handleUnifyCreateGroup(msg) {
|
|
|
297
302
|
const payload = (msg && msg.payload) || {};
|
|
298
303
|
try {
|
|
299
304
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
300
|
-
const
|
|
305
|
+
const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
|
|
306
|
+
const group = createGroupFromSpec(yeaftDir, payload, memoryRoot ? { memoryRoot } : {});
|
|
301
307
|
sendGroupCrudResult({ op: 'create', requestId, ok: true, group });
|
|
302
308
|
sendGroupSnapshotBroadcast();
|
|
303
309
|
} catch (err) {
|
|
@@ -376,7 +382,8 @@ export function handleUnifyDeleteGroup(msg) {
|
|
|
376
382
|
const groupId = msg && msg.groupId;
|
|
377
383
|
try {
|
|
378
384
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
379
|
-
const
|
|
385
|
+
const memoryRoot = yeaftDir ? join(yeaftDir, 'memory') : undefined;
|
|
386
|
+
const result = deleteGroup(yeaftDir, groupId, memoryRoot ? { memoryRoot } : {});
|
|
380
387
|
// Cascade: remove every persisted message stamped with this group id.
|
|
381
388
|
// Hard delete (per user spec): no soft-archive, the bytes are gone.
|
|
382
389
|
// Skipped silently if the session/store isn't initialized — the next
|
|
@@ -792,7 +799,7 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
792
799
|
groupHandle = openGroup(root, groupId);
|
|
793
800
|
} else if (groupId === 'grp_default') {
|
|
794
801
|
try {
|
|
795
|
-
const seeded = seedDefaultGroup(yeaftDir, {});
|
|
802
|
+
const seeded = seedDefaultGroup(yeaftDir, { memoryRoot: join(yeaftDir, 'memory') });
|
|
796
803
|
groupHandle = seeded.group;
|
|
797
804
|
} catch (seedErr) {
|
|
798
805
|
seedFailed = true;
|
|
@@ -1146,9 +1153,16 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
|
|
|
1146
1153
|
vpId,
|
|
1147
1154
|
turnId,
|
|
1148
1155
|
};
|
|
1156
|
+
// Always trim the snapshot before passing to engine.query. This is
|
|
1157
|
+
// the second-line defense (history-compact only fires above 30K
|
|
1158
|
+
// tokens — small chats with many turns still bloat the messages
|
|
1159
|
+
// array). See `trimSnapshotForBudget` doc-block for policy.
|
|
1160
|
+
const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
|
|
1161
|
+
messageTokenBudget: session?.config?.messageTokenBudget,
|
|
1162
|
+
});
|
|
1149
1163
|
for await (const event of session.engine.query({
|
|
1150
1164
|
prompt,
|
|
1151
|
-
messages:
|
|
1165
|
+
messages: trimmedMessages,
|
|
1152
1166
|
signal: vpAbort.signal,
|
|
1153
1167
|
...queryOpts,
|
|
1154
1168
|
})) {
|