@yeaft/webchat-agent 1.0.349 → 1.0.351

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.
Files changed (41) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +91 -97
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +2 -2
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/package.json +1 -1
  8. package/yeaft/dream/apply.js +59 -30
  9. package/yeaft/dream/output-snapshot.js +8 -4
  10. package/yeaft/dream/prompts/consolidate-topics.md +31 -0
  11. package/yeaft/dream/prompts/create.md +8 -8
  12. package/yeaft/dream/prompts/index.js +4 -2
  13. package/yeaft/dream/prompts/merge-topics.md +35 -0
  14. package/yeaft/dream/prompts/triage-pass1.md +2 -2
  15. package/yeaft/dream/prompts/triage-pass2.md +4 -2
  16. package/yeaft/dream/prompts/update.md +16 -14
  17. package/yeaft/dream/runner.js +69 -13
  18. package/yeaft/dream/segment-extract.js +16 -13
  19. package/yeaft/dream/session-wiring.js +2 -2
  20. package/yeaft/dream/snapshot.js +3 -3
  21. package/yeaft/dream/topic-consolidation.js +316 -0
  22. package/yeaft/dream/triage.js +7 -2
  23. package/yeaft/engine.js +221 -236
  24. package/yeaft/memory/ams-registry.js +42 -61
  25. package/yeaft/memory/ams.js +17 -9
  26. package/yeaft/memory/budget.js +15 -18
  27. package/yeaft/memory/content-backfill.js +118 -0
  28. package/yeaft/memory/index-db.js +10 -3
  29. package/yeaft/memory/keywords.js +25 -7
  30. package/yeaft/memory/preflow.js +26 -9
  31. package/yeaft/memory/segment-store.js +44 -8
  32. package/yeaft/memory/segment-sync.js +8 -4
  33. package/yeaft/memory/segment.js +10 -3
  34. package/yeaft/memory/store.js +88 -38
  35. package/yeaft/memory/summary-store.js +3 -3
  36. package/yeaft/memory/topic-redirect.js +28 -0
  37. package/yeaft/session.js +18 -19
  38. package/yeaft/sessions/pre-flow.js +7 -3
  39. package/yeaft/sub-agent/runner.js +10 -1
  40. package/yeaft/work-center/bridge.js +1 -0
  41. package/yeaft/work-center/runner.js +33 -4
@@ -1,9 +1,10 @@
1
1
  /**
2
- * memory/segment-store.js — disk I/O for segment-formatted memory.md.
2
+ * memory/segment-store.js — disk I/O for segment evidence in memory.md.
3
3
  *
4
- * Bridges between the on-disk format (memory.md per scope, multiple
5
- * segment blocks) and the SQLite segment index. This layer handles
6
- * scope <-> file path mapping; the index layer is scope-agnostic.
4
+ * Bridges between the on-disk evidence format (memory.md per scope, multiple
5
+ * segment blocks) and the SQLite segment index. Canonical prompt-facing prose
6
+ * is stored separately in content.md. This layer handles scope <-> file path
7
+ * mapping; the index layer is scope-agnostic.
7
8
  *
8
9
  * Path conventions:
9
10
  * ~/.yeaft/memory/user/memory.md
@@ -18,8 +19,10 @@ import {
18
19
  readFileSync, writeFileSync, existsSync, mkdirSync,
19
20
  readdirSync, statSync, renameSync,
20
21
  } from 'node:fs';
22
+ import { createHash } from 'node:crypto';
21
23
  import { join, dirname, relative, sep } from 'node:path';
22
24
  import { stripDreamStateBlocks } from './prompt-cleanup.js';
25
+ import { extractKeywords } from './keywords.js';
23
26
  import { parseSegments, serializeSegments } from './segment.js';
24
27
 
25
28
  /**
@@ -33,7 +36,9 @@ export function readScope(memoryRoot, scope) {
33
36
  const path = scopeFilePath(memoryRoot, scope);
34
37
  if (!existsSync(path)) return [];
35
38
  const text = readFileSync(path, 'utf8');
36
- return parseSegments(stripDreamStateBlocks(text), { defaultScope: scope });
39
+ const stripped = stripDreamStateBlocks(text);
40
+ if (!stripped.trimStart().startsWith('---')) return [];
41
+ return parseSegments(stripped, { defaultScope: scope });
37
42
  }
38
43
 
39
44
  /**
@@ -56,7 +61,37 @@ export function writeScope(memoryRoot, scope, segments) {
56
61
  }
57
62
 
58
63
  /**
59
- * Walk the memory root and return all scopes that have a memory.md.
64
+ * Read canonical content as a derived FTS record. The stable id lets normal
65
+ * sync delete or update it without changing the segment schema. This record is
66
+ * a scope selector only; Engine always reloads prompt text from content.md.
67
+ *
68
+ * @param {string} memoryRoot
69
+ * @param {string} scope
70
+ * @returns {import('./segment.js').Segment|null}
71
+ */
72
+ export function readCanonicalContentRecord(memoryRoot, scope) {
73
+ const path = join(memoryRoot, scope, 'content.md');
74
+ if (!existsSync(path)) return null;
75
+ const body = readFileSync(path, 'utf8').trim();
76
+ if (!body) return null;
77
+ const stat = statSync(path);
78
+ const timestamp = stat.mtime.toISOString();
79
+ const digest = createHash('sha256').update(scope).digest('hex').slice(0, 12);
80
+ return {
81
+ id: `content_${digest}`,
82
+ scope,
83
+ kind: 'context',
84
+ tags: ['canonical-content', ...extractKeywords(`${scope} ${body}`).slice(0, 128)],
85
+ sourceMessages: [],
86
+ createdAt: timestamp,
87
+ updatedAt: timestamp,
88
+ body,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Walk the memory root and return all scopes that have evidence memory.md or
94
+ * canonical content.md. Either representation must be queryable.
60
95
  *
61
96
  * @param {string} memoryRoot
62
97
  * @returns {string[]}
@@ -74,10 +109,11 @@ function walk(root, dir, out) {
74
109
  let st;
75
110
  try { st = statSync(full); } catch { continue; }
76
111
  if (st.isDirectory()) {
112
+ if (entry.startsWith('.')) continue;
77
113
  walk(root, full, out);
78
- } else if (entry === 'memory.md') {
114
+ } else if (entry === 'memory.md' || entry === 'content.md') {
79
115
  const rel = relative(root, dir).split(sep).join('/');
80
- if (rel) out.push(rel);
116
+ if (rel && !out.includes(rel)) out.push(rel);
81
117
  }
82
118
  }
83
119
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * memory/segment-sync.js — disk → SQLite reconciliation.
3
3
  *
4
- * Source of truth is on-disk memory.md per scope. SQLite is a derived
5
- * index. This module reads disk, diffs against SQLite, and emits
4
+ * Sources of truth are on-disk memory.md evidence and canonical content.md.
5
+ * SQLite is a derived index. This module reads disk, diffs against SQLite, and emits
6
6
  * upsert / delete operations.
7
7
  *
8
8
  * Strategy:
@@ -16,7 +16,7 @@
16
16
  * scope to limit work (`syncScope`).
17
17
  */
18
18
 
19
- import { listScopes, readScope } from './segment-store.js';
19
+ import { listScopes, readCanonicalContentRecord, readScope } from './segment-store.js';
20
20
 
21
21
  /**
22
22
  * Full sync: walk disk, reconcile every scope into the index. Returns
@@ -61,7 +61,11 @@ export function syncAll(memoryRoot, index) {
61
61
  * @returns {{ upserted: number, deleted: number }}
62
62
  */
63
63
  export function syncScope(memoryRoot, index, scope) {
64
- const onDisk = readScope(memoryRoot, scope);
64
+ const contentRecord = readCanonicalContentRecord(memoryRoot, scope);
65
+ const onDisk = [
66
+ ...readScope(memoryRoot, scope),
67
+ ...(contentRecord ? [contentRecord] : []),
68
+ ];
65
69
  const onDiskIds = new Set(onDisk.map(s => s.id));
66
70
  const inIndex = index.listByScope(scope);
67
71
  const inIndexIds = new Set(inIndex.map(s => s.id));
@@ -5,8 +5,10 @@
5
5
  * a self-contained semantic chunk (one segment per topic). NOT a copy
6
6
  * of messages — messages already live in conversation/messages/.
7
7
  *
8
- * Physical layout: each scope's `memory.md` is multiple segments
9
- * concatenated, each with a YAML frontmatter block and a body.
8
+ * Physical layout: each scope's `memory.md` is the evidence store containing
9
+ * multiple segments concatenated with YAML frontmatter. Prompt-facing canonical
10
+ * prose lives separately in `content.md`; segment bodies are never injected
11
+ * directly into the normal system prompt.
10
12
  *
11
13
  * ---
12
14
  * id: seg_<8hex>
@@ -52,7 +54,12 @@ export const KIND_VALUES = new Set([
52
54
  'workflow', 'pitfall', 'correction', 'project-convention',
53
55
  ]);
54
56
 
55
- const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|sessions\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|chat\/[\w-]+(?:\/vp\/[\w-]+)?|session\/[\w-]+(?:\/vp\/[\w-]+)?)$/;
57
+ const TOPIC_PART = '[\\w.\\-\\u4e00-\\u9fff]+';
58
+ const SCOPE_RE = new RegExp(`^(user|global|group\\/[\\w-]+(?:\\/(?:user|vp\\/[\\w-]+|feature\\/[\\w-]+|topic\\/${TOPIC_PART}(?:\\/${TOPIC_PART})?))?|sessions\\/[\\w-]+(?:\\/(?:user|vp\\/[\\w-]+|feature\\/[\\w-]+|topic\\/${TOPIC_PART}(?:\\/${TOPIC_PART})?))?|chat\\/[\\w-]+(?:\\/vp\\/[\\w-]+)?|session\\/[\\w-]+(?:\\/vp\\/[\\w-]+)?)$`);
59
+
60
+ export function isValidSegmentScope(value) {
61
+ return typeof value === 'string' && SCOPE_RE.test(value);
62
+ }
56
63
 
57
64
  /**
58
65
  * Compute a stable id from segment content. Same body + scope + kind →
@@ -1,16 +1,18 @@
1
1
  /**
2
- * memory/store.js — per-scope memory.md + summary.md (Layer-A storage).
2
+ * memory/store.js — per-scope canonical content + summary storage.
3
3
  *
4
- * One pair of files per scope. No shards, no entries/, no index.md, no
5
- * index.json. The five scope kinds — user, vp, group, feature, topic — share
6
- * a single shape:
4
+ * Every scope can carry three distinct representations:
5
+ *
6
+ * - content.md: Dream-maintained canonical prose. This is the prompt-facing
7
+ * source and preserves all currently valid durable information.
8
+ * - summary.md: a short catalog / triage index. It is not authoritative.
9
+ * - memory.md: atomic evidence segments with source message ids. Segment I/O
10
+ * lives in segment-store.js; this module retains the legacy raw accessors.
7
11
  *
8
12
  * ~/.yeaft/memory/
9
- * user/ memory.md summary.md
10
- * vp/<vpId>/ memory.md summary.md
11
- * group/<sessionId>/ memory.md summary.md
12
- * feature/<featureId>/ memory.md summary.md
13
- * topic/<l1>[/<l2>]/ memory.md summary.md (≤ 2 levels)
13
+ * user/ content.md memory.md summary.md
14
+ * sessions/<sessionId>/ content.md memory.md summary.md
15
+ * .../topic/<l1>[/<l2>]/ content.md memory.md summary.md (≤ 2 levels)
14
16
  *
15
17
  * Atomicity contract:
16
18
  * - Every write goes via `.tmp.<rand>` + rename. Renames are atomic on a
@@ -21,7 +23,7 @@
21
23
  *
22
24
  * Concurrency rules:
23
25
  * - Two writers to the same memory.md: last-rename wins. Dream is the only
24
- * code path that overwrites memory.md in v2; daily writes append. Append
26
+ * code path that overwrites memory.md; daily writes append. Append
25
27
  * is a single fs.appendFile call that POSIX guarantees is atomic for
26
28
  * buffers ≤ PIPE_BUF (≥ 4KB on every supported platform), which fits a
27
29
  * single fragment.
@@ -50,11 +52,12 @@ import {
50
52
  } from 'fs';
51
53
  import { join, dirname } from 'path';
52
54
  import { homedir } from 'os';
55
+ import { resolveTopicRedirect } from './topic-redirect.js';
53
56
 
54
57
  /** Default memory root. Tests override via `opts.root`. */
55
58
  export const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
56
59
 
57
- /** Scope kinds recognised by v2 (group-isolated layout). */
60
+ /** Scope kinds recognised by the current store, including legacy aliases. */
58
61
  export const SCOPE_KINDS = Object.freeze([
59
62
  'user',
60
63
  'group',
@@ -89,10 +92,14 @@ export const SCOPE_KINDS = Object.freeze([
89
92
  * @param {Scope} scope
90
93
  * @returns {string}
91
94
  */
92
- export function scopeDir(scope) {
95
+ export function scopeDir(scope, opts = {}) {
93
96
  if (!scope || typeof scope !== 'object') {
94
97
  throw new Error('scopeDir: scope is required');
95
98
  }
99
+ if (scope.kind === 'session-topic' && opts.root) {
100
+ const redirected = resolveTopicRedirect(opts.root, scope.sessionId, (scope.path || []).join('/'));
101
+ if (redirected) scope = { ...scope, path: redirected.split('/') };
102
+ }
96
103
  switch (scope.kind) {
97
104
  case 'user':
98
105
  return 'user';
@@ -230,9 +237,8 @@ export function isValidTopic(scope) {
230
237
  // ─── ACL ───────────────────────────────────────────────────────
231
238
 
232
239
  /**
233
- * The single ACL: `group/<g>/vp/<other>` is foreign when `currentVpId` is given.
234
- * Across groups, every `group/<g>/vp/...` path is foreign by construction
235
- * (the calling VP only runs inside its own group dir).
240
+ * The single ACL: a Session VP path owned by another VP is foreign when
241
+ * `currentVpId` is given. The `group/` spelling remains a legacy storage alias.
236
242
  *
237
243
  * @param {string} relPath
238
244
  * @param {string} currentVpId
@@ -268,7 +274,46 @@ async function atomicWrite(absPath, content) {
268
274
  await fsp.rename(tmp, absPath);
269
275
  }
270
276
 
271
- // ─── memory.md ─────────────────────────────────────────────────
277
+ // ─── content.md ────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Read a scope's canonical Dream content. Missing → empty string.
281
+ *
282
+ * Deliberately does not fall back to memory.md: modern memory.md is the
283
+ * segment evidence store. Migration callers must make an explicit,
284
+ * format-aware fallback decision instead of injecting evidence blocks as prose.
285
+ *
286
+ * @param {Scope} scope
287
+ * @param {{ root?: string, currentVpId?: string }} [opts]
288
+ * @returns {Promise<string>}
289
+ */
290
+ export async function readContent(scope, opts = {}) {
291
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
292
+ const rel = `${scopeDir(scope, { root })}/content.md`;
293
+ enforceVpAcl(rel, currentVpId);
294
+ const abs = join(root, rel);
295
+ try { return await fsp.readFile(abs, 'utf8'); }
296
+ catch (err) {
297
+ if (err && err.code === 'ENOENT') return '';
298
+ throw err;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Atomically rewrite a scope's canonical Dream content.
304
+ *
305
+ * @param {Scope} scope
306
+ * @param {string} content
307
+ * @param {{ root?: string, currentVpId?: string }} [opts]
308
+ */
309
+ export async function writeContent(scope, content, opts = {}) {
310
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
311
+ const rel = `${scopeDir(scope, { root })}/content.md`;
312
+ enforceVpAcl(rel, currentVpId);
313
+ await atomicWrite(join(root, rel), typeof content === 'string' ? content : '');
314
+ }
315
+
316
+ // ─── memory.md (segment evidence / legacy raw access) ──────────
272
317
 
273
318
  /**
274
319
  * Read a scope's memory.md. Missing → empty string.
@@ -279,7 +324,7 @@ async function atomicWrite(absPath, content) {
279
324
  */
280
325
  export async function readMemory(scope, opts = {}) {
281
326
  const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
282
- const rel = `${scopeDir(scope)}/memory.md`;
327
+ const rel = `${scopeDir(scope, { root })}/memory.md`;
283
328
  enforceVpAcl(rel, currentVpId);
284
329
  const abs = join(root, rel);
285
330
  try { return await fsp.readFile(abs, 'utf8'); }
@@ -298,7 +343,7 @@ export async function readMemory(scope, opts = {}) {
298
343
  */
299
344
  export async function writeMemory(scope, content, opts = {}) {
300
345
  const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
301
- const rel = `${scopeDir(scope)}/memory.md`;
346
+ const rel = `${scopeDir(scope, { root })}/memory.md`;
302
347
  enforceVpAcl(rel, currentVpId);
303
348
  const abs = join(root, rel);
304
349
  await atomicWrite(abs, typeof content === 'string' ? content : '');
@@ -320,7 +365,7 @@ export async function writeMemory(scope, content, opts = {}) {
320
365
  */
321
366
  export async function appendMemory(scope, chunk, opts = {}) {
322
367
  const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
323
- const rel = `${scopeDir(scope)}/memory.md`;
368
+ const rel = `${scopeDir(scope, { root })}/memory.md`;
324
369
  enforceVpAcl(rel, currentVpId);
325
370
  const abs = join(root, rel);
326
371
  await fsp.mkdir(dirname(abs), { recursive: true });
@@ -337,8 +382,8 @@ function summaryFileName(language) {
337
382
  return normalized === 'zh' ? 'summary.zh.md' : 'summary.md';
338
383
  }
339
384
 
340
- function summaryCandidateRels(scope, language) {
341
- const dir = scopeDir(scope);
385
+ function summaryCandidateRels(scope, language, root) {
386
+ const dir = scopeDir(scope, { root });
342
387
  const primary = `${dir}/${summaryFileName(language)}`;
343
388
  const fallback = `${dir}/summary.md`;
344
389
  return primary === fallback ? [fallback] : [primary, fallback];
@@ -353,7 +398,7 @@ function summaryCandidateRels(scope, language) {
353
398
  */
354
399
  export async function readSummary(scope, opts = {}) {
355
400
  const { root = DEFAULT_MEMORY_ROOT, currentVpId, language } = opts;
356
- for (const rel of summaryCandidateRels(scope, language)) {
401
+ for (const rel of summaryCandidateRels(scope, language, root)) {
357
402
  enforceVpAcl(rel, currentVpId);
358
403
  const abs = join(root, rel);
359
404
  try { return (await fsp.readFile(abs, 'utf8')).trim(); }
@@ -374,7 +419,7 @@ export async function readSummary(scope, opts = {}) {
374
419
  */
375
420
  export async function writeSummary(scope, body, opts = {}) {
376
421
  const { root = DEFAULT_MEMORY_ROOT, currentVpId, language } = opts;
377
- const rel = `${scopeDir(scope)}/${summaryFileName(language)}`;
422
+ const rel = `${scopeDir(scope, { root })}/${summaryFileName(language)}`;
378
423
  enforceVpAcl(rel, currentVpId);
379
424
  const abs = join(root, rel);
380
425
  await atomicWrite(abs, `${(body || '').trim()}\n`);
@@ -382,14 +427,12 @@ export async function writeSummary(scope, body, opts = {}) {
382
427
 
383
428
  /**
384
429
  * Seed a scope's summary.md if (and only if) it is missing or empty. Used
385
- * at create-time for VPs and groups so a fresh session has SOMETHING for
386
- * `engine.#prepareAms` to pull into the Layer-A resident summary — the
387
- * earlier behavior of "no summary.md until Dream-v2 runs" left the memory
388
- * section empty for the entire first session.
430
+ * at create-time for legacy bootstrap and catalog readers. The prompt path no
431
+ * longer consumes these seeds; it selects canonical content.md instead.
389
432
  *
390
433
  * Intentionally a no-op if a non-empty summary.md already exists, so this
391
- * is safe to call from any place that creates the scope (VP create, group
392
- * create, first-session bootstrap) without clobbering Dream-v2's writes.
434
+ * is safe to call from any existing scope-creation path without clobbering
435
+ * Dream's catalog writes.
393
436
  *
394
437
  * @param {Scope} scope
395
438
  * @param {string} body
@@ -422,7 +465,7 @@ export async function seedSummaryIfMissing(scope, body, opts = {}) {
422
465
  */
423
466
  export function seedSummaryIfMissingSync(scope, body, opts = {}) {
424
467
  const { root = DEFAULT_MEMORY_ROOT } = opts;
425
- const rel = `${scopeDir(scope)}/summary.md`;
468
+ const rel = `${scopeDir(scope, { root })}/summary.md`;
426
469
  const abs = join(root, rel);
427
470
  let existing = '';
428
471
  if (existsSync(abs)) {
@@ -438,7 +481,7 @@ export function seedSummaryIfMissingSync(scope, body, opts = {}) {
438
481
  /**
439
482
  * Synchronously remove a scope's directory under the memory root. Used by
440
483
  * `deleteVp` / `deleteSession` to cascade memory cleanup so a recreate of the
441
- * same id doesn't see stale `summary.md` / `memory.md` / `segments/` files.
484
+ * same id doesn't see stale `content.md` / `summary.md` / `memory.md` files.
442
485
  *
443
486
  * Idempotent — missing directory is a no-op.
444
487
  *
@@ -447,7 +490,7 @@ export function seedSummaryIfMissingSync(scope, body, opts = {}) {
447
490
  */
448
491
  export function removeScopeDirSync(scope, opts = {}) {
449
492
  const { root = DEFAULT_MEMORY_ROOT } = opts;
450
- const abs = join(root, scopeDir(scope));
493
+ const abs = join(root, scopeDir(scope, { root }));
451
494
  if (!existsSync(abs)) return;
452
495
  rmSync(abs, { recursive: true, force: true });
453
496
  }
@@ -462,7 +505,7 @@ export function removeScopeDirSync(scope, opts = {}) {
462
505
  */
463
506
  export function ensureScopeSync(scope, opts = {}) {
464
507
  const { root = DEFAULT_MEMORY_ROOT } = opts;
465
- const dir = join(root, scopeDir(scope));
508
+ const dir = join(root, scopeDir(scope, { root }));
466
509
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
467
510
  }
468
511
 
@@ -474,7 +517,7 @@ export function ensureScopeSync(scope, opts = {}) {
474
517
  */
475
518
  export async function ensureScope(scope, opts = {}) {
476
519
  const { root = DEFAULT_MEMORY_ROOT } = opts;
477
- const dir = join(root, scopeDir(scope));
520
+ const dir = join(root, scopeDir(scope, { root }));
478
521
  await fsp.mkdir(dir, { recursive: true });
479
522
  }
480
523
 
@@ -488,7 +531,8 @@ export async function ensureScope(scope, opts = {}) {
488
531
  * group/<g>/user/ → { kind: 'group-user', sessionId: g }
489
532
  * group/<g>/vp/<v>/ → { kind: 'group-vp', sessionId: g, id: v }
490
533
  * group/<g>/feature/<f>/ → { kind: 'group-feature', sessionId: g, id: f }
491
- * group/<g>/topic/<l1>[/<l2>]/ → { kind: 'group-topic', sessionId: g, path: [...] }
534
+ * group/<g>/topic/<l1>[/<l2>]/ → legacy group-topic
535
+ * sessions/<s>/topic/<l1>[/<l2>]/ → { kind: 'session-topic', sessionId: s, path: [...] }
492
536
  *
493
537
  * Skips `.legacy/` and any dotfile / unsafe segment.
494
538
  *
@@ -646,14 +690,20 @@ export async function listScopes(opts = {}) {
646
690
  for (const tent of topics) {
647
691
  if (!tent.isDirectory()) continue;
648
692
  if (!isSafeId(tent.name)) continue;
649
- out.push({ kind: 'session-topic', sessionId: s, path: [tent.name] });
693
+ const topicAbs = join(topicDir, tent.name);
650
694
  let subTopics;
651
- try { subTopics = await fsp.readdir(join(topicDir, tent.name), { withFileTypes: true }); }
695
+ try { subTopics = await fsp.readdir(topicAbs, { withFileTypes: true }); }
652
696
  catch { subTopics = []; }
697
+ const hasOwnMemory = ['content.md', 'memory.md', 'summary.md', 'summary.zh.md']
698
+ .some(name => existsSync(join(topicAbs, name)));
699
+ if (hasOwnMemory) out.push({ kind: 'session-topic', sessionId: s, path: [tent.name] });
653
700
  for (const sub of subTopics) {
654
701
  if (!sub.isDirectory()) continue;
655
702
  if (!isSafeId(sub.name)) continue;
656
- out.push({ kind: 'session-topic', sessionId: s, path: [tent.name, sub.name] });
703
+ const subAbs = join(topicAbs, sub.name);
704
+ const hasSubMemory = ['content.md', 'memory.md', 'summary.md', 'summary.zh.md']
705
+ .some(name => existsSync(join(subAbs, name)));
706
+ if (hasSubMemory) out.push({ kind: 'session-topic', sessionId: s, path: [tent.name, sub.name] });
657
707
  }
658
708
  }
659
709
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * memory/summary-store.js — DESIGN-H2-AMS §3.
3
3
  *
4
- * `summary.md` is a bounded, per-scope prose digest derived from all
5
- * segments in that scope. Resident AMS layer = concatenation of all
6
- * relevant scope summaries. Regenerated by Dream after segments change.
4
+ * `summary.md` is a bounded catalog description used by Dream triage and
5
+ * topic consolidation. It is not authoritative and is not prompt content;
6
+ * canonical prose lives in content.md, while evidence lives in memory.md.
7
7
  *
8
8
  * Layout:
9
9
  * ~/.yeaft/memory/<scope>/summary.md
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const TOPIC_REDIRECT_FILE = 'redirect.json';
5
+
6
+ export function normalizeTopicPath(value) {
7
+ const parts = String(value || '').split('/').map(part => part.trim()).filter(Boolean);
8
+ if (parts.length === 0 || parts.length > 2) return '';
9
+ if (parts.some(part => part === '.' || part === '..' || /[\\\0]/.test(part))) return '';
10
+ return parts.join('/');
11
+ }
12
+
13
+ export function resolveTopicRedirect(root, sessionId, path) {
14
+ let current = normalizeTopicPath(path);
15
+ const visited = new Set();
16
+ while (current && !visited.has(current)) {
17
+ visited.add(current);
18
+ const redirectPath = join(root, 'sessions', sessionId, 'topic', current, TOPIC_REDIRECT_FILE);
19
+ if (!existsSync(redirectPath)) break;
20
+ let payload;
21
+ try { payload = JSON.parse(readFileSync(redirectPath, 'utf8') || '{}'); }
22
+ catch { break; }
23
+ const next = normalizeTopicPath(payload?.canonical);
24
+ if (!next || next === current) break;
25
+ current = next;
26
+ }
27
+ return current;
28
+ }
package/yeaft/session.js CHANGED
@@ -35,18 +35,16 @@ import { TaskManager } from './tasks/manager.js';
35
35
  //
36
36
  // GC.1 (final): the session opens a SegmentIndex (SQLite FTS5 over
37
37
  // memory.md) and passes it to the Engine. Engine.#recallMemory routes
38
- // pre-turn recall through groups/pre-flow.js → memory/preflow.js (the
38
+ // pre-turn recall through sessions/pre-flow.js → memory/preflow.js (the
39
39
  // previous per-scope file reader recall-v2.js has been deleted).
40
40
  // The `config.memoryV2` opt-out flag was retired in task-710; wiring is
41
41
  // unconditional.
42
42
  //
43
- // GC.1 follow-up: when memoryIndex is wired we also open an
44
- // AmsRegistry. The registry caches per-group ActiveMemorySet
45
- // instances and persists their identity-only state under
46
- // `~/.yeaft/memory/groups/<gid>/ams.json` so a deactivated group
47
- // resumes with the same onDemand/recent membership it had on
48
- // disconnect. Engine.#runQuery uses the registry to populate the
49
- // AMS each turn and to run `memory/adjust.js` post-turn.
43
+ // When memoryIndex is wired we also open an AmsRegistry. It caches the
44
+ // per-Session ActiveMemorySet object and keeps the version-1 ams.json shape for
45
+ // disk compatibility. Engine rebuilds prompt-facing Resident entries from
46
+ // query-selected canonical content on every turn; persisted segment ids are
47
+ // never rehydrated into the prompt.
50
48
  import { ensureDefaultSessionIfEmpty, migrateRegisteredWorkDirSessions } from './sessions/session-crud.js';
51
49
  import { seedDefaultVps } from './vp/seed-defaults.js';
52
50
  import { topUpDefaultVps } from './vp/seed-topup.js';
@@ -54,6 +52,7 @@ import { archiveLegacyScopes } from './memory/seed-backfill.js';
54
52
  import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream/session-wiring.js';
55
53
  import { openSegmentIndex } from './memory/index-db.js';
56
54
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
55
+ import { backfillCanonicalContent } from './memory/content-backfill.js';
57
56
  import { openAmsRegistry } from './memory/ams-registry.js';
58
57
  import { join } from 'path';
59
58
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, mkdirSync as mkdirSyncSafe } from 'fs';
@@ -267,9 +266,10 @@ export async function loadSession(options = {}) {
267
266
  const conversationStore = new ConversationStore(yeaftDir);
268
267
 
269
268
  // ─── 5-fts. (GC.1) Open SegmentIndex for FTS pre-flow ────
270
- // Build a SQLite FTS5 index over ~/.yeaft/memory/<scope>/memory.md
271
- // and pass it to the Engine. Engine.#recallMemory uses it via
272
- // groups/pre-flow.js → memory/preflow.js. Disk is the source of
269
+ // Build a SQLite FTS5 index over per-scope evidence memory.md and
270
+ // canonical content.md, then pass it to Engine for scope selection.
271
+ // Engine.#recallMemory uses it via sessions/pre-flow.js →
272
+ // memory/preflow.js. Disk is the source of
273
273
  // truth; on boot we reconcile disk → index via syncAll. Failure
274
274
  // to open the index is non-fatal: #recallMemory returns an empty
275
275
  // result and the turn proceeds without pre-injected memory.
@@ -290,6 +290,7 @@ export async function loadSession(options = {}) {
290
290
  }
291
291
  }
292
292
  try {
293
+ backfillCanonicalContent(memoryRoot);
293
294
  syncSegmentIndex(memoryRoot, memoryIndex);
294
295
  } catch (syncErr) {
295
296
  // Sync is best-effort; an empty / partial index just produces
@@ -304,12 +305,11 @@ export async function loadSession(options = {}) {
304
305
  }
305
306
  }
306
307
 
307
- // ─── 5-ams. (GC.1 follow-up) Group-keyed AMS registry ────
308
+ // ─── 5-ams. Session-keyed AMS registry ─────────────────
308
309
  // The registry caches one ActiveMemorySet per sessionId and
309
- // persists their state to disk so a deactivated group can be
310
- // reactivated with the same onDemand/recent membership it had
311
- // on disconnect. Without memoryIndex we have nothing to
312
- // re-hydrate against, so the registry is left null in that case.
310
+ // retains version-1 metadata for disk compatibility. Prompt state is
311
+ // rebuilt from selected canonical content each turn; old segment ids are
312
+ // not rehydrated. Without memoryIndex the registry remains disabled.
313
313
  let amsRegistry = null;
314
314
  if (memoryIndex && !config._readOnly) {
315
315
  try {
@@ -559,9 +559,8 @@ export async function loadSession(options = {}) {
559
559
 
560
560
  // H2.f.5 retired the old session-level thread engine registry, input queue,
561
561
  // and dispatcher. The session exposes a default `engine`; PR #797 keeps
562
- // group VP thread engines in web-bridge runtime state and calls engine.query()
563
- // directly. Memory recall happens via memory/preflow.js (pre-turn) and
564
- // memory/adjust.js (post-turn).
562
+ // Session VP thread engines in web-bridge runtime state and calls engine.query()
563
+ // directly. Query-time recall happens via memory/preflow.js.
565
564
 
566
565
  // ─── 10. Build session ─────────────────────────────────
567
566
  const status = {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * groups/pre-flow.js — explicit pre-flow stage for Yeaft.
2
+ * sessions/pre-flow.js — explicit pre-flow stage for Yeaft.
3
3
  *
4
4
  * Pre-flow is the "before any VP runs" stage. It owns:
5
5
  *
@@ -252,14 +252,16 @@ export function formatPickedForInjection(picked) {
252
252
  /**
253
253
  * @typedef {object} MemoryPreflowOptions
254
254
  * @property {string} userMsg The user's message
255
- * @property {string} [sessionId] Active group, if any
255
+ * @property {string} [sessionId] Active Session, if any
256
256
  * @property {string} [vpId] Responding VP id, if any
257
257
  * @property {string} [featureId] Active feature, if any
258
258
  * @property {string[]} [extraScopes] Additional scopes to include
259
259
  * @property {string[]} [currentTags] Contextual tags for rerank
260
- * @property {number} [topK] Max FTS rows fetched (default 50)
260
+ * @property {number} [topK] Max FTS rows fetched (default 200)
261
261
  * @property {number} [budgetTokens] Token budget for picked segments
262
262
  * @property {number} [pickLimit] Max picked segments (default 8)
263
+ * @property {boolean} [uniqueScopes] Pick only the best hit per scope
264
+ * @property {boolean} [canonicalOnly] Search canonical content records only
263
265
  * @property {boolean} [fallbackOnEmpty] Include bounded recent scoped segments when FTS has no hits
264
266
  * @property {number} [fallbackPerScope] Max fallback segments per scope
265
267
  */
@@ -355,6 +357,8 @@ export function runMemoryPreflow(index, opts) {
355
357
  topK: opts.topK,
356
358
  budgetTokens: opts.budgetTokens,
357
359
  pickLimit: opts.pickLimit,
360
+ uniqueScopes: opts.uniqueScopes === true,
361
+ canonicalOnly: opts.canonicalOnly === true,
358
362
  });
359
363
 
360
364
  let fallbackUsed = false;
@@ -353,7 +353,16 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
353
353
  // Seed: mission becomes the first user prompt.
354
354
  if (!agent.pendingPrompts) agent.pendingPrompts = [];
355
355
  if (agent.mission && !agent.__missionSeeded) {
356
- agent.pendingPrompts.push(agent.mission);
356
+ agent.pendingPrompts.push({
357
+ prompt: agent.mission,
358
+ projectSessionIds: Array.isArray(deps.projectSessionIds)
359
+ ? deps.projectSessionIds.slice()
360
+ : [],
361
+ projectLabel: typeof deps.projectLabel === 'string' ? deps.projectLabel : '',
362
+ projectInstruction: typeof deps.projectInstruction === 'string'
363
+ ? deps.projectInstruction
364
+ : '',
365
+ });
357
366
  agent.__missionSeeded = true;
358
367
  }
359
368
 
@@ -115,6 +115,7 @@ async function createDefaultService() {
115
115
  }
116
116
  return {
117
117
  ...runtime,
118
+ yeaftDir: requireYeaftDir(),
118
119
  defaultWorkDir: ctx.CONFIG?.workDir || process.cwd(),
119
120
  };
120
121
  },