mixdog 0.9.46 → 0.9.49
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 +14 -4
- package/scripts/agent-parallel-smoke.mjs +4 -1
- package/scripts/agent-route-batch-test.mjs +40 -0
- package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
- package/scripts/code-graph-description-contract.mjs +185 -0
- package/scripts/code-graph-disk-hit-test.mjs +55 -0
- package/scripts/code-graph-dispatch-test.mjs +96 -0
- package/scripts/compact-pressure-test.mjs +40 -0
- package/scripts/deferred-tool-loading-test.mjs +233 -0
- package/scripts/execution-completion-dedup-test.mjs +48 -0
- package/scripts/explore-prompt-policy-test.mjs +88 -3
- package/scripts/live-worker-smoke.mjs +68 -16
- package/scripts/memory-core-input-test.mjs +33 -13
- package/scripts/native-edit-wire-test.mjs +152 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
- package/scripts/patch-binary-cache-test.mjs +181 -0
- package/scripts/prompt-immediate-render-test.mjs +89 -0
- package/scripts/provider-toolcall-test.mjs +280 -15
- package/scripts/shell-failure-diagnostics-test.mjs +211 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
- package/scripts/streaming-tail-window-test.mjs +29 -0
- package/scripts/tool-failures.mjs +21 -3
- package/scripts/tool-smoke.mjs +263 -38
- package/scripts/tool-tui-presentation-test.mjs +17 -1
- package/scripts/tui-perf-run.ps1 +26 -0
- package/scripts/tui-transcript-perf-test.mjs +7 -1
- package/scripts/verify-release-assets-test.mjs +647 -0
- package/scripts/verify-release-assets.mjs +293 -0
- package/scripts/windows-hide-spawn-options-test.mjs +19 -0
- package/src/cli.mjs +1 -0
- package/src/defaults/cycle3-review-prompt.md +10 -4
- package/src/defaults/memory-promote-prompt.md +4 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
- package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
- package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
- package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
- package/src/runtime/memory/lib/query-handlers.mjs +49 -6
- package/src/runtime/memory/lib/recall-format.mjs +21 -6
- package/src/runtime/memory/tool-defs.mjs +2 -3
- package/src/session-runtime/context-status.mjs +25 -16
- package/src/session-runtime/model-route-api.mjs +2 -0
- package/src/session-runtime/runtime-core.mjs +2 -2
- package/src/session-runtime/session-turn-api.mjs +4 -1
- package/src/session-runtime/tool-catalog.mjs +113 -19
- package/src/session-runtime/tool-defs.mjs +1 -0
- package/src/standalone/agent-tool/helpers.mjs +25 -6
- package/src/standalone/agent-tool.mjs +75 -41
- package/src/tui/App.jsx +4 -0
- package/src/tui/app/input-parsers.mjs +8 -9
- package/src/tui/components/Markdown.jsx +6 -1
- package/src/tui/components/Message.jsx +11 -2
- package/src/tui/components/PromptInput.jsx +19 -21
- package/src/tui/components/Spinner.jsx +4 -4
- package/src/tui/components/StatusLine.jsx +6 -6
- package/src/tui/components/TranscriptItem.jsx +2 -2
- package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
- package/src/tui/dist/index.mjs +130 -45
- package/src/tui/engine/agent-job-feed.mjs +21 -2
- package/src/tui/engine/turn.mjs +12 -1
- package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +20 -0
- package/src/ui/statusline.mjs +5 -5
- package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
|
@@ -406,6 +406,9 @@ export class ExecResult {
|
|
|
406
406
|
this.signal = opts.signal || null;
|
|
407
407
|
this.timedOut = opts.timedOut === true;
|
|
408
408
|
this.killed = opts.killed === true;
|
|
409
|
+
// Why mixdog initiated a tree kill. Keep this separate from `signal`,
|
|
410
|
+
// which is the process-reported termination signal (SIGTERM/SIGKILL).
|
|
411
|
+
this.killCause = opts.killCause || null;
|
|
409
412
|
this.stdoutPath = opts.stdoutPath || null;
|
|
410
413
|
this.stdoutFileSize = opts.stdoutFileSize || 0;
|
|
411
414
|
this.stderrPath = opts.stderrPath || null;
|
|
@@ -463,7 +466,40 @@ async function _spawnShellWithRetry({ shell, argv, spawnOptions, shellArg, cwd }
|
|
|
463
466
|
let attempt = 0;
|
|
464
467
|
for (;;) {
|
|
465
468
|
try {
|
|
466
|
-
|
|
469
|
+
const child = spawn(shell, argv, spawnOptions);
|
|
470
|
+
// spawn() reports ENOENT/EACCES on nextTick. Keep a guard listener
|
|
471
|
+
// attached from the same synchronous frame as spawn(), and do not
|
|
472
|
+
// resolve this helper until the child emits `spawn`. The caller adopts
|
|
473
|
+
// the guard atomically (new listener first, guard removal second), so
|
|
474
|
+
// there is never an unhandled-error window across either await.
|
|
475
|
+
let bufferedError = null;
|
|
476
|
+
const guardError = (err) => { bufferedError = bufferedError || err; };
|
|
477
|
+
child.on('error', guardError);
|
|
478
|
+
try {
|
|
479
|
+
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
480
|
+
const onSpawn = () => {
|
|
481
|
+
child.removeListener('error', onError);
|
|
482
|
+
resolveSpawn();
|
|
483
|
+
};
|
|
484
|
+
const onError = (err) => {
|
|
485
|
+
child.removeListener('spawn', onSpawn);
|
|
486
|
+
rejectSpawn(err);
|
|
487
|
+
};
|
|
488
|
+
child.once('spawn', onSpawn);
|
|
489
|
+
child.once('error', onError);
|
|
490
|
+
});
|
|
491
|
+
} catch (err) {
|
|
492
|
+
child.removeListener('error', guardError);
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
child,
|
|
497
|
+
adoptErrorHandler(handler) {
|
|
498
|
+
child.on('error', handler);
|
|
499
|
+
child.removeListener('error', guardError);
|
|
500
|
+
if (bufferedError) handler(bufferedError);
|
|
501
|
+
},
|
|
502
|
+
};
|
|
467
503
|
} catch (err) {
|
|
468
504
|
try {
|
|
469
505
|
console.error('[shell-spawn-retry] ' + JSON.stringify({
|
|
@@ -507,6 +543,12 @@ export function execShellCommand({
|
|
|
507
543
|
const taskOutput = new TaskOutput(taskId);
|
|
508
544
|
let timedOut = false;
|
|
509
545
|
let killed = false;
|
|
546
|
+
let killCause = null;
|
|
547
|
+
let failurePhase = null;
|
|
548
|
+
let failureReason = null;
|
|
549
|
+
let spawnError = null;
|
|
550
|
+
let pendingChildError = null;
|
|
551
|
+
let settle = null;
|
|
510
552
|
let settled = false;
|
|
511
553
|
let timer = null;
|
|
512
554
|
let abortHandler = null;
|
|
@@ -535,7 +577,9 @@ export function execShellCommand({
|
|
|
535
577
|
// outside the timeout branch. Function declaration so callers placed
|
|
536
578
|
// above settle()'s const definition still resolve via hoisting; the
|
|
537
579
|
// 5 s deadline always fires after settle is constructed.
|
|
538
|
-
function _treeKillForceSettle() {
|
|
580
|
+
function _treeKillForceSettle(cause) {
|
|
581
|
+
killed = true;
|
|
582
|
+
killCause = killCause || cause || 'runtime-guard';
|
|
539
583
|
treeKill(child);
|
|
540
584
|
const _killDeadline = setTimeout(() => {
|
|
541
585
|
if (settled) return;
|
|
@@ -574,7 +618,7 @@ export function execShellCommand({
|
|
|
574
618
|
const argv = Array.isArray(shellArgs) && shellArgs.length > 0
|
|
575
619
|
? [...shellArgs, _spawnCommand]
|
|
576
620
|
: [shellArg, _spawnCommand];
|
|
577
|
-
|
|
621
|
+
const spawned = await _spawnShellWithRetry({
|
|
578
622
|
shell,
|
|
579
623
|
argv,
|
|
580
624
|
shellArg,
|
|
@@ -598,6 +642,14 @@ export function execShellCommand({
|
|
|
598
642
|
detached: process.platform !== 'win32',
|
|
599
643
|
},
|
|
600
644
|
});
|
|
645
|
+
child = spawned.child;
|
|
646
|
+
spawned.adoptErrorHandler((err) => {
|
|
647
|
+
spawnError = spawnError || err;
|
|
648
|
+
failurePhase = 'tool';
|
|
649
|
+
failureReason = 'spawn failed';
|
|
650
|
+
if (settle) settle(1, null);
|
|
651
|
+
else pendingChildError = pendingChildError || err;
|
|
652
|
+
});
|
|
601
653
|
startChildGuardian({
|
|
602
654
|
childPid: child.pid,
|
|
603
655
|
childGroupPid: child.pid,
|
|
@@ -624,8 +676,7 @@ export function execShellCommand({
|
|
|
624
676
|
// before spawn returned (synchronous reentry from a parent abort), so
|
|
625
677
|
// the child doesn't run for the full timeoutMs window.
|
|
626
678
|
if (abortSignal && abortSignal.aborted) {
|
|
627
|
-
|
|
628
|
-
_treeKillForceSettle();
|
|
679
|
+
_treeKillForceSettle('cancellation');
|
|
629
680
|
}
|
|
630
681
|
|
|
631
682
|
child.stdout.setEncoding('utf-8');
|
|
@@ -640,13 +691,12 @@ export function execShellCommand({
|
|
|
640
691
|
// by a successful exit code on a truncated capture.
|
|
641
692
|
const _abortOnCaptureError = () => {
|
|
642
693
|
if (taskOutput.writeError && !killed && !settled && !autoBackgrounded) {
|
|
643
|
-
|
|
644
|
-
_treeKillForceSettle();
|
|
694
|
+
_treeKillForceSettle('output-capture-error');
|
|
645
695
|
}
|
|
646
696
|
};
|
|
647
697
|
|
|
648
698
|
let sizeWatchdog = null;
|
|
649
|
-
|
|
699
|
+
settle = async (exitCode, signal) => {
|
|
650
700
|
if (settled || autoBackgrounded) return;
|
|
651
701
|
settled = true;
|
|
652
702
|
if (timer) {
|
|
@@ -678,6 +728,7 @@ export function execShellCommand({
|
|
|
678
728
|
catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
|
|
679
729
|
try { stderr = await taskOutput.getStderr(); }
|
|
680
730
|
catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
|
|
731
|
+
if (spawnError && !stderr) stderr = String(spawnError.message || spawnError);
|
|
681
732
|
// Inline-only path: nothing spilled. Nothing to clean up.
|
|
682
733
|
// Spilled but tiny: drop the files — outputFilePath would duplicate
|
|
683
734
|
// the inline body. Spilled and large: keep the files, caller renders
|
|
@@ -698,6 +749,7 @@ export function execShellCommand({
|
|
|
698
749
|
signal,
|
|
699
750
|
timedOut,
|
|
700
751
|
killed,
|
|
752
|
+
killCause,
|
|
701
753
|
stdoutPath: taskOutput.spilled ? taskOutput.stdoutPath : null,
|
|
702
754
|
stdoutFileSize: taskOutput.stdoutFileSize,
|
|
703
755
|
stderrPath: taskOutput.spilled ? taskOutput.stderrPath : null,
|
|
@@ -705,6 +757,8 @@ export function execShellCommand({
|
|
|
705
757
|
taskId,
|
|
706
758
|
partialOutput,
|
|
707
759
|
outputCaptureError: taskOutput.writeError,
|
|
760
|
+
failurePhase,
|
|
761
|
+
failureReason,
|
|
708
762
|
}),
|
|
709
763
|
);
|
|
710
764
|
};
|
|
@@ -715,7 +769,7 @@ export function execShellCommand({
|
|
|
715
769
|
// after stdio is fully drained, so getStdout()/getStderr() see the
|
|
716
770
|
// complete capture.
|
|
717
771
|
child.once('close', (code, signal) => settle(code, signal));
|
|
718
|
-
|
|
772
|
+
if (pendingChildError) settle(1, null);
|
|
719
773
|
// 'close' only fires after stdio drains; a forked grandchild that
|
|
720
774
|
// inherited stdout/stderr fds can hold them open past direct-child
|
|
721
775
|
// exit and stall settle() until timeoutMs. 'exit' fires on direct
|
|
@@ -801,8 +855,12 @@ export function execShellCommand({
|
|
|
801
855
|
// is honored by the kill path too.)
|
|
802
856
|
if (!job) {
|
|
803
857
|
autoBackgrounded = false;
|
|
804
|
-
if (reason === 'timeout')
|
|
805
|
-
|
|
858
|
+
if (reason === 'timeout') {
|
|
859
|
+
timedOut = true;
|
|
860
|
+
_treeKillForceSettle('timeout');
|
|
861
|
+
} else {
|
|
862
|
+
_treeKillForceSettle('background-adoption-failed');
|
|
863
|
+
}
|
|
806
864
|
return;
|
|
807
865
|
}
|
|
808
866
|
// Wire the lifecycle: on close, write the exit-code file FIRST then
|
|
@@ -815,14 +873,6 @@ export function execShellCommand({
|
|
|
815
873
|
try { writeFileSync(job.donePath, ''); } catch {}
|
|
816
874
|
});
|
|
817
875
|
}
|
|
818
|
-
// Adoption committed. If a cancel already fired (before or during the
|
|
819
|
-
// adoption window), bring the now-adopted child down via its jobId so the
|
|
820
|
-
// promotion can't outlive a user abort. Idempotent with the still-attached
|
|
821
|
-
// abort handler's treeKill.
|
|
822
|
-
if (abortSignal && abortSignal.aborted) {
|
|
823
|
-
try { killShellJob(job.jobId); } catch {}
|
|
824
|
-
try { treeKill(child); } catch {}
|
|
825
|
-
}
|
|
826
876
|
// Snapshot the partial output captured so far for the immediate result.
|
|
827
877
|
let stdout = '';
|
|
828
878
|
let stderr = '';
|
|
@@ -831,6 +881,33 @@ export function execShellCommand({
|
|
|
831
881
|
try { stderr = await taskOutput.getStderr(); }
|
|
832
882
|
catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
|
|
833
883
|
const jobId = job ? job.jobId : null;
|
|
884
|
+
// Re-check after the awaited capture reads: cancellation can race after
|
|
885
|
+
// adoption commits. Never report that cancelled process as a successful
|
|
886
|
+
// still-running background task.
|
|
887
|
+
if (abortSignal && abortSignal.aborted) {
|
|
888
|
+
killed = true;
|
|
889
|
+
killCause = 'cancellation';
|
|
890
|
+
try { killShellJob(jobId); } catch {}
|
|
891
|
+
try { treeKill(child); } catch {}
|
|
892
|
+
resolve(new ExecResult({
|
|
893
|
+
stdout,
|
|
894
|
+
stderr,
|
|
895
|
+
exitCode: null,
|
|
896
|
+
signal: child.signalCode || null,
|
|
897
|
+
timedOut: false,
|
|
898
|
+
killed: true,
|
|
899
|
+
killCause,
|
|
900
|
+
stdoutPath,
|
|
901
|
+
stdoutFileSize: taskOutput.stdoutFileSize,
|
|
902
|
+
stderrPath: taskOutput.spilled ? taskOutput.stderrPath : null,
|
|
903
|
+
stderrFileSize: taskOutput.stderrFileSize,
|
|
904
|
+
taskId,
|
|
905
|
+
partialOutput: true,
|
|
906
|
+
outputCaptureError: taskOutput.writeError,
|
|
907
|
+
backgrounded: false,
|
|
908
|
+
}));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
834
911
|
const secs = Math.max(0, Math.round((Date.now() - _startMs) / 1000));
|
|
835
912
|
const _verb = reason === 'timeout'
|
|
836
913
|
? `moved to background at timeout after ${secs}s`
|
|
@@ -880,7 +957,7 @@ export function execShellCommand({
|
|
|
880
957
|
return;
|
|
881
958
|
}
|
|
882
959
|
timedOut = true;
|
|
883
|
-
_treeKillForceSettle();
|
|
960
|
+
_treeKillForceSettle('timeout');
|
|
884
961
|
}, timeoutMs);
|
|
885
962
|
if (timer.unref) timer.unref();
|
|
886
963
|
}
|
|
@@ -921,16 +998,14 @@ export function execShellCommand({
|
|
|
921
998
|
if (settled || autoBackgrounded) return;
|
|
922
999
|
_abortOnCaptureError();
|
|
923
1000
|
if (taskOutput.totalDiskBytes() > SHELL_OUTPUT_DISK_CAP) {
|
|
924
|
-
|
|
925
|
-
_treeKillForceSettle();
|
|
1001
|
+
_treeKillForceSettle('output-limit');
|
|
926
1002
|
}
|
|
927
1003
|
}, SIZE_WATCHDOG_INTERVAL_MS);
|
|
928
1004
|
if (sizeWatchdog.unref) sizeWatchdog.unref();
|
|
929
1005
|
|
|
930
1006
|
if (abortSignal) {
|
|
931
1007
|
abortHandler = () => {
|
|
932
|
-
|
|
933
|
-
_treeKillForceSettle();
|
|
1008
|
+
_treeKillForceSettle('cancellation');
|
|
934
1009
|
};
|
|
935
1010
|
try {
|
|
936
1011
|
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
@@ -622,7 +622,7 @@ export function createMemoryActionHandlers({
|
|
|
622
622
|
// project=<pool> lets the UI thread project_id into the follow-up
|
|
623
623
|
// promote/dismiss call — matters under project_id:'*' listing where
|
|
624
624
|
// rows span pools. Uses the same COMMON/slug convention as op:'list'.
|
|
625
|
-
`id=${c.id} project=${c.project_id == null ? 'COMMON' : c.project_id}
|
|
625
|
+
`id=${c.id} project=${c.project_id == null ? 'COMMON' : c.project_id} score=${c.score == null ? '-' : c.score.toFixed(2)} ${c.element} — ${String(c.summary || '').slice(0, 200)} (${c.reason})`,
|
|
626
626
|
).join('\n'),
|
|
627
627
|
}
|
|
628
628
|
}
|
|
@@ -634,11 +634,11 @@ export function createMemoryActionHandlers({
|
|
|
634
634
|
if (op === 'promote') {
|
|
635
635
|
const entry = await promoteCoreCandidate(coreDataDir, args.id, { ...args, scope })
|
|
636
636
|
const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
|
|
637
|
-
return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}:
|
|
637
|
+
return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: ${entry.element}` }
|
|
638
638
|
}
|
|
639
639
|
// dismiss
|
|
640
640
|
const removed = await dismissCoreCandidate(coreDataDir, args.id, { scope })
|
|
641
|
-
return { text: `core candidate dismissed (id=${removed.id}):
|
|
641
|
+
return { text: `core candidate dismissed (id=${removed.id}): ${removed.element}` }
|
|
642
642
|
} catch (e) {
|
|
643
643
|
return { text: `core ${op} failed: ${e.message}`, isError: true }
|
|
644
644
|
}
|
|
@@ -656,10 +656,15 @@ export function createMemoryActionHandlers({
|
|
|
656
656
|
})()
|
|
657
657
|
try {
|
|
658
658
|
if (op === 'add' || op === 'edit') {
|
|
659
|
-
|
|
659
|
+
// Category is intentionally absent from the public memory schema.
|
|
660
|
+
// New direct entries use normalizeCoreInput's internal compatibility
|
|
661
|
+
// default; edits omit the field so editCore preserves the stored value.
|
|
662
|
+
const categoryFreeArgs = { ...args }
|
|
663
|
+
delete categoryFreeArgs.category
|
|
664
|
+
const normalized = normalizeCoreInput(categoryFreeArgs, {
|
|
660
665
|
requireElement: true,
|
|
661
666
|
requireSummary: true,
|
|
662
|
-
requireCategory:
|
|
667
|
+
requireCategory: false,
|
|
663
668
|
})
|
|
664
669
|
const errors = [...normalized.errors]
|
|
665
670
|
if (op === 'add') {
|
|
@@ -680,7 +685,12 @@ export function createMemoryActionHandlers({
|
|
|
680
685
|
if (errors.length) {
|
|
681
686
|
return { text: `core ${op}: ${errors.join('; ')}`, isError: true }
|
|
682
687
|
}
|
|
683
|
-
args = {
|
|
688
|
+
args = {
|
|
689
|
+
...categoryFreeArgs,
|
|
690
|
+
element: normalized.element,
|
|
691
|
+
summary: normalized.summary,
|
|
692
|
+
...(op === 'add' ? { category: normalized.category } : {}),
|
|
693
|
+
}
|
|
684
694
|
}
|
|
685
695
|
if (projectId === '*' && op !== 'list') {
|
|
686
696
|
return { text: `core ${op}: project_id "*" only valid for op="list"`, isError: true }
|
|
@@ -689,7 +699,7 @@ export function createMemoryActionHandlers({
|
|
|
689
699
|
if (projectId !== '*') {
|
|
690
700
|
const entries = await listCore(coreDataDir, projectId)
|
|
691
701
|
if (entries.length === 0) return { text: 'core: empty' }
|
|
692
|
-
return { text: entries.map(e => `id=${e.id}
|
|
702
|
+
return { text: entries.map(e => `id=${e.id} ${e.element} — ${String(e.summary || '').slice(0, 200)}`).join('\n') }
|
|
693
703
|
}
|
|
694
704
|
// Cross-pool listing — group by project_id, COMMON first
|
|
695
705
|
const entries = await listCore(coreDataDir, '*')
|
|
@@ -704,22 +714,22 @@ export function createMemoryActionHandlers({
|
|
|
704
714
|
for (const [key, rows] of groups) {
|
|
705
715
|
lines.push(`${key === null ? 'COMMON' : key}:`)
|
|
706
716
|
for (const e of rows) {
|
|
707
|
-
lines.push(` id=${e.id}
|
|
717
|
+
lines.push(` id=${e.id} ${e.element} — ${String(e.summary || '').slice(0, 200)}`)
|
|
708
718
|
}
|
|
709
719
|
}
|
|
710
720
|
return { text: lines.join('\n') }
|
|
711
721
|
}
|
|
712
722
|
if (op === 'add') {
|
|
713
723
|
const entry = await addCore(coreDataDir, args, projectId)
|
|
714
|
-
return { text: `core added (id=${entry.id}):
|
|
724
|
+
return { text: `core added (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
|
|
715
725
|
}
|
|
716
726
|
if (op === 'edit') {
|
|
717
727
|
const entry = await editCoreImpl(coreDataDir, args.id, args)
|
|
718
|
-
return { text: `core edited (id=${entry.id}):
|
|
728
|
+
return { text: `core edited (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
|
|
719
729
|
}
|
|
720
730
|
if (op === 'delete') {
|
|
721
731
|
const removed = await deleteCore(coreDataDir, args.id)
|
|
722
|
-
return { text: `core deleted (id=${removed.id}):
|
|
732
|
+
return { text: `core deleted (id=${removed.id}): ${removed.element}` }
|
|
723
733
|
}
|
|
724
734
|
} catch (e) {
|
|
725
735
|
return { text: `core ${op} failed: ${e.message}`, isError: true }
|
|
@@ -11,11 +11,10 @@ import { __mixdogMemoryLog, throwIfAborted, resourceDir } from './memory-cycle2-
|
|
|
11
11
|
|
|
12
12
|
export const CYCLE2_ACTIVE_TARGET_CAP = 100
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
export const CYCLE2_ACTIVE_MIN_FLOOR = 20
|
|
14
|
+
// Default active-row floor for cycle2. It is zero so rolling active rechecks
|
|
15
|
+
// always run; protection from archive verdicts is opt-in via
|
|
16
|
+
// config.active_floor.
|
|
17
|
+
export const CYCLE2_ACTIVE_MIN_FLOOR = 0
|
|
19
18
|
|
|
20
19
|
// Status-based verb whitelist. 3-tier policy: pending → active/archived,
|
|
21
20
|
// active → active/archived/update/merge.
|
|
@@ -224,6 +223,15 @@ export function loadCurrentRulesDigest() {
|
|
|
224
223
|
}
|
|
225
224
|
} catch {}
|
|
226
225
|
}
|
|
226
|
+
const workflows = join(resourceDir(), 'workflows')
|
|
227
|
+
try {
|
|
228
|
+
if (existsSync(workflows)) {
|
|
229
|
+
for (const dir of readdirSync(workflows).sort()) {
|
|
230
|
+
const workflow = join(workflows, dir, 'WORKFLOW.md')
|
|
231
|
+
if (existsSync(workflow)) sources.push(workflow)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
227
235
|
const parts = []
|
|
228
236
|
for (const p of sources) {
|
|
229
237
|
try {
|
|
@@ -259,16 +259,19 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
259
259
|
// over-cap path was historically the ONLY one that re-examined active.
|
|
260
260
|
// Reserve a bounded slice of batch slots for the stalest active rows so
|
|
261
261
|
// rule-duplicate / drifted promotions are archived continuously instead of
|
|
262
|
-
// sitting forever un-rechecked. Bounded count + reviewed_at rotation
|
|
263
|
-
//
|
|
262
|
+
// sitting forever un-rechecked. Bounded count + reviewed_at rotation keep
|
|
263
|
+
// each cycle's exposure small (with the default floor of 0 the pool CAN
|
|
264
|
+
// erode to zero over repeated cycles if the gate keeps archiving; set
|
|
265
|
+
// config.active_floor to retain a hard minimum):
|
|
264
266
|
// only the oldest few are re-judged per cycle, and the gate — shown
|
|
265
267
|
// {{CURRENT_RULES}} — keeps genuine A/B entries and archives only
|
|
266
268
|
// restatements. Embedding dedup is skipped on purpose: rule restatements
|
|
267
269
|
// are often cross-language paraphrases whose cosine never clears the merge
|
|
268
270
|
// threshold, but the LLM gate catches the semantic overlap.
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
271
|
+
// Rolling active recheck always runs outside the over-cap broad review,
|
|
272
|
+
// including when the active pool is small. Floor protection is opt-in via
|
|
273
|
+
// config.active_floor and is enforced by the shared floor guard below.
|
|
274
|
+
const activeRecheckQuota = reviewActiveRows
|
|
272
275
|
? 0
|
|
273
276
|
: Math.max(0, Math.min(Number(config.active_recheck_quota ?? 8), batchSize - 1))
|
|
274
277
|
const pendingLimit = batchSize - activeRecheckQuota
|
|
@@ -112,7 +112,30 @@ function coreText(core) {
|
|
|
112
112
|
return `${core?.element || ''}\n${core?.summary || ''}`
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function
|
|
115
|
+
function hasSubstantialNonLatinScript(value) {
|
|
116
|
+
const text = String(value ?? '')
|
|
117
|
+
const letters = text.match(/\p{L}/gu) || []
|
|
118
|
+
const latinLetters = letters.filter((letter) => /\p{Script=Latin}/u.test(letter))
|
|
119
|
+
const nonLatinLetters = letters.length - latinLetters.length
|
|
120
|
+
return nonLatinLetters >= 3 && nonLatinLetters >= letters.length * 0.3
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cosineSimilarity(a, b) {
|
|
124
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return null
|
|
125
|
+
let dot = 0
|
|
126
|
+
let aNorm = 0
|
|
127
|
+
let bNorm = 0
|
|
128
|
+
for (let i = 0; i < a.length; i++) {
|
|
129
|
+
if (!Number.isFinite(a[i]) || !Number.isFinite(b[i])) return null
|
|
130
|
+
dot += a[i] * b[i]
|
|
131
|
+
aNorm += a[i] ** 2
|
|
132
|
+
bNorm += b[i] ** 2
|
|
133
|
+
}
|
|
134
|
+
if (aNorm === 0 || bNorm === 0) return null
|
|
135
|
+
return dot / Math.sqrt(aNorm * bNorm)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function isSafeConservativeUpdate(current, action) {
|
|
116
139
|
if (!current || !action?.element || !action?.summary) return { ok: false, reason: 'missing text' }
|
|
117
140
|
const newElement = normalizeComparable(action.element)
|
|
118
141
|
const newSummary = normalizeComparable(action.summary)
|
|
@@ -123,11 +146,23 @@ function isSafeConservativeUpdate(current, action) {
|
|
|
123
146
|
const newText = `${action.element}\n${action.summary}`
|
|
124
147
|
const oldLen = normalizeComparable(oldText).length
|
|
125
148
|
const newLen = normalizeComparable(newText).length
|
|
126
|
-
if (oldLen > 0 && newLen > oldLen + 20) return { ok: false, reason: 'rewrite expands entry' }
|
|
127
|
-
|
|
128
149
|
const sim = charDice(oldText, newText)
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
const crossLanguageRewrite = sim < 0.28 && hasSubstantialNonLatinScript(oldText)
|
|
151
|
+
if (!crossLanguageRewrite) {
|
|
152
|
+
if (oldLen > 0 && newLen > oldLen + 20) return { ok: false, reason: 'rewrite expands entry' }
|
|
153
|
+
if (sim < 0.28) return { ok: false, reason: `rewrite drift sim=${sim.toFixed(2)}` }
|
|
154
|
+
return { ok: true, reason: 'safe compression' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const [oldEmbedding, newEmbedding] = await Promise.all([embedText(oldText), embedText(newText)])
|
|
159
|
+
const cosine = cosineSimilarity(oldEmbedding, newEmbedding)
|
|
160
|
+
if (cosine == null) return { ok: false, reason: 'cross-language embedding invalid' }
|
|
161
|
+
if (cosine < 0.6) return { ok: false, reason: `cross-language semantic drift cosine=${cosine.toFixed(2)}` }
|
|
162
|
+
return { ok: true, reason: `safe cross-language rewrite cosine=${cosine.toFixed(2)}` }
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { ok: false, reason: `cross-language embedding failed: ${err?.message || 'unknown error'}` }
|
|
165
|
+
}
|
|
131
166
|
}
|
|
132
167
|
|
|
133
168
|
function findElementConflict(coreById, currentId, element, projectId) {
|
|
@@ -659,7 +694,7 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
659
694
|
}
|
|
660
695
|
if (a.verb === 'update') {
|
|
661
696
|
proposed.updated++
|
|
662
|
-
const safety = conservative ? isSafeConservativeUpdate(coreById.get(a.id), a) : { ok: true, reason: 'confirmed' }
|
|
697
|
+
const safety = conservative ? await isSafeConservativeUpdate(coreById.get(a.id), a) : { ok: true, reason: 'confirmed' }
|
|
663
698
|
const conflictId = conservative
|
|
664
699
|
? findElementConflict(coreById, a.id, a.element, coreById.get(a.id)?.project_id ?? null)
|
|
665
700
|
: null
|
|
@@ -49,7 +49,7 @@ export function createQueryHandlers({
|
|
|
49
49
|
// Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
|
|
50
50
|
// chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
|
|
51
51
|
// cycle1 runs, so they always carry the freshest turns in the DB.
|
|
52
|
-
async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms } = {}) {
|
|
52
|
+
async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms, minHits: minHitsOverride } = {}) {
|
|
53
53
|
try {
|
|
54
54
|
// Composable WHERE assembly (mirrors retrieveEntries' filter semantics so
|
|
55
55
|
// raw and chunked legs stay in filter parity: projectScope AND sessionId
|
|
@@ -78,7 +78,9 @@ export function createQueryHandlers({
|
|
|
78
78
|
// into the page. 1-2 term queries keep single-hit contains semantics —
|
|
79
79
|
// short Korean queries are often exactly two meaningful tokens and a
|
|
80
80
|
// 2-of-2 requirement silently emptied the raw leg for them.
|
|
81
|
-
const minHits =
|
|
81
|
+
const minHits = Number.isFinite(Number(minHitsOverride))
|
|
82
|
+
? Math.max(1, Math.floor(Number(minHitsOverride)))
|
|
83
|
+
: (terms.length >= 3 ? 2 : 1)
|
|
82
84
|
where.push(`(${clauses.join(' + ')}) >= ${minHits}`)
|
|
83
85
|
}
|
|
84
86
|
params.push(hardLimit)
|
|
@@ -722,7 +724,7 @@ export function createQueryHandlers({
|
|
|
722
724
|
// Deterministic tie-breaker: equal MAX(ts) across sessions would let the
|
|
723
725
|
// LIMIT/OFFSET window skip or duplicate a session between offset pages
|
|
724
726
|
// without a stable secondary sort.
|
|
725
|
-
const sessSql = `SELECT session_id, MAX(ts) AS last_ts
|
|
727
|
+
const sessSql = `SELECT session_id, MIN(ts) AS first_ts, MAX(ts) AS last_ts
|
|
726
728
|
FROM entries
|
|
727
729
|
WHERE ${selWhere.join(' AND ')}
|
|
728
730
|
GROUP BY session_id
|
|
@@ -732,6 +734,7 @@ export function createQueryHandlers({
|
|
|
732
734
|
// 2) per selected session, fetch its newest rows (roots+members, sort by
|
|
733
735
|
// date) plus the fresh raw window, capped at PER_SESSION_ROW_CAP.
|
|
734
736
|
const allRows = []
|
|
737
|
+
const sessionMeta = new Map()
|
|
735
738
|
for (const s of sessRows) {
|
|
736
739
|
const sid = String(s?.session_id || '').trim()
|
|
737
740
|
if (!sid) continue
|
|
@@ -743,7 +746,24 @@ export function createQueryHandlers({
|
|
|
743
746
|
const sRows = await retrieveEntries(db, sf)
|
|
744
747
|
let merged = sRows
|
|
745
748
|
if (includeRaw) {
|
|
746
|
-
|
|
749
|
+
let rawRows
|
|
750
|
+
if (queryTerms.length > 0) {
|
|
751
|
+
// Keep a full unfiltered recency window for the newest-row floor,
|
|
752
|
+
// while separately retaining the deeper term-matched raw window.
|
|
753
|
+
const [recentRawRows, matchedRawRows] = await Promise.all([
|
|
754
|
+
readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: [] }),
|
|
755
|
+
readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms, minHits: 1 }),
|
|
756
|
+
])
|
|
757
|
+
const rawIds = new Set()
|
|
758
|
+
rawRows = [...recentRawRows, ...matchedRawRows].filter((r) => {
|
|
759
|
+
const id = Number(r.id)
|
|
760
|
+
if (rawIds.has(id)) return false
|
|
761
|
+
rawIds.add(id)
|
|
762
|
+
return true
|
|
763
|
+
})
|
|
764
|
+
} else {
|
|
765
|
+
rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: [] })
|
|
766
|
+
}
|
|
747
767
|
const seenIds = new Set(sRows.map((r) => Number(r.id)))
|
|
748
768
|
for (const r of sRows) if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
|
|
749
769
|
// readRawRowsInWindow carries no category filter, so a category-
|
|
@@ -768,14 +788,37 @@ export function createQueryHandlers({
|
|
|
768
788
|
merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
|
|
769
789
|
}
|
|
770
790
|
}
|
|
791
|
+
const fetchedCount = merged.length
|
|
792
|
+
let queryFiltered = false
|
|
771
793
|
if (queryTerms.length > 0) {
|
|
772
|
-
|
|
794
|
+
const newestRows = merged.slice(0, 3)
|
|
795
|
+
const newestIds = new Set(newestRows.map((r) => r.id))
|
|
796
|
+
const matchedRows = merged.filter(matchesQueryTerms)
|
|
797
|
+
// A topic query must not obscure a session's latest activity: keep
|
|
798
|
+
// its three newest rows, then use term matches for the remaining
|
|
799
|
+
// display slots without duplicating rows already kept for recency.
|
|
800
|
+
merged = [...newestRows, ...matchedRows.filter((r) => !newestIds.has(r.id))]
|
|
801
|
+
// Only mark query filtering when its floor+match union actually
|
|
802
|
+
// excludes fetched rows. The later display cap is independent.
|
|
803
|
+
queryFiltered = merged.length < fetchedCount
|
|
773
804
|
}
|
|
774
805
|
merged = merged.slice(0, PER_SESSION_ROW_CAP)
|
|
806
|
+
sessionMeta.set(sid, {
|
|
807
|
+
minTs: Number(s.first_ts),
|
|
808
|
+
maxTs: Number(s.last_ts),
|
|
809
|
+
fetchedCount,
|
|
810
|
+
shownCount: merged.length,
|
|
811
|
+
queryFiltered,
|
|
812
|
+
})
|
|
775
813
|
for (const r of merged) allRows.push(r)
|
|
776
814
|
}
|
|
777
815
|
const _currentSessionHint = String(args?.currentSessionId || '').trim()
|
|
778
|
-
return { text: recallCapPrefix + renderSessionGroupedLines(allRows, {
|
|
816
|
+
return { text: recallCapPrefix + renderSessionGroupedLines(allRows, {
|
|
817
|
+
currentSessionId: _currentSessionHint,
|
|
818
|
+
recencyOrder: true,
|
|
819
|
+
spanHeaders: true,
|
|
820
|
+
sessionMeta,
|
|
821
|
+
}) }
|
|
779
822
|
}
|
|
780
823
|
|
|
781
824
|
const filters = { limit: limit + offset }
|
|
@@ -336,10 +336,16 @@ function spanHeaderSuffix(minTs, maxTs, n) {
|
|
|
336
336
|
// group's lines are globally ts-desc: without it a chunk root's members (stored
|
|
337
337
|
// ts-ASC) interleave with raw rows and invert the visible timeline within a
|
|
338
338
|
// session (e.g. 04:33 rendered above 04:41).
|
|
339
|
-
export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false } = {}) {
|
|
340
|
-
|
|
339
|
+
export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false, spanHeaders = false, sessionMeta } = {}) {
|
|
340
|
+
const hasSessionMeta = spanHeaders && Number(sessionMeta?.size) > 0
|
|
341
|
+
if ((!rows || rows.length === 0) && !hasSessionMeta) return '(no results)'
|
|
341
342
|
const groups = new Map()
|
|
342
|
-
|
|
343
|
+
// Seed selected sessions so an entirely query-filtered session still
|
|
344
|
+
// reports its real activity span and filtering status.
|
|
345
|
+
if (hasSessionMeta) {
|
|
346
|
+
for (const sid of sessionMeta.keys()) groups.set(sid, [])
|
|
347
|
+
}
|
|
348
|
+
for (const r of rows || []) {
|
|
343
349
|
const sid = String(r?.session_id || '').trim()
|
|
344
350
|
const key = sid || '(no session)'
|
|
345
351
|
if (!groups.has(key)) groups.set(key, [])
|
|
@@ -356,11 +362,20 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
|
|
|
356
362
|
for (const [sid, groupRows] of groups) {
|
|
357
363
|
const mark = current && sid === current ? ' (current)' : ''
|
|
358
364
|
const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
|
|
365
|
+
const meta = sessionMeta?.get?.(sid)
|
|
359
366
|
const tsAll = collectGroupTs(groupRows)
|
|
360
|
-
const
|
|
361
|
-
|
|
367
|
+
const minTs = Number(meta?.minTs)
|
|
368
|
+
const maxTs = Number(meta?.maxTs)
|
|
369
|
+
const hasActivitySpan = Number.isFinite(minTs) && Number.isFinite(maxTs)
|
|
370
|
+
const suffix = hasActivitySpan
|
|
371
|
+
? ` ${spanHeaderSuffix(minTs, maxTs, groupRows.length)}`
|
|
372
|
+
: tsAll.length
|
|
373
|
+
? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
|
|
362
374
|
: ` (${groupRows.length} entries)`
|
|
363
|
-
|
|
375
|
+
const filterNote = meta?.queryFiltered
|
|
376
|
+
? ` · query-filtered ${meta.shownCount}/${meta.fetchedCount} rows`
|
|
377
|
+
: ''
|
|
378
|
+
parts.push(`## ${label}${mark}${suffix}${filterNote}`)
|
|
364
379
|
const bodyStr = renderEntryLines(groupRows, { recencyOrder })
|
|
365
380
|
const bodyLines = bodyStr === '(no results)'
|
|
366
381
|
? []
|
|
@@ -8,7 +8,7 @@ export const TOOL_DEFS = [
|
|
|
8
8
|
name: 'memory',
|
|
9
9
|
title: 'Memory Cycle',
|
|
10
10
|
annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
11
|
-
description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one
|
|
11
|
+
description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one compact ENGLISH clause each — never transient task state. add requires project_id+summary; edit works by id alone.',
|
|
12
12
|
inputSchema: {
|
|
13
13
|
type: 'object',
|
|
14
14
|
properties: {
|
|
@@ -16,8 +16,7 @@ export const TOOL_DEFS = [
|
|
|
16
16
|
op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
|
|
17
17
|
id: { type: 'number', description: 'Exact memory id.' },
|
|
18
18
|
element: { type: 'string', maxLength: 40, description: 'Memory key/title. Defaults to the first 40 chars of summary. Max 40 chars.' },
|
|
19
|
-
summary: { type: 'string', maxLength: 100, description: 'Memory content: one short
|
|
20
|
-
category: { type: 'string', enum: ['rule','constraint','decision','fact','goal','preference','task','issue'], description: 'Category.' },
|
|
19
|
+
summary: { type: 'string', maxLength: 100, description: 'Memory content: one short English clause, max 100 chars.' },
|
|
21
20
|
status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
|
|
22
21
|
limit: { type: 'number', description: 'Max rows/items.' },
|
|
23
22
|
confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
|