@yeaft/webchat-agent 0.1.856 → 0.1.859

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.
@@ -2,7 +2,7 @@
2
2
  * persist.js — Conversation message persistence
3
3
  *
4
4
  * Each message is stored as a .md file with YAML frontmatter in
5
- * ~/.yeaft/chat/messages/ or ~/.yeaft/group/messages/. Design: zero JSON, all Markdown.
5
+ * ~/.yeaft/chat/messages/ or ~/.yeaft/groups/<groupId>/conversation/messages/. Design: zero JSON, all Markdown.
6
6
  *
7
7
  * Message format:
8
8
  * ---
@@ -18,7 +18,7 @@
18
18
  * Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
19
19
  */
20
20
 
21
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
21
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync } from 'fs';
22
22
  import { join, basename } from 'path';
23
23
  import { isPermissionError } from '../init.js';
24
24
  import { pairSanitize } from '../pair-sanitize.js';
@@ -328,34 +328,30 @@ export function parseMessage(raw) {
328
328
  * messages/
329
329
  * cold/
330
330
  * blobs/
331
- * group/ — group mode history
332
- * index.md
333
- * compact.md
331
+ * groups/<groupId>/conversation/
332
+ * compact/
334
333
  * messages/
335
334
  * cold/
336
335
  * blobs/
337
336
  *
338
337
  * Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store.
339
- * New writes are split by mode: records with groupId go to group/, all others
340
- * go to chat/.
338
+ * New writes are split by mode: records with groupId go to
339
+ * groups/<groupId>/conversation/, all others go to chat/.
341
340
  */
342
341
  export class ConversationStore {
343
342
  #dir; // root dir (e.g. ~/.yeaft)
344
343
  #chatDir; // ~/.yeaft/chat
345
- #groupDir; // ~/.yeaft/group
344
+ #groupsDir; // ~/.yeaft/groups
346
345
  #legacyConvDir; // ~/.yeaft/conversation (read-only compatibility)
347
346
  #convDir; // default thread dir root: ~/.yeaft/chat
348
347
  #msgDir; // default hot messages dir: ~/.yeaft/chat/messages
349
348
  #coldDir; // default cold messages dir: ~/.yeaft/chat/cold
350
349
  #indexPath; // ~/.yeaft/chat/index.md
351
350
  #compactPath; // ~/.yeaft/chat/compact.md
352
- #compactScopedDir; // ~/.yeaft/group/compact/ (per-(group,vp))
353
351
  #legacyCompactPath;
354
352
  #legacyCompactScopedDir;
355
353
  #chatMsgDir;
356
354
  #chatColdDir;
357
- #groupMsgDir;
358
- #groupColdDir;
359
355
  #legacyMsgDir;
360
356
  #legacyColdDir;
361
357
  #nextSeq; // next message sequence number across chat/group/legacy
@@ -367,7 +363,7 @@ export class ConversationStore {
367
363
  constructor(dir) {
368
364
  this.#dir = dir;
369
365
  this.#chatDir = join(dir, 'chat');
370
- this.#groupDir = join(dir, 'group');
366
+ this.#groupsDir = join(dir, 'groups');
371
367
  this.#legacyConvDir = join(dir, 'conversation');
372
368
 
373
369
  this.#convDir = this.#chatDir;
@@ -379,23 +375,23 @@ export class ConversationStore {
379
375
 
380
376
  this.#chatMsgDir = this.#msgDir;
381
377
  this.#chatColdDir = this.#coldDir;
382
- this.#groupMsgDir = join(this.#groupDir, 'messages');
383
- this.#groupColdDir = join(this.#groupDir, 'cold');
384
378
  this.#legacyMsgDir = join(this.#legacyConvDir, 'messages');
385
379
  this.#legacyColdDir = join(this.#legacyConvDir, 'cold');
386
380
 
387
- // Per-(groupId, vpId) compact summary files live in group mode. The legacy
388
- // ~/.yeaft/conversation/compact directory is read for compatibility.
389
- this.#compactScopedDir = join(this.#groupDir, 'compact');
381
+ // Per-(groupId, vpId) compact summary files live under that group's
382
+ // conversation directory. The legacy ~/.yeaft/conversation/compact directory
383
+ // is read for compatibility.
390
384
  this.#legacyCompactScopedDir = join(this.#legacyConvDir, 'compact');
391
385
  this.#nextSeq = null;
392
386
  this.#nextSeqByThread = new Map();
393
387
 
394
- // Ensure new chat/group directories exist (graceful on permission errors).
395
- // The legacy conversation directory is never created by new versions.
388
+ // Ensure new chat and group-root directories exist (graceful on permission
389
+ // errors). Per-group conversation directories are created lazily once a
390
+ // groupId is known. The legacy conversation directory is never created by
391
+ // new versions.
396
392
  for (const d of [
397
393
  this.#chatDir, join(this.#chatDir, 'blobs'), this.#chatMsgDir, this.#chatColdDir,
398
- this.#groupDir, join(this.#groupDir, 'blobs'), this.#groupMsgDir, this.#groupColdDir, this.#compactScopedDir,
394
+ this.#groupsDir,
399
395
  ]) {
400
396
  try {
401
397
  if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
@@ -539,10 +535,8 @@ export class ConversationStore {
539
535
  /**
540
536
  * Sanitize one id (groupId or vpId) into a safe filename component.
541
537
  * Anything outside `[A-Za-z0-9._-]` collapses to `_`; max 120 chars.
542
- * The result is only ever used as a basename joined to `compactScopedDir`
543
- * path traversal is blocked by the basename-only `join`, not by the
544
- * regex (a literal `..` stays as `..` here and becomes part of a
545
- * regular filename via the `__` separator + `.md` suffix).
538
+ * For directory path components, use `#safeDirComponent` instead; this
539
+ * helper intentionally preserves historical compact-summary filenames.
546
540
  *
547
541
  * @param {string} s
548
542
  * @returns {string}
@@ -551,6 +545,11 @@ export class ConversationStore {
551
545
  return String(s).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
552
546
  }
553
547
 
548
+ #safeDirComponent(s) {
549
+ const safe = this.#safeIdComponent(s).replace(/^\.+$/, '_');
550
+ return safe || '_';
551
+ }
552
+
554
553
  /**
555
554
  * Sanitize a (groupId, vpId) pair into a safe filename. We accept
556
555
  * arbitrary user strings here (groupIds and vpIds are user-set), so
@@ -562,7 +561,8 @@ export class ConversationStore {
562
561
  */
563
562
  #scopedCompactPath(groupId, vpId) {
564
563
  if (!groupId || !vpId) return null;
565
- return join(this.#compactScopedDir, `${this.#safeIdComponent(groupId)}__${this.#safeIdComponent(vpId)}.md`);
564
+ const compactDir = join(this.#groupConversationDir(groupId, { create: true }), 'compact');
565
+ return join(compactDir, `${this.#safeIdComponent(vpId)}.md`);
566
566
  }
567
567
 
568
568
  #legacyScopedCompactPath(groupId, vpId) {
@@ -632,12 +632,13 @@ export class ConversationStore {
632
632
  */
633
633
  hasAnyCompactSummaryForGroup(groupId) {
634
634
  if (!groupId) return false;
635
- const prefix = `${this.#safeIdComponent(groupId)}__`;
636
- for (const dir of [this.#compactScopedDir, this.#legacyCompactScopedDir]) {
635
+ const compactDir = join(this.#groupConversationDir(groupId), 'compact');
636
+ for (const dir of [compactDir, this.#legacyCompactScopedDir]) {
637
637
  if (!existsSync(dir)) continue;
638
638
  try {
639
639
  for (const f of readdirSync(dir)) {
640
- if (f.startsWith(prefix) && f.endsWith('.md')) return true;
640
+ if (dir === compactDir && f.endsWith('.md')) return true;
641
+ if (dir === this.#legacyCompactScopedDir && f.startsWith(`${this.#safeIdComponent(groupId)}__`) && f.endsWith('.md')) return true;
641
642
  }
642
643
  } catch { /* best-effort */ }
643
644
  }
@@ -686,7 +687,7 @@ export class ConversationStore {
686
687
  * Clear all messages (hot + cold + compact).
687
688
  */
688
689
  clear() {
689
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#groupMsgDir, this.#groupColdDir]) {
690
+ for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold')]) {
690
691
  if (existsSync(dir)) {
691
692
  for (const file of readdirSync(dir)) {
692
693
  if (file.endsWith('.md')) {
@@ -785,7 +786,7 @@ export class ConversationStore {
785
786
  */
786
787
  loadRecentByGroup(groupId, turnsLimit = DEFAULT_RECENT_TURNS) {
787
788
  if (!groupId) return [];
788
- const all = this.#loadGroupMessages();
789
+ const all = this.#loadGroupMessages(groupId)
789
790
  const filtered = all.filter(m => m && m.groupId === groupId);
790
791
  if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
791
792
  return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
@@ -828,7 +829,7 @@ export class ConversationStore {
828
829
  */
829
830
  loadGroupHistoryForVp(groupId, vpId) {
830
831
  if (!groupId || !vpId) return [];
831
- const all = this.#loadGroupMessages();
832
+ const all = this.#loadGroupMessages(groupId)
832
833
  const out = [];
833
834
  for (const m of all) {
834
835
  if (!m || m.groupId !== groupId) continue;
@@ -900,8 +901,8 @@ export class ConversationStore {
900
901
  */
901
902
  loadOlderByGroup(groupId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
902
903
  if (!groupId) return { messages: [], oldestSeq: null, hasMore: false };
903
- const hot = this.#loadGroupHotMessages();
904
- const cold = this.#loadGroupColdMessages();
904
+ const hot = this.#loadGroupHotMessages(groupId);
905
+ const cold = this.#loadGroupColdMessages(groupId);
905
906
  // Cold ids strictly < hot ids by construction → chronological concat.
906
907
  const all = [...cold, ...hot];
907
908
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
@@ -941,8 +942,8 @@ export class ConversationStore {
941
942
  if (!groupId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
942
943
 
943
944
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
944
- const hot = this.#loadVisibleFromDirsByGroup([this.#groupMsgDir, this.#legacyMsgDir], groupId, cutoff);
945
- const cold = this.#loadVisibleFromDirsByGroup([this.#groupColdDir, this.#legacyColdDir], groupId, cutoff);
945
+ const hot = this.#loadVisibleFromDirsByGroup([...this.#groupMessageDirs('messages', groupId), this.#legacyMsgDir], groupId, cutoff);
946
+ const cold = this.#loadVisibleFromDirsByGroup([...this.#groupMessageDirs('cold', groupId), this.#legacyColdDir], groupId, cutoff);
946
947
  const visible = [...cold, ...hot];
947
948
  if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
948
949
 
@@ -969,7 +970,7 @@ export class ConversationStore {
969
970
  * @returns {number}
970
971
  */
971
972
  countHot() {
972
- return this.#countFilesInDirs([this.#chatMsgDir, this.#groupMsgDir, this.#legacyMsgDir]);
973
+ return this.#countFilesInDirs([this.#chatMsgDir, ...this.#groupMessageDirs('messages'), this.#legacyMsgDir]);
973
974
  }
974
975
 
975
976
  /**
@@ -978,7 +979,7 @@ export class ConversationStore {
978
979
  * @returns {number}
979
980
  */
980
981
  countCold() {
981
- return this.#countFilesInDirs([this.#chatColdDir, this.#groupColdDir, this.#legacyColdDir]);
982
+ return this.#countFilesInDirs([this.#chatColdDir, ...this.#groupMessageDirs('cold'), this.#legacyColdDir]);
982
983
  }
983
984
 
984
985
  /**
@@ -1034,7 +1035,7 @@ export class ConversationStore {
1034
1035
  deleteByGroup(groupId) {
1035
1036
  if (!groupId) return 0;
1036
1037
  let removed = 0;
1037
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#groupMsgDir, this.#groupColdDir, this.#legacyMsgDir, this.#legacyColdDir]) {
1038
+ for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1038
1039
  if (!existsSync(dir)) continue;
1039
1040
  let files;
1040
1041
  try {
@@ -1092,7 +1093,7 @@ export class ConversationStore {
1092
1093
  let scanned = 0;
1093
1094
  let removed = 0;
1094
1095
  const orphans = [];
1095
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#groupMsgDir, this.#groupColdDir, this.#legacyMsgDir, this.#legacyColdDir]) {
1096
+ for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1096
1097
  if (!existsSync(dir)) continue;
1097
1098
  let files;
1098
1099
  try {
@@ -1146,7 +1147,7 @@ export class ConversationStore {
1146
1147
  reassignThread(sourceId, targetId) {
1147
1148
  if (!sourceId || !targetId || sourceId === targetId) return 0;
1148
1149
  let rewritten = 0;
1149
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#groupMsgDir, this.#groupColdDir, this.#legacyMsgDir, this.#legacyColdDir]) {
1150
+ for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1150
1151
  if (!existsSync(dir)) continue;
1151
1152
  let files;
1152
1153
  try {
@@ -1225,7 +1226,7 @@ export class ConversationStore {
1225
1226
 
1226
1227
  // Collect source-thread candidate files from both hot + cold dirs.
1227
1228
  const candidates = [];
1228
- for (const dir of [this.#chatColdDir, this.#chatMsgDir, this.#groupColdDir, this.#groupMsgDir, this.#legacyColdDir, this.#legacyMsgDir]) {
1229
+ for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#groupMessageDirs('cold'), ...this.#groupMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
1229
1230
  if (!existsSync(dir)) continue;
1230
1231
  let files;
1231
1232
  try {
@@ -1332,7 +1333,7 @@ export class ConversationStore {
1332
1333
  }
1333
1334
  // Legacy: messages live in the flat dir stamped with threadId.
1334
1335
  const collected = [];
1335
- for (const dir of [this.#chatColdDir, this.#chatMsgDir, this.#groupColdDir, this.#groupMsgDir, this.#legacyColdDir, this.#legacyMsgDir]) {
1336
+ for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#groupMessageDirs('cold'), ...this.#groupMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
1336
1337
  if (!existsSync(dir)) continue;
1337
1338
  for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
1338
1339
  try {
@@ -1353,13 +1354,53 @@ export class ConversationStore {
1353
1354
  }
1354
1355
 
1355
1356
  #messageDirFor(msg) {
1356
- return msg?.groupId ? this.#groupMsgDir : this.#chatMsgDir;
1357
+ if (!msg?.groupId) return this.#chatMsgDir;
1358
+ return join(this.#groupConversationDir(msg.groupId, { create: true }), 'messages');
1359
+ }
1360
+
1361
+ #groupConversationDir(groupId, { create = false } = {}) {
1362
+ const dir = join(this.#groupsDir, this.#safeDirComponent(groupId), 'conversation');
1363
+ if (create) this.#ensureConversationDirs(dir);
1364
+ return dir;
1365
+ }
1366
+
1367
+ #ensureConversationDirs(dir) {
1368
+ for (const d of [dir, join(dir, 'blobs'), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
1369
+ if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
1370
+ }
1371
+ }
1372
+
1373
+ #groupConversationDirs() {
1374
+ if (!existsSync(this.#groupsDir)) return [];
1375
+ const dirs = [];
1376
+ for (const name of readdirSync(this.#groupsDir)) {
1377
+ const groupDir = join(this.#groupsDir, name);
1378
+ try {
1379
+ if (!statSync(groupDir).isDirectory()) continue;
1380
+ } catch (err) {
1381
+ if (isPermissionError(err)) continue;
1382
+ throw err;
1383
+ }
1384
+ const conversationDir = join(groupDir, 'conversation');
1385
+ if (existsSync(conversationDir)) dirs.push(conversationDir);
1386
+ }
1387
+ return dirs;
1388
+ }
1389
+
1390
+ #groupMessageDirs(kind, groupId = null) {
1391
+ if (groupId) {
1392
+ const dir = join(this.#groupConversationDir(groupId), kind);
1393
+ return existsSync(dir) ? [dir] : [];
1394
+ }
1395
+ return this.#groupConversationDirs()
1396
+ .map(dir => join(dir, kind))
1397
+ .filter(dir => existsSync(dir));
1357
1398
  }
1358
1399
 
1359
1400
  #hotColdDirPairs({ includeLegacy = true } = {}) {
1360
1401
  const pairs = [
1361
1402
  [this.#chatMsgDir, this.#chatColdDir],
1362
- [this.#groupMsgDir, this.#groupColdDir],
1403
+ ...this.#groupConversationDirs().map(dir => [join(dir, 'messages'), join(dir, 'cold')]),
1363
1404
  ];
1364
1405
  if (includeLegacy) pairs.push([this.#legacyMsgDir, this.#legacyColdDir]);
1365
1406
  return pairs;
@@ -1375,22 +1416,22 @@ export class ConversationStore {
1375
1416
  ].sort(compareMessagesBySeq);
1376
1417
  }
1377
1418
 
1378
- #loadGroupHotMessages() {
1419
+ #loadGroupHotMessages(groupId = null) {
1379
1420
  return [
1380
1421
  ...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => m?.groupId),
1381
- ...this.#loadFromDir(this.#groupMsgDir, Infinity),
1422
+ ...this.#groupMessageDirs('messages', groupId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1382
1423
  ].sort(compareMessagesBySeq);
1383
1424
  }
1384
1425
 
1385
- #loadGroupColdMessages() {
1426
+ #loadGroupColdMessages(groupId = null) {
1386
1427
  return [
1387
1428
  ...this.#loadFromDir(this.#legacyColdDir, Infinity).filter(m => m?.groupId),
1388
- ...this.#loadFromDir(this.#groupColdDir, Infinity),
1429
+ ...this.#groupMessageDirs('cold', groupId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1389
1430
  ].sort(compareMessagesBySeq);
1390
1431
  }
1391
1432
 
1392
- #loadGroupMessages() {
1393
- return [...this.#loadGroupColdMessages(), ...this.#loadGroupHotMessages()].sort(compareMessagesBySeq);
1433
+ #loadGroupMessages(groupId = null) {
1434
+ return [...this.#loadGroupColdMessages(groupId), ...this.#loadGroupHotMessages(groupId)].sort(compareMessagesBySeq);
1394
1435
  }
1395
1436
 
1396
1437
  #loadAllMessages() {
@@ -1399,8 +1440,8 @@ export class ConversationStore {
1399
1440
  ...this.#loadFromDir(this.#legacyMsgDir, Infinity),
1400
1441
  ...this.#loadFromDir(this.#chatColdDir, Infinity),
1401
1442
  ...this.#loadFromDir(this.#chatMsgDir, Infinity),
1402
- ...this.#loadFromDir(this.#groupColdDir, Infinity),
1403
- ...this.#loadFromDir(this.#groupMsgDir, Infinity),
1443
+ ...this.#groupMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
1444
+ ...this.#groupMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
1404
1445
  ].sort(compareMessagesBySeq);
1405
1446
  }
1406
1447
 
@@ -1514,7 +1555,7 @@ export class ConversationStore {
1514
1555
  if (this.#nextSeq != null) return this.#nextSeq;
1515
1556
 
1516
1557
  let maxSeq = 0;
1517
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, this.#groupMsgDir, this.#groupColdDir, this.#legacyMsgDir, this.#legacyColdDir]) {
1558
+ for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1518
1559
  if (!existsSync(dir)) continue;
1519
1560
  for (const file of readdirSync(dir)) {
1520
1561
  const match = file.match(/^m(\d+)\.md$/);
@@ -4,7 +4,7 @@
4
4
  * Simple keyword search across hot and cold messages.
5
5
  */
6
6
 
7
- import { existsSync, readdirSync, readFileSync } from 'fs';
7
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { parseMessage, parseSeqFromId } from './persist.js';
10
10
 
@@ -35,15 +35,34 @@ function searchDir(dir, keyword) {
35
35
  return results;
36
36
  }
37
37
 
38
- function compareNewestFirst(a, b) {
38
+ function compareNewest(a, b) {
39
39
  const sa = parseSeqFromId(a?.id);
40
40
  const sb = parseSeqFromId(b?.id);
41
41
  if (Number.isFinite(sa) && Number.isFinite(sb) && sa !== sb) return sb - sa;
42
42
  return String(b?.time || '').localeCompare(String(a?.time || ''));
43
43
  }
44
44
 
45
+ function groupConversationMessageDirs(dir) {
46
+ const groupsDir = join(dir, 'groups');
47
+ if (!existsSync(groupsDir)) return [];
48
+
49
+ const dirs = [];
50
+ for (const name of readdirSync(groupsDir)) {
51
+ const groupDir = join(groupsDir, name);
52
+ try {
53
+ if (!statSync(groupDir).isDirectory()) continue;
54
+ } catch {
55
+ continue;
56
+ }
57
+
58
+ const conversationDir = join(groupDir, 'conversation');
59
+ dirs.push(join(conversationDir, 'messages'), join(conversationDir, 'cold'));
60
+ }
61
+ return dirs;
62
+ }
63
+
45
64
  /**
46
- * Search Yeaft history (chat + group + legacy conversation) for a keyword.
65
+ * Search Yeaft history (chat + per-group + legacy conversation) for a keyword.
47
66
  *
48
67
  * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
49
68
  * @param {string} keyword — search term
@@ -56,8 +75,7 @@ export function searchMessages(dir, keyword, limit = 20) {
56
75
  const dirs = [
57
76
  join(dir, 'chat', 'messages'),
58
77
  join(dir, 'chat', 'cold'),
59
- join(dir, 'group', 'messages'),
60
- join(dir, 'group', 'cold'),
78
+ ...groupConversationMessageDirs(dir),
61
79
  // Compatibility for profiles created before chat/group split.
62
80
  join(dir, 'conversation', 'messages'),
63
81
  join(dir, 'conversation', 'cold'),
@@ -65,6 +83,6 @@ export function searchMessages(dir, keyword, limit = 20) {
65
83
 
66
84
  return dirs
67
85
  .flatMap(d => searchDir(d, keyword))
68
- .sort(compareNewestFirst)
86
+ .sort(compareNewest)
69
87
  .slice(0, limit);
70
88
  }
@@ -120,12 +120,28 @@ export function targetToScope(target) {
120
120
  if (!target || typeof target !== 'string') throw new Error('apply.targetToScope: target required');
121
121
  if (target === 'user') return { kind: 'user' };
122
122
  const segs = target.split('/').filter(Boolean);
123
- const head = segs[0];
124
- if (head === 'vp' && segs.length === 2) return { kind: 'vp', id: segs[1] };
125
- if (head === 'group' && segs.length === 2) return { kind: 'group', id: segs[1] };
126
- if (head === 'feature' && segs.length === 2) return { kind: 'feature', id: segs[1] };
127
- if (head === 'topic' && (segs.length === 2 || segs.length === 3)) {
128
- return { kind: 'topic', path: segs.slice(1) };
123
+ // Legacy scopes — explicitly rejected. Old data lives under .legacy/.
124
+ if (segs[0] === 'vp' || segs[0] === 'feature' || segs[0] === 'topic') {
125
+ throw new Error(`apply.targetToScope: legacy root scope ${JSON.stringify(target)} rejected use group/<g>/${segs[0]}/...`);
126
+ }
127
+ if (segs[0] === 'group') {
128
+ if (segs.length === 2) return { kind: 'group', id: segs[1] };
129
+ // group/<g>/user
130
+ if (segs.length === 3 && segs[2] === 'user') {
131
+ return { kind: 'group-user', groupId: segs[1] };
132
+ }
133
+ // group/<g>/vp/<v>
134
+ if (segs.length === 4 && segs[2] === 'vp') {
135
+ return { kind: 'group-vp', groupId: segs[1], id: segs[3] };
136
+ }
137
+ // group/<g>/feature/<f>
138
+ if (segs.length === 4 && segs[2] === 'feature') {
139
+ return { kind: 'group-feature', groupId: segs[1], id: segs[3] };
140
+ }
141
+ // group/<g>/topic/<l1>[/<l2>]
142
+ if (segs[2] === 'topic' && (segs.length === 4 || segs.length === 5)) {
143
+ return { kind: 'group-topic', groupId: segs[1], path: segs.slice(3) };
144
+ }
129
145
  }
130
146
  throw new Error(`apply.targetToScope: malformed target ${JSON.stringify(target)}`);
131
147
  }
@@ -252,11 +268,12 @@ export async function applyMergedTarget(merged, opts) {
252
268
 
253
269
  function scopeRelDir(scope) {
254
270
  switch (scope.kind) {
255
- case 'user': return 'user';
256
- case 'vp': return `vp/${scope.id}`;
257
- case 'group': return `group/${scope.id}`;
258
- case 'feature': return `feature/${scope.id}`;
259
- case 'topic': return `topic/${scope.path.join('/')}`;
271
+ case 'user': return 'user';
272
+ case 'group': return `group/${scope.id}`;
273
+ case 'group-user': return `group/${scope.groupId}/user`;
274
+ case 'group-vp': return `group/${scope.groupId}/vp/${scope.id}`;
275
+ case 'group-feature': return `group/${scope.groupId}/feature/${scope.id}`;
276
+ case 'group-topic': return `group/${scope.groupId}/topic/${scope.path.join('/')}`;
260
277
  default: throw new Error(`apply.scopeRelDir: unknown kind ${scope.kind}`);
261
278
  }
262
279
  }
@@ -41,8 +41,15 @@ const FILES = {
41
41
  export function extractTemplateForScope(scope) {
42
42
  if (!scope || typeof scope !== 'string') return 'extractTopic';
43
43
  if (scope === 'user') return 'extractUser';
44
- if (scope.startsWith('vp/')) return 'extractVp';
44
+ // Nested group-isolated scopes must be matched BEFORE the bare `group/<g>`
45
+ // branch so VPs/topics/features under a group don't get the group template.
46
+ if (/^group\/[^/]+\/vp\//.test(scope)) return 'extractVp';
47
+ if (/^group\/[^/]+\/topic\//.test(scope)) return 'extractTopic';
48
+ if (/^group\/[^/]+\/user(?:\/|$)/.test(scope)) return 'extractUser';
45
49
  if (scope.startsWith('group/')) return 'extractGroup';
50
+ // Legacy top-level vp/topic scopes (archived to .legacy/ on boot — kept
51
+ // here defensively in case something still constructs the old strings).
52
+ if (scope.startsWith('vp/')) return 'extractVp';
46
53
  if (scope.startsWith('topic/')) return 'extractTopic';
47
54
  return 'extractTopic';
48
55
  }
@@ -95,9 +95,14 @@ export async function runDream(opts) {
95
95
  const processedGroups = [];
96
96
 
97
97
  // 2. per-group: skip / segment / triage
98
- const topicSummaries = opts.listTopicSummaries
99
- ? await safeCall(opts.listTopicSummaries, [])
100
- : await defaultListTopicSummaries(opts.root, opts.language).catch(() => []);
98
+ // Topic summaries are now per-group (group/<g>/topic/...), so resolve
99
+ // them inside the per-group loop instead of once up front.
100
+ const resolveTopicSummaries = async (groupId) => {
101
+ if (opts.listTopicSummaries) {
102
+ return await safeCall(() => opts.listTopicSummaries(groupId), []);
103
+ }
104
+ return await defaultListTopicSummaries(opts.root, groupId, opts.language).catch(() => []);
105
+ };
101
106
 
102
107
  for (const groupId of groupIds) {
103
108
  // Current-group manual dream passes are the one case where scopeFilter
@@ -152,6 +157,7 @@ export async function runDream(opts) {
152
157
 
153
158
  let actions;
154
159
  try {
160
+ const topicSummaries = await resolveTopicSummaries(groupId);
155
161
  actions = await triageGroupSegments({
156
162
  groupId,
157
163
  segments,
@@ -304,11 +310,12 @@ async function safeCall(fn, fallback) {
304
310
  }
305
311
  }
306
312
 
307
- async function defaultListTopicSummaries(root, language) {
313
+ async function defaultListTopicSummaries(root, groupId, language) {
308
314
  const all = await listScopes({ root });
309
315
  const out = [];
310
316
  for (const sc of all) {
311
- if (sc.kind !== 'topic') continue;
317
+ if (sc.kind !== 'group-topic') continue;
318
+ if (sc.groupId !== groupId) continue;
312
319
  const summary = await readSummary(sc, { root, language });
313
320
  out.push({ path: sc.path.join('/'), summary });
314
321
  }
@@ -62,20 +62,24 @@ export function applyHardRules({ groupId, messages }) {
62
62
  const out = new Map();
63
63
  const add = (scope) => { if (!out.has(scope)) out.set(scope, { kind: 'update', scope }); };
64
64
 
65
- // user is always in.
65
+ // global user is always in.
66
66
  add('user');
67
67
 
68
- // active group, except the virtual _no-group bucket.
69
- if (groupId && groupId !== '_no-group') add(`group/${groupId}`);
68
+ // active group + its per-group user layer, except the virtual _no-group bucket.
69
+ if (groupId && groupId !== '_no-group') {
70
+ add(`group/${groupId}`);
71
+ add(`group/${groupId}/user`);
72
+ }
70
73
 
71
74
  for (const m of (messages || [])) {
72
75
  if (!m || typeof m !== 'object') continue;
73
- // Active VP: any assistant message's vpId.
76
+ // Active VP: any assistant message's vpId — now group-internal.
74
77
  if (m.role === 'assistant') {
75
78
  const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
76
- if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp)) add(`vp/${vp}`);
79
+ if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp) && groupId && groupId !== '_no-group') {
80
+ add(`group/${groupId}/vp/${vp}`);
81
+ }
77
82
  }
78
- // (Active feature scope was dropped 2026-05-13 with the Feature system.)
79
83
  }
80
84
 
81
85
  return Array.from(out.values());
@@ -161,8 +165,9 @@ export async function classifySoft({ groupId, messages, topicSummaries, llm, lan
161
165
  const path = String(pass2.path || '').trim();
162
166
  if (!path) continue;
163
167
  const segs = path.split('/').filter(Boolean);
164
- if (!isValidTopic({ kind: 'topic', path: segs })) continue;
165
- const scope = `topic/${segs.join('/')}`;
168
+ if (!groupId || groupId === '_no-group') continue;
169
+ if (!isValidTopic({ kind: 'group-topic', groupId, path: segs })) continue;
170
+ const scope = `group/${groupId}/topic/${segs.join('/')}`;
166
171
  if (pass2.decision === 'match') {
167
172
  out.push({ kind: 'update', scope });
168
173
  } else if (pass2.decision === 'new') {
package/yeaft/engine.js CHANGED
@@ -569,8 +569,8 @@ export class Engine {
569
569
  groupId
570
570
  ? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot, language }).catch(() => '')
571
571
  : Promise.resolve(''),
572
- vpId
573
- ? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot, language }).catch(() => '')
572
+ vpId && groupId
573
+ ? readScopeSummary({ kind: 'group-vp', groupId, id: vpId }, { root: memoryRoot, language }).catch(() => '')
574
574
  : Promise.resolve(''),
575
575
  ];
576
576
  const [user, group, vp] = await Promise.all(tasks);
@@ -167,6 +167,15 @@ export function selectRespondingVps(input) {
167
167
  */
168
168
  function scopeHeading(scope) {
169
169
  if (scope === 'user') return '## Memory: User';
170
+ // Nested group scopes first.
171
+ let m = /^group\/([^/]+)\/vp\/(.+)$/.exec(scope);
172
+ if (m) return `## Memory: VP ${m[2]}`;
173
+ m = /^group\/([^/]+)\/user$/.exec(scope);
174
+ if (m) return `## Memory: Group ${m[1]} (user)`;
175
+ m = /^group\/([^/]+)\/feature\/(.+)$/.exec(scope);
176
+ if (m) return `## Memory: Feature ${m[2]}`;
177
+ m = /^group\/([^/]+)\/topic\/(.+)$/.exec(scope);
178
+ if (m) return `## Memory: Topic ${m[2]}`;
170
179
  if (scope.startsWith('group/')) return `## Memory: Group ${scope.slice(6)}`;
171
180
  if (scope.startsWith('vp/')) return `## Memory: VP ${scope.slice(3)}`;
172
181
  if (scope.startsWith('feature/')) return `## Memory: Feature ${scope.slice(8)}`;
@@ -238,8 +247,11 @@ export function formatPickedForInjection(picked) {
238
247
  */
239
248
  export function buildRelevantScopes({ groupId, vpId, extra } = {}) {
240
249
  const scopes = ['user'];
241
- if (groupId) scopes.push(`group/${groupId}`);
242
- if (vpId) scopes.push(`vp/${vpId}`);
250
+ if (groupId) {
251
+ scopes.push(`group/${groupId}`);
252
+ scopes.push(`group/${groupId}/user`);
253
+ if (vpId) scopes.push(`group/${groupId}/vp/${vpId}`);
254
+ }
243
255
  if (Array.isArray(extra)) {
244
256
  for (const s of extra) {
245
257
  if (s && !scopes.includes(s)) scopes.push(s);