@yeaft/webchat-agent 0.1.703 → 0.1.705
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/groups/group-crud.js +63 -2
- package/unify/groups/seed-default.js +50 -2
- package/unify/history-compact.js +112 -11
- package/unify/llm/adapter.js +21 -0
- package/unify/llm/anthropic.js +9 -13
- package/unify/llm/openai-responses.js +69 -8
- package/unify/memory/seed-backfill.js +177 -0
- package/unify/memory/store-v2.js +75 -0
- package/unify/session.js +17 -1
- package/unify/vp/vp-crud.js +73 -1
- package/unify/web-bridge.js +20 -6
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/unify/llm/adapter.js
CHANGED
|
@@ -135,6 +135,27 @@ export function redactRawRequest(req) {
|
|
|
135
135
|
return { url: req.url, method: req.method, headers, body: req.body };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Snapshot a Fetch Response's headers into a plain object for the debug
|
|
140
|
+
* panel. Defensive against polyfilled / mocked Response shapes that don't
|
|
141
|
+
* implement `Headers#entries()` — falls back to `{}` rather than throwing.
|
|
142
|
+
*
|
|
143
|
+
* NOTE: multi-valued headers (e.g. `Set-Cookie`) collapse to the last value
|
|
144
|
+
* because `Object.fromEntries` can't represent duplicates. For LLM debug
|
|
145
|
+
* traffic this is fine; if a future use case needs multi-valued capture,
|
|
146
|
+
* switch the return to an array of [k, v] pairs.
|
|
147
|
+
*
|
|
148
|
+
* @param {Response | { headers?: { entries?: () => Iterable<[string, string]> } }} response
|
|
149
|
+
* @returns {Record<string, string>}
|
|
150
|
+
*/
|
|
151
|
+
export function safeHeaders(response) {
|
|
152
|
+
const h = response && response.headers;
|
|
153
|
+
if (h && typeof h.entries === 'function') {
|
|
154
|
+
return Object.fromEntries(h.entries());
|
|
155
|
+
}
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
// ─── Base Class ────────────────────────────────────────────────
|
|
139
160
|
|
|
140
161
|
/**
|
package/unify/llm/anthropic.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
LLMServerError,
|
|
15
15
|
LLMAbortError,
|
|
16
16
|
redactRawRequest,
|
|
17
|
+
safeHeaders,
|
|
17
18
|
} from './adapter.js';
|
|
18
19
|
import {
|
|
19
20
|
normalizeEffort,
|
|
@@ -202,9 +203,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
202
203
|
rawRequest,
|
|
203
204
|
rawResponse: {
|
|
204
205
|
status: response.status,
|
|
205
|
-
headers: response
|
|
206
|
-
? Object.fromEntries(response.headers.entries())
|
|
207
|
-
: {},
|
|
206
|
+
headers: safeHeaders(response),
|
|
208
207
|
body: errorBody,
|
|
209
208
|
},
|
|
210
209
|
});
|
|
@@ -221,14 +220,11 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
221
220
|
let currentToolName = null;
|
|
222
221
|
let currentToolInput = '';
|
|
223
222
|
// Accumulate raw SSE body verbatim for the debug panel. No truncation:
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const responseHeaders = response.headers && typeof response.headers.entries === 'function'
|
|
230
|
-
? Object.fromEntries(response.headers.entries())
|
|
231
|
-
: {};
|
|
223
|
+
// see `redactRawRequest` in adapter.js for the verbatim-design rationale.
|
|
224
|
+
// Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
|
|
225
|
+
// O(n²) string concat).
|
|
226
|
+
const rawSseBodyChunks = [];
|
|
227
|
+
const responseHeaders = safeHeaders(response);
|
|
232
228
|
const responseStatus = response.status;
|
|
233
229
|
|
|
234
230
|
try {
|
|
@@ -238,7 +234,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
238
234
|
|
|
239
235
|
const chunkText = decoder.decode(value, { stream: true });
|
|
240
236
|
buffer += chunkText;
|
|
241
|
-
|
|
237
|
+
rawSseBodyChunks.push(chunkText);
|
|
242
238
|
const lines = buffer.split('\n');
|
|
243
239
|
buffer = lines.pop() || ''; // Keep incomplete line
|
|
244
240
|
|
|
@@ -337,7 +333,7 @@ export class AnthropicAdapter extends LLMAdapter {
|
|
|
337
333
|
rawResponse: {
|
|
338
334
|
status: responseStatus,
|
|
339
335
|
headers: responseHeaders,
|
|
340
|
-
body:
|
|
336
|
+
body: rawSseBodyChunks.join(''),
|
|
341
337
|
format: 'sse',
|
|
342
338
|
},
|
|
343
339
|
});
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
LLMContextError,
|
|
33
33
|
LLMServerError,
|
|
34
34
|
LLMAbortError,
|
|
35
|
+
redactRawRequest,
|
|
36
|
+
safeHeaders,
|
|
35
37
|
} from './adapter.js';
|
|
36
38
|
import {
|
|
37
39
|
normalizeEffort,
|
|
@@ -225,9 +227,16 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
225
227
|
// ─── Streaming ──────────────────────────────────────────
|
|
226
228
|
|
|
227
229
|
/**
|
|
228
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
|
|
230
|
+
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
|
|
231
|
+
*
|
|
232
|
+
* NOTE on `extraBody`: any keys you spread here are merged verbatim into
|
|
233
|
+
* the wire body and — because the verbatim debug feature is intentionally
|
|
234
|
+
* non-truncating — will surface in the debug panel via `rawRequest.body`.
|
|
235
|
+
* Do NOT put secrets in `extraBody`. Only `Authorization` / `x-api-key` /
|
|
236
|
+
* `api-key` headers are auto-redacted (see `redactRawRequest` in
|
|
237
|
+
* `adapter.js`); request-body fields are caller-controlled.
|
|
229
238
|
*/
|
|
230
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
|
|
239
|
+
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal, onRawExchange }) {
|
|
231
240
|
if (signal?.aborted) throw new LLMAbortError();
|
|
232
241
|
|
|
233
242
|
const body = {
|
|
@@ -255,14 +264,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
255
264
|
|
|
256
265
|
if (extraBody) Object.assign(body, extraBody);
|
|
257
266
|
|
|
267
|
+
const url = `${this.#baseUrl}/responses`;
|
|
268
|
+
const headers = {
|
|
269
|
+
'Content-Type': 'application/json',
|
|
270
|
+
'Authorization': `Bearer ${this.#apiKey}`,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Expose the raw request (auth-redacted) for the debug panel. See
|
|
274
|
+
// `redactRawRequest` in adapter.js for the verbatim-design rationale.
|
|
275
|
+
const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
|
|
276
|
+
|
|
258
277
|
let response;
|
|
259
278
|
try {
|
|
260
|
-
response = await fetch(
|
|
279
|
+
response = await fetch(url, {
|
|
261
280
|
method: 'POST',
|
|
262
|
-
headers
|
|
263
|
-
'Content-Type': 'application/json',
|
|
264
|
-
'Authorization': `Bearer ${this.#apiKey}`,
|
|
265
|
-
},
|
|
281
|
+
headers,
|
|
266
282
|
body: JSON.stringify(body),
|
|
267
283
|
signal,
|
|
268
284
|
});
|
|
@@ -273,6 +289,19 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
273
289
|
|
|
274
290
|
if (!response.ok) {
|
|
275
291
|
const errorBody = await response.text();
|
|
292
|
+
// Capture error response too, then throw. Parity with anthropic.js.
|
|
293
|
+
if (onRawExchange) {
|
|
294
|
+
try {
|
|
295
|
+
onRawExchange({
|
|
296
|
+
rawRequest,
|
|
297
|
+
rawResponse: {
|
|
298
|
+
status: response.status,
|
|
299
|
+
headers: safeHeaders(response),
|
|
300
|
+
body: errorBody,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
} catch { /* ignore */ }
|
|
304
|
+
}
|
|
276
305
|
throw this.#classifyError(response.status, errorBody);
|
|
277
306
|
}
|
|
278
307
|
|
|
@@ -287,11 +316,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
287
316
|
const emittedToolCallIds = new Set();
|
|
288
317
|
let sawToolCall = false;
|
|
289
318
|
|
|
319
|
+
// Accumulate raw SSE body verbatim for the debug panel. No truncation:
|
|
320
|
+
// see `redactRawRequest` in adapter.js for the verbatim-design rationale.
|
|
321
|
+
// Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
|
|
322
|
+
// O(n²) string concat).
|
|
323
|
+
const rawSseBodyChunks = [];
|
|
324
|
+
const responseHeaders = safeHeaders(response);
|
|
325
|
+
const responseStatus = response.status;
|
|
326
|
+
|
|
290
327
|
try {
|
|
291
328
|
while (true) {
|
|
292
329
|
const { done, value } = await reader.read();
|
|
293
330
|
if (done) break;
|
|
294
|
-
|
|
331
|
+
const chunkText = decoder.decode(value, { stream: true });
|
|
332
|
+
buffer += chunkText;
|
|
333
|
+
rawSseBodyChunks.push(chunkText);
|
|
295
334
|
|
|
296
335
|
// SSE events are separated by blank lines; split on \n
|
|
297
336
|
const lines = buffer.split('\n');
|
|
@@ -409,11 +448,33 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
409
448
|
throw err;
|
|
410
449
|
} finally {
|
|
411
450
|
try { reader.releaseLock(); } catch { /* noop */ }
|
|
451
|
+
// Emit raw exchange after stream completes (or errors). Body is the
|
|
452
|
+
// verbatim SSE — never truncated. Parity with anthropic.js.
|
|
453
|
+
if (onRawExchange) {
|
|
454
|
+
try {
|
|
455
|
+
onRawExchange({
|
|
456
|
+
rawRequest,
|
|
457
|
+
rawResponse: {
|
|
458
|
+
status: responseStatus,
|
|
459
|
+
headers: responseHeaders,
|
|
460
|
+
body: rawSseBodyChunks.join(''),
|
|
461
|
+
format: 'sse',
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
} catch { /* ignore */ }
|
|
465
|
+
}
|
|
412
466
|
}
|
|
413
467
|
}
|
|
414
468
|
|
|
415
469
|
// ─── Non-streaming call() ───────────────────────────────
|
|
416
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Side-query (consolidate / dream / recall / light) entry point. Does
|
|
473
|
+
* NOT accept `onRawExchange` — these calls intentionally don't surface
|
|
474
|
+
* in the user-facing debug panel. If a future product change wants to
|
|
475
|
+
* expose them, mirror the stream() instrumentation. Parity with
|
|
476
|
+
* anthropic.js's `call()`.
|
|
477
|
+
*/
|
|
417
478
|
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
418
479
|
if (signal?.aborted) throw new LLMAbortError();
|
|
419
480
|
|
|
@@ -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/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/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
|
})) {
|