@yeaft/webchat-agent 1.0.59 → 1.0.62
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/yeaft/cli.js +11 -11
- package/yeaft/debug-trace.js +318 -132
- package/yeaft/engine.js +4 -1
- package/yeaft/session.js +6 -4
- package/yeaft/sessions/pre-flow.js +7 -4
- package/yeaft/web-bridge.js +22 -7
package/package.json
CHANGED
package/yeaft/cli.js
CHANGED
|
@@ -116,7 +116,7 @@ function parseArgs(argv) {
|
|
|
116
116
|
|
|
117
117
|
// ─── Trace query handler ───────────────────────────────────────
|
|
118
118
|
|
|
119
|
-
function handleTraceQuery(args, config) {
|
|
119
|
+
async function handleTraceQuery(args, config) {
|
|
120
120
|
const traceDir = config.dir;
|
|
121
121
|
let trace;
|
|
122
122
|
try {
|
|
@@ -128,7 +128,7 @@ function handleTraceQuery(args, config) {
|
|
|
128
128
|
try {
|
|
129
129
|
switch (args.trace) {
|
|
130
130
|
case 'stats': {
|
|
131
|
-
const s = trace.stats();
|
|
131
|
+
const s = await trace.stats();
|
|
132
132
|
console.log('Debug Trace Statistics:');
|
|
133
133
|
console.log(` Turns: ${s.turnCount}`);
|
|
134
134
|
console.log(` Tools: ${s.toolCount}`);
|
|
@@ -137,7 +137,7 @@ function handleTraceQuery(args, config) {
|
|
|
137
137
|
break;
|
|
138
138
|
}
|
|
139
139
|
case 'recent': {
|
|
140
|
-
const turns = trace.queryRecent(20);
|
|
140
|
+
const turns = await trace.queryRecent(20);
|
|
141
141
|
if (turns.length === 0) {
|
|
142
142
|
console.log('No recent turns.');
|
|
143
143
|
} else {
|
|
@@ -154,7 +154,7 @@ function handleTraceQuery(args, config) {
|
|
|
154
154
|
if (!args.traceArg) {
|
|
155
155
|
throw new Error('Usage: --trace search <keyword>');
|
|
156
156
|
}
|
|
157
|
-
const results = trace.search(args.traceArg);
|
|
157
|
+
const results = await trace.search(args.traceArg);
|
|
158
158
|
console.log(`Found ${results.length} turns matching "${args.traceArg}":`);
|
|
159
159
|
for (const t of results) {
|
|
160
160
|
const time = new Date(t.started_at).toLocaleString();
|
|
@@ -164,7 +164,7 @@ function handleTraceQuery(args, config) {
|
|
|
164
164
|
break;
|
|
165
165
|
}
|
|
166
166
|
case 'tools': {
|
|
167
|
-
const tools = trace.queryTools({ name: args.traceArg });
|
|
167
|
+
const tools = await trace.queryTools({ name: args.traceArg });
|
|
168
168
|
console.log(`Found ${tools.length} tool calls${args.traceArg ? ` for "${args.traceArg}"` : ''}:`);
|
|
169
169
|
for (const t of tools.slice(0, 20)) {
|
|
170
170
|
const time = new Date(t.created_at).toLocaleString();
|
|
@@ -174,10 +174,10 @@ function handleTraceQuery(args, config) {
|
|
|
174
174
|
break;
|
|
175
175
|
}
|
|
176
176
|
case 'compact': {
|
|
177
|
-
const s = trace.stats();
|
|
177
|
+
const s = await trace.stats();
|
|
178
178
|
console.log(`Compacting debug trace files (${(s.dbSizeBytes / 1048576).toFixed(1)} MB, ${s.turnCount} turns)...`);
|
|
179
179
|
console.log('This prunes old request folders and may take a moment. Do not interrupt.');
|
|
180
|
-
const { before, after } = trace.compact();
|
|
180
|
+
const { before, after } = await trace.compact();
|
|
181
181
|
const saved = Math.max(0, before - after);
|
|
182
182
|
console.log(`Done. ${(before / 1048576).toFixed(1)} MB → ${(after / 1048576).toFixed(1)} MB (reclaimed ${(saved / 1048576).toFixed(1)} MB).`);
|
|
183
183
|
break;
|
|
@@ -186,7 +186,7 @@ function handleTraceQuery(args, config) {
|
|
|
186
186
|
throw new Error(`Unknown trace command: ${args.trace}. Available: stats, recent, search <keyword>, tools [name], compact`);
|
|
187
187
|
}
|
|
188
188
|
} finally {
|
|
189
|
-
trace.close();
|
|
189
|
+
await trace.close();
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
|
|
@@ -383,7 +383,7 @@ async function runREPL(config, args) {
|
|
|
383
383
|
case 'trace': {
|
|
384
384
|
const subcmd = cmdArgs[0] || 'stats';
|
|
385
385
|
try {
|
|
386
|
-
handleTraceQuery({ trace: subcmd, traceArg: cmdArgs[1] }, session.config);
|
|
386
|
+
await handleTraceQuery({ trace: subcmd, traceArg: cmdArgs[1] }, session.config);
|
|
387
387
|
} catch (e) {
|
|
388
388
|
console.error(`Trace error: ${e.message}`);
|
|
389
389
|
}
|
|
@@ -454,7 +454,7 @@ async function runREPL(config, args) {
|
|
|
454
454
|
break;
|
|
455
455
|
|
|
456
456
|
case 'stats': {
|
|
457
|
-
const s = trace.stats();
|
|
457
|
+
const s = await trace.stats();
|
|
458
458
|
console.log(`Session stats:`);
|
|
459
459
|
console.log(` Debug: ${session.config.debug}`);
|
|
460
460
|
console.log(` Turns: ${s.turnCount}`);
|
|
@@ -799,7 +799,7 @@ async function main() {
|
|
|
799
799
|
|
|
800
800
|
// Handle --trace queries (no LLM needed, no session needed)
|
|
801
801
|
if (args.trace) {
|
|
802
|
-
handleTraceQuery(args, config);
|
|
802
|
+
await handleTraceQuery(args, config);
|
|
803
803
|
return;
|
|
804
804
|
}
|
|
805
805
|
|
package/yeaft/debug-trace.js
CHANGED
|
@@ -11,16 +11,7 @@
|
|
|
11
11
|
* and dropped, never allowed to stop the agent.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
existsSync,
|
|
16
|
-
mkdirSync,
|
|
17
|
-
readFileSync,
|
|
18
|
-
readdirSync,
|
|
19
|
-
renameSync,
|
|
20
|
-
rmSync,
|
|
21
|
-
statSync,
|
|
22
|
-
writeFileSync,
|
|
23
|
-
} from 'fs';
|
|
14
|
+
import { promises as fsp } from 'fs';
|
|
24
15
|
import { basename, dirname, extname, join } from 'path';
|
|
25
16
|
import { randomUUID } from 'crypto';
|
|
26
17
|
|
|
@@ -56,20 +47,20 @@ function fileTraceRoot(inputPath) {
|
|
|
56
47
|
return extname(basename(raw)) ? `${raw}.files` : raw;
|
|
57
48
|
}
|
|
58
49
|
|
|
59
|
-
function ensureDir(dir) {
|
|
60
|
-
|
|
50
|
+
async function ensureDir(dir) {
|
|
51
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
61
52
|
}
|
|
62
53
|
|
|
63
|
-
function
|
|
64
|
-
ensureDir(dirname(filePath));
|
|
65
|
-
const tmp = `${filePath}.${process.pid}.${
|
|
66
|
-
|
|
67
|
-
|
|
54
|
+
async function atomicWriteText(filePath, text) {
|
|
55
|
+
await ensureDir(dirname(filePath));
|
|
56
|
+
const tmp = `${filePath}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
57
|
+
await fsp.writeFile(tmp, text, 'utf8');
|
|
58
|
+
await fsp.rename(tmp, filePath);
|
|
68
59
|
}
|
|
69
60
|
|
|
70
|
-
function readJson(filePath) {
|
|
61
|
+
async function readJson(filePath) {
|
|
71
62
|
try {
|
|
72
|
-
return JSON.parse(
|
|
63
|
+
return JSON.parse(await fsp.readFile(filePath, 'utf8'));
|
|
73
64
|
} catch {
|
|
74
65
|
return null;
|
|
75
66
|
}
|
|
@@ -533,38 +524,40 @@ function traceToolToLegacy(trace, tool) {
|
|
|
533
524
|
};
|
|
534
525
|
}
|
|
535
526
|
|
|
536
|
-
function
|
|
527
|
+
async function readdirSafe(dir) {
|
|
528
|
+
try { return await fsp.readdir(dir, { withFileTypes: true }); }
|
|
529
|
+
catch { return []; }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function collectTraceFiles(rootDir, sessionId = null) {
|
|
537
533
|
const files = [];
|
|
538
|
-
const addFromRequestsDir = (requestsDir) => {
|
|
539
|
-
|
|
540
|
-
try { entries = readdirSync(requestsDir, { withFileTypes: true }); }
|
|
541
|
-
catch { return; }
|
|
542
|
-
for (const entry of entries) {
|
|
534
|
+
const addFromRequestsDir = async (requestsDir) => {
|
|
535
|
+
for (const entry of await readdirSafe(requestsDir)) {
|
|
543
536
|
if (!entry.isDirectory()) continue;
|
|
544
537
|
const file = requestFilePath(join(requestsDir, entry.name));
|
|
545
|
-
|
|
538
|
+
try {
|
|
539
|
+
await fsp.access(file);
|
|
540
|
+
files.push(file);
|
|
541
|
+
} catch { /* trace.json not yet written for this request dir */ }
|
|
546
542
|
}
|
|
547
543
|
};
|
|
548
544
|
if (sessionId) {
|
|
549
|
-
addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
|
|
545
|
+
await addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
|
|
550
546
|
return files;
|
|
551
547
|
}
|
|
552
|
-
addFromRequestsDir(sessionRequestsDir(rootDir, null));
|
|
548
|
+
await addFromRequestsDir(sessionRequestsDir(rootDir, null));
|
|
553
549
|
const sessionsRoot = join(rootDir, 'sessions');
|
|
554
|
-
|
|
555
|
-
try { sessionEntries = readdirSync(sessionsRoot, { withFileTypes: true }); }
|
|
556
|
-
catch { return files; }
|
|
557
|
-
for (const entry of sessionEntries) {
|
|
550
|
+
for (const entry of await readdirSafe(sessionsRoot)) {
|
|
558
551
|
if (!entry.isDirectory()) continue;
|
|
559
|
-
addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
|
|
552
|
+
await addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
|
|
560
553
|
}
|
|
561
554
|
return files;
|
|
562
555
|
}
|
|
563
556
|
|
|
564
|
-
function readTraceSummaries(rootDir, sessionId = null) {
|
|
557
|
+
async function readTraceSummaries(rootDir, sessionId = null) {
|
|
565
558
|
const traces = [];
|
|
566
|
-
for (const file of collectTraceFiles(rootDir, sessionId)) {
|
|
567
|
-
const trace = readJson(file);
|
|
559
|
+
for (const file of await collectTraceFiles(rootDir, sessionId)) {
|
|
560
|
+
const trace = await readJson(file);
|
|
568
561
|
if (!trace || !trace.requestId) continue;
|
|
569
562
|
traces.push({ trace, file, openedAt: Number(trace.openedAt || 0) });
|
|
570
563
|
}
|
|
@@ -572,23 +565,20 @@ function readTraceSummaries(rootDir, sessionId = null) {
|
|
|
572
565
|
return traces;
|
|
573
566
|
}
|
|
574
567
|
|
|
575
|
-
function countDirFiles(rootDir) {
|
|
568
|
+
async function countDirFiles(rootDir) {
|
|
576
569
|
let files = 0;
|
|
577
570
|
let bytes = 0;
|
|
578
|
-
const walk = (dir) => {
|
|
579
|
-
|
|
580
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
581
|
-
catch { return; }
|
|
582
|
-
for (const entry of entries) {
|
|
571
|
+
const walk = async (dir) => {
|
|
572
|
+
for (const entry of await readdirSafe(dir)) {
|
|
583
573
|
const p = join(dir, entry.name);
|
|
584
|
-
if (entry.isDirectory()) walk(p);
|
|
574
|
+
if (entry.isDirectory()) await walk(p);
|
|
585
575
|
else {
|
|
586
576
|
files += 1;
|
|
587
|
-
try { bytes +=
|
|
577
|
+
try { bytes += (await fsp.stat(p)).size; } catch { /* ignore */ }
|
|
588
578
|
}
|
|
589
579
|
}
|
|
590
580
|
};
|
|
591
|
-
walk(rootDir);
|
|
581
|
+
await walk(rootDir);
|
|
592
582
|
return { files, bytes };
|
|
593
583
|
}
|
|
594
584
|
|
|
@@ -605,6 +595,38 @@ export class DebugTrace {
|
|
|
605
595
|
#flushTimer = null;
|
|
606
596
|
/** @type {number} */
|
|
607
597
|
#sequence = 0;
|
|
598
|
+
/**
|
|
599
|
+
* In-memory dream/event ring (authoritative copy; persisted async).
|
|
600
|
+
* @type {Array<object>}
|
|
601
|
+
*/
|
|
602
|
+
#events = [];
|
|
603
|
+
/** @type {boolean} */
|
|
604
|
+
#eventsDirty = false;
|
|
605
|
+
/**
|
|
606
|
+
* Whether the on-disk events.json has been folded into #events yet. The
|
|
607
|
+
* events ring must merge prior-run history exactly once before any flush
|
|
608
|
+
* overwrites the file, or a live append landing before first hydrate would
|
|
609
|
+
* silently destroy cross-restart dream history.
|
|
610
|
+
* @type {boolean}
|
|
611
|
+
*/
|
|
612
|
+
#eventsHydrated = false;
|
|
613
|
+
/** @type {NodeJS.Timeout|null} */
|
|
614
|
+
#eventFlushTimer = null;
|
|
615
|
+
/**
|
|
616
|
+
* One-time hydrate guard. Reads/maintenance load every on-disk trace into
|
|
617
|
+
* #requestCache exactly once; afterwards every query is served from memory
|
|
618
|
+
* with zero disk reads. The write path never hydrates — it only appends.
|
|
619
|
+
* @type {boolean}
|
|
620
|
+
*/
|
|
621
|
+
#hydrated = false;
|
|
622
|
+
/** @type {Promise<void>|null} */
|
|
623
|
+
#hydratePromise = null;
|
|
624
|
+
/**
|
|
625
|
+
* Serializes async flushes so two overlapping flushes never interleave
|
|
626
|
+
* writes to the same trace file. close()/purge() await this to drain.
|
|
627
|
+
* @type {Promise<void>}
|
|
628
|
+
*/
|
|
629
|
+
#flushChain = Promise.resolve();
|
|
608
630
|
|
|
609
631
|
/**
|
|
610
632
|
* @param {string} tracePath — Back-compatible path. If it looks like a DB
|
|
@@ -614,7 +636,9 @@ export class DebugTrace {
|
|
|
614
636
|
const rootDir = fileTraceRoot(tracePath);
|
|
615
637
|
if (!rootDir) throw new Error('DebugTrace requires a storage path');
|
|
616
638
|
this.#rootDir = rootDir;
|
|
617
|
-
|
|
639
|
+
// Best-effort, fire-and-forget: atomicWriteText re-ensures the request
|
|
640
|
+
// subdir before every write, so a missed mkdir here is harmless.
|
|
641
|
+
ensureDir(rootDir).catch(() => {});
|
|
618
642
|
}
|
|
619
643
|
|
|
620
644
|
startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
|
|
@@ -713,19 +737,20 @@ export class DebugTrace {
|
|
|
713
737
|
|
|
714
738
|
logEvent({ traceId, eventType, eventData = null } = {}) {
|
|
715
739
|
const id = randomUUID();
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
events.push({
|
|
740
|
+
// In-memory authoritative ring; persisted async (fire-and-forget). The
|
|
741
|
+
// dream/event sink runs on the engine hot path, so it must not block on a
|
|
742
|
+
// synchronous read-modify-write of events.json.
|
|
743
|
+
this.#events.push({
|
|
720
744
|
id,
|
|
721
745
|
traceId: traceId || String(eventType || 'event'),
|
|
722
746
|
eventType: eventType || 'event',
|
|
723
747
|
eventData: safeJsonValue(eventData),
|
|
724
748
|
createdAt: Date.now(),
|
|
725
749
|
});
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
750
|
+
if (this.#events.length > MAX_DREAM_EVENTS) {
|
|
751
|
+
this.#events = this.#events.slice(-MAX_DREAM_EVENTS);
|
|
752
|
+
}
|
|
753
|
+
this.#scheduleEventFlush();
|
|
729
754
|
return id;
|
|
730
755
|
}
|
|
731
756
|
|
|
@@ -736,24 +761,27 @@ export class DebugTrace {
|
|
|
736
761
|
return this.logEvent({ traceId, eventType, eventData });
|
|
737
762
|
}
|
|
738
763
|
|
|
739
|
-
queryByMessage(messageId) {
|
|
740
|
-
this.#
|
|
764
|
+
async queryByMessage(messageId) {
|
|
765
|
+
await this.#ensureHydrated();
|
|
766
|
+
await this.#drainWrites();
|
|
741
767
|
const traces = this.#traceSummaries()
|
|
742
768
|
.filter(({ trace }) => trace.messageId === messageId)
|
|
743
769
|
.map(({ trace }) => trace);
|
|
744
770
|
return this.#expandLegacy(traces);
|
|
745
771
|
}
|
|
746
772
|
|
|
747
|
-
queryByTrace(traceId) {
|
|
748
|
-
this.#
|
|
773
|
+
async queryByTrace(traceId) {
|
|
774
|
+
await this.#ensureHydrated();
|
|
775
|
+
await this.#drainWrites();
|
|
749
776
|
const traces = this.#traceSummaries()
|
|
750
777
|
.filter(({ trace }) => trace.traceId === traceId || trace.requestId === traceId)
|
|
751
778
|
.map(({ trace }) => trace);
|
|
752
779
|
return this.#expandLegacy(traces);
|
|
753
780
|
}
|
|
754
781
|
|
|
755
|
-
queryRecent(limit = 20) {
|
|
756
|
-
this.#
|
|
782
|
+
async queryRecent(limit = 20) {
|
|
783
|
+
await this.#ensureHydrated();
|
|
784
|
+
await this.#drainWrites();
|
|
757
785
|
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
758
786
|
return this.#traceSummaries()
|
|
759
787
|
.slice(-lim)
|
|
@@ -761,8 +789,9 @@ export class DebugTrace {
|
|
|
761
789
|
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
762
790
|
}
|
|
763
791
|
|
|
764
|
-
fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null, search = '' } = {}) {
|
|
765
|
-
this.#
|
|
792
|
+
async fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null, search = '' } = {}) {
|
|
793
|
+
await this.#ensureHydrated();
|
|
794
|
+
await this.#drainWrites();
|
|
766
795
|
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
767
796
|
const requestedDetailTurnId = typeof detailTurnId === 'string' && detailTurnId ? detailTurnId : null;
|
|
768
797
|
const searchRegex = requestedDetailTurnId ? null : compileTraceSearchRegex(search);
|
|
@@ -797,8 +826,9 @@ export class DebugTrace {
|
|
|
797
826
|
return { ...expanded, dreamEvents, hasMore: traces.length > selected.length, limit: lim, indexOnly: false };
|
|
798
827
|
}
|
|
799
828
|
|
|
800
|
-
queryTools({ name = null, since = null } = {}) {
|
|
801
|
-
this.#
|
|
829
|
+
async queryTools({ name = null, since = null } = {}) {
|
|
830
|
+
await this.#ensureHydrated();
|
|
831
|
+
await this.#drainWrites();
|
|
802
832
|
const tools = [];
|
|
803
833
|
for (const { trace } of this.#traceSummaries()) {
|
|
804
834
|
for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
|
|
@@ -812,8 +842,9 @@ export class DebugTrace {
|
|
|
812
842
|
return tools.slice(0, 100);
|
|
813
843
|
}
|
|
814
844
|
|
|
815
|
-
search(keyword) {
|
|
816
|
-
this.#
|
|
845
|
+
async search(keyword) {
|
|
846
|
+
await this.#ensureHydrated();
|
|
847
|
+
await this.#drainWrites();
|
|
817
848
|
const needle = String(keyword || '').toLowerCase();
|
|
818
849
|
if (!needle) return [];
|
|
819
850
|
return this.#traceSummaries()
|
|
@@ -823,62 +854,79 @@ export class DebugTrace {
|
|
|
823
854
|
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
824
855
|
}
|
|
825
856
|
|
|
826
|
-
stats() {
|
|
827
|
-
this.#
|
|
857
|
+
async stats() {
|
|
858
|
+
await this.#ensureHydrated();
|
|
859
|
+
await this.#drainWrites();
|
|
828
860
|
const traces = this.#traceSummaries().map(({ trace }) => trace);
|
|
829
861
|
const turnCount = traces.reduce((n, trace) => n + (Array.isArray(trace.loops) ? trace.loops.length : 0), 0);
|
|
830
862
|
const toolCount = traces.reduce((n, trace) => n + (Array.isArray(trace.tools) ? trace.tools.length : 0), 0);
|
|
831
|
-
const
|
|
832
|
-
const
|
|
833
|
-
const { bytes } = countDirFiles(this.#rootDir);
|
|
863
|
+
const eventCount = this.#events.length;
|
|
864
|
+
const { bytes } = await countDirFiles(this.#rootDir);
|
|
834
865
|
return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
|
|
835
866
|
}
|
|
836
867
|
|
|
837
|
-
cleanup(retention = REQUEST_RETENTION) {
|
|
838
|
-
this.#
|
|
868
|
+
async cleanup(retention = REQUEST_RETENTION) {
|
|
869
|
+
await this.#ensureHydrated();
|
|
870
|
+
await this.#drainWrites();
|
|
839
871
|
const keep = Math.max(1, Math.min(REQUEST_RETENTION, Number(retention) || REQUEST_RETENTION));
|
|
840
|
-
const before =
|
|
841
|
-
this.#pruneAll(keep);
|
|
842
|
-
const after =
|
|
872
|
+
const before = this.#traceSummaries().length;
|
|
873
|
+
await this.#pruneAll(keep);
|
|
874
|
+
const after = this.#traceSummaries().length;
|
|
843
875
|
return { deletedTurns: Math.max(0, before - after), deletedTools: 0, deletedEvents: 0, deletedRequests: Math.max(0, before - after) };
|
|
844
876
|
}
|
|
845
877
|
|
|
846
|
-
compact() {
|
|
847
|
-
this.#
|
|
848
|
-
const before = countDirFiles(this.#rootDir).bytes;
|
|
849
|
-
this.cleanup(REQUEST_RETENTION);
|
|
850
|
-
const after = countDirFiles(this.#rootDir).bytes;
|
|
878
|
+
async compact() {
|
|
879
|
+
await this.#ensureHydrated();
|
|
880
|
+
const before = (await countDirFiles(this.#rootDir)).bytes;
|
|
881
|
+
await this.cleanup(REQUEST_RETENTION);
|
|
882
|
+
const after = (await countDirFiles(this.#rootDir)).bytes;
|
|
851
883
|
return { before, after };
|
|
852
884
|
}
|
|
853
885
|
|
|
854
|
-
purge() {
|
|
855
|
-
this.#
|
|
856
|
-
try {
|
|
886
|
+
async purge() {
|
|
887
|
+
await this.#drainWrites();
|
|
888
|
+
try { await fsp.rm(this.#rootDir, { recursive: true, force: true }); }
|
|
857
889
|
catch { /* ignore */ }
|
|
858
|
-
ensureDir(this.#rootDir);
|
|
890
|
+
await ensureDir(this.#rootDir);
|
|
859
891
|
this.#turnIndex.clear();
|
|
860
892
|
this.#requestCache.clear();
|
|
893
|
+
this.#events = [];
|
|
894
|
+
this.#hydrated = false;
|
|
895
|
+
this.#hydratePromise = null;
|
|
896
|
+
this.#eventsHydrated = false;
|
|
861
897
|
}
|
|
862
898
|
|
|
863
|
-
close() {
|
|
899
|
+
async close() {
|
|
900
|
+
// #drainWrites() flushes pending writes itself; no separate #flushPending().
|
|
901
|
+
await this.#drainWrites();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Force all buffered trace + event writes to disk and await them. Unlike
|
|
906
|
+
* close() this does not tear down state, so the instance keeps accepting
|
|
907
|
+
* writes. Useful when a caller needs durability at a checkpoint.
|
|
908
|
+
*/
|
|
909
|
+
async flush() {
|
|
910
|
+
await this.#drainWrites();
|
|
911
|
+
}
|
|
864
912
|
|
|
865
913
|
#getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, now, turnRowId }) {
|
|
866
914
|
const normalizedSessionId = sessionId || null;
|
|
867
|
-
let all = null;
|
|
868
915
|
const isUsableExisting = (t) => (
|
|
869
916
|
t?.sessionId === normalizedSessionId
|
|
870
917
|
&& t?.traceId === traceId
|
|
871
918
|
&& !(turnNumber === 1 && (t.loops || []).some(l => l.loopNumber === 1))
|
|
872
919
|
);
|
|
873
|
-
|
|
920
|
+
// Cache-only: the write path must NEVER touch disk (that was the O(N^2)
|
|
921
|
+
// event-loop stall). Every trace created in this process lives in
|
|
922
|
+
// #requestCache; same-traceId reuse and the legacy-duplicate-Loop-1 split
|
|
923
|
+
// both only concern traces opened in THIS run.
|
|
924
|
+
const sameSession = Array.from(this.#requestCache.values())
|
|
925
|
+
.filter(t => (t?.sessionId || null) === normalizedSessionId);
|
|
926
|
+
const existing = sameSession
|
|
874
927
|
.filter(isUsableExisting)
|
|
875
928
|
.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.requestKey || '').localeCompare(String(b.requestKey || '')))
|
|
876
929
|
.at(-1) || null;
|
|
877
|
-
let existing = newestTrace(Array.from(this.#requestCache.values()));
|
|
878
|
-
if (!existing) {
|
|
879
|
-
all = this.#traceSummaries(normalizedSessionId).map(({ trace }) => trace);
|
|
880
|
-
existing = newestTrace(all);
|
|
881
|
-
}
|
|
882
930
|
if (existing) {
|
|
883
931
|
existing.updatedAt = now;
|
|
884
932
|
this.#requestCache.set(existing.requestKey, existing);
|
|
@@ -886,7 +934,7 @@ export class DebugTrace {
|
|
|
886
934
|
}
|
|
887
935
|
const seq = (this.#sequence = (this.#sequence + 1) % 1_000_000);
|
|
888
936
|
const requestKey = `${String(now).padStart(13, '0')}-${String(seq).padStart(6, '0')}-${safeDirComponent(traceId || turnRowId, 'request')}-${turnRowId.slice(0, 8)}`;
|
|
889
|
-
const requestId = turnNumber === 1 &&
|
|
937
|
+
const requestId = turnNumber === 1 && sameSession.some(t => t.traceId === traceId) ? turnRowId : traceId;
|
|
890
938
|
const trace = {
|
|
891
939
|
version: TRACE_VERSION,
|
|
892
940
|
requestKey,
|
|
@@ -911,27 +959,80 @@ export class DebugTrace {
|
|
|
911
959
|
}
|
|
912
960
|
|
|
913
961
|
#loadRequest(sessionId, requestKey) {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (trace) this.#requestCache.set(requestKey, trace);
|
|
919
|
-
return trace;
|
|
962
|
+
// Cache-only by design: endTurn/logTool always run in the same turn that
|
|
963
|
+
// startTurn minted the trace, so it is already resident. Never read disk
|
|
964
|
+
// on the write path — that is the stall we are eliminating.
|
|
965
|
+
return this.#requestCache.get(requestKey) || null;
|
|
920
966
|
}
|
|
921
967
|
|
|
922
968
|
#traceSummaries(sessionId = null) {
|
|
923
|
-
|
|
969
|
+
// Cache-only: #ensureHydrated() has already merged every on-disk trace
|
|
970
|
+
// into #requestCache exactly once, so we never touch disk here. This is
|
|
971
|
+
// what turns the old O(N^2) per-query rescan into an in-memory filter.
|
|
972
|
+
const out = [];
|
|
924
973
|
for (const trace of this.#requestCache.values()) {
|
|
925
974
|
if (sessionId && trace.sessionId !== sessionId) continue;
|
|
926
975
|
if (!trace?.requestId || !trace?.requestKey) continue;
|
|
927
|
-
|
|
976
|
+
out.push({
|
|
928
977
|
trace,
|
|
929
978
|
file: this.#traceFile(trace),
|
|
930
979
|
openedAt: Number(trace.openedAt || 0),
|
|
931
980
|
});
|
|
932
981
|
}
|
|
933
|
-
return
|
|
934
|
-
|
|
982
|
+
return out.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Load every on-disk trace into #requestCache exactly once. Live entries
|
|
987
|
+
* (created by this process) always win over their disk copy — a flush may
|
|
988
|
+
* be mid-flight, and the in-memory trace is the newer truth. Idempotent and
|
|
989
|
+
* concurrency-safe: overlapping callers await the same promise.
|
|
990
|
+
*
|
|
991
|
+
* Footprint trade-off (deliberate): this pins every session's full trace
|
|
992
|
+
* payload (systemPrompt + cumulative messages + rawRequest, each already
|
|
993
|
+
* byte-bounded) in #requestCache for the instance's lifetime — O(retention ×
|
|
994
|
+
* total sessions) memory instead of the old O(N²) per-query CPU rescan that
|
|
995
|
+
* stalled the event loop. Retention (10/session) and the byte caps keep it
|
|
996
|
+
* bounded, and the web path uses a single module-level instance. Follow-up if
|
|
997
|
+
* footprint ever bites: hydrate per queried sessionId, or keep only a
|
|
998
|
+
* lightweight summary resident and lazy-load full payloads on detailTurnId.
|
|
999
|
+
*/
|
|
1000
|
+
async #ensureHydrated() {
|
|
1001
|
+
if (this.#hydrated) return;
|
|
1002
|
+
if (this.#hydratePromise) return this.#hydratePromise;
|
|
1003
|
+
this.#hydratePromise = (async () => {
|
|
1004
|
+
const [summaries, storedEvents] = await Promise.all([
|
|
1005
|
+
readTraceSummaries(this.#rootDir),
|
|
1006
|
+
readJson(join(this.#rootDir, 'events.json')),
|
|
1007
|
+
]);
|
|
1008
|
+
for (const { trace } of summaries) {
|
|
1009
|
+
if (!trace?.requestKey) continue;
|
|
1010
|
+
// Never clobber a live in-memory trace with its older disk snapshot.
|
|
1011
|
+
if (!this.#requestCache.has(trace.requestKey)) {
|
|
1012
|
+
this.#requestCache.set(trace.requestKey, trace);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (Array.isArray(storedEvents)) this.#mergeStoredEvents(storedEvents);
|
|
1016
|
+
this.#hydrated = true;
|
|
1017
|
+
this.#hydratePromise = null;
|
|
1018
|
+
})();
|
|
1019
|
+
return this.#hydratePromise;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Fold on-disk events into the in-memory ring by id, preserving live order
|
|
1024
|
+
* and bounding to MAX_DREAM_EVENTS. Unseen disk records are prepended (they
|
|
1025
|
+
* are older); a live append therefore never erases prior-run history. Sets
|
|
1026
|
+
* #eventsHydrated so this runs at most once.
|
|
1027
|
+
*/
|
|
1028
|
+
#mergeStoredEvents(stored) {
|
|
1029
|
+
if (this.#eventsHydrated) return;
|
|
1030
|
+
this.#eventsHydrated = true;
|
|
1031
|
+
if (!Array.isArray(stored) || stored.length === 0) return;
|
|
1032
|
+
const seen = new Set(this.#events.map(e => e?.id).filter(Boolean));
|
|
1033
|
+
const older = stored.filter(e => e && (!e.id || !seen.has(e.id)));
|
|
1034
|
+
if (older.length === 0) return;
|
|
1035
|
+
this.#events = [...older, ...this.#events].slice(-MAX_DREAM_EVENTS);
|
|
935
1036
|
}
|
|
936
1037
|
|
|
937
1038
|
#traceWriteKey(trace) {
|
|
@@ -960,7 +1061,7 @@ export class DebugTrace {
|
|
|
960
1061
|
this.#requestCache.set(trace.requestKey, trace);
|
|
961
1062
|
|
|
962
1063
|
if (force || item.dirtyLoops >= TRACE_FLUSH_DIRTY_LOOPS) {
|
|
963
|
-
this.#
|
|
1064
|
+
this.#flushPending();
|
|
964
1065
|
return;
|
|
965
1066
|
}
|
|
966
1067
|
this.#scheduleFlushTimer(now);
|
|
@@ -972,27 +1073,108 @@ export class DebugTrace {
|
|
|
972
1073
|
const dueIn = Math.max(0, TRACE_FLUSH_INTERVAL_MS - (now - oldestDirtyAt));
|
|
973
1074
|
this.#flushTimer = setTimeout(() => {
|
|
974
1075
|
this.#flushTimer = null;
|
|
975
|
-
this.#
|
|
1076
|
+
this.#flushPending();
|
|
976
1077
|
}, dueIn);
|
|
977
1078
|
if (typeof this.#flushTimer.unref === 'function') this.#flushTimer.unref();
|
|
978
1079
|
}
|
|
979
1080
|
|
|
980
|
-
#
|
|
1081
|
+
#scheduleEventFlush() {
|
|
1082
|
+
this.#eventsDirty = true;
|
|
1083
|
+
if (this.#eventFlushTimer) return;
|
|
1084
|
+
this.#eventFlushTimer = setTimeout(() => {
|
|
1085
|
+
this.#eventFlushTimer = null;
|
|
1086
|
+
this.#flushEvents();
|
|
1087
|
+
}, TRACE_FLUSH_INTERVAL_MS);
|
|
1088
|
+
if (typeof this.#eventFlushTimer.unref === 'function') this.#eventFlushTimer.unref();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Drain pending trace writes synchronously-from-the-caller's-view: snapshot
|
|
1093
|
+
* each dirty trace to a JSON STRING now (so later in-place mutation of the
|
|
1094
|
+
* live `loops` array can't tear the payload), then chain the async writes
|
|
1095
|
+
* onto #flushChain. Returns nothing; callers that need durability await
|
|
1096
|
+
* #drainWrites().
|
|
1097
|
+
*/
|
|
1098
|
+
#flushPending() {
|
|
981
1099
|
if (this.#flushTimer) {
|
|
982
1100
|
clearTimeout(this.#flushTimer);
|
|
983
1101
|
this.#flushTimer = null;
|
|
984
1102
|
}
|
|
985
1103
|
const entries = Array.from(this.#pendingWrites.values());
|
|
986
|
-
if (entries.length === 0)
|
|
1104
|
+
if (entries.length === 0) {
|
|
1105
|
+
this.#flushEvents();
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
987
1108
|
this.#pendingWrites.clear();
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1109
|
+
// Capture point-in-time payloads synchronously (so later in-place mutation
|
|
1110
|
+
// of the live `loops` array can't tear a payload), but remember each
|
|
1111
|
+
// request key: a prune chained ahead of this write may evict the request
|
|
1112
|
+
// before the write runs, and we must NOT resurrect a pruned trace on disk.
|
|
1113
|
+
const jobs = entries.map(({ trace }) => ({
|
|
1114
|
+
requestKey: trace.requestKey,
|
|
1115
|
+
file: this.#traceFile(trace),
|
|
1116
|
+
text: JSON.stringify(this.#serializableTrace(trace)),
|
|
1117
|
+
}));
|
|
1118
|
+
this.#chain(async () => {
|
|
1119
|
+
for (const { requestKey, file, text } of jobs) {
|
|
1120
|
+
// Skip if an earlier chained prune already evicted this request.
|
|
1121
|
+
if (!this.#requestCache.has(requestKey)) continue;
|
|
1122
|
+
try { await atomicWriteText(file, text); }
|
|
1123
|
+
catch (err) { console.warn('[Yeaft] debug trace write failed:', err?.message || err); }
|
|
993
1124
|
}
|
|
1125
|
+
try { await this.#pruneAll(REQUEST_RETENTION); }
|
|
1126
|
+
catch (err) { console.warn('[Yeaft] debug trace prune failed:', err?.message || err); }
|
|
1127
|
+
});
|
|
1128
|
+
this.#flushEvents();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Append `task` to the single-writer flush mutex. Each link is isolated:
|
|
1133
|
+
* a rejected predecessor can NEVER skip a later task (the classic
|
|
1134
|
+
* `chain = chain.then(task)` footgun, where one rejection poisons every
|
|
1135
|
+
* subsequent `.then(onFulfilled)` and silently stops all future writes).
|
|
1136
|
+
* Here we always `await prev` inside a swallowing try/catch before running
|
|
1137
|
+
* `task`, so the chain stays alive for the instance's lifetime.
|
|
1138
|
+
*/
|
|
1139
|
+
#chain(task) {
|
|
1140
|
+
const prev = this.#flushChain;
|
|
1141
|
+
this.#flushChain = (async () => {
|
|
1142
|
+
try { await prev; } catch { /* predecessor errors already logged */ }
|
|
1143
|
+
await task();
|
|
1144
|
+
})();
|
|
1145
|
+
return this.#flushChain;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
#flushEvents() {
|
|
1149
|
+
if (!this.#eventsDirty) return;
|
|
1150
|
+
this.#eventsDirty = false;
|
|
1151
|
+
if (this.#eventFlushTimer) {
|
|
1152
|
+
clearTimeout(this.#eventFlushTimer);
|
|
1153
|
+
this.#eventFlushTimer = null;
|
|
1154
|
+
}
|
|
1155
|
+
const file = join(this.#rootDir, 'events.json');
|
|
1156
|
+
this.#chain(async () => {
|
|
1157
|
+
// Fold in any prior-run events we haven't loaded yet BEFORE overwriting,
|
|
1158
|
+
// so a write that beats #ensureHydrated can't drop cross-restart history.
|
|
1159
|
+
if (!this.#eventsHydrated) {
|
|
1160
|
+
this.#mergeStoredEvents(await readJson(file));
|
|
1161
|
+
}
|
|
1162
|
+
const text = JSON.stringify(this.#events.slice(-MAX_DREAM_EVENTS));
|
|
1163
|
+
try { await atomicWriteText(file, text); }
|
|
1164
|
+
catch (err) { console.warn('[Yeaft] debug trace event write failed:', err?.message || err); }
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Await every scheduled write (and prune) to settle. Used by close()/queries. */
|
|
1169
|
+
async #drainWrites() {
|
|
1170
|
+
this.#flushPending();
|
|
1171
|
+
let prev = null;
|
|
1172
|
+
// The chain can grow while we await (a query's flush appends more); loop
|
|
1173
|
+
// until it stabilises so close() is a true barrier.
|
|
1174
|
+
while (this.#flushChain !== prev) {
|
|
1175
|
+
prev = this.#flushChain;
|
|
1176
|
+
try { await prev; } catch { /* per-write errors already logged */ }
|
|
994
1177
|
}
|
|
995
|
-
this.#pruneAll(REQUEST_RETENTION);
|
|
996
1178
|
}
|
|
997
1179
|
|
|
998
1180
|
#reconstructLastSnapshot(trace) {
|
|
@@ -1006,8 +1188,8 @@ export class DebugTrace {
|
|
|
1006
1188
|
#readDreamEvents({ sessionId = null, dreamLimit = 5 } = {}) {
|
|
1007
1189
|
const limit = Number.isFinite(Number(dreamLimit)) ? Math.max(0, Math.min(50, Number(dreamLimit))) : 5;
|
|
1008
1190
|
if (limit <= 0) return [];
|
|
1009
|
-
|
|
1010
|
-
const events =
|
|
1191
|
+
// In-memory ring (hydrated once); no disk read on the query path.
|
|
1192
|
+
const events = this.#events;
|
|
1011
1193
|
const out = [];
|
|
1012
1194
|
for (const event of events.slice().reverse()) {
|
|
1013
1195
|
const data = isPlainObject(event.eventData) ? event.eventData : {};
|
|
@@ -1029,22 +1211,25 @@ export class DebugTrace {
|
|
|
1029
1211
|
return out.reverse();
|
|
1030
1212
|
}
|
|
1031
1213
|
|
|
1032
|
-
#pruneAll(keep) {
|
|
1214
|
+
async #pruneAll(keep) {
|
|
1215
|
+
// Cache-only enumeration; rm the pruned request dirs async. No disk scan.
|
|
1033
1216
|
const sessions = new Set([null]);
|
|
1034
|
-
for (const
|
|
1035
|
-
for (const sid of sessions) this.#pruneSession(sid, keep);
|
|
1217
|
+
for (const trace of this.#requestCache.values()) sessions.add(trace.sessionId || null);
|
|
1218
|
+
for (const sid of sessions) await this.#pruneSession(sid, keep);
|
|
1036
1219
|
}
|
|
1037
1220
|
|
|
1038
|
-
#pruneSession(sessionId, keep = REQUEST_RETENTION) {
|
|
1221
|
+
async #pruneSession(sessionId, keep = REQUEST_RETENTION) {
|
|
1039
1222
|
const traces = this.#traceSummaries(sessionId);
|
|
1040
1223
|
const activeCutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
1041
1224
|
const protectedItems = traces.filter(item => item.trace?.active && Number(item.trace?.updatedAt || 0) >= activeCutoff);
|
|
1042
1225
|
const pruneCandidates = traces.filter(item => !protectedItems.includes(item));
|
|
1043
1226
|
const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
|
|
1044
1227
|
for (const item of stale) {
|
|
1045
|
-
|
|
1046
|
-
|
|
1228
|
+
// Drop from cache first so subsequent queries never resurface it, even
|
|
1229
|
+
// if the async rm is still in flight or fails.
|
|
1047
1230
|
this.#requestCache.delete(item.trace.requestKey);
|
|
1231
|
+
try { await fsp.rm(dirname(item.file), { recursive: true, force: true }); }
|
|
1232
|
+
catch { /* ignore */ }
|
|
1048
1233
|
}
|
|
1049
1234
|
}
|
|
1050
1235
|
|
|
@@ -1066,17 +1251,18 @@ export class NullTrace {
|
|
|
1066
1251
|
logTool() { return 'null'; }
|
|
1067
1252
|
logEvent() { return 'null'; }
|
|
1068
1253
|
event() { return 'null'; }
|
|
1069
|
-
queryByMessage() { return { turns: [], tools: [], events: [] }; }
|
|
1070
|
-
queryByTrace() { return { turns: [], tools: [], events: [] }; }
|
|
1071
|
-
queryRecent() { return []; }
|
|
1072
|
-
queryTools() { return []; }
|
|
1073
|
-
search() { return []; }
|
|
1074
|
-
stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0, fileSizeBytes: 0, requestCount: 0 }; }
|
|
1075
|
-
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0, deletedRequests: 0 }; }
|
|
1076
|
-
compact() { return { before: 0, after: 0 }; }
|
|
1077
|
-
purge() {}
|
|
1078
|
-
close() {}
|
|
1079
|
-
|
|
1254
|
+
async queryByMessage() { return { turns: [], tools: [], events: [] }; }
|
|
1255
|
+
async queryByTrace() { return { turns: [], tools: [], events: [] }; }
|
|
1256
|
+
async queryRecent() { return []; }
|
|
1257
|
+
async queryTools() { return []; }
|
|
1258
|
+
async search() { return []; }
|
|
1259
|
+
async stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0, fileSizeBytes: 0, requestCount: 0 }; }
|
|
1260
|
+
async cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0, deletedRequests: 0 }; }
|
|
1261
|
+
async compact() { return { before: 0, after: 0 }; }
|
|
1262
|
+
async purge() {}
|
|
1263
|
+
async close() {}
|
|
1264
|
+
async flush() {}
|
|
1265
|
+
async fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
|
|
1080
1266
|
}
|
|
1081
1267
|
|
|
1082
1268
|
export function createTrace({ enabled, dbPath, dirPath }) {
|
package/yeaft/engine.js
CHANGED
|
@@ -96,7 +96,10 @@ const RETRY_DEFAULTS = Object.freeze({
|
|
|
96
96
|
jitterRatio: 0.25,
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
// Accept the Yeaft-native alias and the Claude Code plugin-style command.
|
|
100
|
+
// Autocomplete prefers /yeaft-skills:<name>; /skill:<name> remains supported
|
|
101
|
+
// for older clients and typed history.
|
|
102
|
+
const EXPLICIT_SKILL_COMMAND_RE = /^\/(?:skill|yeaft-skills):([^\s]+)(\s+|$)/;
|
|
100
103
|
|
|
101
104
|
function parseExplicitSkillCommand(prompt) {
|
|
102
105
|
if (typeof prompt !== 'string') {
|
package/yeaft/session.js
CHANGED
|
@@ -229,10 +229,12 @@ export async function loadSession(options = {}) {
|
|
|
229
229
|
dirPath: yeaftDir,
|
|
230
230
|
});
|
|
231
231
|
// Hard cap debug history to the most recent 10 requests per Session. Trace
|
|
232
|
-
// failures are best-effort and must never stop the agent loop.
|
|
233
|
-
|
|
232
|
+
// failures are best-effort and must never stop the agent loop. cleanup() is
|
|
233
|
+
// now async (it hydrates the in-memory index, then prunes); run it
|
|
234
|
+
// fire-and-forget so session load never blocks on the startup disk scan.
|
|
235
|
+
Promise.resolve(trace.cleanup?.(10)).catch((err) => {
|
|
234
236
|
console.warn('[Yeaft] trace.cleanup failed:', err?.message || err);
|
|
235
|
-
}
|
|
237
|
+
});
|
|
236
238
|
|
|
237
239
|
// ─── 4. Create LLM adapter ────────────────────────────
|
|
238
240
|
const adapter = await createLLMAdapter(config);
|
|
@@ -562,7 +564,7 @@ export async function loadSession(options = {}) {
|
|
|
562
564
|
// Best-effort cleanup
|
|
563
565
|
}
|
|
564
566
|
try {
|
|
565
|
-
trace.close();
|
|
567
|
+
await trace.close();
|
|
566
568
|
} catch {
|
|
567
569
|
// Trace might not have close() (NullTrace)
|
|
568
570
|
}
|
|
@@ -266,15 +266,18 @@ export function buildRelevantScopes({ sessionId, chatId, vpId, extra } = {}) {
|
|
|
266
266
|
scopes.push(`chat/${chatId}`);
|
|
267
267
|
if (vpId) scopes.push(`chat/${chatId}/vp/${vpId}`);
|
|
268
268
|
} else if (sessionId) {
|
|
269
|
-
// Read
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
269
|
+
// Read every persisted Session spelling that can exist on disk. Current
|
|
270
|
+
// Dream writers use `sessions/<id>`; older readers/writers used singular
|
|
271
|
+
// `session/<id>` or legacy `group/<id>`. SQLite FTS is scope-filtered, so
|
|
272
|
+
// omitting any spelling makes valid indexed memory invisible.
|
|
273
|
+
scopes.push(`sessions/${sessionId}`);
|
|
274
|
+
scopes.push(`sessions/${sessionId}/user`);
|
|
273
275
|
scopes.push(`session/${sessionId}`);
|
|
274
276
|
scopes.push(`session/${sessionId}/user`);
|
|
275
277
|
scopes.push(`group/${sessionId}`);
|
|
276
278
|
scopes.push(`group/${sessionId}/user`);
|
|
277
279
|
if (vpId) {
|
|
280
|
+
scopes.push(`sessions/${sessionId}/vp/${vpId}`);
|
|
278
281
|
scopes.push(`session/${sessionId}/vp/${vpId}`);
|
|
279
282
|
scopes.push(`group/${sessionId}/vp/${vpId}`);
|
|
280
283
|
}
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -76,7 +76,8 @@ import { getAgentRegistry, agentBelongsToScope } from './tools/agent.js';
|
|
|
76
76
|
import { isPromptableAgentStatus } from './sub-agent/status.js';
|
|
77
77
|
import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
78
78
|
|
|
79
|
-
const
|
|
79
|
+
const LEGACY_SKILL_COMMAND_PREFIX = 'skill:';
|
|
80
|
+
const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
|
|
80
81
|
|
|
81
82
|
/** @type {import('./session.js').Session | null} */
|
|
82
83
|
let session = null;
|
|
@@ -1854,9 +1855,15 @@ export function buildSkillSlashCommands(skillManager) {
|
|
|
1854
1855
|
const descriptions = {};
|
|
1855
1856
|
for (const skill of skillManager.list()) {
|
|
1856
1857
|
if (!skill?.name || typeof skill.name !== 'string') continue;
|
|
1857
|
-
const commandName = `${
|
|
1858
|
+
const commandName = `${YEAFT_SKILL_COMMAND_PREFIX}${skill.name}`;
|
|
1858
1859
|
commands.push(commandName);
|
|
1859
1860
|
descriptions[commandName] = skill.description || skill.trigger || 'Load Yeaft skill';
|
|
1861
|
+
|
|
1862
|
+
// Legacy alias for older typed commands and persisted drafts. Do not add it
|
|
1863
|
+
// to the visible command list; autocomplete should show the same plugin-style
|
|
1864
|
+
// names users see in Claude Code, e.g. /yeaft-skills:code-review.
|
|
1865
|
+
const legacyCommandName = `${LEGACY_SKILL_COMMAND_PREFIX}${skill.name}`;
|
|
1866
|
+
descriptions[legacyCommandName] = descriptions[commandName];
|
|
1860
1867
|
}
|
|
1861
1868
|
commands.sort((a, b) => a.localeCompare(b));
|
|
1862
1869
|
return { commands, descriptions };
|
|
@@ -1876,12 +1883,13 @@ export function buildMergedSkillSlashCommands(skillManagers = []) {
|
|
|
1876
1883
|
function broadcastSkillSlashCommands(sessionLike, extraSkillManagers = []) {
|
|
1877
1884
|
const managers = [sessionLike?.skillManager, ...extraSkillManagers].filter(Boolean);
|
|
1878
1885
|
const { commands, descriptions } = buildMergedSkillSlashCommands(managers);
|
|
1879
|
-
const
|
|
1880
|
-
|
|
1886
|
+
const isYeaftSkillCommand = (cmd) => typeof cmd === 'string'
|
|
1887
|
+
&& (cmd.startsWith(LEGACY_SKILL_COMMAND_PREFIX) || cmd.startsWith(YEAFT_SKILL_COMMAND_PREFIX));
|
|
1888
|
+
const nonSkillCommands = (ctx.slashCommands || []).filter(cmd => !isYeaftSkillCommand(cmd));
|
|
1881
1889
|
const slashCommands = [...new Set([...nonSkillCommands, ...commands])];
|
|
1882
1890
|
const slashCommandDescriptions = Object.fromEntries(
|
|
1883
1891
|
Object.entries(ctx.slashCommandDescriptions || {})
|
|
1884
|
-
.filter(([cmd]) => !(
|
|
1892
|
+
.filter(([cmd]) => !isYeaftSkillCommand(cmd))
|
|
1885
1893
|
);
|
|
1886
1894
|
Object.assign(slashCommandDescriptions, descriptions);
|
|
1887
1895
|
ctx.slashCommands = slashCommands;
|
|
@@ -4813,6 +4821,12 @@ export function __testAppendTurnToSessionHistory(...args) {
|
|
|
4813
4821
|
* Backwards-compat: when neither field is set, defaults to `vpId='default'`
|
|
4814
4822
|
* which matches the pre-v0.1.754 behavior.
|
|
4815
4823
|
*/
|
|
4824
|
+
function resolveDreamTriggerSessionId(msg = {}) {
|
|
4825
|
+
return typeof msg.sessionId === 'string' && msg.sessionId
|
|
4826
|
+
? msg.sessionId
|
|
4827
|
+
: (typeof msg.groupId === 'string' && msg.groupId ? msg.groupId : null);
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4816
4830
|
export function normalizeDreamResult(result) {
|
|
4817
4831
|
const sessions = Array.isArray(result?.sessions) ? result.sessions : [];
|
|
4818
4832
|
const targets = Array.isArray(result?.targets) ? result.targets : [];
|
|
@@ -4861,7 +4875,7 @@ export async function handleYeaftDreamTrigger(msg = {}) {
|
|
|
4861
4875
|
// route the error event back to the right row and the per-group
|
|
4862
4876
|
// "Run dream now" button would stay stuck on "Running…" forever
|
|
4863
4877
|
// (review feedback from PR #757).
|
|
4864
|
-
const sessionId =
|
|
4878
|
+
const sessionId = resolveDreamTriggerSessionId(msg);
|
|
4865
4879
|
const vpId = !sessionId ? (msg.vpId || 'default') : null;
|
|
4866
4880
|
const tag = sessionId ? { sessionId } : { vpId };
|
|
4867
4881
|
|
|
@@ -5069,7 +5083,7 @@ export async function handleYeaftFetchDebugHistory(msg = {}) {
|
|
|
5069
5083
|
let hasMore = false;
|
|
5070
5084
|
try {
|
|
5071
5085
|
if (session?.trace && typeof session.trace.fetchRecentDebugHistory === 'function') {
|
|
5072
|
-
const out = session.trace.fetchRecentDebugHistory({ limit, dreamLimit, sessionId, threadId, indexOnly, detailTurnId, search });
|
|
5086
|
+
const out = await session.trace.fetchRecentDebugHistory({ limit, dreamLimit, sessionId, threadId, indexOnly, detailTurnId, search });
|
|
5073
5087
|
loops = Array.isArray(out?.loops) ? out.loops : [];
|
|
5074
5088
|
turns = Array.isArray(out?.turns) ? out.turns : [];
|
|
5075
5089
|
dreamEvents = Array.isArray(out?.dreamEvents) ? out.dreamEvents : [];
|
|
@@ -5916,6 +5930,7 @@ export const __testHooks = {
|
|
|
5916
5930
|
return getVpStatusBroker().transition(status);
|
|
5917
5931
|
},
|
|
5918
5932
|
decorateSessionsWithRuntimeState,
|
|
5933
|
+
resolveDreamTriggerSessionId,
|
|
5919
5934
|
async loadProjectRuntime(workDir) {
|
|
5920
5935
|
return loadProjectRuntime(workDir);
|
|
5921
5936
|
},
|