mixdog 1.0.2 → 1.0.4
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/README.md +8 -0
- package/package.json +1 -1
- package/src/defaults/skills/browser-use/SKILL.md +14 -6
- package/src/defaults/skills/goal-management/SKILL.md +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +10 -1
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +40 -99
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +18 -38
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-path-fanout.mjs +59 -32
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +15 -10
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-request.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shared-native-scan.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
- package/src/runtime/agent/orchestrator/tools/patch/apply-patch/codex-batch.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +17 -9
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch/wave.mjs +4 -0
- package/src/runtime/browser-bridge/input-fields.mjs +1 -1
- package/src/runtime/browser-bridge/tool-defs.mjs +1 -1
- package/src/runtime/computer-bridge/actions.mjs +0 -13
- package/src/runtime/media/adapters/antigravity-image.mjs +7 -3
- package/src/runtime/media/adapters/codex-image.mjs +12 -1
- package/src/runtime/media/adapters/gemini-image.mjs +17 -6
- package/src/runtime/media/adapters/gemini-video.mjs +19 -9
- package/src/runtime/media/adapters/xai-media.mjs +20 -7
- package/src/runtime/media/jobs.mjs +35 -3
- package/src/runtime/media/media-usage.mjs +126 -0
- package/src/runtime/media/tool.mjs +16 -4
- package/src/runtime/shared/llm/cost.mjs +50 -3
- package/src/runtime/shared/llm/model-catalog-projection.mjs +7 -2
- package/src/runtime/shared/llm/model-catalog.mjs +2 -1
- package/src/runtime/shared/llm/model-pricing-rates.mjs +21 -0
- package/src/runtime/shared/llm/usage-accounting.mjs +25 -5
- package/src/runtime/shared/llm/usage-context.mjs +6 -0
- package/src/runtime/shared/llm/usage-ledger-import.mjs +1 -0
- package/src/session-runtime/internal-tool-executor/feature-tools.mjs +9 -1
- package/src/session-runtime/services/agent-tool/index-write-queue.mjs +38 -6
- package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +9 -2
- package/src/session-runtime/services/agent-tool/worker-index.mjs +6 -3
- package/src/session-runtime/services/hook-bus/event-runner.mjs +10 -2
- package/src/session-runtime/services/hook-bus/tool-gate.mjs +5 -2
- package/src/tui/dist/index.mjs +7 -3
package/README.md
CHANGED
|
@@ -487,6 +487,14 @@ npm install
|
|
|
487
487
|
npm run dev
|
|
488
488
|
```
|
|
489
489
|
|
|
490
|
+
Desktop development uses a fresh, isolated test profile on each launch, including
|
|
491
|
+
its data, daemon and tool connections. It does not copy the installed app's
|
|
492
|
+
settings or sign-ins. The profile path is printed and retained in the system
|
|
493
|
+
temporary directory for diagnosis. CDP uses port `9342`; if it is occupied, reuse
|
|
494
|
+
the running test app or choose another port with `npm run dev -- --port 9343`.
|
|
495
|
+
On Windows, `npm run e2e:direct` and `npm run e2e:direct:source` also use isolated
|
|
496
|
+
profiles and port `9342` (override with `-- -Port 9343`).
|
|
497
|
+
|
|
490
498
|
Both packages discover `*.test.mjs` and `*-test.mjs` under their `src/` and
|
|
491
499
|
`scripts/` directories. Fast, slow, and live tests run in separate lanes;
|
|
492
500
|
live checks need their corresponding built artifacts or services. See
|
package/package.json
CHANGED
|
@@ -159,12 +159,20 @@ not as two blocks joined.
|
|
|
159
159
|
|
|
160
160
|
## Foreground vs background
|
|
161
161
|
|
|
162
|
-
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
- Default to named `background:true` pages; the panel stays closed and the
|
|
163
|
+
user's current page stays intact. If the answer can be given in the
|
|
164
|
+
conversation, keep the page hidden even when it produced the answer;
|
|
165
|
+
screenshots, reports, and result-only actions never need a reveal.
|
|
166
|
+
- Reveal a page only with `open` and its `tab`, and only when the page itself
|
|
167
|
+
is what the user needs after the turn:
|
|
168
|
+
- **Deliverable** — the user asked to see, show, or keep a page open, or the
|
|
169
|
+
page is the output: an edited document or dashboard, a cart or checkout,
|
|
170
|
+
a submitted form's result.
|
|
171
|
+
- **Handoff** — the user must act on the page: sign-in, CAPTCHA, 2FA,
|
|
172
|
+
payment, approval, or an unfinished workflow they continue.
|
|
173
|
+
- When the user refers to the page already visible in this session's panel,
|
|
174
|
+
act on that page instead of opening another.
|
|
175
|
+
- Later calls may omit `background`: naming a support page does not promote it.
|
|
168
176
|
- Background pages run concurrently, so independent background work can share
|
|
169
177
|
one assistant turn.
|
|
170
178
|
- Pages, tabs, URLs, and targets are session-local; sign-in state, cookies,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: goal-management
|
|
3
|
-
description: Manage
|
|
4
|
-
when_to_use: 'Explicit
|
|
3
|
+
description: Manage the task list of approved work carried across turns, its progress, approvals, and completion.
|
|
4
|
+
when_to_use: 'Explicit Goal (`goal` tool) requests, approved multi-turn work in progress (migrations, improvement passes); not single-turn tasks or recurring schedules.'
|
|
5
5
|
dependencies:
|
|
6
6
|
tools:
|
|
7
7
|
- type: tool
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './retry-classifier.mjs';
|
|
25
25
|
import { _classifyMidstreamError, _midstreamSleepWithAbort, stampAnthropicStreamOutcome } from './anthropic-sse.mjs';
|
|
26
26
|
import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
|
|
27
|
+
import { noteAbandonedUsage } from '../../../shared/llm/usage-context.mjs';
|
|
27
28
|
|
|
28
29
|
export function createAnthropicMidState(attemptIndex) {
|
|
29
30
|
return {
|
|
@@ -144,7 +145,7 @@ export function createAnthropicMidstreamRecovery({
|
|
|
144
145
|
* `{ retry: false, value }` when a non-streaming replay produced the turn,
|
|
145
146
|
* and throws the (stamped) error when the turn is over.
|
|
146
147
|
*/
|
|
147
|
-
const
|
|
148
|
+
const decideStreamError = async ({ err, midState, controller, response, attemptIndex }) => {
|
|
148
149
|
// Canonical stream-outcome contract: stamp before ANY safety
|
|
149
150
|
// decision below. The parser's stamped verdict (when present)
|
|
150
151
|
// is authoritative — coarse midState.partialToolCall must not
|
|
@@ -309,6 +310,14 @@ export function createAnthropicMidstreamRecovery({
|
|
|
309
310
|
throw err;
|
|
310
311
|
};
|
|
311
312
|
|
|
313
|
+
// A thrown error carries its partial usage to the send's accounting; an
|
|
314
|
+
// attempt replaced by a retry or a non-streaming replay is noted here.
|
|
315
|
+
const onStreamError = async (args) => {
|
|
316
|
+
const decision = await decideStreamError(args);
|
|
317
|
+
noteAbandonedUsage(args.err?.partialUsage, args.err?.partialModel);
|
|
318
|
+
return decision;
|
|
319
|
+
};
|
|
320
|
+
|
|
312
321
|
const exhaustedError = () => firstAttemptError || new Error(unreachableMessage);
|
|
313
322
|
|
|
314
323
|
return { onStreamError, exhaustedError };
|
|
@@ -31,7 +31,7 @@ export const GOAL_TOOL_DEFS = Object.freeze([
|
|
|
31
31
|
name: 'goal',
|
|
32
32
|
title: 'Goal',
|
|
33
33
|
description: [
|
|
34
|
-
'
|
|
34
|
+
'Task list kept across turns, with an idle reminder for unfinished work. Use for an explicit Goal request or approved multi-turn work (staged refactors, migrations, improvement passes, open-ended objectives), optionally time-boxed. Not for single-turn tasks, plans or estimates alone, unaccepted proposals, recurring schedules, or work waiting weeks on external events.',
|
|
35
35
|
'Obtain required approval, load the goal-management skill for lifecycle and completion policy, then create or reconcile the Goal before starting approved work. Mutations need the latest revision.',
|
|
36
36
|
].join(' '),
|
|
37
37
|
annotations: {
|
|
@@ -20,9 +20,6 @@ export function scopedCacheGeneration() {
|
|
|
20
20
|
// sessionId -> Map<key, { content, ts, firstToolUseId, depRoots }>
|
|
21
21
|
const _scopedBySession = new Map();
|
|
22
22
|
|
|
23
|
-
// sessionId -> Map<absPath, Set<cacheKey>> — reverse index for O(1) path-targeted invalidation
|
|
24
|
-
const _scopedReverseIdx = new Map();
|
|
25
|
-
|
|
26
23
|
function _canonicalArgs(args) {
|
|
27
24
|
if (args === null || args === undefined) return '';
|
|
28
25
|
if (typeof args !== 'object') return String(args);
|
|
@@ -243,16 +240,6 @@ function _pathTouchesRoot(absPath, root) {
|
|
|
243
240
|
);
|
|
244
241
|
}
|
|
245
242
|
|
|
246
|
-
function _dropScopedEntry(sessionId, key) {
|
|
247
|
-
_scopedBySession.get(sessionId)?.delete(key);
|
|
248
|
-
const index = _scopedReverseIdx.get(sessionId);
|
|
249
|
-
if (!index) return;
|
|
250
|
-
for (const [path, keys] of index) {
|
|
251
|
-
keys.delete(key);
|
|
252
|
-
if (keys.size === 0) index.delete(path);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
243
|
/**
|
|
257
244
|
* Look up a cached result for a deterministic multi-file-scope tool. Returns
|
|
258
245
|
* null on miss. On hit returns the full entry
|
|
@@ -265,7 +252,7 @@ export function tryScopedToolCached({ sessionId, toolName, args, cwd, touch = tr
|
|
|
265
252
|
const key = _scopedKey(toolName, args, cwd);
|
|
266
253
|
const entry = map.get(key);
|
|
267
254
|
if (!entry || Date.now() - entry.ts >= SCOPED_CACHE_TTL_MS) {
|
|
268
|
-
if (entry)
|
|
255
|
+
if (entry) map.delete(key);
|
|
269
256
|
return null;
|
|
270
257
|
}
|
|
271
258
|
if (touch) {
|
|
@@ -301,33 +288,12 @@ export function setScopedToolCached({
|
|
|
301
288
|
_scopedBySession.set(sessionId, map);
|
|
302
289
|
}
|
|
303
290
|
const depRoots = _scopedDependencyRoots(toolName, args, cwd);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
)
|
|
311
|
-
)
|
|
312
|
-
return;
|
|
313
|
-
// Register key in reverse index for dependency roots. Exact root hits use
|
|
314
|
-
// O(1) lookup; touched files under a root are caught by the small prefix scan
|
|
315
|
-
// in clearScopedToolsForSessionPaths (MAX_PER_SESSION is 100).
|
|
316
|
-
let ridx = _scopedReverseIdx.get(sessionId);
|
|
317
|
-
if (!ridx) {
|
|
318
|
-
ridx = new Map();
|
|
319
|
-
_scopedReverseIdx.set(sessionId, ridx);
|
|
320
|
-
}
|
|
321
|
-
const _registerAbs = (abs) => {
|
|
322
|
-
if (!abs || typeof abs !== 'string') return;
|
|
323
|
-
let s = ridx.get(abs);
|
|
324
|
-
if (!s) {
|
|
325
|
-
s = new Set();
|
|
326
|
-
ridx.set(abs, s);
|
|
327
|
-
}
|
|
328
|
-
s.add(key);
|
|
329
|
-
};
|
|
330
|
-
for (const dep of depRoots) _registerAbs(dep);
|
|
291
|
+
setBoundedTextCacheEntry(
|
|
292
|
+
map,
|
|
293
|
+
key,
|
|
294
|
+
{ content, ts: Date.now(), firstToolUseId: toolUseId || null, depRoots },
|
|
295
|
+
{ maxEntries: MAX_PER_SESSION }
|
|
296
|
+
);
|
|
331
297
|
}
|
|
332
298
|
|
|
333
299
|
/**
|
|
@@ -338,78 +304,51 @@ export function clearScopedToolsForSession(sessionId) {
|
|
|
338
304
|
if (!sessionId) return;
|
|
339
305
|
mutationGeneration += 1;
|
|
340
306
|
_scopedBySession.delete(sessionId);
|
|
341
|
-
_scopedReverseIdx.delete(sessionId);
|
|
342
307
|
}
|
|
343
308
|
|
|
344
309
|
/**
|
|
345
|
-
* Targeted scoped-cache invalidation: evict only entries whose
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* root-prefix scan for files nested under cached directories/globs. Full wipe
|
|
310
|
+
* Targeted scoped-cache invalidation: evict only entries whose registered
|
|
311
|
+
* dependency roots contain, or are contained by, at least one of the given
|
|
312
|
+
* touched paths (single bounded pass; MAX_PER_SESSION entries). Full wipe
|
|
349
313
|
* when paths cannot be resolved.
|
|
350
314
|
*/
|
|
351
315
|
export function clearScopedToolsForSessionPaths(sessionId, touchedPaths, cwd) {
|
|
352
316
|
if (!sessionId || !Array.isArray(touchedPaths) || touchedPaths.length === 0) return;
|
|
353
317
|
mutationGeneration += 1;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const absPaths = touchedCacheKeys(touchedPaths, cwd);
|
|
357
|
-
if (absPaths.length === 0) {
|
|
358
|
-
// Fallback: can't resolve — full wipe.
|
|
359
|
-
_scopedBySession.delete(sessionId);
|
|
360
|
-
_scopedReverseIdx.delete(sessionId);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
const ridx = _scopedReverseIdx.get(sessionId);
|
|
364
|
-
const evictedKeys = new Set();
|
|
365
|
-
for (const abs of absPaths) evictScopedEntriesForPath(map, ridx, abs, evictedKeys);
|
|
366
|
-
// Remove evicted keys from any other reverse-index sets they appeared in; prune empty Sets.
|
|
367
|
-
if (ridx && evictedKeys.size > 0) {
|
|
368
|
-
for (const [absKey, keySet] of ridx) {
|
|
369
|
-
for (const k of evictedKeys) keySet.delete(k);
|
|
370
|
-
if (keySet.size === 0) ridx.delete(absKey);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
318
|
+
if (!_scopedBySession.has(sessionId)) return;
|
|
319
|
+
clearSessionForAbsPaths(sessionId, touchedCacheKeys(touchedPaths, cwd));
|
|
373
320
|
}
|
|
374
321
|
|
|
375
|
-
//
|
|
376
|
-
// entries are dropped.
|
|
322
|
+
// Normalized, deduplicated absolute cache keys of the touched paths;
|
|
323
|
+
// unresolvable entries are dropped.
|
|
377
324
|
function touchedCacheKeys(touchedPaths, cwd) {
|
|
378
325
|
const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
326
|
+
const out = new Set();
|
|
327
|
+
for (const p of touchedPaths) {
|
|
328
|
+
if (typeof p !== 'string' || p.length === 0) continue;
|
|
329
|
+
try {
|
|
330
|
+
out.add(_normalizeCacheKey(_pathNorm(_pathIsAbs(p) ? p : _pathResolve(base, p))));
|
|
331
|
+
} catch {
|
|
332
|
+
/* unresolvable: dropped */
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
out.delete('');
|
|
336
|
+
return [...out];
|
|
389
337
|
}
|
|
390
338
|
|
|
391
|
-
// Evicts
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
for (const key of keys) {
|
|
399
|
-
if (map.has(key)) {
|
|
400
|
-
map.delete(key);
|
|
401
|
-
evictedKeys.add(key);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
keys.clear();
|
|
405
|
-
ridx.delete(abs);
|
|
339
|
+
// Evicts the session's entries whose dependency roots touch any of the
|
|
340
|
+
// already-normalized `absPaths` in a single pass. Empty `absPaths` means the
|
|
341
|
+
// paths were unresolvable: full wipe.
|
|
342
|
+
function clearSessionForAbsPaths(sessionId, absPaths) {
|
|
343
|
+
if (absPaths.length === 0) {
|
|
344
|
+
_scopedBySession.delete(sessionId);
|
|
345
|
+
return;
|
|
406
346
|
}
|
|
347
|
+
const map = _scopedBySession.get(sessionId);
|
|
348
|
+
if (!map) return;
|
|
407
349
|
for (const [key, entry] of map) {
|
|
408
350
|
const roots = Array.isArray(entry?.depRoots) ? entry.depRoots : [];
|
|
409
|
-
if (roots.some((root) => _pathTouchesRoot(abs, root)))
|
|
410
|
-
map.delete(key);
|
|
411
|
-
evictedKeys.add(key);
|
|
412
|
-
}
|
|
351
|
+
if (roots.some((root) => absPaths.some((abs) => _pathTouchesRoot(abs, root)))) map.delete(key);
|
|
413
352
|
}
|
|
414
353
|
}
|
|
415
354
|
|
|
@@ -418,8 +357,10 @@ function evictScopedEntriesForPath(map, ridx, abs, evictedKeys) {
|
|
|
418
357
|
// that no watcher reports.
|
|
419
358
|
registerCacheInvalidationListener((paths) => {
|
|
420
359
|
mutationGeneration += 1;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
360
|
+
if (paths?.length) {
|
|
361
|
+
const absPaths = touchedCacheKeys(paths);
|
|
362
|
+
for (const sessionId of [..._scopedBySession.keys()]) clearSessionForAbsPaths(sessionId, absPaths);
|
|
363
|
+
} else {
|
|
364
|
+
for (const sessionId of [..._scopedBySession.keys()]) clearScopedToolsForSession(sessionId);
|
|
424
365
|
}
|
|
425
366
|
});
|
|
@@ -133,7 +133,12 @@ function summarySendOpts(opts) {
|
|
|
133
133
|
onSteerMessage: undefined,
|
|
134
134
|
signal: combinedSignal(opts.signal || opts.sendOpts?.signal || null, opts.timeoutMs || HANDOFF_TIMEOUT_MAX_MS),
|
|
135
135
|
};
|
|
136
|
-
|
|
136
|
+
// The `:compact` id isolates provider state (cache, socket pool); the
|
|
137
|
+
// spend still belongs to the source session.
|
|
138
|
+
if (opts.sessionId) {
|
|
139
|
+
sendOpts.sessionId = `${opts.sessionId}:compact`;
|
|
140
|
+
sendOpts.usageSessionId = opts.sessionId;
|
|
141
|
+
}
|
|
137
142
|
if (opts.promptCacheKey || opts.sendOpts?.promptCacheKey) {
|
|
138
143
|
sendOpts.promptCacheKey = `${opts.promptCacheKey || opts.sendOpts.promptCacheKey}:compact`;
|
|
139
144
|
}
|
|
@@ -78,6 +78,8 @@ function summarizeConversation(target, ctx) {
|
|
|
78
78
|
);
|
|
79
79
|
const summarySession = {
|
|
80
80
|
id: `${sessionId || 'unknown'}:compact`,
|
|
81
|
+
// Usage attribution: background sessions stay background.
|
|
82
|
+
sourceType: sessionRef?.sourceType,
|
|
81
83
|
provider: target.providerName,
|
|
82
84
|
model: target.model,
|
|
83
85
|
cwd: sessionRef?.cwd,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* ask for approval before the tool runs.
|
|
4
4
|
*/
|
|
5
5
|
import { resolvePreToolAskApproval } from '../tool-helpers.mjs';
|
|
6
|
-
import { runAbortable } from '../../../../../shared/abort-race.mjs';
|
|
6
|
+
import { runAbortable, throwIfAborted } from '../../../../../shared/abort-race.mjs';
|
|
7
7
|
|
|
8
8
|
const isArgsObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
9
9
|
|
|
@@ -67,8 +67,12 @@ export async function applyBeforeToolHook({
|
|
|
67
67
|
const denied = await askApproval(call, { callerSessionId, executeOpts, toolApprovalHook, decision });
|
|
68
68
|
if (denied) return denied;
|
|
69
69
|
}
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throwIfAborted(executeOpts.signal);
|
|
72
|
+
// A failed policy check fails closed: the tool must not run.
|
|
73
|
+
return {
|
|
74
|
+
denial: `Error: tool "${name}" denied: PreToolUse policy check failed: ${error?.message || String(error)}`,
|
|
75
|
+
};
|
|
72
76
|
}
|
|
73
77
|
return call;
|
|
74
78
|
}
|
|
@@ -46,16 +46,13 @@ const STAT_CACHE = new Map(); // fullPath → { ts, stat }
|
|
|
46
46
|
const STAT_CACHE_TTL_MS = 5_000;
|
|
47
47
|
const STAT_CACHE_MAX_ENTRIES = 2_000;
|
|
48
48
|
const RAW_CONTENT_CACHE = new Map(); // fullPath → { ts, mtimeMs, ctimeMs, size, rawBuf }
|
|
49
|
-
const RAW_CONTENT_INFLIGHT = new Map(); // canonical path → {
|
|
49
|
+
const RAW_CONTENT_INFLIGHT = new Map(); // canonical path → { path, promise }
|
|
50
50
|
const RAW_CONTENT_CACHE_TTL_MS = 30_000;
|
|
51
51
|
// 64: a parallel read batch fanning out over a component directory easily
|
|
52
52
|
// tops 16 files; at 16 the batch thrashed its own cache before re-reads hit.
|
|
53
53
|
// The 64MB byte cap below still bounds memory.
|
|
54
54
|
const RAW_CONTENT_CACHE_MAX_ENTRIES = 64;
|
|
55
|
-
const
|
|
56
|
-
const PATH_MUTATION_GENERATION_MAX_ENTRIES = 4096;
|
|
57
|
-
let PATH_MUTATION_GLOBAL_GENERATION = 0;
|
|
58
|
-
const READ_ONLY_STAT_INFLIGHT = new Map(); // kind + canonical path → { generation, promise }
|
|
55
|
+
const READ_ONLY_STAT_INFLIGHT = new Map(); // kind + canonical path → { path, promise }
|
|
59
56
|
const RAW_CONTENT_CACHE_MAX_BYTES = envByteBudget(
|
|
60
57
|
'MIXDOG_RAW_CONTENT_CACHE_MAX_BYTES',
|
|
61
58
|
'MIXDOG_RAW_CONTENT_CACHE_MAX_MB',
|
|
@@ -74,25 +71,6 @@ function cachePathsOverlap(a, b) {
|
|
|
74
71
|
return a.startsWith(b.endsWith(sep) ? b : `${b}${sep}`) || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
|
|
75
72
|
}
|
|
76
73
|
|
|
77
|
-
function bumpPathMutationGeneration(path) {
|
|
78
|
-
const key = canonicalCachePath(path);
|
|
79
|
-
PATH_MUTATION_GENERATIONS.set(key, (PATH_MUTATION_GENERATIONS.get(key) || 0) + 1);
|
|
80
|
-
while (PATH_MUTATION_GENERATIONS.size > PATH_MUTATION_GENERATION_MAX_ENTRIES) {
|
|
81
|
-
const oldest = PATH_MUTATION_GENERATIONS.keys().next().value;
|
|
82
|
-
if (!oldest) break;
|
|
83
|
-
PATH_MUTATION_GENERATIONS.delete(oldest);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function getPathMutationGeneration(path) {
|
|
88
|
-
const key = canonicalCachePath(path);
|
|
89
|
-
let generation = PATH_MUTATION_GLOBAL_GENERATION;
|
|
90
|
-
for (const [changedPath, value] of PATH_MUTATION_GENERATIONS) {
|
|
91
|
-
if (cachePathsOverlap(key, changedPath)) generation += value;
|
|
92
|
-
}
|
|
93
|
-
return generation;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
74
|
function cacheEntryOverlapsPaths(entry, affectedPaths) {
|
|
97
75
|
const entryPaths = Array.isArray(entry?.paths) ? entry.paths : [];
|
|
98
76
|
const entryScopes = Array.isArray(entry?.scopes) ? entry.scopes : [];
|
|
@@ -237,12 +215,12 @@ export async function runResultCacheInFlight(key, compute, options = {}) {
|
|
|
237
215
|
return subscribeResultCacheInFlight(entry, subscriberSignal);
|
|
238
216
|
}
|
|
239
217
|
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
async function
|
|
218
|
+
// Invalidation detaches only affected in-flight entries. New readers never
|
|
219
|
+
// join a pre-mutation operation, while its existing subscribers still finish.
|
|
220
|
+
// Lookups need no scan of the project's historical mutations on every read.
|
|
221
|
+
async function runPathInFlight(inFlight, key, path, start) {
|
|
244
222
|
const existing = inFlight.get(key);
|
|
245
|
-
if (existing
|
|
223
|
+
if (existing) return await existing.promise;
|
|
246
224
|
const promise = Promise.resolve()
|
|
247
225
|
.then(start)
|
|
248
226
|
.finally(() => {
|
|
@@ -250,23 +228,21 @@ async function runInFlightForGeneration(inFlight, key, generation, start) {
|
|
|
250
228
|
inFlight.delete(key);
|
|
251
229
|
}
|
|
252
230
|
});
|
|
253
|
-
inFlight.set(key, {
|
|
231
|
+
inFlight.set(key, { path, promise });
|
|
254
232
|
return await promise;
|
|
255
233
|
}
|
|
256
234
|
|
|
257
235
|
export async function runRawContentInFlight(fullPath, loader = fsPromises.readFile) {
|
|
258
236
|
const key = canonicalCachePath(fullPath);
|
|
259
|
-
return await
|
|
260
|
-
loader(fullPath)
|
|
261
|
-
);
|
|
237
|
+
return await runPathInFlight(RAW_CONTENT_INFLIGHT, key, key, () => loader(fullPath));
|
|
262
238
|
}
|
|
263
239
|
|
|
264
240
|
export async function runReadOnlyStatInFlight(fullPath, loader = fsPromises.stat, kind = 'stat') {
|
|
265
241
|
const canonicalPath = canonicalCachePath(fullPath);
|
|
266
|
-
return await
|
|
242
|
+
return await runPathInFlight(
|
|
267
243
|
READ_ONLY_STAT_INFLIGHT,
|
|
268
244
|
`${kind}|${canonicalPath}`,
|
|
269
|
-
|
|
245
|
+
canonicalPath,
|
|
270
246
|
() => loader(fullPath)
|
|
271
247
|
);
|
|
272
248
|
}
|
|
@@ -484,8 +460,8 @@ function cacheInvalidateAll() {
|
|
|
484
460
|
STAT_CACHE.clear();
|
|
485
461
|
RAW_CONTENT_CACHE.clear();
|
|
486
462
|
RAW_CONTENT_CACHE_BYTES = 0;
|
|
487
|
-
|
|
488
|
-
|
|
463
|
+
RAW_CONTENT_INFLIGHT.clear();
|
|
464
|
+
READ_ONLY_STAT_INFLIGHT.clear();
|
|
489
465
|
runExtraInvalidationListeners();
|
|
490
466
|
}
|
|
491
467
|
|
|
@@ -495,6 +471,11 @@ function cacheInvalidatePaths(paths) {
|
|
|
495
471
|
cacheInvalidateAll();
|
|
496
472
|
return;
|
|
497
473
|
}
|
|
474
|
+
for (const inFlight of [RAW_CONTENT_INFLIGHT, READ_ONLY_STAT_INFLIGHT]) {
|
|
475
|
+
for (const [key, entry] of inFlight) {
|
|
476
|
+
if (affectedPaths.some((affected) => cachePathsOverlap(entry.path, affected))) inFlight.delete(key);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
498
479
|
for (const [key, entry] of RESULT_CACHE_INFLIGHT) {
|
|
499
480
|
const scopes = Array.isArray(entry.scopes) ? entry.scopes : [];
|
|
500
481
|
if (
|
|
@@ -522,7 +503,6 @@ function cacheInvalidatePaths(paths) {
|
|
|
522
503
|
}
|
|
523
504
|
for (const affected of affectedPaths) {
|
|
524
505
|
deleteReadRangeIndexForPath(affected);
|
|
525
|
-
bumpPathMutationGeneration(affected);
|
|
526
506
|
}
|
|
527
507
|
runExtraInvalidationListeners(affectedPaths);
|
|
528
508
|
}
|
|
@@ -14,11 +14,13 @@ import { isUncOrSmbPath, resolveSearchScope } from '../search-path-diagnostics.m
|
|
|
14
14
|
import { buildGrepRgArgs } from '../search-builders.mjs';
|
|
15
15
|
import { runRgWindowedLines } from '../native-search-runner.mjs';
|
|
16
16
|
import { statReachable } from '../fs-reachability.mjs';
|
|
17
|
+
import { runSharedNativeScan } from './shared-native-scan.mjs';
|
|
17
18
|
import { markScopedCacheIncomplete } from '../../../session/cache/scoped-cache-outcome.mjs';
|
|
18
19
|
import { GREP_CONTEXT_MAX, hasUnsupportedRipgrepRegex } from '../arg-guard.mjs';
|
|
19
20
|
import { resolveSearchWindow } from './search-input-helpers.mjs';
|
|
20
21
|
import { formatGrepFanoutSections, formatGrepOutput, grepNoMatchesBody } from './grep-output.mjs';
|
|
21
22
|
import { expandGrepAnchorContextOutput } from './grep-context-expander.mjs';
|
|
23
|
+
import { resolveGrepScope } from './grep-request.mjs';
|
|
22
24
|
|
|
23
25
|
// Case-insensitive path keys on Windows, where rg echoes the operand path
|
|
24
26
|
// in whatever case the caller wrote it.
|
|
@@ -30,12 +32,11 @@ function pathKey(value) {
|
|
|
30
32
|
// The single-pattern request the combined path[] pass can answer, parsed
|
|
31
33
|
// the way the canonical single-path parsing in search-grep-tool.mjs does
|
|
32
34
|
// (keep in sync); null when the arguments fall outside its narrow
|
|
33
|
-
// eligibility: single pattern, no
|
|
35
|
+
// eligibility: single pattern, no type, default flags, content-ish or
|
|
34
36
|
// files_with_matches mode. MIXDOG_GREP_PATH_COMBINED=0 disables.
|
|
35
37
|
function combinedPathRequest(args, defaultHeadLimit) {
|
|
36
38
|
if (
|
|
37
39
|
process.env.MIXDOG_GREP_PATH_COMBINED === '0' ||
|
|
38
|
-
args.glob ||
|
|
39
40
|
args.type ||
|
|
40
41
|
args.multiline === true ||
|
|
41
42
|
args['-o'] === true ||
|
|
@@ -81,8 +82,7 @@ function combinedPathRequest(args, defaultHeadLimit) {
|
|
|
81
82
|
// order; null when one is a glob, unreachable, or on a share — the legacy
|
|
82
83
|
// fan-out owns those diagnostics.
|
|
83
84
|
async function resolvePathRoots(list, workDir) {
|
|
84
|
-
const roots =
|
|
85
|
-
for (const p of list) {
|
|
85
|
+
const roots = await Promise.all(list.map(async (p) => {
|
|
86
86
|
const cleaned = normalizeInputPath(p);
|
|
87
87
|
if (hasGlobMagic(cleaned)) return null;
|
|
88
88
|
const resolved = resolveSearchScope(cleaned, workDir);
|
|
@@ -93,11 +93,21 @@ async function resolvePathRoots(list, workDir) {
|
|
|
93
93
|
} catch {
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
return {
|
|
97
97
|
arg: p,
|
|
98
98
|
abs: normalizeOutputPath(isAbsolute(resolved) ? await trueCasePath(resolved) : resolved),
|
|
99
99
|
isDir: st.isDirectory(),
|
|
100
|
-
}
|
|
100
|
+
};
|
|
101
|
+
}));
|
|
102
|
+
if (roots.some((root) => !root)) return null;
|
|
103
|
+
// An overlapping operand would scan a file twice and then attribute both
|
|
104
|
+
// copies to each containing section. Keep independent scope semantics.
|
|
105
|
+
for (let i = 0; i < roots.length; i++) {
|
|
106
|
+
const left = pathKey(roots[i].abs).replace(/\/+$/, '');
|
|
107
|
+
for (let j = i + 1; j < roots.length; j++) {
|
|
108
|
+
const right = pathKey(roots[j].abs).replace(/\/+$/, '');
|
|
109
|
+
if (left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)) return null;
|
|
110
|
+
}
|
|
101
111
|
}
|
|
102
112
|
return roots;
|
|
103
113
|
}
|
|
@@ -190,17 +200,23 @@ async function runCombinedPathFanout({ args, list, workDir, options, callContext
|
|
|
190
200
|
const roots = await resolvePathRoots(list, workDir);
|
|
191
201
|
if (!roots) return null;
|
|
192
202
|
const { headLimit, offset } = request;
|
|
193
|
-
const rgArgs = combinedPathRgArgs(args, request, roots, workDir);
|
|
203
|
+
const rgArgs = await combinedPathRgArgs(args, request, roots, workDir);
|
|
204
|
+
if (!rgArgs) return null;
|
|
194
205
|
const perWindow = headLimit === Infinity ? 300 : offset + headLimit + 4;
|
|
195
206
|
const cap = Math.min(4000, Math.max(400, perWindow * roots.length));
|
|
196
207
|
let streamed;
|
|
197
208
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
const runWindowedLines =
|
|
210
|
+
typeof options.__runRgWindowedLines === 'function' ? options.__runRgWindowedLines : runRgWindowedLines;
|
|
211
|
+
streamed = await runSharedNativeScan(runWindowedLines, {
|
|
212
|
+
cwd: workDir,
|
|
213
|
+
argv: rgArgs,
|
|
214
|
+
window: { offset: 0, limit: cap, summaryLimit: 0 },
|
|
215
|
+
scopes: roots.map((root) => root.abs),
|
|
216
|
+
signal: options.signal,
|
|
217
|
+
});
|
|
203
218
|
} catch {
|
|
219
|
+
options.signal?.throwIfAborted();
|
|
204
220
|
return null;
|
|
205
221
|
}
|
|
206
222
|
if (!streamed.complete || streamed.partial) return null;
|
|
@@ -211,26 +227,36 @@ async function runCombinedPathFanout({ args, list, workDir, options, callContext
|
|
|
211
227
|
}
|
|
212
228
|
|
|
213
229
|
// One rg invocation over every root's files at once.
|
|
214
|
-
function combinedPathRgArgs(args, request, roots, workDir) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
async function combinedPathRgArgs(args, request, roots, workDir) {
|
|
231
|
+
const perRoot = await Promise.all(roots.map(async (root) => {
|
|
232
|
+
const scope = await resolveGrepScope({ ...args, path: root.arg }, workDir);
|
|
233
|
+
if (scope.result !== undefined) return null;
|
|
234
|
+
return buildGrepRgArgs({
|
|
235
|
+
patterns: [request.pattern],
|
|
236
|
+
includeNoise: args.include_noise === true,
|
|
237
|
+
text: args.text === true,
|
|
238
|
+
searchPath: isAbsolute(scope.searchPath) ? root.abs : scope.searchPath,
|
|
239
|
+
// Match buildGrepScope: an explicit file operand wins over its glob.
|
|
240
|
+
globPatterns: root.isDir ? scope.normalizedGlobPatterns : [],
|
|
241
|
+
outputMode: request.outMode,
|
|
242
|
+
caseInsensitive: request.caseInsensitive,
|
|
243
|
+
showLineNumbers: true,
|
|
244
|
+
beforeN: null,
|
|
245
|
+
afterN: null,
|
|
246
|
+
contextN: null,
|
|
247
|
+
multilineMode: false,
|
|
248
|
+
fileType: '',
|
|
249
|
+
onlyMatching: false,
|
|
250
|
+
pcre2: false,
|
|
251
|
+
withFilename: true,
|
|
252
|
+
}).slice(0, -1);
|
|
253
|
+
}));
|
|
254
|
+
if (perRoot.some((argv) => !argv)) return null;
|
|
255
|
+
const first = JSON.stringify(perRoot[0]);
|
|
256
|
+
// Native filters are rooted at each operand. Only identical normalized
|
|
257
|
+
// rules (including explicit noise-directory exceptions) may share a scan.
|
|
258
|
+
if (perRoot.some((argv) => JSON.stringify(argv) !== first)) return null;
|
|
259
|
+
return [...perRoot[0], ...roots.map((root) => root.abs)];
|
|
234
260
|
}
|
|
235
261
|
|
|
236
262
|
// The per-root sections in argument order; roots without a hit collapse
|
|
@@ -252,6 +278,7 @@ async function renderCombinedPathSections(request, roots, byRoot, bodyContext) {
|
|
|
252
278
|
// recursion bottoms out after one level. Results retain input order even
|
|
253
279
|
// though every path starts immediately.
|
|
254
280
|
export async function runGrepPathFanout(input) {
|
|
281
|
+
input.options.signal?.throwIfAborted();
|
|
255
282
|
const combined = await runCombinedPathFanout(input);
|
|
256
283
|
if (combined !== null) return combined;
|
|
257
284
|
const { args, list, workDir, executeChildBuiltinTool, readStateScope, options, callContextCharBudget } = input;
|