mixdog 0.9.14 → 0.9.16
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/package.json +1 -1
- package/scripts/bench/cache-probe-tasks.json +1 -1
- package/scripts/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench.mjs +5 -4
- package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
- package/scripts/output-style-smoke.mjs +3 -3
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/termio-input-smoke.mjs +199 -0
- package/scripts/tool-efficiency-diag.mjs +88 -0
- package/scripts/tool-smoke.mjs +40 -24
- package/scripts/tui-frame-harness-shim.mjs +32 -0
- package/scripts/tui-frame-harness.mjs +306 -0
- package/src/agents/heavy-worker/AGENT.md +6 -7
- package/src/agents/worker/AGENT.md +6 -7
- package/src/lib/keychain-cjs.cjs +28 -11
- package/src/lib/rules-builder.cjs +6 -10
- package/src/mixdog-session-runtime.mjs +90 -20
- package/src/output-styles/simple.md +22 -24
- package/src/rules/agent/00-core.md +12 -11
- package/src/rules/agent/30-explorer.md +22 -21
- package/src/rules/lead/01-general.md +8 -8
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-brief.md +11 -12
- package/src/rules/shared/01-tool.md +25 -14
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
- package/src/runtime/channels/backends/telegram.mjs +29 -9
- package/src/runtime/channels/lib/event-queue.mjs +6 -2
- package/src/runtime/channels/lib/memory-client.mjs +66 -1
- package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
- package/src/runtime/memory/index.mjs +95 -8
- package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
- package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
- package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
- package/src/runtime/memory/lib/memory-embed.mjs +48 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
- package/src/runtime/memory/lib/memory.mjs +88 -1
- package/src/runtime/memory/lib/session-ingest.mjs +34 -3
- package/src/runtime/memory/lib/trace-store.mjs +52 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
- package/src/runtime/shared/child-guardian.mjs +4 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/singleton-owner.mjs +26 -0
- package/src/runtime/shared/tool-execution-contract.mjs +25 -0
- package/src/runtime/shared/transcript-writer.mjs +3 -1
- package/src/session-runtime/config-helpers.mjs +7 -2
- package/src/session-runtime/cwd-plugins.mjs +6 -1
- package/src/session-runtime/effort.mjs +6 -1
- package/src/session-runtime/plugin-mcp.mjs +116 -12
- package/src/session-runtime/settings-api.mjs +7 -1
- package/src/standalone/channel-worker.mjs +25 -3
- package/src/standalone/explore-tool.mjs +5 -5
- package/src/standalone/hook-bus/config.mjs +38 -2
- package/src/standalone/hook-bus/handlers.mjs +103 -11
- package/src/standalone/hook-bus.mjs +20 -6
- package/src/standalone/memory-runtime-proxy.mjs +40 -5
- package/src/tui/App.jsx +214 -30
- package/src/tui/app/core-memory-picker.mjs +6 -6
- package/src/tui/app/model-options.mjs +10 -4
- package/src/tui/app/settings-picker.mjs +5 -30
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/text-layout.mjs +57 -0
- package/src/tui/app/transcript-window.mjs +53 -2
- package/src/tui/app/use-mouse-input.mjs +60 -47
- package/src/tui/app/use-transcript-window.mjs +38 -10
- package/src/tui/components/PromptInput.jsx +18 -1
- package/src/tui/components/TextEntryPanel.jsx +97 -33
- package/src/tui/dist/index.mjs +567 -229
- package/src/tui/engine/tool-card-results.mjs +22 -5
- package/src/tui/engine.mjs +126 -7
- package/src/tui/index.jsx +4 -1
- package/src/workflows/default/WORKFLOW.md +27 -31
- package/vendor/ink/build/components/App.js +62 -17
- package/vendor/ink/build/ink.js +78 -3
- package/vendor/ink/build/input-parser.d.ts +9 -4
- package/vendor/ink/build/input-parser.js +45 -176
- package/vendor/ink/build/log-update.js +47 -2
- package/vendor/ink/build/termio-keypress.js +240 -0
- package/vendor/ink/build/termio-tokenize.js +253 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// resolution. Extracted verbatim from manager.mjs (behavior-preserving).
|
|
3
3
|
import { getModelMetadataSync } from '../../providers/model-catalog.mjs';
|
|
4
4
|
import { resolveCompactTriggerTokens } from '../context-utils.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { COMPACT_TYPE_SEMANTIC, COMPACT_TYPE_RECALL_FASTTRACK, CONTEXT_SHARE_RATIO } from '../compact.mjs';
|
|
6
|
+
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
6
7
|
|
|
7
8
|
// Known context windows for the current-generation models this plugin
|
|
8
9
|
// routes to. Anything not listed falls through to guessContextWindow() —
|
|
@@ -88,9 +89,8 @@ export function preserveBufferConfigFields(cfg = {}) {
|
|
|
88
89
|
}
|
|
89
90
|
return out;
|
|
90
91
|
}
|
|
91
|
-
const COMPACT_TARGET_RATIO =
|
|
92
|
+
const COMPACT_TARGET_RATIO = CONTEXT_SHARE_RATIO;
|
|
92
93
|
const COMPACT_TARGET_MIN_TOKENS = 4_000;
|
|
93
|
-
const COMPACT_TARGET_MAX_TOKENS = 16_000;
|
|
94
94
|
function compactTargetRatio() {
|
|
95
95
|
const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
|
|
96
96
|
?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
|
|
@@ -108,9 +108,8 @@ function compactTargetTokensForBoundary(boundaryTokens) {
|
|
|
108
108
|
);
|
|
109
109
|
if (explicit) return Math.max(1, Math.min(boundary, explicit));
|
|
110
110
|
const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
|
|
111
|
-
const maxTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MAX_TOKENS) || COMPACT_TARGET_MAX_TOKENS);
|
|
112
111
|
const byRatio = Math.max(1, Math.floor(boundary * compactTargetRatio()));
|
|
113
|
-
return Math.max(1, Math.min(boundary,
|
|
112
|
+
return Math.max(1, Math.min(boundary, Math.max(minTarget, byRatio)));
|
|
114
113
|
}
|
|
115
114
|
function defaultEffectiveContextWindowPercent(provider) {
|
|
116
115
|
// Gateway/statusline route metadata reserves a universal 10% headroom from
|
|
@@ -211,20 +210,16 @@ export function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens
|
|
|
211
210
|
const targetEffective = compactTargetTokensForBoundary(boundary) || boundary;
|
|
212
211
|
return Math.max(1, Math.min(boundary, targetEffective + reserve));
|
|
213
212
|
}
|
|
214
|
-
export function semanticCompactionEnabledForSession(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
|
|
213
|
+
export function semanticCompactionEnabledForSession(_session) {
|
|
214
|
+
// Compact types are hard-locked (agent=semantic, main=recall-fasttrack),
|
|
215
|
+
// so semantic must always be available: it is the agent path AND the
|
|
216
|
+
// degraded fallback when recall-fasttrack fails. Env/config off-switches
|
|
217
|
+
// no longer apply.
|
|
220
218
|
return true;
|
|
221
219
|
}
|
|
222
220
|
export function compactTypeForSession(session) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
?? cfg.compactType
|
|
228
|
-
?? cfg.compact_type;
|
|
229
|
-
return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
|
|
221
|
+
// Hard-locked: agent-owned sessions are always semantic, all other
|
|
222
|
+
// (main/user) sessions are always recall-fasttrack. Env/config overrides
|
|
223
|
+
// no longer change the type.
|
|
224
|
+
return isAgentOwner(session) ? COMPACT_TYPE_SEMANTIC : COMPACT_TYPE_RECALL_FASTTRACK;
|
|
230
225
|
}
|
|
@@ -316,7 +316,11 @@ export function drainPendingMessages(sessionId) {
|
|
|
316
316
|
const q = _sessionPendingMessages.get(sessionId);
|
|
317
317
|
const memory = q && q.length > 0 ? q.slice() : [];
|
|
318
318
|
_sessionPendingMessages.delete(sessionId);
|
|
319
|
-
|
|
319
|
+
// FIFO: disk-persisted entries were flushed before the not-yet-flushed
|
|
320
|
+
// in-memory persist buffer, so they are strictly older — drain them
|
|
321
|
+
// first. Reversing this order (buffer before disk) delivered newer
|
|
322
|
+
// buffered sends ahead of older persisted ones after a restart.
|
|
323
|
+
const persisted = [...drainPersistedPendingMessages(sessionId), ...takeBufferedPendingMessages(sessionId)];
|
|
320
324
|
const memoryVisible = modelVisiblePendingMessages(memory);
|
|
321
325
|
const persistedVisible = modelVisiblePendingMessages(persisted);
|
|
322
326
|
if (memoryVisible.length === 0) return persistedVisible;
|
|
@@ -87,6 +87,10 @@ function stringToolPermissionAllowList(toolPermission) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Read-write agent bundle: edit surface (apply_patch) WITHOUT shell/task.
|
|
91
|
+
// Repo-local shell work (git/build/test/verification) is Lead-owned by
|
|
92
|
+
// workflow contract; write-role agents patch files and hand verification
|
|
93
|
+
// back. Only permission 'full' (no narrowing) retains shell.
|
|
90
94
|
const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
91
95
|
'code_graph',
|
|
92
96
|
'find',
|
|
@@ -95,7 +99,6 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
|
95
99
|
'grep',
|
|
96
100
|
'read',
|
|
97
101
|
'apply_patch',
|
|
98
|
-
'shell',
|
|
99
102
|
'explore',
|
|
100
103
|
'search',
|
|
101
104
|
'web_fetch',
|
|
@@ -645,8 +645,12 @@ export function createSession(opts) {
|
|
|
645
645
|
const agentRulesAgent = opts.agent || opts.role || profile?.taskType || null;
|
|
646
646
|
const agentRulesProfile = isRetrievalAgent ? 'retrieval' : 'full';
|
|
647
647
|
const skipAgentRules = opts.skipAgentRules === true;
|
|
648
|
-
//
|
|
649
|
-
|
|
648
|
+
// BP1 shared tool policy ships to EVERY role (Lead, workers, retrieval,
|
|
649
|
+
// maintenance): its anti-spiral clauses (one anchor is enough, never
|
|
650
|
+
// repeat equivalent patterns/scopes, plausible hit → stop) are exactly
|
|
651
|
+
// what narrow retrieval roles need. Role docs (e.g. 30-explorer.md)
|
|
652
|
+
// override role-inapplicable entries such as the explore routing row.
|
|
653
|
+
const injectedRules = skipAgentRules ? '' : _buildSharedRules();
|
|
650
654
|
const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
|
|
651
655
|
const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
|
|
652
656
|
const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildAgentSpecific(agentRulesAgent) : '';
|
|
@@ -1918,6 +1922,59 @@ export async function clearSessionMessages(sessionId, options = {}) {
|
|
|
1918
1922
|
const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
1919
1923
|
const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
|
|
1920
1924
|
const now = Date.now();
|
|
1925
|
+
// --- Fork the outgoing transcript to a separate resumable session BEFORE
|
|
1926
|
+
// the wipe below. Runs for every clear path (plain /clear, auto-clear,
|
|
1927
|
+
// compact_clear) using the ORIGINAL `messages` (post-compact-gating, i.e.
|
|
1928
|
+
// whatever survived the requireCompactSuccess throw above), so the
|
|
1929
|
+
// conversation about to be discarded stays reachable via /resume under a
|
|
1930
|
+
// fresh id. Skipped for scratch sessions with no real user turn — nothing
|
|
1931
|
+
// worth resuming. Best-effort: any failure here must never block the
|
|
1932
|
+
// clear itself (mirrors the pre-compact failure handling above).
|
|
1933
|
+
if (hasUserConversationMessage(messages)) {
|
|
1934
|
+
try {
|
|
1935
|
+
const forkId = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
|
|
1936
|
+
const fork = {
|
|
1937
|
+
...session,
|
|
1938
|
+
id: forkId,
|
|
1939
|
+
messages: messages.map((m) => (m && typeof m === 'object' ? { ...m } : m)),
|
|
1940
|
+
closed: false,
|
|
1941
|
+
status: 'idle',
|
|
1942
|
+
generation: 0,
|
|
1943
|
+
createdAt: now,
|
|
1944
|
+
updatedAt: now,
|
|
1945
|
+
lastUsedAt: now,
|
|
1946
|
+
lastHeartbeatAt: null,
|
|
1947
|
+
mcpPid: process.pid,
|
|
1948
|
+
// Strip runtime/liveness/routing state — the fork is a cold
|
|
1949
|
+
// snapshot, not a live process-owned session.
|
|
1950
|
+
clientHostPid: null,
|
|
1951
|
+
providerState: undefined,
|
|
1952
|
+
totalInputTokens: 0,
|
|
1953
|
+
totalOutputTokens: 0,
|
|
1954
|
+
totalCachedReadTokens: 0,
|
|
1955
|
+
totalCacheWriteTokens: 0,
|
|
1956
|
+
lastInputTokens: 0,
|
|
1957
|
+
lastOutputTokens: 0,
|
|
1958
|
+
lastCachedReadTokens: 0,
|
|
1959
|
+
lastCacheWriteTokens: 0,
|
|
1960
|
+
lastContextTokens: 0,
|
|
1961
|
+
lastContextTokensUpdatedAt: now,
|
|
1962
|
+
lastContextTokensStaleAfterCompact: false,
|
|
1963
|
+
// Shell state must not alias the live session: resuming the
|
|
1964
|
+
// fork would otherwise reuse/close the original session's
|
|
1965
|
+
// persistent bash shells.
|
|
1966
|
+
implicitBashSessionId: null,
|
|
1967
|
+
allBashSessionIds: undefined,
|
|
1968
|
+
};
|
|
1969
|
+
delete fork.liveTurnMessages;
|
|
1970
|
+
setLiveSession(fork);
|
|
1971
|
+
void saveSessionAsync(fork).catch((err) => {
|
|
1972
|
+
try { process.stderr.write(`[session] clear-fork save failed (sess=${forkId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
|
|
1973
|
+
});
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
try { process.stderr.write(`[session] clear-fork failed (sess=${sessionId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1921
1978
|
session.messages = keep;
|
|
1922
1979
|
session.sessionStartMetaInjected = false;
|
|
1923
1980
|
session.totalInputTokens = 0;
|
|
@@ -187,3 +187,21 @@ export function _removeSessionSummary(id) {
|
|
|
187
187
|
}, { compact: true, lock: true });
|
|
188
188
|
} catch { /* summary index is best-effort */ }
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Batch removal: prune MANY ids from the summary index in a single
|
|
193
|
+
* read-modify-write. Bulk deleters (tombstone sweep) must use this instead
|
|
194
|
+
* of per-id _removeSessionSummary — the index is O(sessions) in size, so
|
|
195
|
+
* per-id rewrites make a large sweep quadratic in total I/O.
|
|
196
|
+
*/
|
|
197
|
+
export function _pruneSummaryIndexIds(ids) {
|
|
198
|
+
if (!(ids instanceof Set) || ids.size === 0) return;
|
|
199
|
+
try {
|
|
200
|
+
updateJsonAtomicSync(summaryIndexPath(), (cur) => {
|
|
201
|
+
const index = _normalizeSummaryIndex(cur);
|
|
202
|
+
const rows = index.rows.filter((r) => !ids.has(r.id));
|
|
203
|
+
if (rows.length === index.rows.length) return undefined;
|
|
204
|
+
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
|
|
205
|
+
}, { compact: true, lock: true });
|
|
206
|
+
} catch { /* summary index is best-effort */ }
|
|
207
|
+
}
|
|
@@ -12,6 +12,7 @@ import { getPluginData } from '../config.mjs';
|
|
|
12
12
|
import { isAgentOwner } from '../agent-owner.mjs';
|
|
13
13
|
import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
|
|
14
14
|
import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
|
|
15
|
+
import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
|
|
15
16
|
import {
|
|
16
17
|
summaryIndexPath,
|
|
17
18
|
_sessionSummary,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
_writeSummaryIndex,
|
|
20
21
|
_upsertSessionSummary,
|
|
21
22
|
_removeSessionSummary,
|
|
23
|
+
_pruneSummaryIndexIds,
|
|
22
24
|
} from './store-summary-index.mjs';
|
|
23
25
|
// Facade re-export: summary-index API moved to store-summary-index.mjs; keep
|
|
24
26
|
// prior importers of store.mjs unchanged.
|
|
@@ -358,28 +360,21 @@ function _shouldDrop(id, opts) {
|
|
|
358
360
|
if (!existsSync(target)) return false;
|
|
359
361
|
try {
|
|
360
362
|
const raw = readFileSync(target, 'utf-8');
|
|
361
|
-
// Tombstone check only needs `closed
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const genMatch = /"generation"\s*:\s*(-?\d+)/.exec(raw);
|
|
377
|
-
const diskGen = genMatch ? Number(genMatch[1]) : 0;
|
|
378
|
-
return diskGen > expected;
|
|
379
|
-
}
|
|
380
|
-
// No `closed` key found by scan — not a tombstone, so nothing to drop.
|
|
381
|
-
if (!closedMatch && !/"closed"/.test(raw)) return false;
|
|
382
|
-
const onDisk = JSON.parse(raw);
|
|
363
|
+
// Tombstone check only needs top-level `closed`/`generation`. A plain
|
|
364
|
+
// substring/regex pre-check is unsafe on its own: a message body can
|
|
365
|
+
// contain literal text like `{"closed":true}` (tool result, pasted
|
|
366
|
+
// JSON) which would spoof the guard if trusted directly. Instead of
|
|
367
|
+
// JSON.parse'ing the whole document on every guarded save (expensive:
|
|
368
|
+
// allocates the entire messages array just to read two scalars),
|
|
369
|
+
// scanTopLevelLifecycle walks the raw text with bracket-depth +
|
|
370
|
+
// string-escape awareness and only *interprets* key/value pairs at
|
|
371
|
+
// depth 1 — nested "closed"/"generation" inside messages are skipped
|
|
372
|
+
// by depth counting, never parsed, so they cannot be mistaken for the
|
|
373
|
+
// real top-level fields. Falls back to a full parse only if the scan
|
|
374
|
+
// reports malformed/truncated JSON (should not happen for files we
|
|
375
|
+
// wrote ourselves, but stay correct over clever).
|
|
376
|
+
let onDisk = scanTopLevelLifecycle(raw);
|
|
377
|
+
if (onDisk === null) onDisk = JSON.parse(raw);
|
|
383
378
|
const diskGen = typeof onDisk.generation === 'number' ? onDisk.generation : 0;
|
|
384
379
|
if (onDisk.closed === true) return diskGen >= expected;
|
|
385
380
|
// Not closed, but `generation` also doubles as an ownership counter:
|
|
@@ -590,7 +585,7 @@ export function clearSessionSaveError(id) {
|
|
|
590
585
|
_lastSaveError.delete(id);
|
|
591
586
|
}
|
|
592
587
|
|
|
593
|
-
export function deleteSession(id) {
|
|
588
|
+
export function deleteSession(id, options = {}) {
|
|
594
589
|
const path = sessionPath(id);
|
|
595
590
|
let removed = false;
|
|
596
591
|
if (existsSync(path)) {
|
|
@@ -601,7 +596,10 @@ export function deleteSession(id) {
|
|
|
601
596
|
catch { /* fall through to .hb cleanup */ }
|
|
602
597
|
}
|
|
603
598
|
_deleteHeartbeat(id);
|
|
604
|
-
|
|
599
|
+
// deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
|
|
600
|
+
// rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
|
|
601
|
+
// summary index once PER DELETION. They batch the index update themselves.
|
|
602
|
+
if (removed && options.deferSummaryUpdate !== true) _removeSessionSummary(id);
|
|
605
603
|
return removed;
|
|
606
604
|
}
|
|
607
605
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
@@ -725,7 +723,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
725
723
|
const age = now - updated;
|
|
726
724
|
if (Number.isFinite(updated) && age >= tombstoneMaxAgeMs) {
|
|
727
725
|
try {
|
|
728
|
-
if (deleteSession(row.id)) {
|
|
726
|
+
if (deleteSession(row.id, { deferSummaryUpdate: true })) {
|
|
729
727
|
tombstonesCleaned++;
|
|
730
728
|
tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
|
|
731
729
|
continue;
|
|
@@ -799,5 +797,15 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
799
797
|
}
|
|
800
798
|
}
|
|
801
799
|
} catch { /* dir scan failure — non-fatal */ }
|
|
800
|
+
// Batched summary-index prune for deferred tombstone deletions: one
|
|
801
|
+
// read-modify-write for the whole sweep instead of one per deleted id
|
|
802
|
+
// (the index is multi-MB at scale; per-id rewrites made large sweeps
|
|
803
|
+
// quadratic and stalled boot for seconds).
|
|
804
|
+
if (tombstoneDetails.length > 0) {
|
|
805
|
+
try {
|
|
806
|
+
const deletedIds = new Set(tombstoneDetails.map((d) => d.id));
|
|
807
|
+
_pruneSummaryIndexIds(deletedIds);
|
|
808
|
+
} catch { /* summary index is best-effort */ }
|
|
809
|
+
}
|
|
802
810
|
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors };
|
|
803
811
|
}
|
|
@@ -87,7 +87,7 @@ export const BUILTIN_TOOLS = [
|
|
|
87
87
|
name: 'grep',
|
|
88
88
|
title: 'Mixdog Grep',
|
|
89
89
|
annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
90
|
-
description: 'Search file contents by text/regex in a known scope. files_with_matches/count for broad anchors, content_with_context for narrow answers. One concept →
|
|
90
|
+
description: 'Search file contents by text/regex in a known scope. files_with_matches/count for broad anchors, content_with_context for narrow answers. One concept → ONE grep: all variants in pattern[], scopes in path[]; parallel single-pattern greps = packing failure.',
|
|
91
91
|
inputSchema: {
|
|
92
92
|
type: 'object',
|
|
93
93
|
properties: {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
normalizeOutputPath,
|
|
9
9
|
resolveAgainstCwd,
|
|
10
10
|
} from './path-utils.mjs';
|
|
11
|
-
import { tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
|
|
11
|
+
import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
|
|
12
12
|
import { normalizeErrorMessage } from './path-diagnostics.mjs';
|
|
13
13
|
import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component } from './device-paths.mjs';
|
|
14
14
|
import {
|
|
@@ -49,7 +49,9 @@ async function readFamilyPathEnoentOrError(workDir, fullPath, inputPath, args, o
|
|
|
49
49
|
rerun: (target, opts) => rerunTool({ ...args, path: target }, workDir, opts),
|
|
50
50
|
});
|
|
51
51
|
if (redirected) return redirected;
|
|
52
|
-
|
|
52
|
+
const msg = `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
|
|
53
|
+
const hint = buildNotFoundHint(workDir, fullPath, 'List', err?.code);
|
|
54
|
+
return msg + finalizeReadFamilyEnoentTail(hint, inputPath, err?.code);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function normalizeListHeadLimit(raw, defaultCap) {
|
|
@@ -344,8 +346,8 @@ export async function executeTreeTool(args, workDir, options = {}) {
|
|
|
344
346
|
export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
345
347
|
if (Array.isArray(args.query)) {
|
|
346
348
|
const list = [...new Set(args.query.map((q) => (typeof q === 'string' ? q.trim() : '')).filter((q) => q.length > 0))];
|
|
347
|
-
const capped = list.length >
|
|
348
|
-
const targets = capped ? list.slice(0,
|
|
349
|
+
const capped = list.length > 5;
|
|
350
|
+
const targets = capped ? list.slice(0, 5) : list;
|
|
349
351
|
if (targets.length > 1) {
|
|
350
352
|
const sections = [];
|
|
351
353
|
for (const q of targets) {
|
|
@@ -357,7 +359,7 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
357
359
|
}
|
|
358
360
|
sections.push(`# find ${q}\n${body}`);
|
|
359
361
|
}
|
|
360
|
-
if (capped) sections.push(`... [capped at
|
|
362
|
+
if (capped) sections.push(`... [capped at 5 of ${list.length} queries]`);
|
|
361
363
|
return sections.join('\n\n');
|
|
362
364
|
}
|
|
363
365
|
args.query = targets[0];
|
|
@@ -111,7 +111,8 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
|
|
|
111
111
|
const rel = relative(searchRoot, candidate);
|
|
112
112
|
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
|
|
113
113
|
try {
|
|
114
|
-
|
|
114
|
+
const st = statSync(candidate);
|
|
115
|
+
if (st.isFile() || st.isDirectory()) {
|
|
115
116
|
return rel.replace(/\\/g, '/');
|
|
116
117
|
}
|
|
117
118
|
} catch { /* miss this tail length, keep peeling */ }
|
|
@@ -120,6 +121,42 @@ export function findBySuffixStrip(searchRoot, fullPath, { maxIterations = 12 } =
|
|
|
120
121
|
} catch { return null; }
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// Same as findFileByBasename but for directories sharing the missing path's
|
|
125
|
+
// final segment name (e.g. guessed `src/shared` → the real `lib/shared`).
|
|
126
|
+
export function findDirectoryByBasename(searchRoot, fullPath, { limit = 3, maxDirs = 6000 } = {}) {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof searchRoot !== 'string' || !searchRoot) return [];
|
|
129
|
+
const target = basename(fullPath).toLowerCase();
|
|
130
|
+
if (!target) return [];
|
|
131
|
+
const matches = [];
|
|
132
|
+
const queue = [searchRoot];
|
|
133
|
+
let scanned = 0;
|
|
134
|
+
while (queue.length && matches.length < limit && scanned < maxDirs) {
|
|
135
|
+
const dir = queue.shift();
|
|
136
|
+
scanned++;
|
|
137
|
+
let entries;
|
|
138
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
139
|
+
catch { continue; }
|
|
140
|
+
for (const ent of entries) {
|
|
141
|
+
const name = ent.name;
|
|
142
|
+
if (ent.isDirectory()) {
|
|
143
|
+
if (name.toLowerCase() === target) {
|
|
144
|
+
const hit = join(dir, name);
|
|
145
|
+
const rel = relative(searchRoot, hit);
|
|
146
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
147
|
+
matches.push(rel.replace(/\\/g, '/'));
|
|
148
|
+
if (matches.length >= limit) break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (name.startsWith('.') || BASENAME_SCAN_SKIP_DIRS.has(name)) continue;
|
|
152
|
+
queue.push(join(dir, name));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return matches;
|
|
157
|
+
} catch { return []; }
|
|
158
|
+
}
|
|
159
|
+
|
|
123
160
|
// Node's native fs errors embed the failing path wrapped in single quotes
|
|
124
161
|
// using OS-native separators ('C:\\Users\\foo\\bar.mjs' on Windows). Without
|
|
125
162
|
// this pass, read error bodies surface backslash paths that
|
|
@@ -243,6 +243,30 @@ export function coerceShapeFlex(value) {
|
|
|
243
243
|
// left untouched — JSON reinterpretation only runs after a stat miss.
|
|
244
244
|
export function coerceReadFamilyPathArg(path, workDir = null) {
|
|
245
245
|
if (path === undefined || path === null || path === '') return path;
|
|
246
|
+
if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
|
|
247
|
+
const trimmed = path.trim();
|
|
248
|
+
if (/\s/.test(trimmed)) {
|
|
249
|
+
let fullPathExists = false;
|
|
250
|
+
try {
|
|
251
|
+
statSync(resolveAgainstCwd(normalizeInputPath(trimmed), workDir));
|
|
252
|
+
fullPathExists = true;
|
|
253
|
+
} catch { /* split only when the literal path is missing */ }
|
|
254
|
+
if (!fullPathExists) {
|
|
255
|
+
const segments = trimmed.split(/\s+/).filter(Boolean).map((s) => normalizeInputPath(s));
|
|
256
|
+
if (segments.length >= 2) {
|
|
257
|
+
const allExist = segments.every((seg) => {
|
|
258
|
+
try {
|
|
259
|
+
statSync(resolveAgainstCwd(seg, workDir));
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
if (allExist) return segments;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
246
270
|
if (typeof path === 'string' && typeof workDir === 'string' && workDir) {
|
|
247
271
|
const trimmed = path.trim();
|
|
248
272
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
@@ -3,7 +3,7 @@ import * as fsPromises from 'fs/promises';
|
|
|
3
3
|
import { readFile } from 'fs/promises';
|
|
4
4
|
import { extname } from 'path';
|
|
5
5
|
import { normalizeInputPath } from './path-utils.mjs';
|
|
6
|
-
import { buildNotFoundHint, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
|
|
6
|
+
import { buildNotFoundHint, finalizeReadFamilyEnoentTail, tryReadFamilyEnoentRedirect } from './search-path-diagnostics.mjs';
|
|
7
7
|
import { getReadSnapshot } from './read-snapshot-runtime.mjs';
|
|
8
8
|
import { snapshotCoversFullFile, statMatchesSnapshot } from './snapshot-helpers.mjs';
|
|
9
9
|
import { formatBinaryReadPreview } from './binary-file.mjs';
|
|
@@ -222,6 +222,7 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
222
222
|
if (!similar) {
|
|
223
223
|
hint = buildNotFoundHint(workDir, fullPath, 'Read', err?.code);
|
|
224
224
|
}
|
|
225
|
+
hint = finalizeReadFamilyEnoentTail(hint, filePath, err?.code);
|
|
225
226
|
const _rawMsg = err instanceof Error ? err.message : String(err);
|
|
226
227
|
const _safeMsg = normalizeErrorMessage(_rawMsg, workDir);
|
|
227
228
|
return `Error: ${_safeMsg}${hint}`;
|
|
@@ -161,6 +161,14 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
161
161
|
// Single turn can touch many files or swap modes without
|
|
162
162
|
// the agent iterating across multiple tool names.
|
|
163
163
|
if (Array.isArray(args.path) && args.path.length > 0 && args.path[0] && typeof args.path[0] === 'object') {
|
|
164
|
+
// Cap batch fan-out: never error on an oversized array, just
|
|
165
|
+
// truncate and note it in the final output (mirrors list-tool.mjs
|
|
166
|
+
// path[] cap pattern).
|
|
167
|
+
if (args.path.length > 10) {
|
|
168
|
+
const _origObjLen = args.path.length;
|
|
169
|
+
args.path = args.path.slice(0, 10);
|
|
170
|
+
args._batchCapNote = `... [capped at 10 of ${_origObjLen} paths]`;
|
|
171
|
+
}
|
|
164
172
|
// Per-file batch: each entry carries its own options.
|
|
165
173
|
// Coalesce same-path entries: multiple chunks for the same
|
|
166
174
|
// file are merged into a single wider read (min offset to max
|
|
@@ -216,6 +224,11 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
216
224
|
args.mode = undefined; args.n = undefined; args.offset = undefined; args.limit = undefined; args.full = undefined;
|
|
217
225
|
}
|
|
218
226
|
if (Array.isArray(args.path)) {
|
|
227
|
+
if (args.path.length > 10) {
|
|
228
|
+
const _origStrLen = args.path.length;
|
|
229
|
+
args.path = args.path.slice(0, 10);
|
|
230
|
+
if (!args._batchCapNote) args._batchCapNote = `... [capped at 10 of ${_origStrLen} paths]`;
|
|
231
|
+
}
|
|
219
232
|
// Schema is `path: string | string[]` — array entries are
|
|
220
233
|
// strings only. Top-level mode / n / offset / limit / full
|
|
221
234
|
// apply uniformly to every entry in the batch (the only
|
|
@@ -398,7 +411,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
|
|
|
398
411
|
const suffix = match ? ` (truncated ${match[1]}L/${match[2]}KB)` : '';
|
|
399
412
|
return `${path} [${mode}] [${status}]${suffix}\n${r.body}`;
|
|
400
413
|
}).join('\n\n');
|
|
401
|
-
return `${header}\n\n${body}`;
|
|
414
|
+
return `${header}\n\n${body}${args._batchCapNote ? `\n\n${args._batchCapNote}` : ''}`;
|
|
402
415
|
}
|
|
403
416
|
// W1 H: device-file / UNC / scope guards must run BEFORE mode
|
|
404
417
|
// dispatches so head/tail/wc internal readers can't bypass the
|
|
@@ -468,6 +468,8 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
|
|
|
468
468
|
let seenAfterOffset = 0;
|
|
469
469
|
let stoppedEarly = false;
|
|
470
470
|
let timedOut = false;
|
|
471
|
+
let capExceeded = false;
|
|
472
|
+
let bufferBytes = 0;
|
|
471
473
|
/** @type {'timeout' | 'early' | null} */
|
|
472
474
|
let killReason = null;
|
|
473
475
|
let settled = false;
|
|
@@ -527,6 +529,14 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
|
|
|
527
529
|
}
|
|
528
530
|
seenAfterOffset++;
|
|
529
531
|
if (seenAfterOffset <= collectLimit) {
|
|
532
|
+
bufferBytes += Buffer.byteLength(line);
|
|
533
|
+
if (bufferBytes > MAX_RG_STDOUT_BYTES) {
|
|
534
|
+
// Enforce the same 20MB ceiling runRg uses: a runaway
|
|
535
|
+
// producer must not balloon the retained-lines heap.
|
|
536
|
+
capExceeded = true;
|
|
537
|
+
stopEarly();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
530
540
|
lines.push(line);
|
|
531
541
|
return;
|
|
532
542
|
}
|
|
@@ -537,6 +547,13 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
|
|
|
537
547
|
proc.stdout.on('data', (chunk) => {
|
|
538
548
|
if (stoppedEarly) return;
|
|
539
549
|
buffer += chunk;
|
|
550
|
+
// Guard against an unbounded single line (no newline): cap the
|
|
551
|
+
// pending buffer at the same 20MB ceiling and stop early.
|
|
552
|
+
if (buffer.length > MAX_RG_STDOUT_BYTES) {
|
|
553
|
+
capExceeded = true;
|
|
554
|
+
stopEarly();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
540
557
|
let start = 0;
|
|
541
558
|
let idx = buffer.indexOf('\n', start);
|
|
542
559
|
while (!stoppedEarly && idx !== -1) {
|
|
@@ -579,7 +596,14 @@ function spawnRgWindowedLines(argsList, execOptions, opts = {}) {
|
|
|
579
596
|
return reject(e);
|
|
580
597
|
}
|
|
581
598
|
if (stoppedEarly) {
|
|
582
|
-
return resolve({
|
|
599
|
+
return resolve({
|
|
600
|
+
lines,
|
|
601
|
+
complete: false,
|
|
602
|
+
totalSeen: seenAfterOffset,
|
|
603
|
+
// Surface the 20MB byte-cap breach so the caller can flag
|
|
604
|
+
// truncation, mirroring runRg's stdoutTruncated.
|
|
605
|
+
...(capExceeded ? { partial: true, truncated: true } : {}),
|
|
606
|
+
});
|
|
583
607
|
}
|
|
584
608
|
if (code === 0 || code === 1) {
|
|
585
609
|
return resolve({ lines, complete: true, totalSeen: seenAfterOffset });
|