create-walle 0.9.30 → 0.9.32

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 (112) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +410 -5
  3. package/template/claude-task-manager/api-reviews.js +251 -3
  4. package/template/claude-task-manager/approval-agent.js +322 -24
  5. package/template/claude-task-manager/db.js +77 -13
  6. package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
  7. package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
  8. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +667 -0
  9. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  10. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  11. package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
  12. package/template/claude-task-manager/git-utils.js +164 -26
  13. package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
  14. package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
  15. package/template/claude-task-manager/lib/agent-presets.js +12 -0
  16. package/template/claude-task-manager/lib/agent-version-service.js +650 -0
  17. package/template/claude-task-manager/lib/approval-drift.js +57 -0
  18. package/template/claude-task-manager/lib/approval-hook.js +13 -0
  19. package/template/claude-task-manager/lib/auth-rules.js +6 -0
  20. package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
  21. package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
  22. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
  23. package/template/claude-task-manager/lib/document-review.js +75 -1
  24. package/template/claude-task-manager/lib/escalation-review.js +33 -3
  25. package/template/claude-task-manager/lib/headless-term-service.js +54 -9
  26. package/template/claude-task-manager/lib/mcp-risk.js +128 -0
  27. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
  28. package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
  29. package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
  30. package/template/claude-task-manager/lib/permission-match.js +77 -5
  31. package/template/claude-task-manager/lib/read-pool-client.js +15 -6
  32. package/template/claude-task-manager/lib/restore-backoff.js +82 -0
  33. package/template/claude-task-manager/lib/runtime-registry.js +1 -0
  34. package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
  35. package/template/claude-task-manager/lib/session-capture.js +33 -6
  36. package/template/claude-task-manager/lib/session-history.js +60 -3
  37. package/template/claude-task-manager/lib/session-jobs.js +19 -0
  38. package/template/claude-task-manager/lib/session-restore.js +5 -4
  39. package/template/claude-task-manager/lib/session-standup.js +2 -0
  40. package/template/claude-task-manager/lib/session-stream.js +9 -0
  41. package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
  42. package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
  43. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  44. package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
  45. package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
  46. package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
  47. package/template/claude-task-manager/package.json +1 -1
  48. package/template/claude-task-manager/providers/claude-code.js +20 -1
  49. package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
  50. package/template/claude-task-manager/providers/codex-mcp.js +23 -20
  51. package/template/claude-task-manager/providers/codex.js +65 -1
  52. package/template/claude-task-manager/public/css/prompts.css +345 -0
  53. package/template/claude-task-manager/public/css/reviews.css +248 -40
  54. package/template/claude-task-manager/public/css/walle-session.css +90 -26
  55. package/template/claude-task-manager/public/index.html +2031 -428
  56. package/template/claude-task-manager/public/js/document-review-links.js +255 -13
  57. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  58. package/template/claude-task-manager/public/js/message-renderer.js +46 -1
  59. package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
  60. package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
  61. package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
  62. package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
  63. package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
  64. package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
  65. package/template/claude-task-manager/public/js/prompts.js +683 -215
  66. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  67. package/template/claude-task-manager/public/js/reviews.js +402 -121
  68. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  69. package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
  70. package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
  71. package/template/claude-task-manager/public/js/stream-view.js +93 -20
  72. package/template/claude-task-manager/public/js/walle-session.js +183 -32
  73. package/template/claude-task-manager/public/js/walle.js +21 -9
  74. package/template/claude-task-manager/public/m/app.css +117 -0
  75. package/template/claude-task-manager/public/m/app.js +309 -3
  76. package/template/claude-task-manager/public/m/index.html +56 -1
  77. package/template/claude-task-manager/server.js +724 -305
  78. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  79. package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
  80. package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
  81. package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
  82. package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
  83. package/template/package.json +1 -1
  84. package/template/wall-e/api-walle.js +21 -3
  85. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
  86. package/template/wall-e/brain.js +50 -5
  87. package/template/wall-e/chat/force-compact.js +73 -0
  88. package/template/wall-e/chat.js +137 -9
  89. package/template/wall-e/coding/action-memory-policy.js +120 -1
  90. package/template/wall-e/coding/compaction-service.js +10 -3
  91. package/template/wall-e/coding/model-router.js +116 -0
  92. package/template/wall-e/coding-orchestrator.js +303 -11
  93. package/template/wall-e/coding-prompts.js +1 -1
  94. package/template/wall-e/docs/skill-self-heal-design.html +429 -0
  95. package/template/wall-e/llm/client.js +115 -6
  96. package/template/wall-e/llm/codex-cli.js +71 -23
  97. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  98. package/template/wall-e/llm/default-fallback.js +71 -11
  99. package/template/wall-e/llm/mlx-worker.js +0 -0
  100. package/template/wall-e/llm/mlx.js +46 -12
  101. package/template/wall-e/llm/provider-error.js +9 -3
  102. package/template/wall-e/llm/provider-health-state.js +561 -11
  103. package/template/wall-e/llm/registry.js +6 -0
  104. package/template/wall-e/llm/routing-policy.js +255 -25
  105. package/template/wall-e/llm/tool-call-validator.js +156 -0
  106. package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
  107. package/template/wall-e/skills/skill-planner.js +72 -5
  108. package/template/wall-e/tools/command-registry.js +34 -0
  109. package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
  110. package/template/wall-e/training/mlx_lora_launch.py +196 -0
  111. package/template/wall-e/training/training-coexistence.sh +87 -0
  112. package/template/wall-e/training/training-mem-guard.sh +76 -0
@@ -520,7 +520,10 @@ function _getCommandRegistry() {
520
520
  if (_commandRegistry) return _commandRegistry;
521
521
  const { CommandRegistry } = require('./tools/command-registry');
522
522
  _commandRegistry = new CommandRegistry();
523
- _commandRegistry.registerBuiltins();
523
+ // getProvider lets the /compact builtin summarize via the active default client.
524
+ _commandRegistry.registerBuiltins({
525
+ getProvider: () => { try { return getDefaultClient(); } catch { return null; } },
526
+ });
524
527
  try {
525
528
  const {
526
529
  filterSkillsForAgent,
@@ -918,7 +921,7 @@ function _providerRowHasRuntimeAccess(row = {}) {
918
921
  }
919
922
 
920
923
  function _fallbackErrorType(providerError = {}) {
921
- return new Set(['rate_limited', 'network', 'provider_unavailable', 'timeout']).has(providerError.type);
924
+ return new Set(['rate_limited', 'network', 'provider_unavailable', 'timeout', 'model_unavailable', 'model_circuit_open']).has(providerError.type);
922
925
  }
923
926
 
924
927
  function _routingPolicySettings() {
@@ -929,6 +932,80 @@ function _routingPolicySettings() {
929
932
  }
930
933
  }
931
934
 
935
+ function _uniqueChatProviders(values = []) {
936
+ const out = [];
937
+ const seen = new Set();
938
+ for (const value of values) {
939
+ const text = String(value || '').trim();
940
+ const key = text.toLowerCase();
941
+ if (!key || seen.has(key)) continue;
942
+ seen.add(key);
943
+ out.push(text);
944
+ }
945
+ return out;
946
+ }
947
+
948
+ function _chatProviderModelCircuit(providers, model, options = {}) {
949
+ try {
950
+ const healthState = require('./llm/provider-health-state');
951
+ const circuit = healthState.getFirstProviderModelCircuit(_uniqueChatProviders(providers), model, { brain });
952
+ if (!circuit) return null;
953
+ if (options.allowProbe && circuit.probe_due) {
954
+ healthState.reserveProviderModelCircuitProbe(circuit.provider, circuit.model, { brain });
955
+ return null;
956
+ }
957
+ return circuit;
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+
963
+ function _recordChatCircuitRoutingNotice(circuit, fallback) {
964
+ try {
965
+ const failures = Number(circuit?.failures || 0);
966
+ const total = Number(circuit?.total || 0);
967
+ const rate = Number(circuit?.failure_rate_pct || Math.round(Number(circuit?.failure_rate || 0) * 100));
968
+ const windowMs = Number(circuit?.window_ms || 0);
969
+ const windowLabel = windowMs >= 60 * 60 * 1000
970
+ ? `${Math.round(windowMs / (60 * 60 * 1000))}h`
971
+ : 'rolling window';
972
+ const fromLabel = `${circuit?.provider || 'provider'}${circuit?.model ? ' / ' + circuit.model : ''}`;
973
+ const hasFallback = !!fallback;
974
+ const toLabel = hasFallback
975
+ ? `${fallback?.providerType || fallback?.provider?.type || 'fallback'}${fallback?.model ? ' / ' + fallback.model : ''}`
976
+ : 'no eligible fallback';
977
+ require('./llm/health').default.recordRoutingNotice({
978
+ kind: 'model_circuit_open',
979
+ severity: 'warning',
980
+ message: hasFallback
981
+ ? `Auto-fallback active for ${fromLabel}; answering via ${toLabel} while recovery probes run periodically.`
982
+ : `Auto-fallback active for ${fromLabel}, but this turn has no eligible fallback route. Waiting for recovery probes or Setup changes.`,
983
+ detail: total ? `${rate}% errors over ${windowLabel} (${failures}/${total}).` : '',
984
+ from: { type: circuit?.provider || null, model: circuit?.model || null },
985
+ to: hasFallback ? { type: fallback?.providerType || fallback?.provider?.type || null, model: fallback?.model || null } : null,
986
+ });
987
+ } catch {}
988
+ }
989
+
990
+ function _chatCircuitOpenError(circuit) {
991
+ const failures = Number(circuit?.failures || 0);
992
+ const total = Number(circuit?.total || 0);
993
+ const rate = Number(circuit?.failure_rate_pct || Math.round(Number(circuit?.failure_rate || 0) * 100));
994
+ const probe = circuit?.probe_due
995
+ ? 'a recovery probe is due now'
996
+ : `next recovery probe in ${Math.max(1, Math.ceil(Number(circuit?.next_probe_in_ms || 0) / 60000))}m`;
997
+ const sample = total ? `${rate}% errors (${failures}/${total})` : 'recent failures';
998
+ const err = new Error(`Auto-fallback is active for ${circuit?.provider || 'provider'}${circuit?.model ? ' / ' + circuit.model : ''}: ${sample}; ${probe}.`);
999
+ err.status = 503;
1000
+ const decorated = decorateProviderError(err, {
1001
+ type: 'provider_unavailable_local',
1002
+ provider: circuit?.provider || null,
1003
+ model: circuit?.model || null,
1004
+ });
1005
+ decorated.suppressProviderHealthRecord = true;
1006
+ return decorated;
1007
+ }
1008
+
932
1009
  function _allowChatProviderFallback(opts = {}) {
933
1010
  if (_clientOverride) return false;
934
1011
  if (opts.modelPinned === true || opts.providerPinned === true) return false;
@@ -1328,7 +1405,7 @@ function _selectFallbackModel(providerId, runtimeType, taskType) {
1328
1405
  return getDefaultModelForProvider(runtimeType);
1329
1406
  }
1330
1407
 
1331
- function _listChatFallbackRows({ attemptedProviderTypes, providerAvailability }) {
1408
+ function _listChatFallbackRows({ attemptedProviderTypes, providerAvailability, taskType = 'chat' }) {
1332
1409
  let rows = [];
1333
1410
  try {
1334
1411
  rows = (brain.listModelProviders?.() || [])
@@ -1352,7 +1429,13 @@ function _listChatFallbackRows({ attemptedProviderTypes, providerAvailability })
1352
1429
  if (providerAvailability?.isProviderUsable) {
1353
1430
  if (!providerAvailability.isProviderUsable(row.id) || !providerAvailability.isProviderUsable(runtimeType)) continue;
1354
1431
  }
1355
- candidates.push({ row, runtimeType, modelCount });
1432
+ const model = resolveCompatibleModel(
1433
+ _selectFallbackModel(row.id, runtimeType, taskType),
1434
+ runtimeType,
1435
+ );
1436
+ const circuit = _chatProviderModelCircuit([runtimeType, row.type, row.id], model, { allowProbe: false });
1437
+ if (circuit) continue;
1438
+ candidates.push({ row, runtimeType, modelCount, model });
1356
1439
  }
1357
1440
  const rank = {
1358
1441
  openai: 10,
@@ -1377,8 +1460,8 @@ function _listChatProviderFallbacks({ attemptedProviderTypes, providerError, tas
1377
1460
  if (!_allowChatProviderFallback(opts)) return null;
1378
1461
  if (!_fallbackErrorType(providerError)) return null;
1379
1462
  const fallbacks = [];
1380
- for (const { row, runtimeType } of _listChatFallbackRows({ attemptedProviderTypes, providerAvailability })) {
1381
- const model = resolveCompatibleModel(
1463
+ for (const { row, runtimeType, model: fallbackModel } of _listChatFallbackRows({ attemptedProviderTypes, providerAvailability, taskType })) {
1464
+ const model = fallbackModel || resolveCompatibleModel(
1382
1465
  _selectFallbackModel(row.id, runtimeType, taskType),
1383
1466
  runtimeType,
1384
1467
  );
@@ -1407,8 +1490,8 @@ function _allowFinalizationProviderFallback(opts = {}) {
1407
1490
  function _listChatFinalizationFallbacks({ attemptedProviderTypes, taskType, opts, providerAvailability }) {
1408
1491
  if (!_allowFinalizationProviderFallback(opts)) return [];
1409
1492
  const fallbacks = [];
1410
- for (const { row, runtimeType } of _listChatFallbackRows({ attemptedProviderTypes, providerAvailability })) {
1411
- const model = resolveCompatibleModel(
1493
+ for (const { row, runtimeType, model: fallbackModel } of _listChatFallbackRows({ attemptedProviderTypes, providerAvailability, taskType })) {
1494
+ const model = fallbackModel || resolveCompatibleModel(
1412
1495
  _selectFallbackModel(row.id, runtimeType, taskType),
1413
1496
  runtimeType,
1414
1497
  );
@@ -3690,6 +3773,49 @@ async function chat(message, opts = {}) {
3690
3773
 
3691
3774
  const chatStart = Date.now();
3692
3775
  const attemptedProviderTypes = new Set([targetProviderType, provider.type].filter(Boolean));
3776
+ const primaryCircuit = !_clientOverride
3777
+ ? _chatProviderModelCircuit([provider.type, targetProviderType, selectedRoute.provider], selectedModel, { allowProbe: true })
3778
+ : null;
3779
+ if (primaryCircuit) {
3780
+ let fallback = null;
3781
+ if (_allowCrossProviderFallbackForTurn({
3782
+ opts,
3783
+ wallECodingMode,
3784
+ provider,
3785
+ model: selectedModel,
3786
+ })) {
3787
+ const fallbacks = _listChatProviderFallbacks({
3788
+ attemptedProviderTypes,
3789
+ providerError: { type: 'model_circuit_open' },
3790
+ taskType: opts.taskType || 'chat',
3791
+ opts,
3792
+ providerAvailability,
3793
+ }) || [];
3794
+ fallback = fallbacks[0] || null;
3795
+ }
3796
+ if (fallback) {
3797
+ const fromProvider = primaryCircuit.provider || targetProviderType;
3798
+ const fromModel = primaryCircuit.model || selectedModel;
3799
+ provider = withProviderMessageGuard(fallback.provider);
3800
+ selectedModel = fallback.model;
3801
+ usedProvider = fallback.provider.type || fallback.providerType;
3802
+ attemptedProviderTypes.add(fallback.providerType);
3803
+ attemptedProviderTypes.add(fallback.provider?.type);
3804
+ _recordChatCircuitRoutingNotice(primaryCircuit, fallback);
3805
+ try {
3806
+ _telemetry.track('chat_provider_failover', {
3807
+ from_provider: fromProvider,
3808
+ from_model: fromModel,
3809
+ to_provider: fallback.providerType,
3810
+ to_model: fallback.model,
3811
+ reason: 'model_circuit_open',
3812
+ });
3813
+ } catch {}
3814
+ } else {
3815
+ _recordChatCircuitRoutingNotice(primaryCircuit, null);
3816
+ throw _chatCircuitOpenError(primaryCircuit);
3817
+ }
3818
+ }
3693
3819
  for (let turn = 0; turn < allowedTurns; turn++) {
3694
3820
  if (opts.abortSignal?.aborted) {
3695
3821
  clearTimeout(timeout);
@@ -3802,7 +3928,7 @@ async function chat(message, opts = {}) {
3802
3928
  provider,
3803
3929
  model: selectedModel,
3804
3930
  })
3805
- && _listChatFallbackRows({ attemptedProviderTypes, providerAvailability }).length > 0)
3931
+ && _listChatFallbackRows({ attemptedProviderTypes, providerAvailability, taskType: opts.taskType || 'chat' }).length > 0)
3806
3932
  ? 0
3807
3933
  : 2;
3808
3934
  // Unified with coding mode: chat gets the 8k floor so longer
@@ -3853,6 +3979,7 @@ async function chat(message, opts = {}) {
3853
3979
  }) || [])
3854
3980
  : [];
3855
3981
  if (fallbacks.length > 0) {
3982
+ recordProviderFailureAlert(decorated.providerError, brain);
3856
3983
  let fallbackSucceeded = false;
3857
3984
  let lastFallbackDecorated = null;
3858
3985
  for (const fallback of fallbacks) {
@@ -3872,6 +3999,7 @@ async function chat(message, opts = {}) {
3872
3999
  });
3873
4000
  } catch {}
3874
4001
  try {
4002
+ resetTurnTimeout(Math.min(FINALIZATION_TIMEOUT_MS, MESSAGE_TIMEOUT_MS));
3875
4003
  const fallbackReasoningOptions = resolveCurrentReasoningOptions(provider, selectedModel);
3876
4004
  response = await withRetry(() => provider.chat({
3877
4005
  model: selectedModel,
@@ -3,7 +3,20 @@
3
3
  const DEFAULT_MIN_SCORE = 120;
4
4
  const DEFAULT_MIN_MARGIN = 15;
5
5
  const DEFAULT_LIMIT = 8;
6
- const MODES = new Set(['off', 'audit', 'assist', 'apply']);
6
+ // Modes, from most honest to most invasive:
7
+ // off — disabled.
8
+ // audit — observe candidates, never touch the call.
9
+ // exemplar — HONEST RAG (recommended): never mutate the call; surface
10
+ // retrieved verified examples for prompt injection so the model
11
+ // writes its OWN call (adapts params → generalizes).
12
+ // assist — fill ONLY missing fields from memory (model still drives).
13
+ // apply — replace the whole input with a remembered action. This is
14
+ // deterministic replay, NOT model capability: passes produced
15
+ // this way are flagged memory-mutated and excluded from
16
+ // capability metrics (see eval-splits.hasSyntheticPass).
17
+ const MODES = new Set(['off', 'audit', 'exemplar', 'assist', 'apply']);
18
+ // Modes where memory substitutes the action rather than the model deciding it.
19
+ const MUTATING_MODES = new Set(['assist', 'apply']);
7
20
  const SELECTORS = new Set(['store', 'replay', 'auto']);
8
21
  const REPLAY_SELECTOR_TOOLS = new Set(['run_shell', 'apply_patch', 'edit_file', 'write_file']);
9
22
 
@@ -71,19 +84,30 @@ class ActionMemoryPolicy {
71
84
  };
72
85
 
73
86
  let preparedCall = clonedCall;
87
+ decision.mutatedByMemory = false;
74
88
  if (this.mode === 'audit') {
75
89
  decision.action = 'observe';
76
90
  decision.accepted = false;
77
91
  decision.reason = selection?.id ? 'audit_only' : 'no_candidate';
92
+ } else if (this.mode === 'exemplar') {
93
+ // Honest RAG: never touch the call. Candidates are returned for the
94
+ // caller to inject into the prompt as worked examples; the model still
95
+ // emits its own tool call.
96
+ decision.action = 'exemplar';
97
+ decision.accepted = false;
98
+ decision.exemplarCount = candidates.length;
99
+ decision.reason = candidates.length ? 'exemplars_available' : 'no_candidate';
78
100
  } else if (canUseSelection && this.mode === 'assist') {
79
101
  const mergedInput = fillMissingFields(clonedCall.input || {}, selectedInput);
80
102
  preparedCall = { ...clonedCall, input: mergedInput };
81
103
  decision.action = 'assist';
82
104
  decision.accepted = true;
105
+ decision.mutatedByMemory = true;
83
106
  } else if (canUseSelection && this.mode === 'apply') {
84
107
  preparedCall = { ...clonedCall, input: cloneJson(selectedInput) };
85
108
  decision.action = 'apply';
86
109
  decision.accepted = true;
110
+ decision.mutatedByMemory = true;
87
111
  }
88
112
 
89
113
  this.recordAudit({
@@ -149,6 +173,38 @@ class ActionMemoryPolicy {
149
173
  }
150
174
  }
151
175
 
176
+ /**
177
+ * Honest RAG retrieval: return verified past actions as worked exemplars for
178
+ * prompt injection. Does NOT mutate anything. Optionally re-ranks by embedding
179
+ * similarity of the task text when an embedder is provided.
180
+ * @returns {{ exemplars: object[], text: string }}
181
+ */
182
+ async retrieveExemplars({ context = {}, toolName = '', limit = 3, embedder = null } = {}) {
183
+ if (!this.store?.searchActionMemoryCandidates) return { exemplars: [], text: '' };
184
+ let candidates = [];
185
+ try {
186
+ candidates = this.store.searchActionMemoryCandidates({
187
+ context,
188
+ prediction: toolName ? { name: toolName, input: {} } : null,
189
+ toolName,
190
+ limit: Math.max(limit, this.limit),
191
+ positiveOnly: true,
192
+ }) || [];
193
+ } catch (err) {
194
+ this.logger?.warn?.(`[action-memory] exemplar retrieval failed: ${err.message}`);
195
+ return { exemplars: [], text: '' };
196
+ }
197
+ const ranked = await rerankByEmbedding(candidates, context, embedder, this.logger);
198
+ const exemplars = ranked.slice(0, Math.max(1, limit)).map((candidate) => ({
199
+ tool: candidate.tool || candidate.toolName || toolName,
200
+ input: safeObject(candidate.input),
201
+ outcome: candidate.outcomeStatus || candidate.outcome_status || 'verified',
202
+ score: Number.isFinite(Number(candidate.score)) ? Number(candidate.score) : null,
203
+ source: candidate.sourceKind || candidate.source_kind || 'memory',
204
+ }));
205
+ return { exemplars, text: formatExemplars(exemplars) };
206
+ }
207
+
152
208
  recordAudit(row) {
153
209
  if (!this.store?.recordSelectionAudit) return null;
154
210
  try {
@@ -175,14 +231,73 @@ async function executeToolCallWithActionMemoryPolicy({
175
231
  : { call: cloneToolCall(call), decision: { mode: 'off', action: 'skip', accepted: false }, candidates: [] };
176
232
  const execution = await execute(evaluated.call, evaluated.decision);
177
233
  if (execution && typeof execution === 'object') {
234
+ const action = evaluated.decision?.action || 'skip';
178
235
  execution.actionMemory = {
179
236
  decision: evaluated.decision,
180
237
  candidateCount: evaluated.candidates.length,
238
+ action,
239
+ mutated: Boolean(evaluated.decision?.mutatedByMemory),
181
240
  };
241
+ // A pass where memory replaced the model's whole action ('apply') is
242
+ // deterministic replay, not model capability — tag it so honest eval
243
+ // (eval-splits.hasSyntheticPass) excludes it from capability metrics.
244
+ if (action === 'apply') {
245
+ execution.memoryMutatedToolResults = Number(execution.memoryMutatedToolResults || 0) + 1;
246
+ }
182
247
  }
183
248
  return execution;
184
249
  }
185
250
 
251
+ function formatExemplars(exemplars = []) {
252
+ if (!exemplars.length) return '';
253
+ const lines = ['[MEMORY] Similar verified actions from past work (for reference — adapt to the current task, do not copy blindly):'];
254
+ exemplars.forEach((ex, index) => {
255
+ lines.push(`${index + 1}. ${ex.tool}(${compactJson(ex.input)}) → ${ex.outcome}`);
256
+ });
257
+ return lines.join('\n');
258
+ }
259
+
260
+ function compactJson(value) {
261
+ try {
262
+ const text = JSON.stringify(value ?? {});
263
+ return text.length > 300 ? `${text.slice(0, 297)}...` : text;
264
+ } catch {
265
+ return '{}';
266
+ }
267
+ }
268
+
269
+ function cosineSimilarity(a = [], b = []) {
270
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0;
271
+ let dot = 0; let na = 0; let nb = 0;
272
+ for (let i = 0; i < a.length; i += 1) {
273
+ dot += a[i] * b[i];
274
+ na += a[i] * a[i];
275
+ nb += b[i] * b[i];
276
+ }
277
+ if (na === 0 || nb === 0) return 0;
278
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
279
+ }
280
+
281
+ async function rerankByEmbedding(candidates = [], context = {}, embedder = null, logger = console) {
282
+ const taskText = String(context.taskText || context.task || context.taskPreview || '').trim();
283
+ if (!embedder || typeof embedder.embed !== 'function' || !taskText || candidates.length <= 1) {
284
+ return candidates;
285
+ }
286
+ try {
287
+ const texts = [taskText, ...candidates.map((c) => String(c.taskPreview || c.task_preview || '').trim())];
288
+ const vectors = await embedder.embed(texts);
289
+ if (!Array.isArray(vectors) || vectors.length !== texts.length) return candidates;
290
+ const queryVec = vectors[0];
291
+ return candidates
292
+ .map((candidate, index) => ({ candidate, sim: cosineSimilarity(queryVec, vectors[index + 1]) }))
293
+ .sort((left, right) => (right.sim - left.sim) || ((Number(right.candidate.score) || 0) - (Number(left.candidate.score) || 0)))
294
+ .map((entry) => entry.candidate);
295
+ } catch (err) {
296
+ logger?.warn?.(`[action-memory] embedding rerank failed: ${err.message}`);
297
+ return candidates;
298
+ }
299
+ }
300
+
186
301
  function cloneToolCall(call = {}) {
187
302
  return {
188
303
  ...call,
@@ -347,7 +462,11 @@ module.exports = {
347
462
  DEFAULT_LIMIT,
348
463
  DEFAULT_MIN_MARGIN,
349
464
  DEFAULT_MIN_SCORE,
465
+ MODES,
466
+ MUTATING_MODES,
350
467
  executeToolCallWithActionMemoryPolicy,
351
468
  fillMissingFields,
469
+ formatExemplars,
470
+ cosineSimilarity,
352
471
  replayExampleFromRuntimeContext,
353
472
  };
@@ -56,9 +56,16 @@ class CompactionService {
56
56
  const actualInput = Number.isFinite(usage?.input) ? usage.input
57
57
  : Number.isFinite(usage?.inputTokens) ? usage.inputTokens
58
58
  : null;
59
- return actualInput == null
60
- ? systemTokens + estimateMessagesTokens(messages)
61
- : actualInput;
59
+ const estimated = systemTokens + estimateMessagesTokens(messages);
60
+ // Use the LARGER of the provider's real last-turn occupancy and our text
61
+ // estimate. estimateMessagesTokens is a chars/4 heuristic — image-blind and
62
+ // it undercounts code/diff/JSON-heavy history, so on its own it let the live
63
+ // window silently run past 100% (e.g. real 234k while the estimate read
64
+ // <150k). The real occupancy is authoritative but can be slightly stale (it
65
+ // predates messages appended since that turn). max() defeats both blind
66
+ // spots: compaction fires when EITHER signal crosses the threshold.
67
+ if (actualInput == null) return estimated;
68
+ return Math.max(actualInput, estimated);
62
69
  }
63
70
 
64
71
  shouldCompact({ messages = [], systemTokens = 0, usage = null, contextWindow = this.contextWindow } = {}) {
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Difficulty / confidence model routing.
5
+ *
6
+ * A 4B local model is fast and good enough for seen/simple repeated workflows,
7
+ * but has a real capability ceiling on hard/unseen work (the session's own
8
+ * research: Gemma A4B ~77 vs E4B ~52 on LiveCodeBench). Rather than pretend the
9
+ * small model learned everything, route by difficulty:
10
+ * - simple/seen → local model (cheap, fast, retrieval-grounded);
11
+ * - hard/unseen → a stronger model (or cloud).
12
+ *
13
+ * Routing uses only cheap, pre-run signals (retrieval hit, prompt shape, file
14
+ * count, explicit difficulty), and a runtime escalation check that promotes to
15
+ * the strong model when the local model is provably failing (read-loops, no
16
+ * decisive action, invalid tool calls) past a turn budget.
17
+ *
18
+ * Pure + provider-agnostic: callers pass a { local, strong } model map.
19
+ */
20
+
21
+ const DIFFICULTY = Object.freeze({ SIMPLE: 'simple', HARD: 'hard' });
22
+
23
+ const MULTI_STEP_RE = /\b(refactor|migrate|migration|across|end[- ]?to[- ]?end|rewrite|redesign|architecture|all callers|every|multiple files|orchestrat|concurren|race condition|distributed)\b/i;
24
+ const HARD_PROMPT_CHARS = 1200;
25
+ const HARD_FILE_COUNT = 3;
26
+
27
+ function normalizeDifficultyHint(value) {
28
+ const text = String(value || '').trim().toLowerCase();
29
+ if (['easy', 'simple', 'trivial', 'low'].includes(text)) return DIFFICULTY.SIMPLE;
30
+ if (['hard', 'complex', 'difficult', 'high', 'medium'].includes(text)) {
31
+ // 'medium' leans hard for routing — better to use the strong model than to
32
+ // fail on a small one.
33
+ return text === 'medium' ? DIFFICULTY.HARD : DIFFICULTY.HARD;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * @returns {{ difficulty: string, score: number, reasons: string[] }}
40
+ */
41
+ function assessDifficulty({ prompt = '', fileHints = [], retrievalHit = null, explicitDifficulty = null } = {}) {
42
+ const reasons = [];
43
+
44
+ const hint = normalizeDifficultyHint(explicitDifficulty);
45
+ if (hint) {
46
+ return { difficulty: hint, score: hint === DIFFICULTY.HARD ? 1 : 0, reasons: [`explicit difficulty: ${explicitDifficulty}`] };
47
+ }
48
+
49
+ let score = 0;
50
+ const text = String(prompt || '');
51
+ const files = Array.isArray(fileHints) ? fileHints.filter(Boolean) : [];
52
+
53
+ if (retrievalHit === false) { score += 2; reasons.push('unseen: no retrieval/memory match'); }
54
+ else if (retrievalHit === true) { score -= 1; reasons.push('seen: retrieval match available'); }
55
+
56
+ if (text.length >= HARD_PROMPT_CHARS) { score += 1; reasons.push(`long prompt (${text.length} chars)`); }
57
+ if (files.length >= HARD_FILE_COUNT) { score += 1; reasons.push(`multi-file (${files.length} hints)`); }
58
+ if (MULTI_STEP_RE.test(text)) { score += 2; reasons.push('multi-step / cross-cutting keywords'); }
59
+
60
+ const difficulty = score >= 2 ? DIFFICULTY.HARD : DIFFICULTY.SIMPLE;
61
+ return { difficulty, score, reasons };
62
+ }
63
+
64
+ /** Pick a model spec for a difficulty; falls back to local when no strong model. */
65
+ function routeModel({ difficulty = DIFFICULTY.SIMPLE, models = {} } = {}) {
66
+ const local = models.local || models.base || null;
67
+ const strong = models.strong || models.large || null;
68
+ if (difficulty === DIFFICULTY.HARD && strong) return strong;
69
+ return local || strong || null;
70
+ }
71
+
72
+ /**
73
+ * One-shot pre-run routing decision.
74
+ * @returns {{ model: object|null, difficulty: string, score: number, reasons: string[] }}
75
+ */
76
+ function decideRoute({ prompt = '', fileHints = [], retrievalHit = null, explicitDifficulty = null, models = {} } = {}) {
77
+ const assessment = assessDifficulty({ prompt, fileHints, retrievalHit, explicitDifficulty });
78
+ const model = routeModel({ difficulty: assessment.difficulty, models });
79
+ const usedStrong = model && models.strong && model === models.strong;
80
+ return {
81
+ ...assessment,
82
+ model,
83
+ routedTo: usedStrong ? 'strong' : 'local',
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Runtime confidence escalation: should we abandon the local model mid-run for
89
+ * the strong one? Only past a turn budget, only when behavior is clearly failing,
90
+ * and only when a strong model exists.
91
+ * @returns {{ escalate: boolean, reason: string }}
92
+ */
93
+ function shouldEscalate({ behavioral = {}, turn = 0, maxLocalTurns = 5, hasStrongModel = true } = {}) {
94
+ if (!hasStrongModel) return { escalate: false, reason: 'no_strong_model' };
95
+ if (turn < maxLocalTurns) return { escalate: false, reason: 'within_local_budget' };
96
+
97
+ const readLoop = Number(behavioral.readLoopRate || 0);
98
+ const decisive = Number(behavioral.decisiveActionRate || 0);
99
+ const valid = behavioral.validToolCallRate == null ? 1 : Number(behavioral.validToolCallRate);
100
+
101
+ const failing = [];
102
+ if (readLoop >= 0.5) failing.push('read-loop');
103
+ if (decisive <= 0.1) failing.push('no decisive action');
104
+ if (valid <= 0.5) failing.push('invalid tool calls');
105
+
106
+ if (failing.length) return { escalate: true, reason: `local model failing past budget: ${failing.join(', ')}` };
107
+ return { escalate: false, reason: 'making_progress' };
108
+ }
109
+
110
+ module.exports = {
111
+ DIFFICULTY,
112
+ assessDifficulty,
113
+ routeModel,
114
+ decideRoute,
115
+ shouldEscalate,
116
+ };