mixdog 0.7.12 → 0.7.13

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 (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -74
  4. package/bun.lock +128 -3
  5. package/defaults/hidden-roles.json +3 -0
  6. package/defaults/user-workflow.json +1 -2
  7. package/defaults/user-workflow.md +5 -1
  8. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  9. package/package.json +9 -2
  10. package/scripts/ensure-deps.mjs +2 -2
  11. package/scripts/run-mcp.mjs +65 -9
  12. package/setup/launch-core.mjs +0 -1
  13. package/setup/setup-server.mjs +80 -33
  14. package/setup/setup.html +1 -3
  15. package/skills/setup/SKILL.md +12 -2
  16. package/src/agent/index.mjs +1 -1
  17. package/src/agent/orchestrator/config.mjs +58 -6
  18. package/src/agent/orchestrator/providers/model-catalog.mjs +1 -1
  19. package/src/agent/orchestrator/session/loop.mjs +3 -3
  20. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +6 -2
  21. package/src/agent/orchestrator/tools/bash-session.mjs +1 -0
  22. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  23. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +29 -6
  24. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +8 -4
  25. package/src/agent/orchestrator/tools/builtin.mjs +5 -2
  26. package/src/agent/orchestrator/tools/cwd-tool.mjs +17 -17
  27. package/src/agent/orchestrator/tools/graph-manifest.json +7 -7
  28. package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
  29. package/src/agent/tool-defs.mjs +1 -1
  30. package/src/channels/index.mjs +12 -1
  31. package/src/channels/lib/webhook.mjs +35 -18
  32. package/src/memory/index.mjs +5 -1
  33. package/src/memory/lib/core-memory-store.mjs +1 -1
  34. package/src/memory/lib/memory-cycle1.mjs +1 -1
  35. package/src/memory/lib/memory-cycle2.mjs +1 -1
  36. package/src/memory/lib/memory-cycle3.mjs +1 -1
  37. package/tools.json +2 -2
@@ -1,4 +1,4 @@
1
- import { readdirSync } from 'fs';
1
+ import { readdir } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
 
4
4
  // Glob-to-RegExp compiler for name filters used by find_files and the
@@ -125,21 +125,34 @@ export const NOISE_DIR_NAMES = new Set([
125
125
  '.gradle', 'coverage',
126
126
  ]);
127
127
 
128
+ export const MAX_WALK_ENTRIES = 200_000;
129
+
128
130
  // Unified directory walk used by list / tree / find_files. The visitor
129
131
  // callback owns the "should I record this entry?" decision; returning
130
132
  // literal false aborts the whole walk.
131
133
  // `onWarn(dir, err)` (optional) is invoked for any readdir failure so
132
134
  // callers can surface skipped paths instead of silently dropping them.
133
- export function walkDir(root, { hidden = false, maxDepth = Infinity, visit, sort, excludeDirNames, onWarn } = {}) {
135
+ export async function walkDir(root, { hidden = false, maxDepth = Infinity, visit, sort, excludeDirNames, onWarn, maxEntries = MAX_WALK_ENTRIES, signal } = {}) {
134
136
  // Windows filesystems are case-insensitive — match exclusion names the
135
137
  // same way so e.g. Node_Modules is pruned like node_modules.
136
138
  const _exclCI = process.platform === 'win32' && excludeDirNames && excludeDirNames.size > 0
137
139
  ? new Set([...excludeDirNames].map((n) => n.toLowerCase()))
138
140
  : null;
139
- const _walk = (dir, depth) => {
141
+ let truncated = false;
142
+ const cap = maxEntries;
143
+ let entriesVisited = 0;
144
+ const _walk = async (dir, depth) => {
145
+ if (signal?.aborted) {
146
+ truncated = true;
147
+ return false;
148
+ }
149
+ if (entriesVisited >= cap) {
150
+ truncated = true;
151
+ return false;
152
+ }
140
153
  if (depth > maxDepth) return true;
141
154
  let entries;
142
- try { entries = readdirSync(dir, { withFileTypes: true }); }
155
+ try { entries = await readdir(dir, { withFileTypes: true }); }
143
156
  catch (err) {
144
157
  if (typeof onWarn === 'function') {
145
158
  try { onWarn(dir, err); } catch { /* warning sink must not abort */ }
@@ -155,16 +168,26 @@ export function walkDir(root, { hidden = false, maxDepth = Infinity, visit, sort
155
168
  if (sort) entries.sort(sort);
156
169
  const total = entries.length;
157
170
  for (let i = 0; i < total; i++) {
171
+ if (signal?.aborted) {
172
+ truncated = true;
173
+ return false;
174
+ }
175
+ if (entriesVisited >= cap) {
176
+ truncated = true;
177
+ return false;
178
+ }
179
+ entriesVisited += 1;
158
180
  const ent = entries[i];
159
181
  const entPath = join(dir, ent.name);
160
182
  const ctx = { depth, index: i, total, isLast: i === total - 1 };
161
183
  const cont = visit(ent, entPath, ctx);
162
184
  if (cont === false) return false;
163
185
  if (ent.isDirectory()) {
164
- if (_walk(entPath, depth + 1) === false) return false;
186
+ if ((await _walk(entPath, depth + 1)) === false) return false;
165
187
  }
166
188
  }
167
189
  return true;
168
190
  };
169
- _walk(root, 1);
191
+ await _walk(root, 1);
192
+ return { truncated, entriesVisited };
170
193
  }
@@ -111,10 +111,11 @@ export async function executeListTool(args, workDir, options = {}) {
111
111
  // never hit either bound, so normal behavior is unchanged.
112
112
  let truncatedByCap = false;
113
113
  const walkDeadline = Date.now() + LIST_WALK_TIMEOUT_MS;
114
- walkDir(fullPath, {
114
+ await walkDir(fullPath, {
115
115
  hidden,
116
116
  maxDepth: depth,
117
117
  excludeDirNames: includeNoise ? null : NOISE_DIR_NAMES,
118
+ signal: options.signal,
118
119
  visit: (ent, entPath) => {
119
120
  if (Date.now() > walkDeadline) { truncatedByCap = true; return false; }
120
121
  const isDir = ent.isDirectory();
@@ -235,10 +236,11 @@ export async function executeTreeTool(args, workDir, options = {}) {
235
236
  const lines = [`${normalizeOutputPath(fullPath)}/`];
236
237
  const prefixStack = [''];
237
238
  const TREE_BRANCH_LINE_CAP = 500;
238
- walkDir(fullPath, {
239
+ await walkDir(fullPath, {
239
240
  hidden,
240
241
  maxDepth: depth,
241
242
  excludeDirNames: includeNoise ? null : NOISE_DIR_NAMES,
243
+ signal: options.signal,
242
244
  sort: (a, b) => {
243
245
  const ad = a.isDirectory(), bd = b.isDirectory();
244
246
  if (ad !== bd) return ad ? -1 : 1;
@@ -500,10 +502,11 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
500
502
  if (!handledByRgFiles && useBatchedStat) {
501
503
  const candidates = [];
502
504
  const walkDeadline1 = Date.now() + FIND_WALK_TIMEOUT_MS;
503
- walkDir(fullPath, {
505
+ await walkDir(fullPath, {
504
506
  hidden,
505
507
  maxDepth: depth ?? Infinity,
506
508
  excludeDirNames: includeNoise ? null : NOISE_DIR_NAMES,
509
+ signal: options.signal,
507
510
  visit: (ent, entPath) => {
508
511
  if (Date.now() > walkDeadline1) { truncatedByCap = true; return false; }
509
512
  const isDir = ent.isDirectory();
@@ -532,10 +535,11 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
532
535
  const effectiveTypeFilter = sizeFiltered && typeFilter === 'any' ? 'file' : typeFilter;
533
536
  const candidates = [];
534
537
  const walkDeadline2 = Date.now() + FIND_WALK_TIMEOUT_MS;
535
- walkDir(fullPath, {
538
+ await walkDir(fullPath, {
536
539
  hidden,
537
540
  maxDepth: depth ?? Infinity,
538
541
  excludeDirNames: includeNoise ? null : NOISE_DIR_NAMES,
542
+ signal: options.signal,
539
543
  visit: (ent, entPath) => {
540
544
  if (Date.now() > walkDeadline2) { truncatedByCap = true; return false; }
541
545
  const isDir = ent.isDirectory();
@@ -361,6 +361,9 @@ function capToolOutput(result, options = {}) {
361
361
  }
362
362
 
363
363
  export async function executeBuiltinTool(name, args, cwd, options = {}) {
364
+ if (options.abortSignal && !options.signal) {
365
+ options = { ...options, signal: options.abortSignal };
366
+ }
364
367
  const toolName = canonicalizeBuiltinToolName(name);
365
368
  const argError = validateBuiltinArgs(toolName, args);
366
369
  if (argError) return argError;
@@ -414,9 +417,9 @@ export async function executeBuiltinTool(name, args, cwd, options = {}) {
414
417
  case 'list':
415
418
  return executeListTool(args, workDir, options);
416
419
  case 'tree':
417
- return executeTreeTool(args, workDir);
420
+ return executeTreeTool(args, workDir, options);
418
421
  case 'find_files':
419
- return executeFindFilesTool(args, workDir);
422
+ return executeFindFilesTool(args, workDir, options);
420
423
  case 'head':
421
424
  return executeHeadTool(args, workDir, readStateScope, _readModeHelpers);
422
425
  case 'tail':
@@ -58,15 +58,14 @@ const PRUNED_DIR_NAMES = new Set([...NOISE_DIR_NAMES, ...EXTRA_NOISE_DIR_NAMES])
58
58
 
59
59
  let _listCache = null
60
60
 
61
- // Per-scan set used by the visitor to mark repos whose subtree must not
62
- // be descended. walkDir does not natively support "stop descent into
63
- // this dir but keep the walk going", so we approximate by checking
64
- // ancestry on every visit and bailing early when the entry lives under
65
- // a previously-recorded repo.
66
- const _recordedRepos = new Set()
67
-
68
- function _scanReposUnderFiltered(root) {
69
- _recordedRepos.clear()
61
+ async function _scanReposUnderFiltered(root, signal) {
62
+ // Per-scan set used by the visitor to mark repos whose subtree must not be
63
+ // descended. walkDir does not natively support "stop descent into this dir
64
+ // but keep the walk going", so we approximate by checking ancestry on every
65
+ // visit and bailing early when the entry lives under a previously-recorded
66
+ // repo. MUST be local per-call: walkDir is async, so a module-global set
67
+ // would race across concurrent/background scans suspended in `await readdir`.
68
+ const recordedRepos = new Set()
70
69
  const repos = []
71
70
  if (!root || !existsSync(root)) return repos
72
71
  try {
@@ -79,17 +78,18 @@ function _scanReposUnderFiltered(root) {
79
78
  return repos
80
79
  }
81
80
 
82
- walkDir(root, {
81
+ await walkDir(root, {
83
82
  hidden: true,
84
83
  maxDepth: 4,
85
84
  excludeDirNames: PRUNED_DIR_NAMES,
85
+ signal,
86
86
  visit: (ent, entPath) => {
87
87
  if (!ent.isDirectory()) return
88
88
  // Skip subtree of a previously-recorded repo. walkDir invokes
89
89
  // visit before recursing, so flipping ent.isDirectory() here
90
90
  // would not stop descent. Instead, check ancestry on every
91
91
  // visit and bail early.
92
- for (const recorded of _recordedRepos) {
92
+ for (const recorded of recordedRepos) {
93
93
  if (entPath === recorded || entPath.startsWith(recorded + '/') || entPath.startsWith(recorded + '\\')) {
94
94
  return
95
95
  }
@@ -97,7 +97,7 @@ function _scanReposUnderFiltered(root) {
97
97
  try {
98
98
  if (existsSync(`${entPath}/.git`)) {
99
99
  repos.push(entPath)
100
- _recordedRepos.add(entPath)
100
+ recordedRepos.add(entPath)
101
101
  }
102
102
  } catch { /* ignore stat races */ }
103
103
  },
@@ -151,7 +151,7 @@ function _authoredByIdentity(repoPath, emails) {
151
151
  }
152
152
  }
153
153
 
154
- function _buildRepoList(includeAll = false) {
154
+ async function _buildRepoList(includeAll = false, signal) {
155
155
  const cfg = _cwdConfig()
156
156
  const user = rawUserCwd()
157
157
  const roots = []
@@ -169,7 +169,7 @@ function _buildRepoList(includeAll = false) {
169
169
 
170
170
  const repoSet = new Map() // canonical → original path
171
171
  for (const root of roots) {
172
- for (const repo of _scanReposUnderFiltered(root)) {
172
+ for (const repo of await _scanReposUnderFiltered(root, signal)) {
173
173
  const key = _canonicalSessionCwd(repo)
174
174
  if (!repoSet.has(key)) repoSet.set(key, repo)
175
175
  }
@@ -257,9 +257,9 @@ export async function executeCwdTool(name, args, callerCwd, opts = {}) {
257
257
  if (action === 'list') {
258
258
  // `all:true` bypasses the owned-project filter and transient prune — a
259
259
  // raw scan of every .git repo under the roots. Not cached (rare).
260
- if (args?.all === true) return _formatList(_buildRepoList(true))
260
+ if (args?.all === true) return _formatList(await _buildRepoList(true, opts.signal))
261
261
  if (args?.refresh === true) _listCache = null
262
- if (!_listCache) _listCache = _buildRepoList(false)
262
+ if (!_listCache) _listCache = await _buildRepoList(false, opts.signal)
263
263
  return _formatList(_listCache)
264
264
  }
265
265
 
@@ -293,6 +293,6 @@ export const CWD_TOOL_DEFS = [
293
293
  // and unref'd so it never blocks startup or keeps the process alive.
294
294
  try {
295
295
  setTimeout(() => {
296
- try { if (!_listCache) _listCache = _buildRepoList(false) } catch { /* best-effort */ }
296
+ try { if (!_listCache) _buildRepoList(false).then((r) => { _listCache = r }) } catch { /* best-effort */ }
297
297
  }, 1000).unref?.()
298
298
  } catch { /* environments without setTimeout */ }
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.12",
2
+ "version": "0.7.13",
3
3
  "_comment": "Rewritten by .github/workflows/graph-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-graph binary on the GitHub release. A local cargo build under native/mixdog-graph/target/release always takes precedence at runtime. (v0.5.236 entries were filled manually after CI's commit step hit detached HEAD; the workflow now checks out ref: main so future releases self-update.)",
4
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-graph-darwin-arm64",
6
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-graph-darwin-arm64",
7
7
  "sha256": "75bfdd200b2f8553b72dc877ec2637208f581800083d1ee5f9caf33f87792bf7"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-graph-darwin-x64",
10
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-graph-darwin-x64",
11
11
  "sha256": "04742fbb4cbe09bb76943f312ee129c05814543e7bc9d37e1241fb4e65b97137"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-graph-linux-arm64",
14
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-graph-linux-arm64",
15
15
  "sha256": "4b3edcd7be1ffec7184c48fe6bc7d6bce42f2ea67d4709f44d4402e6b48564f2"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-graph-linux-x64",
18
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-graph-linux-x64",
19
19
  "sha256": "4394bb7884a8706dd6a4eea55f8755c76ba584cd02248863802d94acc3e1413c"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-graph-win32-x64.exe",
23
- "sha256": "cbfe189d690085aee1dfd70f5c0b9c26c260d0a080914cbeb504c84510ec3a5a"
22
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-graph-win32-x64.exe",
23
+ "sha256": "2acf1fc9040cfc78a643daa477b15c8eaa0f520fd8addd1cf6f2f8cddd5c914c"
24
24
  }
25
25
  }
26
26
  }
@@ -1,26 +1,26 @@
1
1
  {
2
- "version": "0.7.12",
2
+ "version": "0.7.13",
3
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
4
  "assets": {
5
5
  "darwin-arm64": {
6
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-patch-darwin-arm64",
6
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-patch-darwin-arm64",
7
7
  "sha256": "836a0b60a443b0a6a8c1bbe24d15a79ed70ee92c2f0fbc05374c4e9ed2536415"
8
8
  },
9
9
  "darwin-x64": {
10
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-patch-darwin-x64",
10
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-patch-darwin-x64",
11
11
  "sha256": "cab10c4e1e8b72d3958241dffdff764712ed74f4861d105bafa8258961215c98"
12
12
  },
13
13
  "linux-arm64": {
14
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-patch-linux-arm64",
14
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-patch-linux-arm64",
15
15
  "sha256": "a90c32ce3417a7d853f2723f82f3613cf2cd030fe885cf710cfc9f8e4b193264"
16
16
  },
17
17
  "linux-x64": {
18
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-patch-linux-x64",
18
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-patch-linux-x64",
19
19
  "sha256": "0fea40ab98acd35bfb47515756024d1882a2abbaddce8a0b51642d20ac405577"
20
20
  },
21
21
  "win32-x64": {
22
- "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.12/mixdog-patch-win32-x64.exe",
23
- "sha256": "f8a74fb9bb7bf333fa441b800b76b28d83a2a7b4795e4a769122faf47a59d1f1"
22
+ "url": "https://github.com/trib-plugin/mixdog/releases/download/v0.7.13/mixdog-patch-win32-x64.exe",
23
+ "sha256": "24b3c2b2b95a2e3b15e0e089ca41297056be7c12df8c4c7524fa91a4833b1f68"
24
24
  }
25
25
  }
26
26
  }
@@ -18,7 +18,7 @@ export const TOOL_DEFS = [
18
18
  title: 'Explore',
19
19
  aiWrapped: true,
20
20
  annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
21
- description: 'Read-only codebase EXPLORATION — fact-finding only: locate/map where and how things are implemented, for open-ended/unknown scope (for a known or partial identifier use code_graph; recall=memory, search=web). NOT a reviewer/auditor: explorers LOCATE and DESCRIBE code, never judge it — bug/quality/risk claims in explore output are UNVERIFIED leads; verify them (reviewer role or direct reads) before acting on or reporting them. Shape every query as a LOCATION/INVENTORY question ("where is X handled", "which files implement Y", "what does Z read/write") — NEVER a verdict question ("is X correct/missing/inconsistent?", "are there gaps/bugs?"); the judgment stays with the caller, applied to the coordinates explore returns. Query shaping (one-line, one topic per item, decompose a multi-part brief) is specified on the query parameter — follow it. Fan-out runs items in parallel; wall-clock = the slowest item. LEAD: default background:true (answer pushed via channel, avoids the 120s sync cap). BRIDGE WORKERS run it sync and SHOULD prefer it for a tree-wide enumeration or broad/unanchored exploration — ONE call offloads the whole sweep into a sub-agent instead of a long grep/code_graph storm; a bounded/known-anchor lookup stays a direct code_graph/grep call.',
21
+ description: 'Read-only codebase EXPLORATION — fact-finding only: locate/map where and how things are implemented, for open-ended/unknown scope (for a known or partial identifier use code_graph; recall=memory, search=web). NOT a reviewer/auditor: explorers LOCATE and DESCRIBE code, never judge it — bug/quality/risk claims in explore output are UNVERIFIED leads; verify them (reviewer role or direct reads) before acting on or reporting them. Shape every query as a LOCATION/INVENTORY question ("where is X handled", "which files implement Y", "what does Z read/write") — NEVER a verdict question ("is X correct/missing/inconsistent?", "are there gaps/bugs?"); the judgment stays with the caller, applied to the coordinates explore returns. Query shaping rules are on the query parameter — follow them. Fan-out runs items in parallel; wall-clock = the slowest item. LEAD: default background:true (answer pushed via channel, avoids the 120s sync cap). BRIDGE WORKERS run it sync and SHOULD prefer it for a tree-wide enumeration or broad/unanchored exploration — ONE call offloads the whole sweep into a sub-agent instead of a long grep/code_graph storm; a bounded/known-anchor lookup stays a direct code_graph/grep call.',
22
22
  inputSchema: {
23
23
  type: 'object',
24
24
  properties: {
@@ -2583,7 +2583,18 @@ async function handleToolCall(name, args, signal) {
2583
2583
  }
2584
2584
  case "reload_config": {
2585
2585
  await reloadRuntimeConfig();
2586
- result = { content: [{ type: "text", text: "config reloaded \u2014 schedules, webhooks, and events re-registered" }] };
2586
+ // Extend reload to the agent module so providers/presets/maintenance
2587
+ // hot-reload on the same call (dynamic import: agent/index.mjs does not
2588
+ // import channels, so this stays acyclic and tolerant of load order).
2589
+ let agentReloadMsg = "";
2590
+ try {
2591
+ const { reloadAgentConfig } = await import("../agent/index.mjs");
2592
+ await reloadAgentConfig("reload_config tool");
2593
+ agentReloadMsg = ", agent providers/presets/maintenance";
2594
+ } catch (err) {
2595
+ process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
2596
+ }
2597
+ result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
2587
2598
  break;
2588
2599
  }
2589
2600
  case "inject_command": {
@@ -171,19 +171,37 @@ function _deliveryIndexFor(name) {
171
171
  }
172
172
  return map;
173
173
  }
174
+ // Bound retained ids so successful ("done") deliveries cannot accumulate
175
+ // forever in RAM or on disk. In-flight claims (received/processing) are
176
+ // ALWAYS kept — dropping one would let a duplicate dispatch through. The
177
+ // remaining DELIVERY_INDEX_MAX_IDS budget goes to the newest "done" rows
178
+ // (dedup of recent retries) first, then newest terminal rows for history.
179
+ // Older "done" rows age out; a sender re-delivering an id that stale is
180
+ // treated as new — acceptable beyond any realistic retry window.
181
+ function _retainedDeliveryIds(entries) {
182
+ const inflight = [];
183
+ const done = [];
184
+ const other = [];
185
+ for (const e of entries) {
186
+ if (e.status === "received" || e.status === "processing") inflight.push(e);
187
+ else if (e.status === "done") done.push(e);
188
+ else other.push(e);
189
+ }
190
+ const keep = new Set(inflight.map((e) => e.id));
191
+ const byTsDesc = (a, b) => String(b.ts || "").localeCompare(String(a.ts || ""));
192
+ done.sort(byTsDesc);
193
+ other.sort(byTsDesc);
194
+ for (const e of [...done, ...other]) {
195
+ if (keep.size >= DELIVERY_INDEX_MAX_IDS) break;
196
+ keep.add(e.id);
197
+ }
198
+ return keep;
199
+ }
174
200
  function _pruneDeliveryIndexMap(byId) {
175
201
  if (byId.size <= DELIVERY_INDEX_MAX_IDS) return;
176
- const rows = [...byId.entries()];
177
- const blocking = rows.filter(([, e]) => _isBlockingDeliveryStatus(e.status));
178
- const nonBlocking = rows.filter(([, e]) => !_isBlockingDeliveryStatus(e.status));
179
- nonBlocking.sort((a, b) => String(b[1].ts || "").localeCompare(String(a[1].ts || "")));
180
- const keepIds = new Set(blocking.map(([id]) => id));
181
- for (const [id] of nonBlocking) {
182
- if (keepIds.size >= DELIVERY_INDEX_MAX_IDS) break;
183
- keepIds.add(id);
184
- }
202
+ const keep = _retainedDeliveryIds([...byId.values()]);
185
203
  for (const id of byId.keys()) {
186
- if (!keepIds.has(id)) byId.delete(id);
204
+ if (!keep.has(id)) byId.delete(id);
187
205
  }
188
206
  }
189
207
  function _deliveryLogLineCount(name) {
@@ -221,7 +239,10 @@ function _ingestDeliveriesFileIntoIndex(name) {
221
239
  for (const [id, row] of merged) byId.set(id, row);
222
240
  _pruneDeliveryIndexMap(byId);
223
241
  _setDeliveryLogLineCount(name, lineCount);
224
- _deliveryKeptCountByEndpoint.set(name, merged.size);
242
+ // Track the RETAINED (post-prune) distinct count, not the raw file count:
243
+ // a pre-existing oversized log then trips the compaction trigger promptly
244
+ // instead of inflating the threshold until it grows even larger.
245
+ _deliveryKeptCountByEndpoint.set(name, byId.size);
225
246
  }
226
247
  function _ensureDeliveryIndex(name) {
227
248
  if (_deliveryIndexWarmed.has(name)) return;
@@ -245,14 +266,10 @@ function _compactDeliveriesLogIfNeeded(name) {
245
266
  if (_deliveryLogLineCount(name) <= threshold) return;
246
267
  const { byId: merged } = _readDeliveriesFileMerged(name);
247
268
  const rows = [...merged.values()];
248
- const blocking = rows.filter((e) => _isBlockingDeliveryStatus(e.status));
249
- const nonBlocking = rows.filter((e) => !_isBlockingDeliveryStatus(e.status));
250
- nonBlocking.sort((a, b) => String(b.ts || "").localeCompare(String(a.ts || "")));
269
+ const keepIds = _retainedDeliveryIds(rows);
251
270
  const keep = new Map();
252
- for (const e of blocking) keep.set(e.id, e);
253
- for (const e of nonBlocking) {
254
- if (keep.size >= DELIVERY_INDEX_MAX_IDS) break;
255
- if (!keep.has(e.id)) keep.set(e.id, e);
271
+ for (const e of rows) {
272
+ if (keepIds.has(e.id)) keep.set(e.id, e);
256
273
  }
257
274
  const lines = [...keep.values()]
258
275
  .sort((a, b) => String(a.ts || "").localeCompare(String(b.ts || "")))
@@ -996,6 +996,10 @@ async function _finalizeCycle2Run(result) {
996
996
  }
997
997
 
998
998
  async function checkCycles() {
999
+ // Poll-on-use: re-read memory config each tick so changed enabled/interval
1000
+ // values apply without a restart (mirrors search/cwd poll-on-use). The fixed
1001
+ // 60s poll bounds latency; manual `memory` tool calls already re-read per-call.
1002
+ mainConfig = readMainConfig();
999
1003
  if (mainConfig?.enabled === false) return
1000
1004
 
1001
1005
  const cycle1Ms = parseInterval(mainConfig?.cycle1?.interval || '10m')
@@ -2827,7 +2831,7 @@ const httpServer = http.createServer(async (req, res) => {
2827
2831
  }
2828
2832
  const chosen = windows.slice(0, sets)
2829
2833
 
2830
- const preset = resolveMaintenancePreset('cycle1')
2834
+ const preset = resolveMaintenancePreset('memory')
2831
2835
 
2832
2836
  function summariseChunks(chunks, totalEntries) {
2833
2837
  const usedIdx = new Set()
@@ -167,7 +167,7 @@ async function _llmJudgeMerge(existing, incoming) {
167
167
  role: 'cycle2-agent',
168
168
  taskType: 'maintenance',
169
169
  mode: 'core-merge-judge',
170
- preset: resolveMaintenancePreset('cycle2'),
170
+ preset: resolveMaintenancePreset('memory'),
171
171
  timeout: 30_000,
172
172
  cwd: null,
173
173
  }, prompt)
@@ -268,7 +268,7 @@ async function _runCycle1Impl(db, config = {}, options = {}, dataDir = null) {
268
268
  // Fallback chain handles flat config + nested cycle1 wrap shapes.
269
269
  const minBatch = Math.max(1, Number(config?.min_batch ?? config?.cycle1?.min_batch ?? CYCLE1_MIN_BATCH))
270
270
  const sessionCap = Math.max(1, Number(config?.session_cap ?? config?.cycle1?.session_cap ?? CYCLE1_SESSION_CAP))
271
- const preset = options.preset || resolveMaintenancePreset('cycle1')
271
+ const preset = options.preset || resolveMaintenancePreset('memory')
272
272
  // Inner LLM timeout aligns to caller deadline -1s so the channel side can ack gracefully.
273
273
  const callerDeadlineMs = Number(options.callerDeadlineMs ?? 0)
274
274
  const baseTimeout = Number(config?.timeout ?? config?.cycle1?.timeout ?? 180000)
@@ -640,7 +640,7 @@ export async function runUnifiedGate(db, rows, activeContext, config = {}, optio
640
640
  .replace('{{ACTIVE_COUNT}}', String(activeCount))
641
641
  .replace('{{ACTIVE_CAP}}', String(activeCap))
642
642
 
643
- const preset = options.preset || resolveMaintenancePreset('cycle2')
643
+ const preset = options.preset || resolveMaintenancePreset('memory')
644
644
  const timeout = Number(config?.cycle2?.timeout ?? 600000)
645
645
  const mode = 'cycle2-unified'
646
646
 
@@ -318,7 +318,7 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
318
318
  .replace('{{CORE_REVIEW}}', coreReview)
319
319
  .replace('{{CURRENT_RULES}}', rulesDigest)
320
320
 
321
- const preset = resolveMaintenancePreset('cycle3')
321
+ const preset = resolveMaintenancePreset('memory')
322
322
  const timeout = Number(config?.cycle3?.timeout ?? 600000)
323
323
  const mode = 'cycle3-review'
324
324
 
package/tools.json CHANGED
@@ -697,7 +697,7 @@
697
697
  "idempotentHint": true,
698
698
  "openWorldHint": false
699
699
  },
700
- "description": "Read-only codebase EXPLORATION — fact-finding only: locate/map where and how things are implemented, for open-ended/unknown scope (for a known or partial identifier use code_graph; recall=memory, search=web). NOT a reviewer/auditor: explorers LOCATE and DESCRIBE code, never judge it — bug/quality/risk claims in explore output are UNVERIFIED leads; verify them (reviewer role or direct reads) before acting on or reporting them. Shape every query as a LOCATION/INVENTORY question (\"where is X handled\", \"which files implement Y\", \"what does Z read/write\") — NEVER a verdict question (\"is X correct/missing/inconsistent?\", \"are there gaps/bugs?\"); the judgment stays with the caller, applied to the coordinates explore returns. Query shaping (one-line, one topic per item, decompose a multi-part brief) is specified on the query parameter — follow it. Fan-out runs items in parallel; wall-clock = the slowest item. LEAD: default background:true (answer pushed via channel, avoids the 120s sync cap). BRIDGE WORKERS run it sync and SHOULD prefer it for a tree-wide enumeration or broad/unanchored exploration — ONE call offloads the whole sweep into a sub-agent instead of a long grep/code_graph storm; a bounded/known-anchor lookup stays a direct code_graph/grep call.",
700
+ "description": "Read-only codebase EXPLORATION — fact-finding only: locate/map where and how things are implemented, for open-ended/unknown scope (for a known or partial identifier use code_graph; recall=memory, search=web). NOT a reviewer/auditor: explorers LOCATE and DESCRIBE code, never judge it — bug/quality/risk claims in explore output are UNVERIFIED leads; verify them (reviewer role or direct reads) before acting on or reporting them. Shape every query as a LOCATION/INVENTORY question (\"where is X handled\", \"which files implement Y\", \"what does Z read/write\") — NEVER a verdict question (\"is X correct/missing/inconsistent?\", \"are there gaps/bugs?\"); the judgment stays with the caller, applied to the coordinates explore returns. Query shaping rules are on the query parameter — follow them. Fan-out runs items in parallel; wall-clock = the slowest item. LEAD: default background:true (answer pushed via channel, avoids the 120s sync cap). BRIDGE WORKERS run it sync and SHOULD prefer it for a tree-wide enumeration or broad/unanchored exploration — ONE call offloads the whole sweep into a sub-agent instead of a long grep/code_graph storm; a bounded/known-anchor lookup stays a direct code_graph/grep call.",
701
701
  "inputSchema": {
702
702
  "type": "object",
703
703
  "properties": {
@@ -1459,7 +1459,7 @@
1459
1459
  "openWorldHint": true,
1460
1460
  "compressible": true
1461
1461
  },
1462
- "description": "Shell for git/build/test/run. Use current-OS syntax: Windows default = PowerShell; POSIX default = /bin/sh. Always pass shell matching your syntax: 'bash' = POSIX via Git Bash, 'powershell' = PS cmdlets; omitting uses the OS default and mis-parses the other. run_in_background works for both shells, including Windows shell:'bash' (Git Bash). Single shell entry point; not for inline code you were asked to return.",
1462
+ "description": "Shell for git/build/test/run. ALWAYS set `shell` explicitly ('bash' = POSIX via Git Bash, 'powershell' = PS cmdlets); omitting defaults to the OS shell (Windows = PowerShell, POSIX = /bin/sh) and mis-parses the other syntax. run_in_background works for both shells, including Windows shell:'bash' (Git Bash). Single shell entry point; not for inline code you were asked to return.",
1463
1463
  "inputSchema": {
1464
1464
  "type": "object",
1465
1465
  "properties": {