mixdog 0.9.35 → 0.9.37

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 (105) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  34. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  37. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  41. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  44. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  45. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  47. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  48. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  49. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  52. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  54. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  58. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  63. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  64. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  66. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  68. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  69. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  70. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  71. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  72. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  73. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  74. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  75. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  76. package/src/runtime/shared/config.mjs +98 -12
  77. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  78. package/src/session-runtime/cwd-plugins.mjs +6 -3
  79. package/src/session-runtime/model-route-api.mjs +50 -2
  80. package/src/session-runtime/provider-auth-api.mjs +8 -1
  81. package/src/session-runtime/resource-api.mjs +22 -0
  82. package/src/session-runtime/runtime-core.mjs +13 -2
  83. package/src/session-runtime/settings-api.mjs +11 -2
  84. package/src/standalone/agent-tool.mjs +15 -9
  85. package/src/standalone/explore-tool.mjs +4 -1
  86. package/src/tui/App.jsx +6 -5
  87. package/src/tui/app/transcript-window.mjs +60 -10
  88. package/src/tui/app/use-transcript-window.mjs +2 -1
  89. package/src/tui/components/ContextPanel.jsx +4 -1
  90. package/src/tui/components/ToolExecution.jsx +15 -12
  91. package/src/tui/components/TranscriptItem.jsx +1 -1
  92. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  93. package/src/tui/dist/index.mjs +473 -155
  94. package/src/tui/engine/agent-job-feed.mjs +26 -6
  95. package/src/tui/engine/context-state.mjs +13 -4
  96. package/src/tui/engine/notification-plan.mjs +3 -3
  97. package/src/tui/engine/render-timing.mjs +104 -8
  98. package/src/tui/engine/session-api.mjs +58 -3
  99. package/src/tui/engine/session-flow.mjs +78 -28
  100. package/src/tui/engine/tool-card-results.mjs +28 -13
  101. package/src/tui/engine/turn.mjs +232 -156
  102. package/src/tui/engine.mjs +17 -8
  103. package/src/tui/index.jsx +10 -1
  104. package/src/workflows/default/WORKFLOW.md +8 -4
  105. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -255,7 +255,11 @@ export function _formatCalleeRow(row) {
255
255
  }
256
256
  export async function _prewarmReferenceSourceText(graph, symbol, language) {
257
257
  const candidateNodes = _lookupCandidateNodes(graph, symbol, language);
258
- if (!candidateNodes.length) return;
258
+ // Return the resolved candidate set so the immediately-following
259
+ // _cheapReferenceSearch (references/callers dispatch) can reuse it instead
260
+ // of recomputing _lookupCandidateNodes for the same (symbol, language) —
261
+ // which on a token-index miss is a full-graph scan run twice per symbol.
262
+ if (!candidateNodes.length) return candidateNodes;
259
263
  const uncached = [];
260
264
  for (const node of candidateNodes) {
261
265
  const cached = graph._sourceTextCache?.get(node.rel);
@@ -263,7 +267,7 @@ export async function _prewarmReferenceSourceText(graph, symbol, language) {
263
267
  uncached.push(node);
264
268
  }
265
269
  }
266
- if (uncached.length === 0) return;
270
+ if (uncached.length === 0) return candidateNodes;
267
271
  const { readFile } = await import('fs/promises');
268
272
  const concurrency = 64;
269
273
  let next = 0;
@@ -280,9 +284,10 @@ export async function _prewarmReferenceSourceText(graph, symbol, language) {
280
284
  }
281
285
  const workerCount = Math.min(Math.max(1, concurrency), uncached.length);
282
286
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
287
+ return candidateNodes;
283
288
  }
284
289
 
285
- export function _cheapReferenceSearch(graph, symbol, cwd, { language = null, limit = null, fileRel = null, scopeRelPrefix = null } = {}) {
290
+ export function _cheapReferenceSearch(graph, symbol, cwd, { language = null, limit = null, fileRel = null, scopeRelPrefix = null, nodes = null } = {}) {
286
291
  const escaped = String(symbol || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
287
292
  if (!escaped) return '(no references)';
288
293
  const cacheKey = `${language || '*'}|${symbol}|${Number.isFinite(limit) && limit > 0 ? String(Math.floor(limit)) : 'd'}|${fileRel || '*'}|${scopeRelPrefix || '*'}`;
@@ -291,7 +296,11 @@ export function _cheapReferenceSearch(graph, symbol, cwd, { language = null, lim
291
296
  return cached;
292
297
  }
293
298
  const lines = [];
294
- let candidateNodes = _lookupCandidateNodes(graph, symbol, language);
299
+ // Reuse the caller's precomputed candidate set (from
300
+ // _prewarmReferenceSourceText) when provided — same (symbol, language) so
301
+ // the node set is identical; the fileRel/scopeRelPrefix filters below still
302
+ // apply, keeping the result byte-for-byte unchanged.
303
+ let candidateNodes = Array.isArray(nodes) ? nodes : _lookupCandidateNodes(graph, symbol, language);
295
304
  if (fileRel) candidateNodes = candidateNodes.filter((node) => node.rel === fileRel);
296
305
  if (scopeRelPrefix) candidateNodes = candidateNodes.filter((node) => node.rel === scopeRelPrefix.slice(0, -1) || node.rel.startsWith(scopeRelPrefix));
297
306
  const ENV_CAP = Math.max(1, Number(process.env.REFERENCE_HIT_CAP) || 200);
@@ -629,16 +638,57 @@ function _formatSearchSymbolRow(name, hit) {
629
638
  return `${name}\t${loc}\t${next}`;
630
639
  }
631
640
 
641
+ const KEYWORD_SEARCH_CACHE_MAX_ENTRIES = Math.max(
642
+ 16,
643
+ Math.floor(Number(process.env.CODE_GRAPH_KEYWORD_SEARCH_CACHE_MAX_ENTRIES) || 128),
644
+ );
645
+ const KEYWORD_SEARCH_CACHE_MAX_BYTES = Math.max(
646
+ 64 * 1024,
647
+ Math.floor(Number(process.env.CODE_GRAPH_KEYWORD_SEARCH_CACHE_MAX_BYTES) || (1024 * 1024)),
648
+ );
649
+
650
+ function _keywordSearchLanguageCacheKey(language) {
651
+ return language == null ? '<none>' : `lang:${String(language)}`;
652
+ }
653
+
654
+ function _setKeywordSearchCache(graph, cacheKey, value) {
655
+ const cache = graph?._keywordSearchCache;
656
+ if (!(cache instanceof Map)) return value;
657
+ const valueBytes = Buffer.byteLength(String(value || ''), 'utf8');
658
+ if (valueBytes > KEYWORD_SEARCH_CACHE_MAX_BYTES) return value;
659
+ if (cache.has(cacheKey)) cache.delete(cacheKey);
660
+ cache.set(cacheKey, value);
661
+ let totalBytes = 0;
662
+ for (const memo of cache.values()) totalBytes += Buffer.byteLength(String(memo || ''), 'utf8');
663
+ while (cache.size > KEYWORD_SEARCH_CACHE_MAX_ENTRIES || totalBytes > KEYWORD_SEARCH_CACHE_MAX_BYTES) {
664
+ const oldest = cache.keys().next().value;
665
+ if (oldest === undefined) break;
666
+ const oldValue = cache.get(oldest);
667
+ totalBytes -= Buffer.byteLength(String(oldValue || ''), 'utf8');
668
+ cache.delete(oldest);
669
+ }
670
+ return value;
671
+ }
672
+
632
673
  export function _searchSymbolsByKeyword(graph, keyword, cwd, { language = null, limit = 30 } = {}) {
633
674
  const clean = String(keyword || '').trim();
634
675
  if (!clean) return '(no keyword)';
635
676
  const cap = Math.max(1, Math.min(100, Math.floor(Number(limit) || 30)));
677
+ // Memoize the full formatted output per (language, keyword, cap). Repeated
678
+ // symbol_search scans (e.g. batched keywords) otherwise re-walk every graph
679
+ // node — native + cheap symbol collection — for each keyword. The cached
680
+ // string already embeds the truncated WARN line, so truncated/incomplete
681
+ // semantics are preserved byte-for-byte on a cache hit.
682
+ const cacheKey = JSON.stringify([_keywordSearchLanguageCacheKey(language), clean, cap]);
683
+ const cached = graph?._keywordSearchCache?.get(cacheKey);
684
+ if (typeof cached === 'string') return cached;
685
+ const _memo = (s) => _setKeywordSearchCache(graph, cacheKey, s);
636
686
  const nativeEntries = _collectNativeKeywordSymbolEntries(graph, clean, { language });
637
687
  const cheapEntries = _collectCheapKeywordSymbolEntries(graph, clean, { language });
638
688
  const entries = [...nativeEntries, ...cheapEntries];
639
689
  if (!entries.length) {
640
690
  const nodeCount = graph?.nodes?.size ?? 0;
641
- return `(no symbol keyword matches in cwd=${cwd})\ngraph: nodes=${nodeCount}${language ? `, language=${language}` : ''}`;
691
+ return _memo(`(no symbol keyword matches in cwd=${cwd})\ngraph: nodes=${nodeCount}${language ? `, language=${language}` : ''}`);
642
692
  }
643
693
  entries.sort((a, b) => {
644
694
  const rank = Number(b.resolved) - Number(a.resolved);
@@ -669,7 +719,7 @@ export function _searchSymbolsByKeyword(graph, keyword, cwd, { language = null,
669
719
  if (graph?.truncated) {
670
720
  lines.push(`WARN: graph truncated at CODE_GRAPH_MAX_FILES=${CODE_GRAPH_MAX_FILES} — matches may be incomplete. Re-run with a narrower cwd.`);
671
721
  }
672
- return lines.join('\n');
722
+ return _memo(lines.join('\n'));
673
723
  }
674
724
 
675
725
  function _parseReferenceEntries(referenceText) {
@@ -413,6 +413,11 @@ export class ExecResult {
413
413
  this.taskId = opts.taskId;
414
414
  this.partialOutput = opts.partialOutput === true;
415
415
  this.outputCaptureError = opts.outputCaptureError || null;
416
+ // Distinguish a shell tool/control-plane failure (spawn/preflight/capture)
417
+ // from a command process failure (non-zero exit/signal/timeout). The
418
+ // renderer in builtin/bash-tool.mjs turns this into a model-visible marker.
419
+ this.failurePhase = opts.failurePhase || null;
420
+ this.failureReason = opts.failureReason || null;
416
421
  // Auto-background transition (CC startBackgrounding analogue). When a
417
422
  // foreground command outlives autoBackgroundMs the call settles with
418
423
  // backgrounded:true + the jobId for manual task control. The
@@ -558,6 +563,8 @@ export function execShellCommand({
558
563
  timedOut: false,
559
564
  killed: false,
560
565
  taskId,
566
+ failurePhase: 'tool',
567
+ failureReason: 'preflight failed',
561
568
  }),
562
569
  );
563
570
  return;
@@ -605,6 +612,8 @@ export function execShellCommand({
605
612
  timedOut: false,
606
613
  killed: false,
607
614
  taskId,
615
+ failurePhase: 'tool',
616
+ failureReason: 'spawn failed',
608
617
  }),
609
618
  );
610
619
  return;
@@ -52,6 +52,7 @@ import {
52
52
  RUNTIME_ROOT
53
53
  } from "./runtime-paths.mjs";
54
54
  import { getDiscordToken } from "./config.mjs";
55
+ import { invalidateConfigReadCache } from "../../shared/config.mjs";
55
56
  import { bootProfile, localTimestamp } from "./boot-profile.mjs";
56
57
  import {
57
58
  isChannelsDegraded,
@@ -720,6 +721,10 @@ async function start() {
720
721
  if (!_configWatcher) {
721
722
  try {
722
723
  _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
724
+ // Cross-process edit landed on disk; drop this process's short-TTL raw
725
+ // config cache synchronously so the debounced reload (and any readAll
726
+ // in between) sees the fresh file immediately, not up to TTL stale.
727
+ invalidateConfigReadCache();
723
728
  if (_reloadDebounce) clearTimeout(_reloadDebounce);
724
729
  _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
725
730
  });
@@ -442,6 +442,110 @@ export async function archiveCore(dataDir, id, expect = null) {
442
442
  }
443
443
  }
444
444
 
445
+ // Non-destructive project reclassification: move a mis-scoped core entry to the
446
+ // correct pool (a project slug, or COMMON when newProjectId is null). Only the
447
+ // project_id + updated_at change — no delete, and no re-embed (the embedding is
448
+ // derived from element/summary text, which is pool-independent). Takes BOTH the
449
+ // source and target pool advisory locks in a deterministic (sorted) order so a
450
+ // move can't deadlock with a concurrent add/edit/archive on either pool, then
451
+ // FOR UPDATEs the row and re-validates under the lock. `expect` carries the
452
+ // element/summary cycle3 reviewed; if the live row drifted, the move is skipped
453
+ // ({ skipped, reason }). A live (project_id, element) collision in the target
454
+ // pool means the fact already exists there → skipped, so the caller holds it for
455
+ // manual resolution instead of clobbering the existing row.
456
+ export async function reclassifyCore(dataDir, id, newProjectId, expect = null) {
457
+ const numId = Number(id)
458
+ if (!Number.isInteger(numId) || numId <= 0) throw new Error('integer id > 0 required')
459
+ const targetPid = newProjectId == null ? null : (String(newProjectId).trim() || null)
460
+ const db = _getDb(dataDir)
461
+ const now = Date.now()
462
+ const client = await checkedConnect(db._pool, 'memory')
463
+ try {
464
+ await client.query('BEGIN')
465
+ await client.query(`SET LOCAL lock_timeout = '5s'`)
466
+ // Read the current pool WITHOUT a row lock first (no FOR UPDATE → acquires
467
+ // no row lock, can't invert ordering) so the advisory locks are taken first.
468
+ const pre = (await client.query(`SELECT project_id FROM core_entries WHERE id = $1`, [numId])).rows[0]
469
+ if (!pre) throw new Error(`no entry with id=${numId}`)
470
+ const curPid = pre.project_id ?? null
471
+ if ((curPid ?? null) === (targetPid ?? null)) {
472
+ await client.query('ROLLBACK')
473
+ return { id: numId, skipped: true, reason: 'already in target pool' }
474
+ }
475
+ // Lock BOTH pools, sorted, so a move in the opposite direction can't deadlock.
476
+ const keys = [...new Set([
477
+ `core:${curPid == null ? 'COMMON' : curPid}`,
478
+ `core:${targetPid == null ? 'COMMON' : targetPid}`,
479
+ ])].sort()
480
+ for (const k of keys) await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [k])
481
+ const locked = (await client.query(
482
+ `SELECT id, element, summary, project_id, status FROM core_entries WHERE id = $1 FOR UPDATE`,
483
+ [numId],
484
+ )).rows[0]
485
+ if (!locked || !(locked.status == null || locked.status === 'active')) {
486
+ await client.query('ROLLBACK')
487
+ return { id: numId, skipped: true, reason: 'concurrently archived/removed' }
488
+ }
489
+ if ((locked.project_id ?? null) !== curPid) {
490
+ await client.query('ROLLBACK')
491
+ return { id: numId, skipped: true, reason: 'pool changed under lock' }
492
+ }
493
+ // Reviewed-source guard: cycle3 decided this move against the pool the row
494
+ // was in AT REVIEW time. If the row was reclassified into a THIRD pool
495
+ // between review and now, the in-tx pre-read above sees that new pool (so
496
+ // the pool-changed-under-lock check passes) but the move is stale → skip.
497
+ // `expect.sourceProjectId` (null = COMMON) carries the reviewed pool.
498
+ if (expect && 'sourceProjectId' in expect) {
499
+ const reviewedPid = expect.sourceProjectId == null ? null : (String(expect.sourceProjectId).trim() || null)
500
+ if ((locked.project_id ?? null) !== reviewedPid) {
501
+ await client.query('ROLLBACK')
502
+ return { id: numId, skipped: true, reason: 'source pool drift' }
503
+ }
504
+ }
505
+ if (expect && (String(expect.element ?? '') !== String(locked.element ?? '') ||
506
+ String(expect.summary ?? '') !== String(locked.summary ?? ''))) {
507
+ await client.query('ROLLBACK')
508
+ return { id: numId, skipped: true, reason: 'content drift' }
509
+ }
510
+ // Unique (project_id, element) guard: a live row with this element already in
511
+ // the target pool means the fact is present there → don't clobber it.
512
+ const dup = (await client.query(
513
+ `SELECT id FROM core_entries
514
+ WHERE project_id IS NOT DISTINCT FROM $1 AND element = $2
515
+ AND (status IS NULL OR status = 'active') AND id != $3
516
+ LIMIT 1`,
517
+ [targetPid, locked.element, numId],
518
+ )).rows[0]
519
+ if (dup) {
520
+ await client.query('ROLLBACK')
521
+ return { id: numId, skipped: true, reason: `target pool already has element (id=${dup.id})` }
522
+ }
523
+ let r
524
+ try {
525
+ await client.query('SAVEPOINT mv')
526
+ r = await client.query(
527
+ `UPDATE core_entries SET project_id = $1, updated_at = $2 WHERE id = $3
528
+ RETURNING id, element, summary, category, project_id, created_at, updated_at`,
529
+ [targetPid, now, numId],
530
+ )
531
+ await client.query('RELEASE SAVEPOINT mv')
532
+ } catch (err) {
533
+ if (err.code === '23505') {
534
+ await client.query('ROLLBACK')
535
+ return { id: numId, skipped: true, reason: 'unique collision in target pool' }
536
+ }
537
+ throw err
538
+ }
539
+ await client.query('COMMIT')
540
+ return { ...r.rows[0], from_project_id: curPid, to_project_id: targetPid }
541
+ } catch (err) {
542
+ try { await client.query('ROLLBACK') } catch {}
543
+ throw err
544
+ } finally {
545
+ client.release()
546
+ }
547
+ }
548
+
445
549
  // ─── Core-candidate promotion pipeline (proposal mode) ───────────────────────
446
550
  //
447
551
  // nominateCoreCandidates flags strong active entries as core-memory
@@ -175,7 +175,7 @@ export function analyze(text) {
175
175
  }
176
176
 
177
177
  // Content-morpheme stem forms for a Korean phrase. null when not ready.
178
- // "개선을" ['개선']; "개선하고" ['개선','하'] filtered to content tags.
178
+ // Example: an inflected Korean noun/verb phrase is reduced to content stems.
179
179
  export function stems(text) {
180
180
  const tokens = analyze(text)
181
181
  if (!tokens) return null
@@ -22,6 +22,7 @@ function __mixdogMemoryLog(...args) {
22
22
  // <id>|update|<element>|<summary>
23
23
  // <id>|merge|<target_id>|<source_ids_csv>
24
24
  // <id>|delete|<reason>
25
+ // <id>|reclassify|<project_slug|common>
25
26
 
26
27
  import { existsSync, readFileSync } from 'fs'
27
28
  import { join } from 'path'
@@ -29,6 +30,7 @@ import { fileURLToPath } from 'url'
29
30
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
30
31
  import { callAgentDispatch } from './agent-ipc.mjs'
31
32
  import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
33
+ import { reclassifyCore } from './core-memory-store.mjs'
32
34
  import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
33
35
  import { embedText } from './embedding-provider.mjs'
34
36
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
@@ -264,6 +266,17 @@ function parseVerdicts(raw, idSet) {
264
266
  } else if (verb === 'delete') {
265
267
  const reason = (parts[2] ?? '').trim()
266
268
  actions.push({ id, verb: 'delete', reason: reason || null })
269
+ } else if (verb === 'reclassify' || verb === 'reproject' || verb === 'rescope') {
270
+ // Project classification correction: move a mis-scoped entry to the right
271
+ // pool. `<id>|reclassify|<project_slug|common>`. A missing target scope
272
+ // has no destination → drop. 'common' (case-insensitive) → COMMON pool.
273
+ const rawTarget = (parts[2] ?? '').trim()
274
+ if (!rawTarget) {
275
+ __mixdogMemoryLog(`[cycle3] reclassify rejected: id=${id} missing target scope\n`)
276
+ continue
277
+ }
278
+ const projectId = /^common$/i.test(rawTarget) ? null : rawTarget
279
+ actions.push({ id, verb: 'reclassify', projectId })
267
280
  } else if (verb === 'superseded' || verb === 'supersede') {
268
281
  // Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
269
282
  // newer active core id the supersession has no evidence → drop to keep.
@@ -288,6 +301,7 @@ function mergeCycle3Counts(a = {}, b = {}) {
288
301
  updated: Number(a.updated || 0) + Number(b.updated || 0),
289
302
  merged: Number(a.merged || 0) + Number(b.merged || 0),
290
303
  deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
304
+ reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
291
305
  }
292
306
  }
293
307
 
@@ -302,11 +316,13 @@ function mergeCycle3Results(a, b) {
302
316
  updated: Number(a.updated || 0) + Number(b.updated || 0),
303
317
  merged: Number(a.merged || 0) + Number(b.merged || 0),
304
318
  deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
319
+ reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
305
320
  proposed: mergeCycle3Counts(a.proposed, b.proposed),
306
321
  held: {
307
322
  updated: Number(a?.held?.updated || 0) + Number(b?.held?.updated || 0),
308
323
  merged: Number(a?.held?.merged || 0) + Number(b?.held?.merged || 0),
309
324
  deleted: Number(a?.held?.deleted || 0) + Number(b?.held?.deleted || 0),
325
+ reclassified: Number(a?.held?.reclassified || 0) + Number(b?.held?.reclassified || 0),
310
326
  },
311
327
  details: [...(a.details || []), ...(b.details || [])],
312
328
  skippedInFlight: false,
@@ -517,6 +533,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
517
533
 
518
534
  const idSet = new Set(cores.map(c => Number(c.id)))
519
535
  const coreById = new Map(cores.map(c => [Number(c.id), c]))
536
+ // Real project pools present in this review — reclassify may only move an
537
+ // entry INTO a pool we already know is real (COMMON is always valid), never
538
+ // invent a typo pool from an LLM-supplied slug.
539
+ const knownPools = new Set(cores.map(c => c.project_id).filter(p => p != null).map(String))
520
540
  const parsed = parseVerdicts(raw, idSet)
521
541
  if (!parsed) {
522
542
  __mixdogMemoryLog(
@@ -605,9 +625,9 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
605
625
  }
606
626
  }
607
627
 
608
- let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0
609
- const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0 }
610
- const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
628
+ let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0, reclassified = 0
629
+ const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
630
+ const held = { updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
611
631
  const details = []
612
632
  const touched = new Set() // ids already acted on this cycle — avoid double action
613
633
  // Safety margin: even when every junk verdict is legitimate, a single
@@ -687,6 +707,44 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
687
707
  }
688
708
  continue
689
709
  }
710
+ if (a.verb === 'reclassify') {
711
+ proposed.reclassified++
712
+ // Project classification correction: move a mis-scoped entry to the right
713
+ // pool. Non-destructive (project_id only, no delete, no re-embed) and
714
+ // reversible, so it applies in DEFAULT (conservative) mode. Guards: the
715
+ // move must actually change pools and the target must be a real pool from
716
+ // this review (or COMMON) — otherwise hold. Unique-collision / content
717
+ // drift are resolved inside reclassifyCore (returns skipped).
718
+ const core = coreById.get(a.id)
719
+ const curPid = core?.project_id ?? null
720
+ const targetPid = a.projectId ?? null
721
+ let holdReason = null
722
+ if ((curPid ?? null) === (targetPid ?? null)) holdReason = 'already in target pool'
723
+ else if (targetPid != null && !knownPools.has(String(targetPid))) holdReason = `unknown target pool "${targetPid}"`
724
+ if (!mutate) holdReason = holdReason || 'proposal mode'
725
+ if (holdReason) {
726
+ held.reclassified++
727
+ details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: holdReason })
728
+ touched.add(a.id)
729
+ continue
730
+ }
731
+ try {
732
+ const res = await reclassifyCore(dataDir, a.id, targetPid, core ? { element: core.element, summary: core.summary, sourceProjectId: curPid } : null)
733
+ if (res?.skipped) {
734
+ held.reclassified++
735
+ details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: res.reason })
736
+ } else {
737
+ reclassified++
738
+ details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: true })
739
+ }
740
+ touched.add(a.id)
741
+ } catch (err) {
742
+ if (signal?.aborted) throw signal.reason ?? err
743
+ __mixdogMemoryLog(`[cycle3] reclassify failed id=${a.id}: ${err.message}\n`)
744
+ details.push({ id: a.id, verb: 'reclassify', error: err.message })
745
+ }
746
+ continue
747
+ }
690
748
  if (a.verb === 'superseded') {
691
749
  proposed.superseded++
692
750
  // Supersession archives (status flip), never physical DELETE, and applies
@@ -808,9 +866,11 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
808
866
  ` proposed_update=${proposed.updated} proposed_merge=${proposed.merged} proposed_delete=${proposed.deleted}` +
809
867
  ` applied_update=${updated} applied_merge=${merged} applied_delete=${deleted}` +
810
868
  ` applied_superseded=${superseded}` +
869
+ ` applied_reclassify=${reclassified}` +
811
870
  ` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted} held_superseded=${held.superseded}` +
871
+ ` held_reclassify=${held.reclassified}` +
812
872
  ` mode=${applyMode}\n`,
813
873
  )
814
874
 
815
- return { reviewed: cores.length, kept, updated, merged, deleted, superseded, proposed, held, applied: mutate, applyMode, details }
875
+ return { reviewed: cores.length, kept, updated, merged, deleted, superseded, reclassified, proposed, held, applied: mutate, applyMode, details }
816
876
  }
@@ -10,7 +10,7 @@ const MEMORY_TOKEN_STOPWORDS = new Set([
10
10
  'your', 'unless', 'with',
11
11
  'user', 'assistant', 'requested', 'request', 'asked', 'ask', 'stated', 'state', 'reported', 'report',
12
12
  'mentioned', 'mention', 'clarified', 'clarify', 'explicitly', 'currently',
13
- '사용자', '유저', '요청', '질문', '답변', '언급', '말씀', '설명', '보고', '무슨', '뭐야', '했지', 'user', 'asks', 'asked', 'request', 'requested', 'question', 'answer', 'reply', 'said', 'mentioned', 'explained', 'reported', 'what', 'huh',
13
+ '\uC0AC\uC6A9\uC790', '\uC720\uC800', '\uC694\uCCAD', '\uC9C8\uBB38', '\uB2F5\uBCC0', '\uC5B8\uAE09', '\uB9D0\uC500', '\uC124\uBA85', '\uBCF4\uACE0', '\uBB34\uC2A8', '\uBB50\uC57C', '\uD588\uC9C0', 'user', 'asks', 'asked', 'request', 'requested', 'question', 'answer', 'reply', 'said', 'mentioned', 'explained', 'reported', 'what', 'huh',
14
14
  ])
15
15
 
16
16
  export function normalizeMemoryToken(token) {
@@ -20,9 +20,9 @@ export function normalizeMemoryToken(token) {
20
20
  // Korean suffix stripping: basic particles + compound endings
21
21
  if (/[\uAC00-\uD7AF]/.test(normalized) && normalized.length > 2) {
22
22
  const stripped = normalized
23
- .replace(/(했었지|했더라|됐었나|됐던가|했는지|였는지|인건가|하려면|에서는|이라서|였더라|에서도|이었지|으로도|거였지|한건지|이었나)$/u, '')
24
- .replace(/(했던|했지|됐던|됐지|하게|되던|이라|에서|으로|하는|없는|있는|었던|하자|않게|할때|인지|인데|인건|이고|보다|처럼|까지|부터|마다|밖에|없이)$/u, '')
25
- .replace(/(은|는|이|가|을|를|랑|과|와|도|에|의|로|만|며|나|고|서|자|요)$/u, '')
23
+ .replace(/(\uD588\uC5C8\uC9C0|\uD588\uB354\uB77C|\uB410\uC5C8\uB098|\uB410\uB358\uAC00|\uD588\uB294\uC9C0|\uC600\uB294\uC9C0|\uC778\uAC74\uAC00|\uD558\uB824\uBA74|\uC5D0\uC11C\uB294|\uC774\uB77C\uC11C|\uC600\uB354\uB77C|\uC5D0\uC11C\uB3C4|\uC774\uC5C8\uC9C0|\uC73C\uB85C\uB3C4|\uAC70\uC600\uC9C0|\uD55C\uAC74\uC9C0|\uC774\uC5C8\uB098)$/u, '')
24
+ .replace(/(\uD588\uB358|\uD588\uC9C0|\uB410\uB358|\uB410\uC9C0|\uD558\uAC8C|\uB418\uB358|\uC774\uB77C|\uC5D0\uC11C|\uC73C\uB85C|\uD558\uB294|\uC5C6\uB294|\uC788\uB294|\uC5C8\uB358|\uD558\uC790|\uC54A\uAC8C|\uD560\uB54C|\uC778\uC9C0|\uC778\uB370|\uC778\uAC74|\uC774\uACE0|\uBCF4\uB2E4|\uCC98\uB7FC|\uAE4C\uC9C0|\uBD80\uD130|\uB9C8\uB2E4|\uBC16\uC5D0|\uC5C6\uC774)$/u, '')
25
+ .replace(/(\uC740|\uB294|\uC774|\uAC00|\uC744|\uB97C|\uB791|\uACFC|\uC640|\uB3C4|\uC5D0|\uC758|\uB85C|\uB9CC|\uBA70|\uB098|\uACE0|\uC11C|\uC790|\uC694)$/u, '')
26
26
  if (stripped.length >= 2) normalized = stripped
27
27
  }
28
28
 
@@ -314,14 +314,14 @@ function collectGroupTs(groupRows) {
314
314
  return all
315
315
  }
316
316
 
317
- // Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n)`; the end keeps the
317
+ // Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n entries)`; the end keeps the
318
318
  // MM-DD prefix only when it falls on a different calendar day than the start.
319
319
  function spanHeaderSuffix(minTs, maxTs, n) {
320
320
  const min = formatTs(minTs) // "YYYY-MM-DD HH:mm"
321
321
  const max = formatTs(maxTs)
322
322
  const startPart = min.slice(5) // "MM-DD HH:mm"
323
323
  const endPart = min.slice(0, 10) === max.slice(0, 10) ? max.slice(11) : max.slice(5)
324
- return `(${startPart} ~ ${endPart}, ${n})`
324
+ return `(${startPart} ~ ${endPart}, ${n} entries)`
325
325
  }
326
326
 
327
327
  // Session-grouped rendering for the GLOBAL query-less browse ("what did we
@@ -359,7 +359,7 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
359
359
  const tsAll = collectGroupTs(groupRows)
360
360
  const suffix = tsAll.length
361
361
  ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
362
- : ` (${groupRows.length})`
362
+ : ` (${groupRows.length} entries)`
363
363
  parts.push(`## ${label}${mark}${suffix}`)
364
364
  const bodyStr = renderEntryLines(groupRows, { recencyOrder })
365
365
  const bodyLines = bodyStr === '(no results)'
@@ -120,6 +120,21 @@ function _abortError() {
120
120
  return e;
121
121
  }
122
122
 
123
+ /**
124
+ * Best-effort capacity probe for NON-COMPETING prewarm/warmup work. Returns
125
+ * true only when a slot could be taken right now without queuing — i.e. below
126
+ * the cap AND with no waiter already queued. Speculative warmers (explore's
127
+ * code_graph / find prewarm) consult this to skip/defer when the daemon is
128
+ * busy, so a fire-and-forget warm never pushes a real tool query into the
129
+ * queue (the "non-competing under fanout" guarantee). This is a probe, NOT a
130
+ * reservation: the answer can go stale under a race, which is acceptable for
131
+ * best-effort work. Real queries must use acquire()/withGate() and never gate
132
+ * themselves on this.
133
+ */
134
+ export function hasSpareCapacity() {
135
+ return _inflight < MAX_INFLIGHT && _queue.length === 0;
136
+ }
137
+
123
138
  /**
124
139
  * Run `fn` while holding one child-spawn slot. Release is guaranteed in a
125
140
  * finally so a throw/return from `fn` cannot leak a slot or deadlock the gate.
@@ -2,7 +2,7 @@
2
2
  * Unified config reader/writer.
3
3
  * Single file: mixdog-config.json with sections such as channels, agent, and memory.
4
4
  */
5
- import { readFileSync, mkdirSync, existsSync } from 'fs'
5
+ import { readFileSync, statSync, mkdirSync, existsSync } from 'fs'
6
6
  import { join, dirname } from 'path'
7
7
  import { createRequire } from 'module'
8
8
  import { resolvePluginData } from './plugin-paths.mjs'
@@ -24,6 +24,78 @@ const CONFIG_PATH = join(DATA_DIR, 'mixdog-config.json')
24
24
 
25
25
  const GENERATED_KEY = '_generated'
26
26
 
27
+ // Process-wide short-TTL cache of the RAW utf8 string of CONFIG_PATH (never
28
+ // the parsed object). Parallel agent spawns each hit the non-RMW read path
29
+ // (readAll → readSection/readConfig/readCapabilities); without this every
30
+ // spawn pays a synchronous readFileSync(CONFIG_PATH) on the event loop,
31
+ // serializing the whole fanout behind redundant disk I/O. Caching the raw
32
+ // string — not the parsed object — lets each caller re-JSON.parse for an
33
+ // isolated, freely-mutable object (no shared-reference poisoning). Only the
34
+ // non-RMW readAll() consults this; the RMW path (readAllForRmW) always reads
35
+ // disk under the lock. Read errors and malformed JSON are never cached.
36
+ let _configReadCache = null // { raw, mtimeMs, size, atMs } | null
37
+ // Optional coarse fast-path window (ms). Default 0 => the cached raw string is
38
+ // validated against the file's mtime+size on EVERY read via a cheap statSync
39
+ // (no content read, no whole-file JSON.parse), so a cross-process edit is
40
+ // observed immediately with ZERO time-based staleness while the expensive
41
+ // full read + parse are still skipped on an unchanged file. Set >0 to also
42
+ // skip the statSync within the window, trading a bounded staleness for fewer
43
+ // syscalls under extreme spawn fanout.
44
+ const CONFIG_READ_TTL_MS = (() => {
45
+ const n = Number(process.env.MIXDOG_CONFIG_READ_TTL_MS)
46
+ return Number.isFinite(n) && n >= 0 ? n : 0
47
+ })()
48
+
49
+ export function invalidateConfigReadCache() {
50
+ _configReadCache = null
51
+ }
52
+
53
+ function readConfigRawCached() {
54
+ const now = Date.now()
55
+ if (_configReadCache) {
56
+ // Fast path (only when a TTL window is configured): serve without any
57
+ // syscall until the window lapses.
58
+ if (CONFIG_READ_TTL_MS > 0 && now - _configReadCache.atMs < CONFIG_READ_TTL_MS) {
59
+ return _configReadCache.raw
60
+ }
61
+ // Freshness gate: metadata-only stat. Unchanged mtime+size => the cached
62
+ // raw is still current, cross-process writes included — atomic writers land
63
+ // a new inode with a fresh mtime, so this reliably detects them.
64
+ try {
65
+ const st = statSync(CONFIG_PATH)
66
+ if (st.mtimeMs === _configReadCache.mtimeMs && st.size === _configReadCache.size) {
67
+ _configReadCache.atMs = now
68
+ return _configReadCache.raw
69
+ }
70
+ } catch {
71
+ // stat failed (e.g. transient ENOENT mid-rename); fall through to a fresh
72
+ // read which re-applies the ENOENT/backup-restore semantics below.
73
+ }
74
+ }
75
+ // Stat BEFORE read: if a write lands between the two we cache newer content
76
+ // against the older mtime, so the very next call re-reads instead of getting
77
+ // stuck on stale bytes — the cache always converges toward fresh.
78
+ let st = null
79
+ try {
80
+ st = statSync(CONFIG_PATH)
81
+ } catch (err) {
82
+ if (err.code === 'ENOENT') { _configReadCache = null; return null }
83
+ process.stderr.write(`[config] readConfigRawCached: unexpected stat error for ${CONFIG_PATH}: ${err.message}\n`)
84
+ throw err
85
+ }
86
+ let raw
87
+ try {
88
+ raw = readFileSync(CONFIG_PATH, 'utf8')
89
+ } catch (err) {
90
+ if (err.code === 'ENOENT') { _configReadCache = null; return null }
91
+ // Fail closed on unknown read errors (EACCES, EIO, …); do NOT cache.
92
+ process.stderr.write(`[config] readJsonFile: unexpected read error for ${CONFIG_PATH}: ${err.message}\n`)
93
+ throw err
94
+ }
95
+ _configReadCache = { raw, mtimeMs: st.mtimeMs, size: st.size, atMs: now }
96
+ return raw
97
+ }
98
+
27
99
  function isPlainObject(value) {
28
100
  return !!value && typeof value === 'object' && !Array.isArray(value)
29
101
  }
@@ -36,24 +108,32 @@ export function stripGeneratedMarker(data) {
36
108
 
37
109
  function readJsonFile(path) {
38
110
  let raw
39
- try {
40
- raw = readFileSync(path, 'utf8')
41
- } catch (err) {
42
- if (err.code === 'ENOENT') return null
43
- // Fail closed on unknown read errors (EACCES, EIO, …). Returning {}
44
- // here would let a subsequent writeSection() serialize an empty
45
- // object back over an existing-but-temporarily-unreadable config and
46
- // erase every other section. Better to surface the read error and
47
- // abort the RMW than silently destroy data.
48
- process.stderr.write(`[config] readJsonFile: unexpected read error for ${path}: ${err.message}\n`)
49
- throw err
111
+ if (path === CONFIG_PATH) {
112
+ // Non-RMW read path: served from the short-TTL raw-string cache. Returning
113
+ // {} on a read error here would let a subsequent writeSection() serialize
114
+ // an empty object over an existing-but-temporarily-unreadable config and
115
+ // erase every other section, so read errors surface (throw) uncached.
116
+ raw = readConfigRawCached()
117
+ } else {
118
+ try {
119
+ raw = readFileSync(path, 'utf8')
120
+ } catch (err) {
121
+ if (err.code === 'ENOENT') return null
122
+ process.stderr.write(`[config] readJsonFile: unexpected read error for ${path}: ${err.message}\n`)
123
+ throw err
124
+ }
50
125
  }
126
+ if (raw == null) return null
51
127
  try {
52
128
  return JSON.parse(raw)
53
129
  } catch (err) {
54
130
  // Quarantine a malformed mixdog-config.json so the next boot starts fresh
55
131
  // instead of looping on a broken file.
56
132
  if (path === CONFIG_PATH) {
133
+ // A cached raw string that fails to parse must not be re-served; drop it
134
+ // so the post-quarantine/restore read hits disk fresh (malformed = never
135
+ // cached).
136
+ invalidateConfigReadCache()
57
137
  const corrupt = `${path}.corrupt-${Date.now()}`
58
138
  try { renameWithRetrySync(path, corrupt) } catch {}
59
139
  process.stderr.write(`[config] mixdog-config.json is malformed (${err.message}). Renamed to ${corrupt}. Restore it or delete to start fresh.\n`)
@@ -81,6 +161,9 @@ function writeJsonFile(path, data) {
81
161
  }
82
162
  writeJsonAtomicSync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
83
163
  if (path === CONFIG_PATH) {
164
+ // Our own write just changed CONFIG_PATH on disk; drop the stale raw cache
165
+ // synchronously so the next in-process readAll() reflects it immediately.
166
+ invalidateConfigReadCache()
84
167
  try { markUserDataInitialized(DATA_DIR) } catch {}
85
168
  try { backupUserData(DATA_DIR, 'post-config-write') } catch {}
86
169
  }
@@ -226,6 +309,9 @@ async function writeJsonFileAsync(path, data) {
226
309
  }
227
310
  await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
228
311
  if (path === CONFIG_PATH) {
312
+ // Parity with writeJsonFile: invalidate synchronously after the async
313
+ // write resolves so this process never serves a stale cached config.
314
+ invalidateConfigReadCache()
229
315
  try { markUserDataInitialized(DATA_DIR) } catch {}
230
316
  try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
231
317
  }