@yeaft/webchat-agent 0.1.700 → 0.1.702
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/cli.js +102 -0
- package/unify/conversation/persist.js +116 -0
- package/unify/web-bridge.js +19 -1
package/package.json
CHANGED
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
|
package/unify/web-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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) });
|