mixdog 0.9.45 → 0.9.47

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 (123) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/headless-command.mjs +139 -0
  34. package/src/headless-role.mjs +121 -10
  35. package/src/help.mjs +4 -1
  36. package/src/rules/agent/00-common.md +3 -3
  37. package/src/rules/agent/00-core.md +8 -9
  38. package/src/rules/agent/20-skip-protocol.md +2 -3
  39. package/src/rules/agent/30-explorer.md +50 -56
  40. package/src/rules/agent/40-cycle1-agent.md +10 -12
  41. package/src/rules/agent/41-cycle2-agent.md +12 -9
  42. package/src/rules/agent/42-cycle3-agent.md +4 -6
  43. package/src/rules/lead/01-general.md +5 -6
  44. package/src/rules/lead/02-channels.md +1 -1
  45. package/src/rules/lead/lead-brief.md +14 -17
  46. package/src/rules/lead/lead-tool.md +3 -3
  47. package/src/rules/shared/01-tool.md +41 -43
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  50. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  51. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  52. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  56. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  57. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  60. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  66. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  67. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  68. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  69. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  70. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  71. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  72. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  73. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  74. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  75. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  76. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  79. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  80. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  81. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  82. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  83. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  86. package/src/runtime/memory/index.mjs +0 -1
  87. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  88. package/src/runtime/memory/lib/http-router.mjs +0 -193
  89. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  90. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  91. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  92. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  93. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  94. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  95. package/src/runtime/memory/tool-defs.mjs +5 -6
  96. package/src/runtime/shared/config.mjs +11 -34
  97. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  98. package/src/runtime/shared/pristine-execution.mjs +356 -0
  99. package/src/runtime/shared/provider-api-key.mjs +43 -0
  100. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  101. package/src/session-runtime/context-status.mjs +61 -13
  102. package/src/session-runtime/mcp-glue.mjs +29 -2
  103. package/src/session-runtime/plugin-mcp.mjs +7 -0
  104. package/src/session-runtime/resource-api.mjs +38 -5
  105. package/src/session-runtime/runtime-core.mjs +5 -1
  106. package/src/session-runtime/session-turn-api.mjs +14 -2
  107. package/src/session-runtime/settings-api.mjs +5 -0
  108. package/src/session-runtime/tool-catalog.mjs +13 -2
  109. package/src/session-runtime/tool-defs.mjs +1 -3
  110. package/src/standalone/agent-task-status.mjs +50 -11
  111. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  112. package/src/standalone/explore-tool.mjs +257 -49
  113. package/src/standalone/seeds.mjs +1 -0
  114. package/src/tui/App.jsx +23 -10
  115. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  116. package/src/tui/app/use-transcript-window.mjs +12 -21
  117. package/src/tui/components/ContextPanel.jsx +19 -25
  118. package/src/tui/dist/index.mjs +77 -65
  119. package/src/tui/engine/agent-envelope.mjs +16 -5
  120. package/src/tui/engine/labels.mjs +1 -1
  121. package/src/workflows/default/WORKFLOW.md +21 -51
  122. package/src/workflows/solo/WORKFLOW.md +12 -17
  123. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -410,55 +410,6 @@ function capToolOutput(result, options = {}) {
410
410
  return `${head}\n... [tool-output truncated: ${Math.round(bytes / 1024)} KB -> ${Math.round(cap / 1024)} KB cap (tool_output_token_limit)] ...\n${tail}`;
411
411
  }
412
412
 
413
- const _GREP_SWEEP_NUDGE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_ENABLE_GREP_SWEEP_NUDGE || ''));
414
- const _GREP_SWEEP_MIN_CALLS = 20;
415
- const _GREP_SWEEP_MIN_UNIQUE = 16;
416
- const _grepSweepState = new Map();
417
-
418
- function _grepSweepTerms(pattern) {
419
- const raw = Array.isArray(pattern) ? pattern.join('|') : String(pattern || '');
420
- return [...new Set(raw
421
- .split(/[|,\s()"'`[\]{}.*+?^$\\:-]+/g)
422
- .map((s) => s.trim().toLowerCase())
423
- .filter((s) => s.length >= 4))];
424
- }
425
-
426
- function _normalizeSweepPath(value) {
427
- return String(value || '.')
428
- .replace(/\\/g, '/')
429
- .replace(/^([A-Za-z]):/, (_, d) => d.toLowerCase() + ':')
430
- .replace(/\/+/g, '/')
431
- .replace(/\/$/, '') || '.';
432
- }
433
-
434
- function _grepSweepKey(args = {}) {
435
- const path = _normalizeSweepPath(args.path || '.');
436
- const glob = Array.isArray(args.glob) ? args.glob.join(',') : String(args.glob || '');
437
- const terms = _grepSweepTerms(args.pattern).slice(0, 3).join('|');
438
- return `${path}::${glob}::${terms}`;
439
- }
440
-
441
- function _maybeAppendGrepSweepNudge(toolName, args, result, scope) {
442
- if (!_GREP_SWEEP_NUDGE_ENABLED || toolName !== 'grep' || typeof result !== 'string') return result;
443
- const key = scope || 'global';
444
- const now = Date.now();
445
- let state = _grepSweepState.get(key);
446
- if (!state || now - state.lastTs > 10 * 60_000) {
447
- state = { count: 0, unique: new Set(), lastTs: 0, nudged: false };
448
- _grepSweepState.set(key, state);
449
- }
450
- state.count += 1;
451
- state.unique.add(_grepSweepKey(args));
452
- state.lastTs = now;
453
- if (_grepSweepState.size > 256) {
454
- const oldest = [..._grepSweepState.entries()].sort((a, b) => (a[1].lastTs || 0) - (b[1].lastTs || 0)).slice(0, 32);
455
- for (const [oldKey] of oldest) _grepSweepState.delete(oldKey);
456
- }
457
- if (state.nudged || state.count < _GREP_SWEEP_MIN_CALLS || state.unique.size < _GREP_SWEEP_MIN_UNIQUE) return result;
458
- state.nudged = true;
459
- return `${result}\n\n[grep_sweep_hint] Many distinct grep searches were used in this session. Stop rewording search terms; synthesize from existing anchors or switch tools once (code_graph/find/read known regions).`;
460
- }
461
-
462
413
  export async function executeBuiltinTool(name, args, cwd, options = {}) {
463
414
  if (options.abortSignal && !options.signal) {
464
415
  options = { ...options, signal: options.abortSignal };
@@ -521,8 +472,7 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
521
472
  return formatUnknownBuiltinToolMessage(name, args);
522
473
  }
523
474
  })();
524
- const _withNudge = _maybeAppendGrepSweepNudge(toolName, args, _toolResult, readStateScope);
525
- return capToolOutput(_appendClampNotices(args, _withNudge), options);
475
+ return capToolOutput(_appendClampNotices(args, _toolResult), options);
526
476
  }
527
477
 
528
478
  // Surface arg-guard clamp notices (args._clampNotices, see pushClampNotice in
@@ -3,13 +3,13 @@ export const CODE_GRAPH_TOOL_DEFS = [
3
3
  name: 'code_graph',
4
4
  title: 'Code Graph',
5
5
  annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
6
- description: 'Repo-local code structure/flow (not web): symbols/references/calls/deps Public files[] must be verified paths; verified source files only. IDs→graph; literal/zero→grep. Exact identifiers: find_symbol/references/callers/callees; keywords: symbol_search/search Unsupported target arrays omitted, never silently mixed. Legacy file/symbol/language compatible, hidden. Batch symbols[]/files[] by mode',
6
+ description: 'Repo code structure/flow over verified source files only. IDs→graph; literal/zero→grep. Exact identifiers: find_symbol/references/callers/callees; keywords: symbol_search/search. Unsupported target arrays are omitted, never silently mixed. Batch symbols[]/files[] by mode.',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
10
- mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'], description: 'Repo-local: file modes={overview,imports,dependents,related,impact}; symbols with files→files[] file outline; symbol modes={find_symbol,symbol_search,search,references,callers,callees}; fileless symbols→symbol_search keywords.' },
11
- files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Verified source files; supported targets only.' },
12
- symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Exact identifiers: find_symbol/references/callers/callees; keywords: symbol_search/search; multiple exact symbols use one symbols[] call; one call.' },
10
+ mode: { type: 'string', enum: ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'], description: 'File modes={overview,imports,dependents,related,impact}; symbols with files→files[] file outline; symbol modes={find_symbol,symbol_search,search,references,callers,callees}; fileless symbols→symbol_search keywords.' },
11
+ files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Source file path(s); supported targets only.' },
12
+ symbols: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Exact identifiers (find_symbol/references/callers/callees) or keywords (symbol_search/search); multiple exact symbols use one symbols[] call.' },
13
13
  body: { type: 'boolean', description: 'Include body.' },
14
14
  limit: { type: 'number', minimum: 1, description: 'Max results.' },
15
15
  depth: { type: 'number', minimum: 1, maximum: 5, description: 'Caller depth.' },
@@ -363,7 +363,7 @@ async function apply_patch(args, cwd, options = {}) {
363
363
  throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
364
364
  }
365
365
  if (isCompactedPlaceholderPatch(patchStr)) {
366
- throw new Error('patch body is a compacted-history placeholder ([mixdog compacted …]), not real patch content. Re-read the target file span and write a fresh patch; never resubmit compacted output as new tool input.');
366
+ throw new Error('patch body is a compacted-history placeholder ([mixdog compacted …]), not real patch content and cannot be executed. Re-read the current target file contents now, then construct and submit a fresh full patch from those contents. Do not reuse the marker, omit the patch argument, or reconstruct the old patch from history.');
367
367
  }
368
368
  const patchByteLen = Buffer.byteLength(patchStr, 'utf8');
369
369
  if (patchByteLen > APPLY_PATCH_MAX_BYTES) {
@@ -0,0 +1,26 @@
1
+ {
2
+ "version": "0.7.19",
3
+ "_comment": "Rewritten by .github/workflows/patch-release.yml on each tagged release. assets maps platformKey (process.platform-process.arch, e.g. win32-x64, linux-x64, darwin-arm64) to { url, sha256 } of the mixdog-patch binary on the GitHub release. A local cargo build under native/mixdog-patch/target/release always takes precedence; otherwise the binary is fetched per this manifest into the data dir (apply is native-only — no JS apply engine).",
4
+ "assets": {
5
+ "darwin-arm64": {
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.19/mixdog-patch-darwin-arm64",
7
+ "sha256": "c808fcfb69d2817fd7678dd364d2e1dd68f6ef2d9807f618bdf110c03ca00ccb"
8
+ },
9
+ "darwin-x64": {
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.19/mixdog-patch-darwin-x64",
11
+ "sha256": "7ed0eeff84796559ca865fc93e7a2a94f2c3fa00126f919064bb8273656fcd85"
12
+ },
13
+ "linux-arm64": {
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.19/mixdog-patch-linux-arm64",
15
+ "sha256": "3cad9c9542f2d97732953a102ef72daf4c79be5361560698d5fd2c5f0c18eda7"
16
+ },
17
+ "linux-x64": {
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.19/mixdog-patch-linux-x64",
19
+ "sha256": "cb0e30346f437811dff19238e1854bd37d97a78f35b13e0b704ebeaa9499ae87"
20
+ },
21
+ "win32-x64": {
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/patch-v0.7.19/mixdog-patch-win32-x64.exe",
23
+ "sha256": "4bf61a99f63e446570d9d2a9c9ef38475d0d70c0a2b632a5f44d4fe9b684b968"
24
+ }
25
+ }
26
+ }
@@ -793,7 +793,6 @@ const _httpRouter = createHttpRouter({
793
793
  registerClient,
794
794
  deregisterClient,
795
795
  getDraining: () => _stopPromise != null,
796
- getCycle1CallLlm,
797
796
  getTraceDb: () => _traceDb,
798
797
  setTraceDb: (v) => { _traceDb = v },
799
798
  ingestTranscriptFile,
@@ -39,6 +39,29 @@ function trimOrNull(v) {
39
39
  return s === '' ? null : s
40
40
  }
41
41
 
42
+ export function normalizeCoreInput(input = {}, options = {}) {
43
+ const summary = trimOrNull(input.summary ?? input.content)
44
+ const element = trimOrNull(input.element) ?? (summary ? summary.slice(0, CORE_ELEMENT_MAX) : null)
45
+ const suppliedCategory = trimOrNull(input.category)
46
+ const category = (suppliedCategory ?? 'fact').toLowerCase()
47
+ const errors = []
48
+
49
+ if (options.requireElement && !element) errors.push('element required')
50
+ if (options.requireSummary && !summary) errors.push('summary required')
51
+ if (options.requireCategory && !suppliedCategory) errors.push('category required')
52
+ if (element && element.length > CORE_ELEMENT_MAX) {
53
+ errors.push(`element too long (${element.length}/${CORE_ELEMENT_MAX} chars, remove ${element.length - CORE_ELEMENT_MAX})`)
54
+ }
55
+ if (summary && summary.length > CORE_SUMMARY_MAX) {
56
+ errors.push(`summary too long (${summary.length}/${CORE_SUMMARY_MAX} chars, remove ${summary.length - CORE_SUMMARY_MAX})`)
57
+ }
58
+ if (suppliedCategory && !VALID_CAT.has(category)) {
59
+ errors.push(`invalid category "${category}". Valid: ${[...VALID_CAT].join(', ')}`)
60
+ }
61
+
62
+ return { element, summary, category, suppliedCategory, errors }
63
+ }
64
+
42
65
  function _getDb(dataDir) {
43
66
  if (!dataDir) throw new Error('core-memory: dataDir required')
44
67
  const db = getDatabase(dataDir)
@@ -206,21 +229,14 @@ export async function listCore(dataDir, projectId = null) {
206
229
  return r.rows
207
230
  }
208
231
 
209
- export async function addCore(dataDir, { element, summary, category }, projectId) {
232
+ export async function addCore(dataDir, input, projectId) {
210
233
  if (projectId === undefined) throw new Error('addCore: projectId required — pass null for COMMON pool, or slug string for scoped pool')
211
- const el = trimOrNull(element)
212
- const sm = trimOrNull(summary) ?? el
213
- if (!el || !sm) throw new Error('add requires element and summary')
214
- if (el.length > CORE_ELEMENT_MAX) {
215
- throw new Error(`core element too long (${el.length}/${CORE_ELEMENT_MAX} chars, remove ${el.length - CORE_ELEMENT_MAX}) — element is a short key/title, not content.`)
216
- }
217
- if (sm.length > CORE_SUMMARY_MAX) {
218
- throw new Error(`core summary too long (${sm.length}/${CORE_SUMMARY_MAX} chars, remove ${sm.length - CORE_SUMMARY_MAX}) — 1 fact in 1-2 sentences; move procedures/multi-step/code to recap or docs.`)
219
- }
220
- const cat = (trimOrNull(category) ?? 'fact').toLowerCase()
221
- if (!VALID_CAT.has(cat)) {
222
- throw new Error(`invalid category "${cat}". Valid: ${[...VALID_CAT].join(', ')}`)
223
- }
234
+ const { element: el, summary: sm, category: cat, errors } = normalizeCoreInput(input, {
235
+ requireElement: true,
236
+ requireSummary: true,
237
+ requireCategory: true,
238
+ })
239
+ if (errors.length) throw new Error(errors.join('; '))
224
240
  const db = _getDb(dataDir)
225
241
  const now = Date.now()
226
242
  await _backfillNullEmbeddings(db)
@@ -299,19 +315,23 @@ export async function editCore(dataDir, id, patch) {
299
315
  const db = _getDb(dataDir)
300
316
  const cur = (await db.query(`SELECT * FROM core_entries WHERE id = $1`, [numId])).rows[0]
301
317
  if (!cur) throw new Error(`no entry with id=${numId}`)
302
- const newElement = trimOrNull(patch.element) ?? cur.element
303
- const newSummary = trimOrNull(patch.summary) ?? cur.summary
304
- const newCategoryRaw = trimOrNull(patch.category)
305
- const newCategory = newCategoryRaw ? newCategoryRaw.toLowerCase() : cur.category
306
- if (!VALID_CAT.has(newCategory)) {
307
- throw new Error(`invalid category "${newCategory}". Valid: ${[...VALID_CAT].join(', ')}`)
308
- }
318
+ const incoming = normalizeCoreInput(patch)
319
+ const newElement = incoming.element ?? cur.element
320
+ const newSummary = incoming.summary ?? cur.summary
321
+ const newCategory = incoming.suppliedCategory ? incoming.category : cur.category
322
+ const { errors } = normalizeCoreInput({
323
+ element: newElement,
324
+ summary: newSummary,
325
+ category: newCategory,
326
+ }, {
327
+ requireElement: true,
328
+ requireSummary: true,
329
+ requireCategory: true,
330
+ })
331
+ if (errors.length) throw new Error(errors.join('; '))
309
332
  if (newElement === cur.element && newSummary === cur.summary && newCategory === cur.category) {
310
333
  throw new Error('no change')
311
334
  }
312
- if (newSummary && newSummary.length > CORE_SUMMARY_MAX) {
313
- throw new Error(`core summary too long (${newSummary.length}/${CORE_SUMMARY_MAX} chars, remove ${newSummary.length - CORE_SUMMARY_MAX}) — 1 fact in 1-2 sentences; move procedures/multi-step/code to recap or docs.`)
314
- }
315
335
  const now = Date.now()
316
336
  const textChanged = newElement !== cur.element || newSummary !== cur.summary
317
337
  if (!textChanged) {
@@ -45,7 +45,6 @@ export function createHttpRouter({
45
45
  registerClient,
46
46
  deregisterClient,
47
47
  getDraining,
48
- getCycle1CallLlm,
49
48
  getTraceDb,
50
49
  setTraceDb,
51
50
  ingestTranscriptFile,
@@ -425,198 +424,6 @@ export function createHttpRouter({
425
424
  return
426
425
  }
427
426
 
428
- // DEV-ONLY cycle1 chunking bench. Gated by env MIXDOG_DEV_BENCH=1 so
429
- // production is untouched (route returns 404 when unset). Mirrors cycle1's
430
- // exact fetch query + per-session windowing, then runs each window through
431
- // buildCycle1ChunkPrompt + callAgentDispatch + parseCycle1LineFormat. STRICT
432
- // read-only — no UPDATE, no transaction, no commit.
433
- if (req.method === 'POST' && req.url === '/dev/cycle1-bench') {
434
- const db = getDb()
435
- // Gate: env MIXDOG_DEV_BENCH=1 OR a runtime flag file, so it can be
436
- // toggled without restarting the host agent (env only reaches the worker
437
- // on a full CC restart, not via dev-sync full-restart).
438
- const _devBenchOn = process.env.MIXDOG_DEV_BENCH === '1'
439
- || (DATA_DIR && fs.existsSync(path.join(DATA_DIR, '.dev-bench-enabled')))
440
- if (!_devBenchOn) {
441
- sendJson(res, { error: 'not found' }, 404)
442
- return
443
- }
444
- if (!isLocalOrigin(req)) {
445
- sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
446
- return
447
- }
448
- try {
449
- const body = await readBody(req)
450
- const sets = Math.max(1, Number(body?.sets ?? 5))
451
- const repeat = Math.max(1, Number(body?.repeat ?? 1))
452
- // Optional variant matrix. Each variant: {name, rules}. rules=null → default prompt.
453
- const rawVariants = Array.isArray(body?.variants) ? body.variants : null
454
- const variants = rawVariants && rawVariants.length > 0
455
- ? rawVariants.map((v, i) => ({
456
- name: typeof v?.name === 'string' && v.name ? v.name : `variant-${i + 1}`,
457
- rules: Array.isArray(v?.rules) ? v.rules : null,
458
- }))
459
- : null
460
-
461
- // Lazy-load LLM + chunking helpers so production boot pays nothing.
462
- // Use the same in-process agent dispatch adapter as real cycle1 — the legacy
463
- // agent-ipc callAgentDispatch() path is dead in the detached standalone
464
- // memory daemon (no connected IPC), so the dev bench must mirror prod.
465
- const [{ buildCycle1ChunkPrompt, parseCycle1LineFormat }, { resolveMaintenancePreset }] = await Promise.all([
466
- import('./memory-cycle1.mjs'),
467
- import('../../shared/llm/index.mjs'),
468
- ])
469
- const benchCallLlm = getCycle1CallLlm()
470
-
471
- const CYCLE1_MIN_BATCH = 3
472
- const CYCLE1_SESSION_CAP = 10
473
- const BATCH_SIZE = 100
474
- const TIMEOUT_MS = 180_000
475
- const fetchLimit = CYCLE1_SESSION_CAP * BATCH_SIZE
476
-
477
- const fetchResult = await db.query(
478
- `SELECT id, ts, role, content, session_id, source_ref, project_id
479
- FROM entries
480
- WHERE chunk_root IS NULL AND session_id IS NOT NULL
481
- ORDER BY ts DESC, id DESC
482
- LIMIT $1`,
483
- [fetchLimit],
484
- )
485
- const rowsDesc = fetchResult.rows
486
-
487
- if (rowsDesc.length < CYCLE1_MIN_BATCH) {
488
- sendJson(res, {
489
- ok: true,
490
- sets, repeat,
491
- windowsAvailable: 0,
492
- note: `not enough pending rows (need >= ${CYCLE1_MIN_BATCH}, got ${rowsDesc.length})`,
493
- results: [],
494
- })
495
- return
496
- }
497
-
498
- // Partition by session_id — same as memory-cycle1.mjs _runCycle1Impl L207-233.
499
- const sessionMap = new Map()
500
- for (const row of rowsDesc.slice().reverse()) {
501
- const sid = row.session_id
502
- if (!sessionMap.has(sid)) sessionMap.set(sid, [])
503
- sessionMap.get(sid).push(row)
504
- }
505
- const windows = []
506
- for (const [sid, sessionRows] of sessionMap) {
507
- if (sessionRows.length < CYCLE1_MIN_BATCH) continue
508
- const windowCount = Math.max(1, Math.ceil(sessionRows.length / BATCH_SIZE))
509
- const baseSize = Math.floor(sessionRows.length / windowCount)
510
- const remainder = sessionRows.length % windowCount
511
- let _offset = 0
512
- for (let i = 0; i < windowCount; i++) {
513
- const size = baseSize + (i < remainder ? 1 : 0)
514
- windows.push({ sid, rows: sessionRows.slice(_offset, _offset + size) })
515
- _offset += size
516
- }
517
- }
518
- const chosen = windows.slice(0, sets)
519
-
520
- const preset = resolveMaintenancePreset('memory')
521
-
522
- function summariseChunks(chunks, totalEntries) {
523
- const usedIdx = new Set()
524
- for (const c of chunks) for (const i of (c._idxList || [])) usedIdx.add(i)
525
- const omitted = []
526
- for (let i = 1; i <= totalEntries; i++) if (!usedIdx.has(i)) omitted.push(i)
527
- return { covered: usedIdx.size, omitted }
528
- }
529
-
530
- // When variants are absent, fall back to a single implicit baseline so the
531
- // pre-variant call shape (single rows × repeat) keeps producing the same
532
- // {runs:[…]} payload the trigger already knows how to print.
533
- const variantList = variants ?? [{ name: 'baseline', rules: null }]
534
-
535
- async function runOnce(rows, customRules) {
536
- const userMessage = buildCycle1ChunkPrompt(rows, customRules)
537
- const t0 = Date.now()
538
- let raw, error
539
- try {
540
- raw = await benchCallLlm({
541
- preset,
542
- timeout: TIMEOUT_MS,
543
- }, userMessage)
544
- } catch (e) {
545
- error = e?.message ?? String(e)
546
- }
547
- const llmMs = Date.now() - t0
548
- if (error) return { ok: false, llmMs, error }
549
- const parsed = parseCycle1LineFormat(raw)
550
- const chunks = Array.isArray(parsed?.chunks) ? parsed.chunks : []
551
- const { covered, omitted } = summariseChunks(chunks, rows.length)
552
- const ratio = chunks.length > 0
553
- ? parseFloat((rows.length / chunks.length).toFixed(2))
554
- : null
555
- return {
556
- ok: true,
557
- llmMs,
558
- entries: rows.length,
559
- chunks: chunks.length,
560
- ratio,
561
- covered,
562
- omitted,
563
- chunkList: chunks.map(c => ({
564
- idx: c._idxList,
565
- element: c.element,
566
- category: c.category,
567
- summary: c.summary,
568
- })),
569
- }
570
- }
571
-
572
- const results = []
573
- for (let s = 0; s < chosen.length; s++) {
574
- const { sid, rows } = chosen[s]
575
- const sidShort = String(sid).slice(0, 8)
576
- if (variants) {
577
- // Variant mode: same rows, one run per variant per repeat.
578
- const variantResults = []
579
- for (const v of variantList) {
580
- const runs = []
581
- for (let r = 0; r < repeat; r++) {
582
- const run = await runOnce(rows, v.rules)
583
- runs.push({ repIdx: r + 1, ...run })
584
- }
585
- variantResults.push({ name: v.name, runs })
586
- }
587
- results.push({
588
- setIdx: s + 1,
589
- sessionIdShort: sidShort,
590
- entries: rows.length,
591
- variants: variantResults,
592
- })
593
- } else {
594
- // Legacy single-baseline payload shape.
595
- const runs = []
596
- for (let r = 0; r < repeat; r++) {
597
- const run = await runOnce(rows, null)
598
- runs.push({ repIdx: r + 1, ...run })
599
- }
600
- results.push({
601
- setIdx: s + 1,
602
- sessionIdShort: sidShort,
603
- entries: rows.length,
604
- runs,
605
- })
606
- }
607
- }
608
- sendJson(res, {
609
- ok: true,
610
- sets, repeat,
611
- windowsAvailable: windows.length,
612
- variants: variants ? variantList.map(v => v.name) : null,
613
- results,
614
- })
615
- } catch (e) {
616
- sendError(res, e?.message || String(e))
617
- }
618
- return
619
- }
620
427
 
621
428
  if (req.method === 'POST' && req.url === '/api/tool') {
622
429
  if (!isLocalOrigin(req)) {
@@ -33,8 +33,9 @@ import {
33
33
  promoteCoreCandidate,
34
34
  dismissCoreCandidate,
35
35
  CORE_SUMMARY_MAX,
36
+ normalizeCoreInput,
36
37
  } from './core-memory-store.mjs'
37
- import { resolveProjectScope } from './project-id-resolver.mjs'
38
+ import { resolveProjectId, resolveProjectScope } from './project-id-resolver.mjs'
38
39
  import { resolvePluginData } from '../../shared/plugin-paths.mjs'
39
40
  import { getMetaValue, isBootstrapComplete } from './memory.mjs'
40
41
 
@@ -57,6 +58,7 @@ export function createMemoryActionHandlers({
57
58
  getCycle3CallLlm,
58
59
  ingestTranscriptFile,
59
60
  cwdFromTranscriptPath,
61
+ editCoreImpl = editCore,
60
62
  }) {
61
63
  const DATA_DIR = dataDir
62
64
 
@@ -643,16 +645,43 @@ export function createMemoryActionHandlers({
643
645
  }
644
646
  // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
645
647
  // that branch and unreachable from here.
646
- // Normalize project_id: 'common' (case-insensitive) or null null (COMMON pool); non-empty string → slug.
648
+ // Only the explicit string "common" selects the COMMON pool. Core add
649
+ // validates missing, null, and blank project_id below; edits use the
650
+ // target entry's stored pool.
647
651
  const hasProjectIdKey = Object.prototype.hasOwnProperty.call(args, 'project_id')
652
+ const projectIdText = typeof args.project_id === 'string' ? args.project_id.trim() : ''
648
653
  const projectId = (() => {
649
- if (!hasProjectIdKey || args.project_id == null) return null
650
- const s = String(args.project_id).trim()
651
- if (s === '' || s.toLowerCase() === 'common') return null
652
- if (s === '*') return '*'
653
- return s
654
+ if (projectIdText.toLowerCase() === 'common') return null
655
+ return projectIdText || null
654
656
  })()
655
657
  try {
658
+ if (op === 'add' || op === 'edit') {
659
+ const normalized = normalizeCoreInput(args, {
660
+ requireElement: true,
661
+ requireSummary: true,
662
+ requireCategory: true,
663
+ })
664
+ const errors = [...normalized.errors]
665
+ if (op === 'add') {
666
+ if (!hasProjectIdKey || !projectIdText) {
667
+ const inferredProjectId = typeof args.cwd === 'string' && args.cwd
668
+ ? resolveProjectId(args.cwd)
669
+ : null
670
+ errors.unshift(
671
+ `project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool` +
672
+ (typeof inferredProjectId === 'string' && /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)?$/.test(inferredProjectId)
673
+ ? ` (cwd suggests "${inferredProjectId}")`
674
+ : ''),
675
+ )
676
+ } else if (projectId === '*') {
677
+ errors.unshift('project_id "*" only valid for op="list"')
678
+ }
679
+ }
680
+ if (errors.length) {
681
+ return { text: `core ${op}: ${errors.join('; ')}`, isError: true }
682
+ }
683
+ args = { ...args, element: normalized.element, summary: normalized.summary, category: normalized.category }
684
+ }
656
685
  if (projectId === '*' && op !== 'list') {
657
686
  return { text: `core ${op}: project_id "*" only valid for op="list"`, isError: true }
658
687
  }
@@ -681,14 +710,11 @@ export function createMemoryActionHandlers({
681
710
  return { text: lines.join('\n') }
682
711
  }
683
712
  if (op === 'add') {
684
- if (!hasProjectIdKey) {
685
- return { text: 'core add: project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool', isError: true }
686
- }
687
713
  const entry = await addCore(coreDataDir, args, projectId)
688
714
  return { text: `core added (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
689
715
  }
690
716
  if (op === 'edit') {
691
- const entry = await editCore(coreDataDir, args.id, args)
717
+ const entry = await editCoreImpl(coreDataDir, args.id, args)
692
718
  return { text: `core edited (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
693
719
  }
694
720
  if (op === 'delete') {
@@ -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