@yeaft/webchat-agent 0.1.723 → 0.1.725

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.723",
3
+ "version": "0.1.725",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -28,6 +28,7 @@ import { archiveTurn } from './archive/turn-archive.js';
28
28
  import { archiveToolResults } from './archive/tool-results.js';
29
29
  import { readSummary as readScopeSummary } from './memory/store-v2.js';
30
30
  import { runAdjust } from './memory/adjust.js';
31
+ import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
31
32
  import { runStopHooks } from './stop-hooks.js';
32
33
  // H2.f.5: threads/ retired. Persisted messages still carry a `threadId`
33
34
  // field for back-compat with old conversation files; new writes always use
@@ -176,6 +177,45 @@ export function estimateMessagesTokens(system, messages) {
176
177
 
177
178
  // ─── Engine ──────────────────────────────────────────────────────
178
179
 
180
+ /**
181
+ * buildResidentEntries — pure helper that builds the AMS Resident entry
182
+ * list from the per-turn Layer-A summaries.
183
+ *
184
+ * Encodes one non-trivial rule on top of "push if non-empty":
185
+ *
186
+ * The `vp/<ownVpId>` summary is skipped when it carries the
187
+ * seed-backfill stub marker. The persona body is already rendered as
188
+ * Section 1 of the system prompt by `renderVpPersona`; surfacing the
189
+ * stub's `# Name / Role` line as a Resident entry would re-label the
190
+ * same identity in Section 6 ("Active Memory Set") with no added
191
+ * information — the visible follow-up to the persona-dup bug fixed in
192
+ * PR #722. Once Dream-v2 writes a real summary for this scope it
193
+ * lacks the marker and is surfaced normally.
194
+ *
195
+ * Other-VP entries (group collaborators) are NOT considered here — only
196
+ * the local VP's summary is loaded into `summaries.vp` upstream by
197
+ * `#loadLayerASummaries`. Cross-VP context flows through onDemand recall.
198
+ *
199
+ * @param {{
200
+ * groupId?: string|null,
201
+ * ownVpId?: string|null,
202
+ * summaries: { user?: string, group?: string, vp?: string }
203
+ * }} args
204
+ * @returns {Array<{scope: string, summary: string}>}
205
+ */
206
+ export function buildResidentEntries(args) {
207
+ const summaries = (args && args.summaries) || {};
208
+ const out = [];
209
+ if (summaries.user) out.push({ scope: 'user', summary: summaries.user });
210
+ if (args.groupId && summaries.group) {
211
+ out.push({ scope: `group/${args.groupId}`, summary: summaries.group });
212
+ }
213
+ if (args.ownVpId && summaries.vp && !isVpSeedBackfillStub(summaries.vp)) {
214
+ out.push({ scope: `vp/${args.ownVpId}`, summary: summaries.vp });
215
+ }
216
+ return out;
217
+ }
218
+
179
219
  export class Engine {
180
220
  /** @type {import('./llm/adapter.js').LLMAdapter} */
181
221
  #adapter;
@@ -505,14 +545,11 @@ export class Engine {
505
545
 
506
546
  // (a) Resident: rebuild from the same scope summaries the worker
507
547
  // prompt is already going to see.
508
- const residentEntries = [];
509
- if (args.summaries?.user) residentEntries.push({ scope: 'user', summary: args.summaries.user });
510
- if (args.groupId && args.summaries?.group) {
511
- residentEntries.push({ scope: `group/${args.groupId}`, summary: args.summaries.group });
512
- }
513
- if (ownVpId && args.summaries?.vp) {
514
- residentEntries.push({ scope: `vp/${ownVpId}`, summary: args.summaries.vp });
515
- }
548
+ const residentEntries = buildResidentEntries({
549
+ groupId: args.groupId,
550
+ ownVpId,
551
+ summaries: args.summaries || {},
552
+ });
516
553
  ams.setResident(residentEntries);
517
554
 
518
555
  // (b) onDemand: replace with this turn's FTS hits.
@@ -22,6 +22,42 @@ import { parseRoleMd } from '../vp/vp-store.js';
22
22
 
23
23
  const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
24
24
 
25
+ /**
26
+ * Marker stamped into every VP summary written by this module.
27
+ *
28
+ * Two consumers care about it:
29
+ * 1. `engine.#prepareAms` uses `isVpSeedBackfillStub` to skip the
30
+ * `vp/<ownVpId>` Resident entry when its summary is just our stub —
31
+ * the real persona is already rendered as Section 1 of the system
32
+ * prompt by `renderVpPersona`. Without the skip, AMS Resident dups
33
+ * Section 1 with redundant `name + role` labels.
34
+ * 2. `migrateLegacyVpSummaries` uses absence-of-marker + presence of
35
+ * `**Persona:**` to identify pre-fix summary.md files (which copied
36
+ * up to 800 chars of `role.md` body) and rewrite them as stubs.
37
+ *
38
+ * Bump the version suffix when the stub format changes meaningfully so
39
+ * old stamps can be re-migrated if needed.
40
+ */
41
+ export const VP_STUB_MARKER = '<!-- seed-backfill:vp-stub v1 -->';
42
+
43
+ /**
44
+ * True iff the given summary text was produced by this module's VP stub
45
+ * writer (i.e. carries the marker comment). Whitespace-tolerant.
46
+ *
47
+ * Used by `engine.#prepareAms` to decide whether to surface the
48
+ * `vp/<ownVpId>` summary as a Resident AMS entry. Stubs are skipped so
49
+ * Section 1 (`renderVpPersona`) is the sole rendering of own-VP identity;
50
+ * Dream-v2's eventual real summary will lack the marker and be surfaced
51
+ * normally.
52
+ *
53
+ * @param {string|null|undefined} text
54
+ * @returns {boolean}
55
+ */
56
+ export function isVpSeedBackfillStub(text) {
57
+ if (typeof text !== 'string' || text.length === 0) return false;
58
+ return text.includes(VP_STUB_MARKER);
59
+ }
60
+
25
61
  function readIfPresent(path) {
26
62
  try {
27
63
  if (!existsSync(path)) return '';
@@ -39,10 +75,25 @@ function writeAtomicSync(path, body) {
39
75
  /**
40
76
  * Build a synthetic VP summary from the on-disk role.md.
41
77
  *
78
+ * IMPORTANT — this is a STUB that lives until Dream-v2 writes a real
79
+ * per-scope summary. Earlier versions copied up to 800 chars of the
80
+ * `role.md` body into `summary.md`. That body is *also* rendered as
81
+ * Section 1 of the system prompt (`renderVpPersona` in `prompts.js`),
82
+ * so the same persona text reappeared in `## Active Memory Set →
83
+ * Resident → vp/<id>` — the user-visible "Why is the persona defined
84
+ * twice?" bug.
85
+ *
86
+ * The summary.md placeholder is therefore deliberately minimal: just
87
+ * the VP's display name + role label. Layer-A AMS still sees a
88
+ * non-empty `vp/<id>` resident entry (so adjust/recall scope wiring
89
+ * stays unchanged), but the persona body is rendered exactly once,
90
+ * by Section 1.
91
+ *
92
+ * Once Dream-v2 produces a real summary for this scope it overwrites
93
+ * this stub — see `idempotency` note at the top of the file.
94
+ *
42
95
  * 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.
96
+ * backfill stays in sync with the production loader.
46
97
  *
47
98
  * @param {string} libDir
48
99
  * @param {string} vpId
@@ -54,17 +105,12 @@ function readVpRoleSummary(libDir, vpId) {
54
105
  let raw = '';
55
106
  try { raw = readFileSync(rolePath, 'utf-8'); } catch { return null; }
56
107
 
57
- const { meta, body } = parseRoleMd(raw);
108
+ const { meta } = parseRoleMd(raw);
58
109
  const name = String(meta.name || vpId).trim() || vpId;
59
110
  const role = typeof meta.role === 'string' ? meta.role.trim() : '';
60
111
 
61
- const persona = typeof body === 'string' ? body.trim() : '';
62
- const lines = [`# ${name}`];
112
+ const lines = [VP_STUB_MARKER, '', `# ${name}`];
63
113
  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
114
  return lines.join('\n').trim();
69
115
  }
70
116
 
@@ -123,6 +169,64 @@ function readGroupSummaryBody(groupDir) {
123
169
  return lines.join('\n').trim();
124
170
  }
125
171
 
172
+ /**
173
+ * Detect the *legacy* (pre-stamp) VP summary shape: a body that lacks
174
+ * `VP_STUB_MARKER` AND contains the `**Persona:**` block written by the
175
+ * older stub. Tight signature on purpose — we don't want to clobber
176
+ * hand-edited or Dream-v2-produced summaries that happen to be missing
177
+ * the marker for unrelated reasons.
178
+ *
179
+ * @param {string} body
180
+ * @returns {boolean}
181
+ */
182
+ function isLegacyVpSummary(body) {
183
+ if (typeof body !== 'string' || body.length === 0) return false;
184
+ if (body.includes(VP_STUB_MARKER)) return false;
185
+ return body.includes('**Persona:**');
186
+ }
187
+
188
+ /**
189
+ * One-shot migration: walk `<root>/vp/<id>/summary.md` and rewrite any
190
+ * file matching the legacy shape (`isLegacyVpSummary`) into the current
191
+ * stamped stub. Idempotent — a stamped or Dream-v2-produced file is left
192
+ * untouched. Safe to run on every session boot.
193
+ *
194
+ * Existing users whose `summary.md` was written by the pre-stamp stub
195
+ * carry the persona body forever, because `backfillVpSummaries` only
196
+ * writes when the file is empty/missing. This pass closes that gap.
197
+ *
198
+ * @param {{ libDir: string, root?: string }} opts
199
+ * @returns {{ scanned: number, migrated: number }}
200
+ */
201
+ export function migrateLegacyVpSummaries({ libDir, root = DEFAULT_MEMORY_ROOT }) {
202
+ let scanned = 0;
203
+ let migrated = 0;
204
+ const vpRoot = join(root, 'vp');
205
+ if (!existsSync(vpRoot)) return { scanned, migrated };
206
+ let entries;
207
+ try { entries = readdirSync(vpRoot); } catch { return { scanned, migrated }; }
208
+ for (const name of entries) {
209
+ if (name.startsWith('.')) continue;
210
+ const summaryPath = join(vpRoot, name, 'summary.md');
211
+ let body = '';
212
+ try {
213
+ if (!existsSync(summaryPath)) continue;
214
+ body = readFileSync(summaryPath, 'utf-8');
215
+ } catch { continue; }
216
+ scanned++;
217
+ if (!isLegacyVpSummary(body)) continue;
218
+ const stub = readVpRoleSummary(libDir, name);
219
+ if (!stub) continue;
220
+ try {
221
+ writeAtomicSync(summaryPath, stub);
222
+ migrated++;
223
+ } catch (err) {
224
+ console.warn(`[seed-backfill] migrate vp ${name}: ${err?.message || err}`);
225
+ }
226
+ }
227
+ return { scanned, migrated };
228
+ }
229
+
126
230
  /**
127
231
  * Walk groups/ and seed `summary.md` for every group without one.
128
232
  *
@@ -161,17 +265,30 @@ export function backfillGroupSummaries({ yeaftDir, root = DEFAULT_MEMORY_ROOT })
161
265
  * Run all backfills sequentially. Best-effort — any per-step error is
162
266
  * logged and the next step still runs.
163
267
  *
268
+ * Order:
269
+ * 1. Migrate legacy VP summaries (rewrite pre-stamp persona-body stubs
270
+ * to current-format stamped stubs). Runs FIRST so that
271
+ * `backfillVpSummaries` sees consistent on-disk state and any
272
+ * future logic that distinguishes "stamped" vs "free-form" works
273
+ * uniformly downstream.
274
+ * 2. Backfill missing VP summaries.
275
+ * 3. Backfill missing group summaries.
276
+ *
164
277
  * @param {{ yeaftDir: string, libDir: string, root?: string }} opts
165
- * @returns {{ vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
278
+ * @returns {{ migrate: {scanned:number, migrated:number}, vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
166
279
  */
167
280
  export function runSummaryBackfill({ yeaftDir, libDir, root = DEFAULT_MEMORY_ROOT }) {
281
+ let migrate = { scanned: 0, migrated: 0 };
168
282
  let vp = { scanned: 0, seeded: 0 };
169
283
  let group = { scanned: 0, seeded: 0 };
284
+ try { migrate = migrateLegacyVpSummaries({ libDir, root }); } catch (err) {
285
+ console.warn('[seed-backfill] vp migrate failed:', err?.message || err);
286
+ }
170
287
  try { vp = backfillVpSummaries({ libDir, root }); } catch (err) {
171
288
  console.warn('[seed-backfill] vp pass failed:', err?.message || err);
172
289
  }
173
290
  try { group = backfillGroupSummaries({ yeaftDir, root }); } catch (err) {
174
291
  console.warn('[seed-backfill] group pass failed:', err?.message || err);
175
292
  }
176
- return { vp, group };
293
+ return { migrate, vp, group };
177
294
  }
@@ -23,6 +23,7 @@ import { homedir } from 'os';
23
23
  import { validateVpId } from '../groups/ids.js';
24
24
  import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
25
25
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
26
+ import { VP_STUB_MARKER } from '../memory/seed-backfill.js';
26
27
 
27
28
  /**
28
29
  * Default memory root used when callers don't pass `options.memoryRoot`.
@@ -40,26 +41,36 @@ const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
40
41
  * @param {object} payload same shape as createVp
41
42
  * @returns {string}
42
43
  */
44
+ /**
45
+ * Build the seed body for a freshly-created VP's `<root>/vp/<id>/summary.md`.
46
+ *
47
+ * IMPORTANT — this is a STUB that mirrors `seed-backfill.js#readVpRoleSummary`.
48
+ * Earlier versions of both writers embedded up to 800 chars of `persona`
49
+ * here. That body is *also* rendered as Section 1 of the system prompt by
50
+ * `renderVpPersona`, so the same persona text reappeared in the AMS
51
+ * Resident block — the user-visible "persona defined twice" bug. PR #722
52
+ * fixed `seed-backfill.js`; this writer is the create-time twin.
53
+ *
54
+ * The seed is therefore deliberately minimal (name + role + traits) and
55
+ * stamped with `VP_STUB_MARKER` so `engine.buildResidentEntries` knows to
56
+ * skip the own-VP Resident push (Section 1 is already the source of truth
57
+ * for own-VP identity). Once Dream-v2 writes a real summary it overwrites
58
+ * this stub and lacks the marker, so it surfaces normally.
59
+ *
60
+ * @param {object} payload same shape as createVp
61
+ * @returns {string}
62
+ */
43
63
  export function buildVpSeedSummary(payload) {
44
64
  const id = String(payload?.vpId || '').trim();
45
65
  const name = (payload?.displayName != null ? String(payload.displayName) : id).trim();
46
66
  const role = (payload?.role != null ? String(payload.role) : '').trim();
47
- const persona = (typeof payload?.persona === 'string' ? payload.persona : '').trim();
48
67
  const traits = Array.isArray(payload?.traits)
49
68
  ? payload.traits.map(t => String(t)).filter(Boolean)
50
69
  : [];
51
70
 
52
- const lines = [];
53
- lines.push(`# ${name}`);
71
+ const lines = [VP_STUB_MARKER, '', `# ${name}`];
54
72
  if (role) lines.push('', `**Role:** ${role}`);
55
73
  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
74
  return lines.join('\n').trim();
64
75
  }
65
76