@yeaft/webchat-agent 0.1.700 → 0.1.701

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.700",
3
+ "version": "0.1.701",
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/cli.js CHANGED
@@ -31,6 +31,8 @@ import { loadSession } from './session.js';
31
31
  import { listModels, resolveModel, parseModelRef } from './models.js';
32
32
  import { buildSystemPrompt } from './prompts.js';
33
33
  import { searchMessages } from './conversation/search.js';
34
+ import { ConversationStore } from './conversation/persist.js';
35
+ import { snapshotGroups } from './groups/group-crud.js';
34
36
 
35
37
  // ─── Argument parsing ──────────────────────────────────────────
36
38
 
@@ -46,6 +48,9 @@ function parseArgs(argv) {
46
48
  dryRun: false,
47
49
  skipMCP: false,
48
50
  skipSkills: false,
51
+ compactOrphans: false,
52
+ compactOrphansDry: false,
53
+ deleteGroup: null,
49
54
  prompt: null,
50
55
  };
51
56
 
@@ -88,6 +93,15 @@ function parseArgs(argv) {
88
93
  case '--skip-skills':
89
94
  args.skipSkills = true;
90
95
  break;
96
+ case '--compact-orphans':
97
+ args.compactOrphans = true;
98
+ break;
99
+ case '--compact-orphans-dry':
100
+ args.compactOrphansDry = true;
101
+ break;
102
+ case '--delete-group':
103
+ args.deleteGroup = rest[++i] || null;
104
+ break;
91
105
  default:
92
106
  if (!arg.startsWith('-') && !args.prompt) {
93
107
  args.prompt = arg;
@@ -198,6 +212,81 @@ function handleDryRun(args, config) {
198
212
  console.log('=== END DRY RUN ===');
199
213
  }
200
214
 
215
+ // ─── Maintenance handlers (no LLM session needed) ──────────────
216
+
217
+ /**
218
+ * One-shot orphan-message sweep. Reads the live group list off disk and
219
+ * deletes every persisted message whose `groupId` frontmatter is missing
220
+ * or points to a group that no longer exists.
221
+ *
222
+ * Exposed via `--compact-orphans` (delete) and `--compact-orphans-dry`
223
+ * (preview only). Defensive: if the live group list is unreadable, we
224
+ * abort rather than wipe everything.
225
+ */
226
+ function handleCompactOrphans(config, { dryRun = false } = {}) {
227
+ const yeaftDir = config.dir;
228
+ let groups;
229
+ try {
230
+ groups = snapshotGroups(yeaftDir);
231
+ } catch (err) {
232
+ console.error(`Cannot read groups directory: ${err.message}`);
233
+ console.error('Refusing to compact orphans without an authoritative live-group list.');
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ const keepGroupIds = (groups || []).map(g => g.id).filter(Boolean);
238
+ const store = new ConversationStore(yeaftDir);
239
+ const result = store.compactOrphans({ keepGroupIds, dryRun });
240
+
241
+ console.log(dryRun ? '=== COMPACT ORPHANS (dry run) ===' : '=== COMPACT ORPHANS ===');
242
+ console.log(` Live groups: ${keepGroupIds.length}${keepGroupIds.length ? ` (${keepGroupIds.join(', ')})` : ''}`);
243
+ console.log(` Scanned: ${result.scanned}`);
244
+ console.log(` Orphan files: ${result.orphans.length}`);
245
+ console.log(` Removed: ${result.removed}${dryRun ? ' (dry run — no files touched)' : ''}`);
246
+ if (result.orphans.length > 0) {
247
+ const preview = result.orphans.slice(0, 10);
248
+ for (const p of preview) console.log(` - ${p}`);
249
+ if (result.orphans.length > preview.length) {
250
+ console.log(` ... and ${result.orphans.length - preview.length} more`);
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * One-shot group hard-delete with cascade. Removes the group directory
257
+ * AND every persisted message stamped with that group id. Same semantics
258
+ * as the web-bridge `unify_delete_group` op, but reachable from the CLI
259
+ * for scripted maintenance.
260
+ */
261
+ function handleDeleteGroup(config, groupId) {
262
+ const yeaftDir = config.dir;
263
+ // Lazy import to avoid loading the whole groups module on every CLI call.
264
+ // (Static `import` at top is fine too — kept dynamic to mirror the web-bridge
265
+ // pattern and keep the maintenance path self-contained.)
266
+ // eslint-disable-next-line global-require
267
+ return import('./groups/group-crud.js').then(({ deleteGroup }) => {
268
+ let result;
269
+ try {
270
+ result = deleteGroup(yeaftDir, groupId);
271
+ } catch (err) {
272
+ console.error(`Failed to delete group ${groupId}: ${err.message}`);
273
+ process.exitCode = 1;
274
+ return;
275
+ }
276
+ let messagesRemoved = 0;
277
+ try {
278
+ const store = new ConversationStore(yeaftDir);
279
+ messagesRemoved = store.deleteByGroup(groupId);
280
+ } catch (err) {
281
+ console.warn(`Group dir removed, but cascade failed: ${err.message}`);
282
+ }
283
+ console.log('=== DELETE GROUP ===');
284
+ console.log(` Group: ${result.groupId}`);
285
+ console.log(` Legacy archives swept: ${result.legacyCleanedUp}`);
286
+ console.log(` Messages cascaded: ${messagesRemoved}`);
287
+ });
288
+ }
289
+
201
290
  // ─── REPL ──────────────────────────────────────────────────────
202
291
 
203
292
  async function runREPL(config, args) {
@@ -674,6 +763,16 @@ async function main() {
674
763
  return;
675
764
  }
676
765
 
766
+ // Handle one-shot maintenance ops (no LLM needed, no session needed)
767
+ if (args.compactOrphans || args.compactOrphansDry) {
768
+ handleCompactOrphans(config, { dryRun: args.compactOrphansDry });
769
+ return;
770
+ }
771
+ if (args.deleteGroup) {
772
+ await handleDeleteGroup(config, args.deleteGroup);
773
+ return;
774
+ }
775
+
677
776
  // Handle interactive mode
678
777
  if (args.interactive) {
679
778
  await runREPL(config, args);
@@ -709,6 +808,9 @@ async function main() {
709
808
  console.log(' node cli.js --trace stats — Debug trace statistics');
710
809
  console.log(' node cli.js --trace recent — Recent turns');
711
810
  console.log(' node cli.js --trace search "keyword" — Search traces');
811
+ console.log(' node cli.js --compact-orphans — Delete orphan messages (no live group)');
812
+ console.log(' node cli.js --compact-orphans-dry — Preview orphan sweep, no delete');
813
+ console.log(' node cli.js --delete-group <id> — Hard delete group + cascade messages');
712
814
  console.log();
713
815
  console.log('Options:');
714
816
  console.log(' -d, --debug Enable debug tracing');
@@ -580,6 +580,122 @@ export class ConversationStore {
580
580
 
581
581
  // ─── Internal ───────────────────────────────────────────
582
582
 
583
+ /**
584
+ * Delete every persisted message stamped with `groupId`. Scans both hot
585
+ * (`messages/`) and cold (`cold/`) directories and `unlink`s matching
586
+ * files. Messages without a `groupId` frontmatter are NOT touched —
587
+ * they may be legitimate pre-grouping legacy messages and are handled
588
+ * by `compactOrphans` instead.
589
+ *
590
+ * Used as the cascade step for hard-deleting a group: when the user
591
+ * deletes a group via web-bridge / CLI, the group's persisted message
592
+ * files would otherwise stick around as orphans.
593
+ *
594
+ * Idempotent and safe: missing dirs / unparseable files are skipped.
595
+ * Returns the number of message files removed.
596
+ *
597
+ * @param {string} groupId
598
+ * @returns {number}
599
+ */
600
+ deleteByGroup(groupId) {
601
+ if (!groupId) return 0;
602
+ let removed = 0;
603
+ for (const dir of [this.#msgDir, this.#coldDir]) {
604
+ if (!existsSync(dir)) continue;
605
+ let files;
606
+ try {
607
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
608
+ } catch (err) {
609
+ if (isPermissionError(err)) continue;
610
+ throw err;
611
+ }
612
+ for (const file of files) {
613
+ const path = join(dir, file);
614
+ let raw;
615
+ try {
616
+ raw = readFileSync(path, 'utf8');
617
+ } catch (err) {
618
+ if (isPermissionError(err)) continue;
619
+ throw err;
620
+ }
621
+ const msg = parseMessage(raw);
622
+ if (!msg || msg.groupId !== groupId) continue;
623
+ try {
624
+ unlinkSync(path);
625
+ removed += 1;
626
+ } catch (err) {
627
+ if (isPermissionError(err)) continue;
628
+ throw err;
629
+ }
630
+ }
631
+ }
632
+ // Invalidate cached next-seq — countHot/loadAll will re-scan.
633
+ this.#nextSeq = null;
634
+ return removed;
635
+ }
636
+
637
+ /**
638
+ * Sweep messages that don't belong to any live group. A message is
639
+ * considered an orphan when its frontmatter `groupId`:
640
+ * - is missing entirely (legacy / pre-grouping); OR
641
+ * - is set to a value not in `keepGroupIds`.
642
+ *
643
+ * One-shot maintenance helper exposed via the CLI (`--compact-orphans`).
644
+ * The caller is responsible for passing the authoritative live-group
645
+ * list — we do NOT auto-discover it here, because a transient failure
646
+ * in group loading (returning an empty list) would otherwise wipe
647
+ * every persisted message. Defensive design: an empty/missing
648
+ * `keepGroupIds` is rejected with a no-op return.
649
+ *
650
+ * @param {{ keepGroupIds: string[], dryRun?: boolean }} opts
651
+ * @returns {{ scanned: number, removed: number, orphans: string[], skipped: boolean }}
652
+ */
653
+ compactOrphans({ keepGroupIds, dryRun = false } = {}) {
654
+ if (!Array.isArray(keepGroupIds)) {
655
+ return { scanned: 0, removed: 0, orphans: [], skipped: true };
656
+ }
657
+ const keep = new Set(keepGroupIds);
658
+ let scanned = 0;
659
+ let removed = 0;
660
+ const orphans = [];
661
+ for (const dir of [this.#msgDir, this.#coldDir]) {
662
+ if (!existsSync(dir)) continue;
663
+ let files;
664
+ try {
665
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
666
+ } catch (err) {
667
+ if (isPermissionError(err)) continue;
668
+ throw err;
669
+ }
670
+ for (const file of files) {
671
+ const path = join(dir, file);
672
+ let raw;
673
+ try {
674
+ raw = readFileSync(path, 'utf8');
675
+ } catch (err) {
676
+ if (isPermissionError(err)) continue;
677
+ throw err;
678
+ }
679
+ const msg = parseMessage(raw);
680
+ if (!msg) continue;
681
+ scanned += 1;
682
+ const isOrphan = !msg.groupId || !keep.has(msg.groupId);
683
+ if (!isOrphan) continue;
684
+ orphans.push(path);
685
+ if (dryRun) continue;
686
+ try {
687
+ unlinkSync(path);
688
+ removed += 1;
689
+ } catch (err) {
690
+ if (isPermissionError(err)) continue;
691
+ throw err;
692
+ }
693
+ }
694
+ }
695
+ if (removed > 0) this.#nextSeq = null;
696
+ return { scanned, removed, orphans, skipped: false };
697
+ }
698
+
583
699
  /**
584
700
  * Reassign every message in this store whose `threadId === sourceId`
585
701
  * to `targetId`. The original thread id is preserved in
@@ -377,7 +377,25 @@ export function handleUnifyDeleteGroup(msg) {
377
377
  try {
378
378
  const yeaftDir = ctx.CONFIG?.yeaftDir;
379
379
  const result = deleteGroup(yeaftDir, groupId);
380
- sendGroupCrudResult({ op: 'delete', requestId, ok: true, groupId: result.groupId });
380
+ // Cascade: remove every persisted message stamped with this group id.
381
+ // Hard delete (per user spec): no soft-archive, the bytes are gone.
382
+ // Skipped silently if the session/store isn't initialized — the next
383
+ // CLI `--compact-orphans` run will sweep them as orphans.
384
+ let messagesRemoved = 0;
385
+ try {
386
+ if (session && session.conversationStore) {
387
+ messagesRemoved = session.conversationStore.deleteByGroup(groupId);
388
+ }
389
+ } catch (cascadeErr) {
390
+ console.warn(`[Yeaft] cascade delete for group ${groupId} failed: ${cascadeErr.message}`);
391
+ }
392
+ sendGroupCrudResult({
393
+ op: 'delete',
394
+ requestId,
395
+ ok: true,
396
+ groupId: result.groupId,
397
+ messagesRemoved,
398
+ });
381
399
  sendGroupSnapshotBroadcast();
382
400
  } catch (err) {
383
401
  sendGroupCrudResult({ op: 'delete', requestId, ok: false, error: groupErrorPayload(err) });