mixdog 0.7.11 → 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.
- package/.claude-plugin/marketplace.json +5 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -74
- package/README.md +193 -249
- package/bin/statusline-launcher.mjs +5 -1
- package/bin/statusline-lib.mjs +14 -6
- package/bin/statusline.mjs +14 -6
- package/bun.lock +128 -3
- package/defaults/hidden-roles.json +3 -0
- package/defaults/user-workflow.json +1 -2
- package/defaults/user-workflow.md +5 -1
- package/hooks/lib/settings-loader.cjs +4 -3
- package/hooks/pre-tool-subagent.cjs +7 -2
- package/hooks/session-start.cjs +52 -24
- package/lib/mixdog-debug.cjs +163 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +9 -2
- package/scripts/builtin-utils-smoke.mjs +14 -8
- package/scripts/bump.mjs +80 -0
- package/scripts/doctor.mjs +8 -3
- package/scripts/ensure-deps.mjs +2 -2
- package/scripts/mutation-io-smoke.mjs +17 -1
- package/scripts/permission-eval-smoke.mjs +18 -1
- package/scripts/run-mcp.mjs +65 -9
- package/scripts/statusline-launcher-smoke.mjs +2 -2
- package/scripts/webhook-selfheal-smoke.mjs +1 -3
- package/server-main.mjs +57 -3
- package/setup/install.mjs +574 -574
- package/setup/launch-core.mjs +0 -1
- package/setup/setup-server.mjs +90 -35
- package/setup/setup.html +44 -11
- package/skills/setup/SKILL.md +12 -2
- package/src/agent/index.mjs +1 -1
- package/src/agent/orchestrator/config.mjs +58 -6
- package/src/agent/orchestrator/providers/model-catalog.mjs +1 -1
- package/src/agent/orchestrator/providers/openai-oauth.mjs +9 -2
- package/src/agent/orchestrator/providers/openai-ws.mjs +23 -0
- package/src/agent/orchestrator/session/loop.mjs +3 -3
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +6 -2
- package/src/agent/orchestrator/tools/bash-session.mjs +1 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +29 -6
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +8 -4
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +29 -8
- package/src/agent/orchestrator/tools/builtin.mjs +5 -2
- package/src/agent/orchestrator/tools/cwd-tool.mjs +17 -17
- package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/agent/tool-defs.mjs +1 -1
- package/src/channels/index.mjs +39 -9
- package/src/channels/lib/event-queue.mjs +24 -1
- package/src/channels/lib/hook-pipe-server.mjs +21 -8
- package/src/channels/lib/webhook.mjs +159 -20
- package/src/memory/index.mjs +5 -1
- package/src/memory/lib/core-memory-store.mjs +1 -1
- package/src/memory/lib/memory-cycle1.mjs +8 -4
- package/src/memory/lib/memory-cycle2.mjs +1 -1
- package/src/memory/lib/memory-cycle3.mjs +1 -1
- package/src/memory/lib/memory-recall-store.mjs +27 -10
- package/src/search/lib/backends/openai-oauth.mjs +6 -2
- package/src/search/lib/cache.mjs +55 -7
- package/tools.json +2 -2
- package/scripts/test-config-rmw-restore.mjs +0 -122
|
@@ -4,7 +4,7 @@ import { join } from "path";
|
|
|
4
4
|
import { spawn, spawnSync, execSync } from "child_process";
|
|
5
5
|
import { DATA_DIR, isInQuietWindow } from "./config.mjs";
|
|
6
6
|
import { getWebhookAuthtoken } from "../../shared/config.mjs";
|
|
7
|
-
import { appendFileSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, statSync, existsSync, watch as fsWatch } from "fs";
|
|
7
|
+
import { appendFileSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, statSync, existsSync, renameSync, watch as fsWatch } from "fs";
|
|
8
8
|
import { randomUUID } from "crypto";
|
|
9
9
|
const WEBHOOKS_DIR = join(DATA_DIR, "webhooks");
|
|
10
10
|
const WEBHOOK_LOG = join(DATA_DIR, "webhook.log");
|
|
@@ -144,39 +144,177 @@ function loadEndpointConfig(name) {
|
|
|
144
144
|
// then {status:"done"|"failed"|"dedup"}. Earlier fields (payloadPreview,
|
|
145
145
|
// headersSummary) are kept on the first line only; later status updates
|
|
146
146
|
// reference the same `id` and are merged latest-wins at read time.
|
|
147
|
+
const DELIVERY_INDEX_MAX_IDS = 2000;
|
|
148
|
+
const DELIVERY_LOG_MAX_LINES = 10_000;
|
|
149
|
+
/** @type {Map<string, Map<string, object>>} */
|
|
150
|
+
const _deliveryIndexByEndpoint = new Map();
|
|
151
|
+
/** @type {Set<string>} */
|
|
152
|
+
const _deliveryIndexWarmed = new Set();
|
|
153
|
+
/** @type {Map<string, number>} */
|
|
154
|
+
const _deliveryLogLineCountByEndpoint = new Map();
|
|
155
|
+
/** @type {Map<string, number>} distinct-id count at last warm/compaction; drives the redundancy-based compaction trigger. */
|
|
156
|
+
const _deliveryKeptCountByEndpoint = new Map();
|
|
147
157
|
function _deliveriesPath(name) {
|
|
148
158
|
return join(WEBHOOKS_DIR, name, "deliveries.jsonl");
|
|
149
159
|
}
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
function _mergeDeliveryRows(prior, entry) {
|
|
161
|
+
return prior ? { ...prior, ...entry } : entry;
|
|
162
|
+
}
|
|
163
|
+
function _isBlockingDeliveryStatus(status) {
|
|
164
|
+
return status === "received" || status === "processing" || status === "done";
|
|
165
|
+
}
|
|
166
|
+
function _deliveryIndexFor(name) {
|
|
167
|
+
let map = _deliveryIndexByEndpoint.get(name);
|
|
168
|
+
if (!map) {
|
|
169
|
+
map = new Map();
|
|
170
|
+
_deliveryIndexByEndpoint.set(name, map);
|
|
160
171
|
}
|
|
172
|
+
return map;
|
|
161
173
|
}
|
|
162
|
-
|
|
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
|
+
}
|
|
200
|
+
function _pruneDeliveryIndexMap(byId) {
|
|
201
|
+
if (byId.size <= DELIVERY_INDEX_MAX_IDS) return;
|
|
202
|
+
const keep = _retainedDeliveryIds([...byId.values()]);
|
|
203
|
+
for (const id of byId.keys()) {
|
|
204
|
+
if (!keep.has(id)) byId.delete(id);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function _deliveryLogLineCount(name) {
|
|
208
|
+
return _deliveryLogLineCountByEndpoint.get(name) ?? 0;
|
|
209
|
+
}
|
|
210
|
+
function _setDeliveryLogLineCount(name, n) {
|
|
211
|
+
_deliveryLogLineCountByEndpoint.set(name, Math.max(0, n));
|
|
212
|
+
}
|
|
213
|
+
function _bumpDeliveryLogLineCount(name, delta = 1) {
|
|
214
|
+
_setDeliveryLogLineCount(name, _deliveryLogLineCount(name) + delta);
|
|
215
|
+
}
|
|
216
|
+
function _readDeliveriesFileMerged(name) {
|
|
163
217
|
const p = _deliveriesPath(name);
|
|
164
|
-
if (!existsSync(p)) return [];
|
|
165
218
|
const byId = new Map();
|
|
219
|
+
let lineCount = 0;
|
|
220
|
+
if (!existsSync(p)) return { byId, lineCount };
|
|
166
221
|
try {
|
|
167
222
|
const raw = readFileSync(p, "utf8");
|
|
168
223
|
for (const line of raw.split("\n")) {
|
|
169
224
|
if (!line) continue;
|
|
225
|
+
lineCount++;
|
|
170
226
|
try {
|
|
171
227
|
const entry = JSON.parse(line);
|
|
172
|
-
if (!entry
|
|
173
|
-
|
|
174
|
-
const merged = prior ? { ...prior, ...entry } : entry;
|
|
175
|
-
byId.set(entry.id, merged);
|
|
228
|
+
if (!entry?.id) continue;
|
|
229
|
+
byId.set(entry.id, _mergeDeliveryRows(byId.get(entry.id), entry));
|
|
176
230
|
} catch {}
|
|
177
231
|
}
|
|
178
232
|
} catch {}
|
|
179
|
-
return
|
|
233
|
+
return { byId, lineCount };
|
|
234
|
+
}
|
|
235
|
+
function _ingestDeliveriesFileIntoIndex(name) {
|
|
236
|
+
const { byId: merged, lineCount } = _readDeliveriesFileMerged(name);
|
|
237
|
+
const byId = _deliveryIndexFor(name);
|
|
238
|
+
byId.clear();
|
|
239
|
+
for (const [id, row] of merged) byId.set(id, row);
|
|
240
|
+
_pruneDeliveryIndexMap(byId);
|
|
241
|
+
_setDeliveryLogLineCount(name, lineCount);
|
|
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);
|
|
246
|
+
}
|
|
247
|
+
function _ensureDeliveryIndex(name) {
|
|
248
|
+
if (_deliveryIndexWarmed.has(name)) return;
|
|
249
|
+
_deliveryIndexWarmed.add(name);
|
|
250
|
+
_ingestDeliveriesFileIntoIndex(name);
|
|
251
|
+
}
|
|
252
|
+
function _applyDeliveryEntryToIndex(name, entry) {
|
|
253
|
+
if (!entry?.id) return;
|
|
254
|
+
const byId = _deliveryIndexFor(name);
|
|
255
|
+
byId.set(entry.id, _mergeDeliveryRows(byId.get(entry.id), entry));
|
|
256
|
+
_pruneDeliveryIndexMap(byId);
|
|
257
|
+
}
|
|
258
|
+
function _compactDeliveriesLogIfNeeded(name) {
|
|
259
|
+
// Redundancy-based trigger: compact only when the log holds meaningfully more
|
|
260
|
+
// lines than distinct ids (i.e. there are status-update rows to collapse).
|
|
261
|
+
// The threshold scales with the distinct-id count so an endpoint with many
|
|
262
|
+
// legitimate blocking ids does NOT re-compact on every append (which would
|
|
263
|
+
// re-read the whole log permanently once distinct > DELIVERY_LOG_MAX_LINES).
|
|
264
|
+
const kept = _deliveryKeptCountByEndpoint.get(name) ?? _deliveryIndexFor(name).size;
|
|
265
|
+
const threshold = Math.max(DELIVERY_LOG_MAX_LINES, kept * 2);
|
|
266
|
+
if (_deliveryLogLineCount(name) <= threshold) return;
|
|
267
|
+
const { byId: merged } = _readDeliveriesFileMerged(name);
|
|
268
|
+
const rows = [...merged.values()];
|
|
269
|
+
const keepIds = _retainedDeliveryIds(rows);
|
|
270
|
+
const keep = new Map();
|
|
271
|
+
for (const e of rows) {
|
|
272
|
+
if (keepIds.has(e.id)) keep.set(e.id, e);
|
|
273
|
+
}
|
|
274
|
+
const lines = [...keep.values()]
|
|
275
|
+
.sort((a, b) => String(a.ts || "").localeCompare(String(b.ts || "")))
|
|
276
|
+
.map((e) => JSON.stringify(e) + "\n")
|
|
277
|
+
.join("");
|
|
278
|
+
const p = _deliveriesPath(name);
|
|
279
|
+
const tmp = `${p}.compact-${process.pid}-${Date.now()}.tmp`;
|
|
280
|
+
try {
|
|
281
|
+
writeFileSync(tmp, lines);
|
|
282
|
+
renameSync(tmp, p);
|
|
283
|
+
// Refresh index + counters by RE-READING the post-rename file (not the
|
|
284
|
+
// pre-rename `keep` snapshot). The webhook daemon is the single writer and
|
|
285
|
+
// append+compact run synchronously in one process, so no append can
|
|
286
|
+
// interleave between the fresh read and the rename; re-reading keeps the
|
|
287
|
+
// warmed state exactly matching on-disk content.
|
|
288
|
+
_ingestDeliveriesFileIntoIndex(name);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
logWebhook(`${name}: deliveries compact failed: ${err?.message ?? err}`);
|
|
291
|
+
try {
|
|
292
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function appendDelivery(name, entry) {
|
|
297
|
+
try {
|
|
298
|
+
const dir = join(WEBHOOKS_DIR, name);
|
|
299
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
300
|
+
const full = { ts: new Date().toISOString(), ...entry };
|
|
301
|
+
const line = JSON.stringify(full) + "\n";
|
|
302
|
+
appendFileSync(_deliveriesPath(name), line);
|
|
303
|
+
const wasWarmed = _deliveryIndexWarmed.has(name);
|
|
304
|
+
_ensureDeliveryIndex(name);
|
|
305
|
+
if (wasWarmed) _bumpDeliveryLogLineCount(name, 1);
|
|
306
|
+
_applyDeliveryEntryToIndex(name, full);
|
|
307
|
+
_compactDeliveriesLogIfNeeded(name);
|
|
308
|
+
return true;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
logWebhook(`${name}: deliveries append failed: ${err?.message ?? err}`);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function readDeliveries(name) {
|
|
315
|
+
_ensureDeliveryIndex(name);
|
|
316
|
+
const byId = _deliveryIndexByEndpoint.get(name);
|
|
317
|
+
return byId ? [...byId.values()] : [];
|
|
180
318
|
}
|
|
181
319
|
// Dedup gate against a still-active claim or a successful prior delivery.
|
|
182
320
|
// Only rows with status "received" (non-terminal claim) or "done"
|
|
@@ -186,12 +324,13 @@ function readDeliveries(name) {
|
|
|
186
324
|
// scoping, every prior row would permanently dedup the id and stop
|
|
187
325
|
// legit redelivery.
|
|
188
326
|
function deliveryExists(name, id) {
|
|
189
|
-
const list = readDeliveries(name);
|
|
190
327
|
// "processing" must also dedup: a delegate dispatch in flight (up to
|
|
191
328
|
// DISPATCH_TIMEOUT_MS = 10 min) would otherwise be duplicated by a
|
|
192
329
|
// retried delivery of the same id while the first handler is still
|
|
193
330
|
// running. Block on any non-terminal status.
|
|
194
|
-
|
|
331
|
+
_ensureDeliveryIndex(name);
|
|
332
|
+
const entry = _deliveryIndexFor(name).get(id);
|
|
333
|
+
return Boolean(entry && _isBlockingDeliveryStatus(entry.status));
|
|
195
334
|
}
|
|
196
335
|
function extractDeliveryId(headers) {
|
|
197
336
|
return headers["x-github-delivery"]
|
package/src/memory/index.mjs
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
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)
|
|
@@ -545,9 +545,13 @@ async function _runCycle1Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
545
545
|
}
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
548
|
+
// Cap fan-out concurrency so a large batch (or a manual run) doesn't fire all
|
|
549
|
+
// window LLM calls at once and spike the provider / collide with the global
|
|
550
|
+
// agent-IPC limit. Small batches (<= cap) still run fully parallel.
|
|
551
|
+
const cycle1Concurrency = Math.max(1, Number(
|
|
552
|
+
config.cycle1_concurrency ?? config.concurrency ?? options.concurrency ?? options.maxConcurrent ?? 4,
|
|
553
|
+
))
|
|
554
|
+
const sem = createSemaphore(Math.min(Math.max(1, windows.length), cycle1Concurrency))
|
|
551
555
|
const settled = await Promise.allSettled(
|
|
552
556
|
windows.map((rows, idx) => sem(() => {
|
|
553
557
|
throwIfAborted(signal)
|
|
@@ -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('
|
|
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('
|
|
321
|
+
const preset = resolveMaintenancePreset('memory')
|
|
322
322
|
const timeout = Number(config?.cycle3?.timeout ?? 600000)
|
|
323
323
|
const mode = 'cycle3-review'
|
|
324
324
|
|
|
@@ -504,6 +504,32 @@ LEFT JOIN exact x ON x.id = c.id`
|
|
|
504
504
|
const rootIdsForReturn = []
|
|
505
505
|
const seen = new Set()
|
|
506
506
|
|
|
507
|
+
// Batch-resolve member-chunk roots in ONE query (was an N+1: a per-row SELECT
|
|
508
|
+
// inside the loop below). Collect the distinct in-scope chunk_root ids, fetch
|
|
509
|
+
// all matching roots at once, then resolve each member from rootById.
|
|
510
|
+
const memberRootIds = []
|
|
511
|
+
const memberRootSeen = new Set()
|
|
512
|
+
for (const { id } of filtered) {
|
|
513
|
+
const r0 = byId.get(id)
|
|
514
|
+
if (!r0 || r0.is_root === 1) continue
|
|
515
|
+
if (r0.chunk_root != null && r0.chunk_root !== r0.id) {
|
|
516
|
+
const rid = Number(r0.chunk_root)
|
|
517
|
+
if (!memberRootSeen.has(rid)) { memberRootSeen.add(rid); memberRootIds.push(rid) }
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const rootById = new Map()
|
|
521
|
+
if (memberRootIds.length > 0) {
|
|
522
|
+
const { clause: rootScopeClause, params: rootScopeParams } = buildScopeClause(2)
|
|
523
|
+
const { rows: rootRows } = await recallReadQuery(
|
|
524
|
+
db,
|
|
525
|
+
`SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
526
|
+
element, category, summary, project_id, status, score, last_seen_at
|
|
527
|
+
FROM entries WHERE id = ANY($1::bigint[]) AND is_root = 1 ${rootScopeClause}`,
|
|
528
|
+
[memberRootIds, ...rootScopeParams],
|
|
529
|
+
)
|
|
530
|
+
for (const rr of rootRows) rootById.set(Number(rr.id), rr)
|
|
531
|
+
}
|
|
532
|
+
|
|
507
533
|
for (const { id, rrf, retrievalScore } of filtered) {
|
|
508
534
|
const row = byId.get(id)
|
|
509
535
|
if (!row) continue
|
|
@@ -511,16 +537,7 @@ LEFT JOIN exact x ON x.id = c.id`
|
|
|
511
537
|
if (row.is_root === 1) {
|
|
512
538
|
targetRow = row
|
|
513
539
|
} else if (row.chunk_root != null && row.chunk_root !== row.id) {
|
|
514
|
-
|
|
515
|
-
const { clause: rootScopeClause, params: rootScopeParams } = buildScopeClause(2)
|
|
516
|
-
const { rows: rootRows } = await recallReadQuery(
|
|
517
|
-
db,
|
|
518
|
-
`SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
|
|
519
|
-
element, category, summary, project_id, status, score, last_seen_at
|
|
520
|
-
FROM entries WHERE id = $1 AND is_root = 1 ${rootScopeClause}`,
|
|
521
|
-
[row.chunk_root, ...rootScopeParams],
|
|
522
|
-
)
|
|
523
|
-
const r = rootRows[0]
|
|
540
|
+
const r = rootById.get(Number(row.chunk_root))
|
|
524
541
|
if (!r) continue
|
|
525
542
|
memberHitRootIds.add(r.id)
|
|
526
543
|
targetRow = r
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* Calls Codex WebSocket endpoint via sendViaWebSocket with web_search server
|
|
6
6
|
* tool. Model is config-driven (search.models.openai default 'gpt-5.4-mini').
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
OpenAIOAuthProvider,
|
|
10
|
+
ensureLatestCodexModel,
|
|
11
|
+
codexModelSupportsServiceTier,
|
|
12
|
+
} from '../../../agent/orchestrator/providers/openai-oauth.mjs'
|
|
9
13
|
import {
|
|
10
14
|
OPENAI_SEARCH_SYSTEM_INSTRUCTIONS,
|
|
11
15
|
buildOpenAISearchPrompt,
|
|
@@ -66,7 +70,7 @@ export async function searchViaOpenAIOAuth({
|
|
|
66
70
|
parallel_tool_calls: false,
|
|
67
71
|
tools: [buildOpenAIWebSearchTool({ site, type, locale, contextSize })],
|
|
68
72
|
}
|
|
69
|
-
if (fast === true) body.service_tier = 'priority'
|
|
73
|
+
if (fast === true && codexModelSupportsServiceTier(useModel, 'priority')) body.service_tier = 'priority'
|
|
70
74
|
// Route through provider.send() (not sendViaWebSocket directly) so the search
|
|
71
75
|
// request inherits the 401/403 force-refresh retry + HTTP/SSE fallback. A
|
|
72
76
|
// stale token or unhealthy WebSocket then recovers instead of hard-failing.
|
package/src/search/lib/cache.mjs
CHANGED
|
@@ -5,6 +5,52 @@ const DEFAULT_CACHE_STATE = {
|
|
|
5
5
|
entries: {},
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// Size bounds on top of TTL expiry so cache.local.json can't grow unbounded.
|
|
9
|
+
const MAX_CACHE_ENTRIES = 500
|
|
10
|
+
const MAX_CACHE_BYTES = 8 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
// Approximate serialized size of ONE entry (never the whole map), so insert and
|
|
13
|
+
// evict stay cheap. A running total on the state (`__approxBytes`) lets the byte
|
|
14
|
+
// cap be checked without re-serializing every entry on each set.
|
|
15
|
+
function approxEntryBytes(key, entry) {
|
|
16
|
+
try { return String(key).length + JSON.stringify(entry).length } catch { return String(key).length }
|
|
17
|
+
}
|
|
18
|
+
function cacheApproxBytes(state) {
|
|
19
|
+
if (typeof state.__approxBytes === 'number') return state.__approxBytes
|
|
20
|
+
let total = 0
|
|
21
|
+
for (const [k, e] of Object.entries(state.entries)) total += approxEntryBytes(k, e)
|
|
22
|
+
state.__approxBytes = total
|
|
23
|
+
return total
|
|
24
|
+
}
|
|
25
|
+
function removeCacheEntry(state, key) {
|
|
26
|
+
const e = state.entries[key]
|
|
27
|
+
if (e === undefined) return
|
|
28
|
+
if (typeof state.__approxBytes === 'number') state.__approxBytes -= approxEntryBytes(key, e)
|
|
29
|
+
delete state.entries[key]
|
|
30
|
+
}
|
|
31
|
+
function evictOldestCacheEntry(state) {
|
|
32
|
+
let oldestKey = null
|
|
33
|
+
let oldestAt = Infinity
|
|
34
|
+
for (const [k, e] of Object.entries(state.entries)) {
|
|
35
|
+
const at = e?.cachedAt ?? 0
|
|
36
|
+
if (at < oldestAt) { oldestAt = at; oldestKey = k }
|
|
37
|
+
}
|
|
38
|
+
if (oldestKey == null) return false
|
|
39
|
+
removeCacheEntry(state, oldestKey)
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
// Best-effort: evict oldest entries until under both the count and byte caps.
|
|
43
|
+
function enforceCacheSizeBounds(state) {
|
|
44
|
+
try {
|
|
45
|
+
while (Object.keys(state.entries).length > MAX_CACHE_ENTRIES) {
|
|
46
|
+
if (!evictOldestCacheEntry(state)) break
|
|
47
|
+
}
|
|
48
|
+
while (cacheApproxBytes(state) > MAX_CACHE_BYTES && Object.keys(state.entries).length > 0) {
|
|
49
|
+
if (!evictOldestCacheEntry(state)) break
|
|
50
|
+
}
|
|
51
|
+
} catch { /* size bounding is best-effort */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
8
54
|
const FLUSH_DELAY_MS = 5000
|
|
9
55
|
|
|
10
56
|
let cacheDirty = false
|
|
@@ -75,6 +121,8 @@ export function loadCacheState() {
|
|
|
75
121
|
_instance = state
|
|
76
122
|
activeCacheState = state
|
|
77
123
|
pruneExpiredEntries(state)
|
|
124
|
+
cacheApproxBytes(state)
|
|
125
|
+
enforceCacheSizeBounds(state)
|
|
78
126
|
return state
|
|
79
127
|
}
|
|
80
128
|
|
|
@@ -90,7 +138,7 @@ export function getCachedEntry(state, key) {
|
|
|
90
138
|
const entry = state.entries[key]
|
|
91
139
|
if (!entry) return null
|
|
92
140
|
if (entry.expiresAt && entry.expiresAt <= nowMs()) {
|
|
93
|
-
|
|
141
|
+
removeCacheEntry(state, key)
|
|
94
142
|
scheduleCacheFlush(state)
|
|
95
143
|
return null
|
|
96
144
|
}
|
|
@@ -99,11 +147,11 @@ export function getCachedEntry(state, key) {
|
|
|
99
147
|
|
|
100
148
|
export function setCachedEntry(state, key, payload, ttlMs) {
|
|
101
149
|
const cachedAt = nowMs()
|
|
102
|
-
state.entries[key]
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
if (state.entries[key] !== undefined) removeCacheEntry(state, key)
|
|
151
|
+
const entry = { cachedAt, expiresAt: cachedAt + ttlMs, payload }
|
|
152
|
+
state.entries[key] = entry
|
|
153
|
+
if (typeof state.__approxBytes === 'number') state.__approxBytes += approxEntryBytes(key, entry)
|
|
154
|
+
enforceCacheSizeBounds(state)
|
|
107
155
|
scheduleCacheFlush(state)
|
|
108
156
|
return state.entries[key]
|
|
109
157
|
}
|
|
@@ -121,7 +169,7 @@ function pruneExpiredEntries(state) {
|
|
|
121
169
|
let dirty = false
|
|
122
170
|
for (const [key, entry] of Object.entries(state.entries)) {
|
|
123
171
|
if (entry?.expiresAt && entry.expiresAt <= current) {
|
|
124
|
-
|
|
172
|
+
removeCacheEntry(state, key)
|
|
125
173
|
dirty = true
|
|
126
174
|
}
|
|
127
175
|
}
|
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
|
|
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.
|
|
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": {
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Repro: malformed mixdog-config.json + writeSection('search', …) must not
|
|
3
|
-
* wipe channels/memory/agent; restores from backup or throws.
|
|
4
|
-
*/
|
|
5
|
-
import assert from 'node:assert/strict';
|
|
6
|
-
import {
|
|
7
|
-
mkdtempSync,
|
|
8
|
-
mkdirSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
rmSync,
|
|
12
|
-
} from 'fs';
|
|
13
|
-
import { tmpdir } from 'os';
|
|
14
|
-
import { join, dirname } from 'path';
|
|
15
|
-
import { fileURLToPath } from 'url';
|
|
16
|
-
|
|
17
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
|
|
19
|
-
async function loadConfigModule(dataDir, backupRoot) {
|
|
20
|
-
process.env.CLAUDE_PLUGIN_DATA = dataDir;
|
|
21
|
-
process.env.MIXDOG_USER_DATA_BACKUP_ROOT = backupRoot;
|
|
22
|
-
process.env.MIXDOG_SKIP_USER_DATA_BACKUP = '1';
|
|
23
|
-
const url = new URL(`../src/shared/config.mjs?run=${Date.now()}`, import.meta.url).href;
|
|
24
|
-
return import(url);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function writeConfig(dataDir, obj) {
|
|
28
|
-
writeFileSync(
|
|
29
|
-
join(dataDir, 'mixdog-config.json'),
|
|
30
|
-
JSON.stringify(obj, null, 2) + '\n',
|
|
31
|
-
'utf8',
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function main() {
|
|
36
|
-
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-config-rmw-'));
|
|
37
|
-
const backupRoot = mkdtempSync(join(tmpdir(), 'mixdog-config-backup-'));
|
|
38
|
-
|
|
39
|
-
const prior = {
|
|
40
|
-
channels: { guild: '111' },
|
|
41
|
-
memory: { enabled: true },
|
|
42
|
-
agent: { presets: { default: { model: 'x' } } },
|
|
43
|
-
};
|
|
44
|
-
writeConfig(dataDir, prior);
|
|
45
|
-
|
|
46
|
-
process.env.CLAUDE_PLUGIN_DATA = dataDir;
|
|
47
|
-
process.env.MIXDOG_USER_DATA_BACKUP_ROOT = backupRoot;
|
|
48
|
-
const guardUrl = new URL(`../src/shared/user-data-guard.mjs?t=${Date.now()}`, import.meta.url).href;
|
|
49
|
-
const { backupUserData, markUserDataInitialized } = await import(guardUrl);
|
|
50
|
-
const snap = backupUserData(dataDir, 'test-fixture');
|
|
51
|
-
assert.ok(snap.dir, 'backup fixture should copy mixdog-config.json');
|
|
52
|
-
|
|
53
|
-
writeFileSync(join(dataDir, 'mixdog-config.json'), '{ not valid json\n', 'utf8');
|
|
54
|
-
|
|
55
|
-
const { writeSection } = await loadConfigModule(dataDir, backupRoot);
|
|
56
|
-
writeSection('search', { provider: 'brave' });
|
|
57
|
-
|
|
58
|
-
const onDisk = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
|
|
59
|
-
assert.deepEqual(onDisk.channels, prior.channels);
|
|
60
|
-
assert.deepEqual(onDisk.memory, prior.memory);
|
|
61
|
-
assert.deepEqual(onDisk.agent, prior.agent);
|
|
62
|
-
assert.deepEqual(onDisk.search, { provider: 'brave' });
|
|
63
|
-
|
|
64
|
-
const freshDir = mkdtempSync(join(tmpdir(), 'mixdog-config-fresh-'));
|
|
65
|
-
const { writeSection: writeFresh } = await loadConfigModule(freshDir, backupRoot);
|
|
66
|
-
writeFresh('search', { only: true });
|
|
67
|
-
const freshDisk = JSON.parse(readFileSync(join(freshDir, 'mixdog-config.json'), 'utf8'));
|
|
68
|
-
assert.deepEqual(freshDisk, { search: { only: true } });
|
|
69
|
-
|
|
70
|
-
const noBackupDir = mkdtempSync(join(tmpdir(), 'mixdog-config-noback-'));
|
|
71
|
-
markUserDataInitialized(noBackupDir);
|
|
72
|
-
writeFileSync(join(noBackupDir, 'mixdog-config.json'), '[]', 'utf8');
|
|
73
|
-
const { writeSection: writeNoBackup } = await loadConfigModule(
|
|
74
|
-
noBackupDir,
|
|
75
|
-
mkdtempSync(join(tmpdir(), 'empty-backup-')),
|
|
76
|
-
);
|
|
77
|
-
let threw = false;
|
|
78
|
-
try {
|
|
79
|
-
writeNoBackup('search', { x: 1 });
|
|
80
|
-
} catch (err) {
|
|
81
|
-
threw = true;
|
|
82
|
-
assert.match(String(err.message), /refusing section write/);
|
|
83
|
-
}
|
|
84
|
-
assert.equal(threw, true, 'malformed config with init marker and no backup must throw');
|
|
85
|
-
|
|
86
|
-
const pickRoot = mkdtempSync(join(tmpdir(), 'mixdog-config-pick-'));
|
|
87
|
-
const fullCfg = {
|
|
88
|
-
channels: { guild: '222' },
|
|
89
|
-
memory: { enabled: false },
|
|
90
|
-
agent: { presets: {} },
|
|
91
|
-
};
|
|
92
|
-
const oldDir = join(pickRoot, '2026-06-03T19-00-00-000Z-old-full');
|
|
93
|
-
const newDir = join(pickRoot, '2026-06-03T21-00-00-000Z-new-degenerate');
|
|
94
|
-
mkdirSync(oldDir, { recursive: true });
|
|
95
|
-
mkdirSync(newDir, { recursive: true });
|
|
96
|
-
writeFileSync(join(oldDir, 'mixdog-config.json'), JSON.stringify(fullCfg) + '\n', 'utf8');
|
|
97
|
-
writeFileSync(
|
|
98
|
-
join(newDir, 'mixdog-config.json'),
|
|
99
|
-
JSON.stringify({ search: { provider: 'tavily' } }) + '\n',
|
|
100
|
-
'utf8',
|
|
101
|
-
);
|
|
102
|
-
process.env.MIXDOG_USER_DATA_BACKUP_ROOT = pickRoot;
|
|
103
|
-
const pickUrl = new URL(`../src/shared/user-data-guard.mjs?pick=${Date.now()}`, import.meta.url).href;
|
|
104
|
-
const { loadLatestMixdogConfigFromBackup } = await import(pickUrl);
|
|
105
|
-
const picked = loadLatestMixdogConfigFromBackup(null);
|
|
106
|
-
assert.deepEqual(picked?.channels, fullCfg.channels);
|
|
107
|
-
assert.deepEqual(picked?.agent, fullCfg.agent);
|
|
108
|
-
assert.equal(picked?.search, undefined, 'must not restore newest search-only snapshot');
|
|
109
|
-
rmSync(pickRoot, { recursive: true, force: true });
|
|
110
|
-
|
|
111
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
112
|
-
rmSync(backupRoot, { recursive: true, force: true });
|
|
113
|
-
rmSync(freshDir, { recursive: true, force: true });
|
|
114
|
-
rmSync(noBackupDir, { recursive: true, force: true });
|
|
115
|
-
|
|
116
|
-
console.log('test-config-rmw-restore: ok');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
main().catch((err) => {
|
|
120
|
-
console.error(err);
|
|
121
|
-
process.exit(1);
|
|
122
|
-
});
|