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.
Files changed (90) hide show
  1. package/package.json +14 -4
  2. package/scripts/agent-parallel-smoke.mjs +4 -1
  3. package/scripts/agent-route-batch-test.mjs +40 -0
  4. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  5. package/scripts/code-graph-description-contract.mjs +185 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  7. package/scripts/code-graph-dispatch-test.mjs +96 -0
  8. package/scripts/compact-pressure-test.mjs +40 -0
  9. package/scripts/deferred-tool-loading-test.mjs +233 -0
  10. package/scripts/execution-completion-dedup-test.mjs +48 -0
  11. package/scripts/explore-prompt-policy-test.mjs +88 -3
  12. package/scripts/live-worker-smoke.mjs +68 -16
  13. package/scripts/memory-core-input-test.mjs +33 -13
  14. package/scripts/native-edit-wire-test.mjs +152 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  16. package/scripts/patch-binary-cache-test.mjs +181 -0
  17. package/scripts/prompt-immediate-render-test.mjs +89 -0
  18. package/scripts/provider-toolcall-test.mjs +280 -15
  19. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  20. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  21. package/scripts/streaming-tail-window-test.mjs +29 -0
  22. package/scripts/tool-failures.mjs +21 -3
  23. package/scripts/tool-smoke.mjs +263 -38
  24. package/scripts/tool-tui-presentation-test.mjs +17 -1
  25. package/scripts/tui-perf-run.ps1 +26 -0
  26. package/scripts/tui-transcript-perf-test.mjs +7 -1
  27. package/scripts/verify-release-assets-test.mjs +647 -0
  28. package/scripts/verify-release-assets.mjs +293 -0
  29. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  30. package/src/cli.mjs +1 -0
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  35. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  36. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  43. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  44. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  45. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  49. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  50. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  51. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  52. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  53. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  55. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  56. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  57. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  58. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  59. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  60. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  61. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  62. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  63. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  64. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  65. package/src/runtime/memory/tool-defs.mjs +2 -3
  66. package/src/session-runtime/context-status.mjs +25 -16
  67. package/src/session-runtime/model-route-api.mjs +2 -0
  68. package/src/session-runtime/runtime-core.mjs +2 -2
  69. package/src/session-runtime/session-turn-api.mjs +4 -1
  70. package/src/session-runtime/tool-catalog.mjs +113 -19
  71. package/src/session-runtime/tool-defs.mjs +1 -0
  72. package/src/standalone/agent-tool/helpers.mjs +25 -6
  73. package/src/standalone/agent-tool.mjs +75 -41
  74. package/src/tui/App.jsx +4 -0
  75. package/src/tui/app/input-parsers.mjs +8 -9
  76. package/src/tui/components/Markdown.jsx +6 -1
  77. package/src/tui/components/Message.jsx +11 -2
  78. package/src/tui/components/PromptInput.jsx +19 -21
  79. package/src/tui/components/Spinner.jsx +4 -4
  80. package/src/tui/components/StatusLine.jsx +6 -6
  81. package/src/tui/components/TranscriptItem.jsx +2 -2
  82. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  83. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  84. package/src/tui/dist/index.mjs +130 -45
  85. package/src/tui/engine/agent-job-feed.mjs +21 -2
  86. package/src/tui/engine/turn.mjs +12 -1
  87. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  88. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  89. package/src/ui/statusline.mjs +5 -5
  90. 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
- return spawn(shell, argv, spawnOptions);
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
- child = await _spawnShellWithRetry({
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
- killed = true;
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
- killed = true;
644
- _treeKillForceSettle();
694
+ _treeKillForceSettle('output-capture-error');
645
695
  }
646
696
  };
647
697
 
648
698
  let sizeWatchdog = null;
649
- const settle = async (exitCode, signal) => {
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
- child.once('error', () => settle(1, null));
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') timedOut = true; else killed = true;
805
- _treeKillForceSettle();
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
- killed = true;
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
- killed = true;
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} [${c.category}] score=${c.score == null ? '-' : c.score.toFixed(2)} ${c.element} — ${String(c.summary || '').slice(0, 200)} (${c.reason})`,
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}: [${entry.category}] ${entry.element}` }
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}): [${removed.category}] ${removed.element}` }
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
- const normalized = normalizeCoreInput(args, {
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: true,
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 = { ...args, element: normalized.element, summary: normalized.summary, category: normalized.category }
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} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`).join('\n') }
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} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`)
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}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
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}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
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}): [${removed.category}] ${removed.element}` }
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
- // Minimum number of active (promoted) rows cycle2 must preserve. When the
15
- // active pool is at or below this floor, active recheck/demotion is skipped
16
- // and no archiving path may push active below it — prevents draining the
17
- // pool to zero. Configurable via config.active_floor.
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
- // prevents eroding the set to zero (the original over-cap-only concern):
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
- // Floor guard: when the active pool is at/below the minimum floor, skip the
270
- // rolling active recheck entirely so demotions cannot drain it further.
271
- const activeRecheckQuota = (reviewActiveRows || activeCount <= activeFloor)
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 isSafeConservativeUpdate(current, action) {
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
- if (sim < 0.28) return { ok: false, reason: `rewrite drift sim=${sim.toFixed(2)}` }
130
- return { ok: true, reason: 'safe compression' }
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 = terms.length >= 3 ? 2 : 1
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
- const rawRows = await readRawRowsInWindow(db, null, Date.now(), perSessionFetchCap, { projectScope, sessionId: sid, terms: queryTerms })
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
- merged = merged.filter(matchesQueryTerms)
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, { currentSessionId: _currentSessionHint, recencyOrder: true, spanHeaders: true }) }
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
- if (!rows || rows.length === 0) return '(no results)'
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
- for (const r of rows) {
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 suffix = tsAll.length
361
- ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
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
- parts.push(`## ${label}${mark}${suffix}`)
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 short clause each — never transient task state. add requires project_id+category+summary; edit works by id alone.',
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 fact, max 100 chars.' },
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.' },