mixdog 0.9.147 → 0.9.149
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 +23 -3
- package/scripts/build-voice-runtime-unix.sh +101 -0
- package/scripts/embedding-quality-probe.mjs +96 -0
- package/scripts/generate-voice-runtime-manifest.mjs +300 -0
- package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
- package/scripts/native-binary-arch.mjs +30 -13
- package/scripts/native-binary-arch.test.mjs +40 -4
- package/scripts/plan-voice-runtime-release.mjs +73 -0
- package/scripts/prepare-native-assets.mjs +50 -9
- package/scripts/prune-embedding-runtime.mjs +3 -3
- package/scripts/sync-voice-runtime-manifest.mjs +46 -0
- package/scripts/verify-release-assets.mjs +106 -6
- package/scripts/verify-voice-runtime-release.mjs +89 -0
- package/scripts/voice-runtime-config.json +52 -0
- package/src/app.mjs +42 -5
- package/src/headless-exec.mjs +58 -32
- package/src/headless-exec.test.mjs +74 -13
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/10-tool-workflow.md +7 -4
- package/src/rules/shared/30-exploration.md +5 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
- package/src/runtime/agent/orchestrator/config.mjs +53 -18
- package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
- package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
- package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
- package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
- package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
- package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
- package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
- package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
- package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
- package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/attachments/store.mjs +11 -3
- package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
- package/src/runtime/channels/lib/event-queue.mjs +51 -41
- package/src/runtime/channels/lib/scheduler.mjs +23 -28
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
- package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
- package/src/runtime/channels/lib/webhook.mjs +39 -20
- package/src/runtime/channels/lib/whisper-server.mjs +46 -1
- package/src/runtime/channels/lib/worker-main.mjs +3 -1
- package/src/runtime/media/adapters/gemini-video.mjs +20 -4
- package/src/runtime/media/jobs.mjs +13 -0
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/media/store.mjs +42 -22
- package/src/runtime/memory/index.mjs +4 -9
- package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
- package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
- package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
- package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
- package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
- package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
- package/src/runtime/memory/lib/ko-morph.mjs +46 -15
- package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
- package/src/runtime/memory/lib/memory.mjs +50 -12
- package/src/runtime/memory/lib/pg/process.mjs +1 -10
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
- package/src/runtime/memory/lib/query-handlers.mjs +2 -4
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
- package/src/runtime/shared/background-tasks.mjs +15 -0
- package/src/runtime/shared/background-tasks.test.mjs +78 -0
- package/src/runtime/shared/child-guardian.mjs +2 -10
- package/src/runtime/shared/pid-liveness.mjs +27 -0
- package/src/runtime/shared/pristine-execution-contract.json +0 -1
- package/src/runtime/shared/runtime-root.mjs +1 -11
- package/src/runtime/shared/service-discovery.mjs +1 -17
- package/src/runtime/shared/singleton-owner.mjs +1 -11
- package/src/runtime/shared/staged-update.mjs +2 -12
- package/src/runtime/shared/tool-status.mjs +5 -1
- package/src/runtime/shared/tool-surface.mjs +15 -7
- package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
- package/src/runtime/shared/turn-snapshot.mjs +137 -13
- package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
- package/src/runtime/shared/webhook-session-run.mjs +16 -2
- package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
- package/src/runtime/web-search/lib/state.mjs +15 -15
- package/src/runtime/web-search/lib/web-tools.mjs +46 -5
- package/src/session-runtime/context-status.mjs +4 -12
- package/src/session-runtime/runtime-core.mjs +20 -1
- package/src/session-runtime/session-lifecycle.mjs +9 -0
- package/src/standalone/agent-dispatch-broker.mjs +33 -2
- package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
- package/src/standalone/agent-tool/notify.mjs +3 -1
- package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
- package/src/standalone/agent-tool/tag-registry.mjs +116 -33
- package/src/standalone/agent-tool/tool-def.mjs +2 -2
- package/src/standalone/agent-tool/worker-index.mjs +8 -11
- package/src/standalone/agent-tool/worker-rows.mjs +4 -1
- package/src/standalone/agent-tool.mjs +20 -55
- package/src/standalone/agent-watchdog-registry.mjs +28 -4
- package/src/standalone/channel-client.mjs +47 -15
- package/src/standalone/channel-transport.mjs +44 -25
- package/src/standalone/daemon.mjs +31 -2
- package/src/standalone/hook-bus/config.mjs +25 -9
- package/src/standalone/hook-bus/handlers.mjs +16 -52
- package/src/standalone/hook-bus.mjs +29 -23
- package/src/standalone/memory-runtime-proxy.mjs +1 -16
- package/src/standalone/session-client.mjs +86 -23
- package/src/standalone/session-protocol.mjs +0 -2
- package/src/standalone/session-runtime-host-health.test.mjs +5 -4
- package/src/standalone/session-runtime-host.mjs +101 -6
- package/src/standalone/session-runtime-record.mjs +1 -5
- package/src/standalone/session-runtime-worker.mjs +52 -57
- package/src/standalone/session-service.mjs +4 -1
- package/src/standalone/session-transport.mjs +56 -25
- package/src/tui/App.jsx +40 -49
- package/src/tui/app/app-view.jsx +14 -21
- package/src/tui/app/core-memory-picker.mjs +29 -16
- package/src/tui/app/create-app-pickers.mjs +15 -36
- package/src/tui/app/extension-pickers.mjs +60 -49
- package/src/tui/app/maintenance-pickers.mjs +100 -57
- package/src/tui/app/message-selector.mjs +6 -5
- package/src/tui/app/model-picker.mjs +30 -13
- package/src/tui/app/onboarding-steps.mjs +21 -12
- package/src/tui/app/panel-epoch.mjs +56 -0
- package/src/tui/app/panel-epoch.test.mjs +1009 -0
- package/src/tui/app/panel-surface.mjs +98 -0
- package/src/tui/app/project-picker.mjs +30 -29
- package/src/tui/app/prompt-submit.mjs +192 -97
- package/src/tui/app/prompt-submit.test.mjs +206 -0
- package/src/tui/app/provider-setup-picker.mjs +74 -43
- package/src/tui/app/resume-picker.mjs +11 -8
- package/src/tui/app/route-pickers.mjs +45 -28
- package/src/tui/app/settings-picker.mjs +69 -18
- package/src/tui/app/shell-layout.mjs +1 -3
- package/src/tui/app/slash-commands.mjs +0 -2
- package/src/tui/app/slash-dispatch.mjs +4 -2
- package/src/tui/app/text-entry-policy.mjs +25 -0
- package/src/tui/app/theme-effort-pickers.mjs +11 -16
- package/src/tui/app/usage-context-panels.mjs +21 -25
- package/src/tui/app/use-global-key-input.mjs +3 -2
- package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
- package/src/tui/app/use-prompt-handlers.mjs +3 -2
- package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
- package/src/tui/components/Picker.jsx +1 -1
- package/src/tui/components/TextEntryPanel.jsx +7 -2
- package/src/tui/dist/index.mjs +652 -561
- package/src/tui/lib/voice-setup.mjs +1 -2
- package/src/tui/session/agent-job-feed.mjs +73 -11
- package/src/tui/session/session-api-ext.mjs +5 -6
- package/src/tui/session/session-api.mjs +1 -0
- package/src/tui/session/tool-approval.mjs +15 -5
- package/src/tui/session/tui-steering-persist.mjs +33 -19
- package/src/tui/session/tui-steering-persist.test.mjs +104 -0
- package/src/tui/session-local.mjs +0 -22
- package/scripts/.tmp-coverage-report.mjs +0 -96
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
|
@@ -19,8 +19,9 @@ import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './pa
|
|
|
19
19
|
import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component, isBlockedDevicePath, isSpecialFileStat } from './device-paths.mjs';
|
|
20
20
|
import { recordReadSnapshot } from './read-snapshot-runtime.mjs';
|
|
21
21
|
import { executeBashTool } from './bash-tool.mjs';
|
|
22
|
-
import { runServerEdit } from '../patch/native-server.mjs';
|
|
23
|
-
import { nearestPatchLineMatch } from '../patch/matcher.mjs';
|
|
22
|
+
import { nativeEditSessionSatisfiesContract, runServerEdit } from '../patch/native-server.mjs';
|
|
23
|
+
import { decodeValidUtf8OrNull, detectPatchTargetCodec, nearestPatchLineMatch } from '../patch/matcher.mjs';
|
|
24
|
+
import { hashText } from './hash-utils.mjs';
|
|
24
25
|
import { invalidateBuiltinResultCache } from './cache-layers.mjs';
|
|
25
26
|
import { markCodeGraphDirtyPaths } from '../code-graph-state.mjs';
|
|
26
27
|
|
|
@@ -134,12 +135,17 @@ function editScope(options) {
|
|
|
134
135
|
return options?.readStateScope || options?.sessionId || null;
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
|
|
138
|
+
// `preMutationStat` is the target's identity immediately BEFORE this write. The
|
|
139
|
+
// snapshot recorder needs it to prove the session's earlier read still
|
|
140
|
+
// described the file at edit time; without that proof an external change made
|
|
141
|
+
// between the read and the edit would stay hidden behind "[file unchanged]".
|
|
142
|
+
function recordEditSnapshot(fullPath, options, contentHash = null, preMutationStat = null) {
|
|
138
143
|
try {
|
|
139
144
|
recordReadSnapshot(fullPath, statSync(fullPath), editScope(options), {
|
|
140
145
|
source: 'edit',
|
|
141
146
|
isPartialView: false,
|
|
142
147
|
replaceExisting: true,
|
|
148
|
+
preMutationStat,
|
|
143
149
|
...(contentHash ? { contentHash } : {}),
|
|
144
150
|
});
|
|
145
151
|
} catch { /* best-effort */ }
|
|
@@ -159,13 +165,6 @@ async function recordEditUiDiff(options, workDir, fullPath, before, after) {
|
|
|
159
165
|
} catch { /* best-effort display channel */ }
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
function countOccurrences(haystack, needle) {
|
|
163
|
-
let count = 0;
|
|
164
|
-
let idx = -1;
|
|
165
|
-
while ((idx = haystack.indexOf(needle, idx + 1)) !== -1) count += 1;
|
|
166
|
-
return count;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
168
|
function formatEditFailureExcerpt(content, oldStr, errorText) {
|
|
170
169
|
const source = String(content ?? '').replace(/\r\n/g, '\n');
|
|
171
170
|
const lines = source.split('\n');
|
|
@@ -270,6 +269,162 @@ function applyFileQuoteStyle(actualSpan, replacement) {
|
|
|
270
269
|
return out.join('');
|
|
271
270
|
}
|
|
272
271
|
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Encoding / line-ending fidelity (Windows-first).
|
|
274
|
+
//
|
|
275
|
+
// The edit path used to decode every target as UTF-8 and hand the native
|
|
276
|
+
// engine LF-delimited UTF-8 bytes. On Windows that fails twice: a multiline
|
|
277
|
+
// old_string copied out of Read (LF) can never match a CRLF file, and a
|
|
278
|
+
// UTF-16 file decoded as UTF-8 is mangled on write. Both are handled here:
|
|
279
|
+
// the file's BOM decides the codec, the file's dominant EOL decides the line
|
|
280
|
+
// endings of old_string/new_string, and non-UTF-8 targets are edited in-process
|
|
281
|
+
// (the native engine's payload is UTF-8 by protocol) and re-encoded in place.
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
const UTF8_BOM_BYTES = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
284
|
+
const UTF16LE_BOM_BYTES = Buffer.from([0xFF, 0xFE]);
|
|
285
|
+
const UTF16BE_BOM_BYTES = Buffer.from([0xFE, 0xFF]);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Decode on-disk bytes. Returns null when the file cannot be round-tripped:
|
|
289
|
+
* invalid UTF-8, or UTF-16 with an odd trailing byte (that byte is undecodable
|
|
290
|
+
* and would be silently dropped on re-encode).
|
|
291
|
+
*/
|
|
292
|
+
function decodeEditBuffer(rawBuf, enc) {
|
|
293
|
+
if (enc.encoding === 'utf16le' || enc.encoding === 'utf16be') {
|
|
294
|
+
const body = rawBuf.subarray(enc.bomLen);
|
|
295
|
+
if (body.length % 2 !== 0) return null;
|
|
296
|
+
return enc.encoding === 'utf16le'
|
|
297
|
+
? body.toString('utf16le')
|
|
298
|
+
// Node has no 'utf16be': swap pairs into LE and decode.
|
|
299
|
+
: Buffer.from(body).swap16().toString('utf16le');
|
|
300
|
+
}
|
|
301
|
+
return decodeValidUtf8OrNull(rawBuf.subarray(enc.bomLen));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function encodeEditText(text, enc) {
|
|
305
|
+
if (enc?.encoding === 'utf16le') return Buffer.from(String(text), 'utf16le');
|
|
306
|
+
if (enc?.encoding === 'utf16be') return Buffer.from(String(text), 'utf16le').swap16();
|
|
307
|
+
return Buffer.from(String(text), 'utf8');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Whole-file payload: re-attaches the BOM the decode stripped. */
|
|
311
|
+
function encodeEditContent(text, enc) {
|
|
312
|
+
const body = encodeEditText(text, enc);
|
|
313
|
+
if (!enc?.bomLen) return body;
|
|
314
|
+
const bom = enc.encoding === 'utf16le'
|
|
315
|
+
? UTF16LE_BOM_BYTES
|
|
316
|
+
: (enc.encoding === 'utf16be' ? UTF16BE_BOM_BYTES : UTF8_BOM_BYTES);
|
|
317
|
+
return Buffer.concat([bom, body]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function convertTextEol(text, eol) {
|
|
321
|
+
const lf = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
322
|
+
return eol === '\n' ? lf : lf.replace(/\n/g, eol);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const EDIT_EOL_RE = /\r\n|\n|\r/;
|
|
326
|
+
|
|
327
|
+
function textEolStyle(text) {
|
|
328
|
+
if (text.includes('\r\n')) return '\r\n';
|
|
329
|
+
if (text.includes('\n')) return '\n';
|
|
330
|
+
if (text.includes('\r')) return '\r';
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The convention SURROUNDING one match: the style inside the matched span,
|
|
335
|
+
// else the terminator of the line the match sits on. Only the replaced span
|
|
336
|
+
// may adopt it — the file is never normalized, so a deliberately mixed region
|
|
337
|
+
// keeps every terminator the edit did not replace.
|
|
338
|
+
function surroundingEol(content, matchStart, matchEnd, matched) {
|
|
339
|
+
const inSpan = textEolStyle(matched);
|
|
340
|
+
if (inSpan) return inSpan;
|
|
341
|
+
const after = EDIT_EOL_RE.exec(content.slice(matchEnd));
|
|
342
|
+
if (after) return after[0];
|
|
343
|
+
const before = content.slice(0, matchStart);
|
|
344
|
+
const nl = before.lastIndexOf('\n');
|
|
345
|
+
const cr = before.lastIndexOf('\r');
|
|
346
|
+
if (nl > 0 && before[nl - 1] === '\r') return '\r\n';
|
|
347
|
+
if (nl >= 0 || cr >= 0) return nl > cr ? '\n' : '\r';
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function findEditOccurrences(content, needle) {
|
|
352
|
+
const out = [];
|
|
353
|
+
if (!needle) return out;
|
|
354
|
+
for (let at = content.indexOf(needle); at !== -1; at = content.indexOf(needle, at + needle.length)) {
|
|
355
|
+
out.push(at);
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Match plan. old_string is rebased onto the file's own line endings for
|
|
361
|
+
// MATCHING only, and every occurrence gets its own replacement text speaking
|
|
362
|
+
// that occurrence's local convention. Nothing outside a matched span is ever
|
|
363
|
+
// considered, let alone rewritten.
|
|
364
|
+
function planEdit(content, oldStr, newStr, { absorbTrailingNewline = false } = {}) {
|
|
365
|
+
let matchOld = oldStr;
|
|
366
|
+
let rebased = false;
|
|
367
|
+
if (!content.includes(matchOld) && EDIT_EOL_RE.test(matchOld)) {
|
|
368
|
+
for (const eol of ['\r\n', '\n', '\r']) {
|
|
369
|
+
const candidate = convertTextEol(matchOld, eol);
|
|
370
|
+
if (candidate !== matchOld && content.includes(candidate)) {
|
|
371
|
+
matchOld = candidate;
|
|
372
|
+
rebased = true;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Block deletion absorbs its own trailing terminator so no blank line
|
|
377
|
+
// is left behind; the pre-rebase attempt could not see the file's real
|
|
378
|
+
// endings.
|
|
379
|
+
if (rebased && absorbTrailingNewline && newStr === '' && !EDIT_EOL_RE.test(matchOld.slice(-2))) {
|
|
380
|
+
for (const eol of ['\r\n', '\n', '\r']) {
|
|
381
|
+
if (content.includes(`${matchOld}${eol}`)) {
|
|
382
|
+
matchOld += eol;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const positions = findEditOccurrences(content, matchOld);
|
|
389
|
+
const replacements = positions.map((at) => {
|
|
390
|
+
if (!EDIT_EOL_RE.test(newStr)) return newStr;
|
|
391
|
+
const eol = surroundingEol(content, at, at + matchOld.length, matchOld);
|
|
392
|
+
return eol ? convertTextEol(newStr, eol) : newStr;
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
oldStr: matchOld,
|
|
396
|
+
rebased,
|
|
397
|
+
positions,
|
|
398
|
+
replacements,
|
|
399
|
+
uniform: replacements.every((replacement) => replacement === newStr),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Splice the planned replacements into the content. Everything outside a
|
|
404
|
+
// matched span is copied verbatim, so the result differs from the input only
|
|
405
|
+
// inside the replaced spans.
|
|
406
|
+
function applyEditPlan(content, oldStr, plan, replaceAll) {
|
|
407
|
+
const count = replaceAll ? plan.positions.length : Math.min(1, plan.positions.length);
|
|
408
|
+
if (count === 0) return null;
|
|
409
|
+
let out = '';
|
|
410
|
+
let cursor = 0;
|
|
411
|
+
for (let k = 0; k < count; k++) {
|
|
412
|
+
const at = plan.positions[k];
|
|
413
|
+
out += content.slice(cursor, at) + plan.replacements[k];
|
|
414
|
+
cursor = at + oldStr.length;
|
|
415
|
+
}
|
|
416
|
+
return out + content.slice(cursor);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function readEditTextForDisplay(fullPath, enc) {
|
|
420
|
+
try {
|
|
421
|
+
const decoded = decodeEditBuffer(readFileSync(fullPath), enc);
|
|
422
|
+
return typeof decoded === 'string' ? decoded : null;
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
273
428
|
async function adaptStrReplace(args, workDir, options) {
|
|
274
429
|
let oldStr = args?.old_string;
|
|
275
430
|
let newStr = args?.new_string;
|
|
@@ -301,17 +456,28 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
301
456
|
return `Created ${fullPath} (${Buffer.byteLength(newStr, 'utf8')} bytes)`;
|
|
302
457
|
}
|
|
303
458
|
let content;
|
|
459
|
+
let fileEnc = { encoding: 'utf8', bomLen: 0 };
|
|
304
460
|
let statBefore = null;
|
|
305
461
|
try {
|
|
306
462
|
try { statBefore = statSync(fullPath); } catch { /* readFileSync below surfaces the real error */ }
|
|
307
|
-
|
|
463
|
+
const rawBuf = readFileSync(fullPath);
|
|
464
|
+
// Codec by DETECTION (BOM, else NUL parity), not by BOM presence: a
|
|
465
|
+
// BOM-less UTF-16 file must not be spliced as UTF-8.
|
|
466
|
+
fileEnc = detectPatchTargetCodec(rawBuf);
|
|
467
|
+
content = fileEnc.certain ? decodeEditBuffer(rawBuf, fileEnc) : null;
|
|
308
468
|
} catch (err) {
|
|
309
469
|
return `Error: cannot read ${fullPath} (${err?.message || err})`;
|
|
310
470
|
}
|
|
471
|
+
if (typeof content !== 'string') {
|
|
472
|
+
return `Error: cannot edit ${normalizeOutputPath(fullPath)}: its bytes are not decidable text`
|
|
473
|
+
+ ' (invalid UTF-8, malformed UTF-16 with an odd trailing byte, or NUL bytes without a'
|
|
474
|
+
+ ' consistent UTF-16 pattern), so any edit would alter bytes outside the replacement.'
|
|
475
|
+
+ ' Convert the encoding first.';
|
|
476
|
+
}
|
|
311
477
|
if (oldStr.length === 0) {
|
|
312
478
|
if (content.length > 0) return `Error: cannot create ${fullPath}: file already exists and is not empty`;
|
|
313
479
|
try {
|
|
314
|
-
await atomicWrite(fullPath, newStr, {
|
|
480
|
+
await atomicWrite(fullPath, encodeEditContent(newStr, fileEnc), {
|
|
315
481
|
sessionId: editScope(options),
|
|
316
482
|
signal: options?.signal,
|
|
317
483
|
expectedTargetSnapshot: {
|
|
@@ -329,7 +495,7 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
329
495
|
throw err;
|
|
330
496
|
}
|
|
331
497
|
invalidateAfterWrite(fullPath);
|
|
332
|
-
recordEditSnapshot(fullPath, options);
|
|
498
|
+
recordEditSnapshot(fullPath, options, null, statBefore);
|
|
333
499
|
await recordEditUiDiff(options, workDir, fullPath, content, newStr);
|
|
334
500
|
return `Updated ${fullPath} (filled empty file)`;
|
|
335
501
|
}
|
|
@@ -366,6 +532,17 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
366
532
|
}
|
|
367
533
|
if (oldStr === newStr) return 'Error: old_string and new_string are exactly the same';
|
|
368
534
|
}
|
|
535
|
+
// EOL planning runs for EVERY edit (batch members included): a CRLF file is
|
|
536
|
+
// the Windows default, the rebase is 1:1 over the same match positions, and
|
|
537
|
+
// the replacement adopts the convention of the span it replaces.
|
|
538
|
+
const editPlan = planEdit(content, oldStr, newStr, {
|
|
539
|
+
absorbTrailingNewline: !options?.editOccurrence,
|
|
540
|
+
});
|
|
541
|
+
oldStr = editPlan.oldStr;
|
|
542
|
+
if (editPlan.positions.length > 0
|
|
543
|
+
&& editPlan.replacements.every((replacement) => replacement === oldStr)) {
|
|
544
|
+
return 'Error: old_string and new_string are exactly the same';
|
|
545
|
+
}
|
|
369
546
|
// Batch sequential occupation (tool-batch `_editSeqGroups`): this call is
|
|
370
547
|
// the next member of a same-anchor edit batch and exactly `expected`
|
|
371
548
|
// occurrences of old_string must remain. Deterministically consume the
|
|
@@ -374,15 +551,12 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
374
551
|
// drift falls through to the native engine's strict ambiguity reject.
|
|
375
552
|
const _occupation = options?.editOccurrence;
|
|
376
553
|
if (_occupation && !replaceAll && Number.isInteger(_occupation.expected) && _occupation.expected >= 2) {
|
|
377
|
-
const positions =
|
|
378
|
-
for (let at = content.indexOf(oldStr); at !== -1; at = content.indexOf(oldStr, at + oldStr.length)) {
|
|
379
|
-
positions.push(at);
|
|
380
|
-
}
|
|
554
|
+
const positions = editPlan.positions;
|
|
381
555
|
if (positions.length === _occupation.expected) {
|
|
382
556
|
const at = positions[0];
|
|
383
|
-
const next = `${content.slice(0, at)}${
|
|
557
|
+
const next = `${content.slice(0, at)}${editPlan.replacements[0]}${content.slice(at + oldStr.length)}`;
|
|
384
558
|
try {
|
|
385
|
-
await atomicWrite(fullPath, next, {
|
|
559
|
+
await atomicWrite(fullPath, encodeEditContent(next, fileEnc), {
|
|
386
560
|
sessionId: editScope(options),
|
|
387
561
|
signal: options?.signal,
|
|
388
562
|
expectedTargetSnapshot: statBefore ? {
|
|
@@ -400,11 +574,55 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
400
574
|
throw err;
|
|
401
575
|
}
|
|
402
576
|
invalidateAfterWrite(fullPath);
|
|
403
|
-
recordEditSnapshot(fullPath, options);
|
|
577
|
+
recordEditSnapshot(fullPath, options, null, statBefore);
|
|
404
578
|
await recordEditUiDiff(options, workDir, fullPath, content, next);
|
|
405
579
|
return `Updated ${fullPath} (1 replacement)`;
|
|
406
580
|
}
|
|
407
581
|
}
|
|
582
|
+
// In-process apply, used whenever the native engine cannot be trusted with
|
|
583
|
+
// these bytes: a non-UTF-8 codec (its EDIT protocol is UTF-8), an
|
|
584
|
+
// old_string rebased onto the file's line endings, or per-occurrence
|
|
585
|
+
// replacement text. The splice copies the file verbatim around each
|
|
586
|
+
// matched span, so no byte outside a replacement can change.
|
|
587
|
+
// An unverified native edit session never receives work: the in-process
|
|
588
|
+
// apply below is the fallback, so a stale/spoofed engine cannot touch bytes.
|
|
589
|
+
const nativeEditVerified = await nativeEditSessionSatisfiesContract();
|
|
590
|
+
if (!nativeEditVerified || fileEnc.encoding !== 'utf8' || editPlan.rebased || !editPlan.uniform) {
|
|
591
|
+
const occurrences = editPlan.positions.length;
|
|
592
|
+
if (occurrences === 0) {
|
|
593
|
+
const message = 'old_string not found';
|
|
594
|
+
return `Error: edit failed (${message})${formatEditFailureExcerpt(content, oldStr, message)}`;
|
|
595
|
+
}
|
|
596
|
+
if (occurrences > 1 && !replaceAll) {
|
|
597
|
+
const message = `old_string found ${occurrences} times`;
|
|
598
|
+
return `Error: edit failed (${message}; pass replace_all:true or extend old_string)`
|
|
599
|
+
+ formatEditFailureExcerpt(content, oldStr, message);
|
|
600
|
+
}
|
|
601
|
+
const applied = replaceAll ? occurrences : 1;
|
|
602
|
+
const next = applyEditPlan(content, oldStr, editPlan, replaceAll);
|
|
603
|
+
try {
|
|
604
|
+
await atomicWrite(fullPath, encodeEditContent(next, fileEnc), {
|
|
605
|
+
sessionId: editScope(options),
|
|
606
|
+
signal: options?.signal,
|
|
607
|
+
expectedTargetSnapshot: statBefore ? {
|
|
608
|
+
exists: true,
|
|
609
|
+
size: statBefore.size,
|
|
610
|
+
mtimeMs: statBefore.mtimeMs,
|
|
611
|
+
ctimeMs: statBefore.ctimeMs,
|
|
612
|
+
ino: statBefore.ino,
|
|
613
|
+
} : { exists: false },
|
|
614
|
+
});
|
|
615
|
+
} catch (err) {
|
|
616
|
+
if (err?.code === 'ESTALE_TARGET') {
|
|
617
|
+
return `Error: ${fullPath} changed on disk during the edit; read it again`;
|
|
618
|
+
}
|
|
619
|
+
throw err;
|
|
620
|
+
}
|
|
621
|
+
invalidateAfterWrite(fullPath);
|
|
622
|
+
recordEditSnapshot(fullPath, options, null, statBefore);
|
|
623
|
+
await recordEditUiDiff(options, workDir, fullPath, content, next);
|
|
624
|
+
return `Updated ${fullPath} (${applied} replacement${applied === 1 ? '' : 's'})`;
|
|
625
|
+
}
|
|
408
626
|
let result;
|
|
409
627
|
try {
|
|
410
628
|
result = await runServerEdit({
|
|
@@ -417,17 +635,29 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
417
635
|
} catch (err) {
|
|
418
636
|
const message = err?.message || String(err);
|
|
419
637
|
if (/old_string (?:not found|found \d+ times)/i.test(message)) {
|
|
420
|
-
|
|
421
|
-
try { latest = readFileSync(fullPath, 'utf8'); } catch { /* retain pre-dispatch content */ }
|
|
638
|
+
const latest = readEditTextForDisplay(fullPath, fileEnc) ?? content;
|
|
422
639
|
return `Error: edit failed (${message})${formatEditFailureExcerpt(latest, oldStr, message)}`;
|
|
423
640
|
}
|
|
424
641
|
throw err;
|
|
425
642
|
}
|
|
426
643
|
invalidateAfterWrite(fullPath);
|
|
427
|
-
|
|
644
|
+
// TOCTOU proof for the native route: the engine reads and writes the file
|
|
645
|
+
// itself, so a stat taken before the call cannot vouch for what it edited.
|
|
646
|
+
// Our pre-edit content plus the planned replacement must hash to exactly
|
|
647
|
+
// what the engine wrote; anything else means it saw different bytes, so the
|
|
648
|
+
// session's earlier read is not evidence of the pre-edit body.
|
|
649
|
+
const expectedAfter = applyEditPlan(content, oldStr, editPlan, replaceAll);
|
|
650
|
+
const editedExactlyWhatWeRead = typeof result?.contentHash === 'string'
|
|
651
|
+
&& expectedAfter !== null
|
|
652
|
+
&& result.contentHash === hashText(expectedAfter);
|
|
653
|
+
recordEditSnapshot(
|
|
654
|
+
fullPath,
|
|
655
|
+
options,
|
|
656
|
+
result.contentHash,
|
|
657
|
+
editedExactlyWhatWeRead ? statBefore : null,
|
|
658
|
+
);
|
|
428
659
|
{
|
|
429
|
-
|
|
430
|
-
try { after = readFileSync(fullPath, 'utf8'); } catch { /* diff omitted */ }
|
|
660
|
+
const after = readEditTextForDisplay(fullPath, fileEnc);
|
|
431
661
|
if (typeof after === 'string') await recordEditUiDiff(options, workDir, fullPath, content, after);
|
|
432
662
|
}
|
|
433
663
|
return `Updated ${fullPath} (${result.replacements} replacement${result.replacements === 1 ? '' : 's'})`;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
import { _searchRemainingMsForTest } from './native-search-client.mjs';
|
|
8
|
+
import { executeFuzzyFindTool } from './list-tool.mjs';
|
|
9
|
+
import { runWithLocalSearchTelemetry } from './local-search-telemetry.mjs';
|
|
10
|
+
|
|
11
|
+
test('server setup is not charged to the search budget', () => {
|
|
12
|
+
const deadline = 1_500;
|
|
13
|
+
const startedAt = 0;
|
|
14
|
+
// 1000ms of the elapsed wall time was server startup: the search itself has
|
|
15
|
+
// not begun, so the full deadline must still be available.
|
|
16
|
+
assert.equal(_searchRemainingMsForTest(deadline, startedAt, 1_000, 1_000), 1_500);
|
|
17
|
+
// Once searching starts, elapsed search time does reduce the budget.
|
|
18
|
+
assert.equal(_searchRemainingMsForTest(deadline, startedAt, 1_000, 1_400), 1_100);
|
|
19
|
+
// No setup recorded means the old behaviour: every elapsed ms counts.
|
|
20
|
+
assert.equal(_searchRemainingMsForTest(deadline, startedAt, 0, 400), 1_100);
|
|
21
|
+
// The budget never goes to zero or negative, which would break timers.
|
|
22
|
+
assert.equal(_searchRemainingMsForTest(deadline, startedAt, 0, 9_000), 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a fuzzy deadline recovers a real candidate through one targeted path scan', async () => {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-budget-'));
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(join(root, 'anchor.txt'), 'anchor\n');
|
|
29
|
+
const timeout = Object.assign(
|
|
30
|
+
new Error('native fuzzy search timed out after 1500ms.'),
|
|
31
|
+
{ code: 'NATIVE_SEARCH_TIMEOUT' },
|
|
32
|
+
);
|
|
33
|
+
let targetedEnumerationCalls = 0;
|
|
34
|
+
let targetedArgs = null;
|
|
35
|
+
const telemetry = {};
|
|
36
|
+
const out = String(await runWithLocalSearchTelemetry(telemetry, () => executeFuzzyFindTool(
|
|
37
|
+
{ query: 'needle-that-never-ranks', path: '.', head_limit: 5 },
|
|
38
|
+
root,
|
|
39
|
+
{
|
|
40
|
+
__tryServeFuzzySearch: async () => { throw timeout; },
|
|
41
|
+
__runRg: async (args) => {
|
|
42
|
+
targetedEnumerationCalls += 1;
|
|
43
|
+
targetedArgs = args;
|
|
44
|
+
return 'src/needle-that-never-ranks.ts\n';
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
)));
|
|
48
|
+
assert.ok(!/^Error[\s:[]/.test(out.trimStart()), `must not surface as a tool failure:\n${out}`);
|
|
49
|
+
assert.match(out, /src\/needle-that-never-ranks\.ts/);
|
|
50
|
+
assert.match(out, /targeted path matches shown/);
|
|
51
|
+
assert.equal(targetedEnumerationCalls, 1);
|
|
52
|
+
assert.ok(targetedArgs.includes('--iglob'), `expected a targeted --iglob scan: ${JSON.stringify(targetedArgs)}`);
|
|
53
|
+
assert.equal(telemetry.native_fuzzy_partials, 1);
|
|
54
|
+
assert.equal(telemetry.native_fuzzy_targeted_hits, 1);
|
|
55
|
+
assert.equal(telemetry.native_fuzzy_errors, undefined);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(root, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a served partial still reports its ranked matches', async () => {
|
|
62
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-partial-'));
|
|
63
|
+
try {
|
|
64
|
+
const out = String(await executeFuzzyFindTool(
|
|
65
|
+
{ query: 'alpha', path: '.', head_limit: 5 },
|
|
66
|
+
root,
|
|
67
|
+
{
|
|
68
|
+
__tryServeFuzzySearch: async () => ({
|
|
69
|
+
matches: ['src/alpha.ts'],
|
|
70
|
+
hasMore: false,
|
|
71
|
+
totalMatches: 1,
|
|
72
|
+
totalSeen: 1,
|
|
73
|
+
complete: false,
|
|
74
|
+
partial: true,
|
|
75
|
+
timeout: true,
|
|
76
|
+
scanErrors: 0,
|
|
77
|
+
walkErrorDetails: [],
|
|
78
|
+
}),
|
|
79
|
+
__runRg: async () => '',
|
|
80
|
+
},
|
|
81
|
+
));
|
|
82
|
+
assert.match(out, /src\/alpha\.ts/);
|
|
83
|
+
assert.ok(!/^Error[\s:[]/.test(out.trimStart()), `partials are results, not failures:\n${out}`);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(root, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -7,7 +7,7 @@ const ALWAYS_READ = new Set([
|
|
|
7
7
|
'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
|
|
8
8
|
'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
|
|
9
9
|
'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
|
|
10
|
-
'whatchanged',
|
|
10
|
+
'version', 'whatchanged',
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
13
|
const SHELL_OPERATOR_CHARS = '|&;<>()';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { tmpdir } from 'node:os';
|
|
3
|
-
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
|
|
5
6
|
import { withBuiltinPathLocks } from './path-locks.mjs';
|
|
6
7
|
import { withAdvisoryLocks } from './advisory-lock.mjs';
|
|
@@ -32,7 +33,19 @@ const SUPPORTED = new Set([
|
|
|
32
33
|
'revert', 'rev-list', 'rev-parse', 'rm', 'shortlog', 'show', 'show-branch',
|
|
33
34
|
'show-ref', 'sparse-checkout', 'stash', 'status', 'submodule', 'switch',
|
|
34
35
|
'symbolic-ref', 'tag', 'update-ref', 'verify-commit', 'verify-pack',
|
|
35
|
-
'verify-tag', 'whatchanged', 'worktree', 'write-tree',
|
|
36
|
+
'verify-tag', 'version', 'whatchanged', 'worktree', 'write-tree',
|
|
37
|
+
]);
|
|
38
|
+
// `git --version` / `git --help` are the standard availability probes and
|
|
39
|
+
// arrive in global-flag position, not as subcommands. Map them onto their
|
|
40
|
+
// subcommand form so the probe answers instead of erroring out.
|
|
41
|
+
// These answer anywhere: demanding a repository first turned an availability
|
|
42
|
+
// probe into `git rev-parse exited 128` inside a plain directory.
|
|
43
|
+
const REPO_FREE_OPERATIONS = new Set(['version', 'help']);
|
|
44
|
+
const OPERATION_ALIASES = new Map([
|
|
45
|
+
['--version', 'version'],
|
|
46
|
+
['-v', 'version'],
|
|
47
|
+
['--help', 'help'],
|
|
48
|
+
['-h', 'help'],
|
|
36
49
|
]);
|
|
37
50
|
const PUSH_NOISE = [
|
|
38
51
|
'Enumerating objects:', 'Counting objects:', 'Compressing objects:',
|
|
@@ -181,8 +194,9 @@ function parseCommand(command, workDir) {
|
|
|
181
194
|
}
|
|
182
195
|
break;
|
|
183
196
|
}
|
|
184
|
-
const
|
|
185
|
-
|
|
197
|
+
const rawOperation = String(tokens[index] || '').toLowerCase();
|
|
198
|
+
const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
|
|
199
|
+
if (!SUPPORTED.has(operation)) throw new Error(`unsupported git subcommand: ${rawOperation || '(missing)'}`);
|
|
186
200
|
return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
|
|
187
201
|
}
|
|
188
202
|
|
|
@@ -298,6 +312,38 @@ function semanticExit(plan, result) {
|
|
|
298
312
|
return null;
|
|
299
313
|
}
|
|
300
314
|
|
|
315
|
+
// "There is no repository here" is the ANSWER to a read-only repository
|
|
316
|
+
// question, not a failure of the tool that asked it. Reported as an error it
|
|
317
|
+
// sent the caller looking for the same fact another way: one measured run
|
|
318
|
+
// repeated status/diff nine times across three directories before accepting
|
|
319
|
+
// it. Mutations keep failing — a merge cannot succeed without a repository.
|
|
320
|
+
function notARepository(result) {
|
|
321
|
+
if (!result || result.error || result.timedOut || result.aborted || result.overflow) return false;
|
|
322
|
+
if (result.exitCode !== 128) return false;
|
|
323
|
+
return /not a git repository|must be run in a work tree/i.test(String(result.stderr || ''));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Merge-family conflicts are git's documented outcome for exit 1: the command
|
|
327
|
+
// ran correctly and the conflicting paths ARE the result the caller needs.
|
|
328
|
+
// Wrapping them in a failure envelope hid that list behind "git merge exited 1".
|
|
329
|
+
const CONFLICT_OPERATIONS = new Set(['am', 'apply', 'cherry-pick', 'merge', 'rebase', 'revert', 'stash']);
|
|
330
|
+
|
|
331
|
+
function conflictOutcome(plan, result) {
|
|
332
|
+
if (!CONFLICT_OPERATIONS.has(plan.operation)) return null;
|
|
333
|
+
if (!result || result.error || result.timedOut || result.aborted || result.overflow) return null;
|
|
334
|
+
if (result.exitCode !== 1) return null;
|
|
335
|
+
const text = `${String(result.stdout || '')}\n${String(result.stderr || '')}`;
|
|
336
|
+
if (!/CONFLICT \(|Automatic merge failed|could not apply|needs merge/i.test(text)) return null;
|
|
337
|
+
const paths = [...text.matchAll(/^CONFLICT \([^)]*\): Merge conflict in (.+)$/gm)]
|
|
338
|
+
.map((match) => match[1].trim())
|
|
339
|
+
.filter(Boolean);
|
|
340
|
+
return {
|
|
341
|
+
summary: 'conflicts require resolution',
|
|
342
|
+
conflicted: true,
|
|
343
|
+
...(paths.length ? { conflicts: [...new Set(paths)] } : {}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
301
347
|
function runGit(plan, argv, options = {}) {
|
|
302
348
|
return runProcess('git', [...plan.globalArgs, ...argv], { cwd: plan.cwd, signal: options.signal });
|
|
303
349
|
}
|
|
@@ -561,9 +607,64 @@ function mutationData(plan, stdout, stderr, limit) {
|
|
|
561
607
|
return { summary, ...(rows.lines.length ? { output: rows.lines } : {}), ...(rows.omitted ? { omittedOutputLines: rows.omitted } : {}) };
|
|
562
608
|
}
|
|
563
609
|
|
|
610
|
+
// Repo-root resolution used to spawn an extra `git rev-parse` on EVERY call —
|
|
611
|
+
// on Windows that is half the cost of a git tool call (82ms for one process,
|
|
612
|
+
// 180ms for two). The answer is cached per resolved cwd and re-validated with
|
|
613
|
+
// a few stats: a cached root stays correct only while it is still the NEAREST
|
|
614
|
+
// repository, so an init/clone/worktree that appears closer to cwd invalidates
|
|
615
|
+
// it. Explicit --git-dir/--work-tree overrides re-point resolution entirely and
|
|
616
|
+
// never read the cache.
|
|
617
|
+
const repoRootCache = new Map();
|
|
618
|
+
const REPO_ROOT_CACHE_MAX = 64;
|
|
619
|
+
|
|
620
|
+
function repoRootCacheKey(plan) {
|
|
621
|
+
return plan.globalArgs.length ? null : resolve(plan.cwd);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function nearestGitDir(startDir) {
|
|
625
|
+
let dir = resolve(startDir);
|
|
626
|
+
for (;;) {
|
|
627
|
+
if (existsSync(join(dir, '.git'))) return dir;
|
|
628
|
+
const parent = dirname(dir);
|
|
629
|
+
if (parent === dir) return null;
|
|
630
|
+
dir = parent;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function cachedRepoRoot(key, plan) {
|
|
635
|
+
if (!key) return null;
|
|
636
|
+
const root = repoRootCache.get(key);
|
|
637
|
+
if (!root) return null;
|
|
638
|
+
const nearest = nearestGitDir(plan.cwd);
|
|
639
|
+
if (!nearest || resolve(nearest) !== resolve(root)) {
|
|
640
|
+
repoRootCache.delete(key);
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
repoRootCache.delete(key);
|
|
644
|
+
repoRootCache.set(key, root);
|
|
645
|
+
return root;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function rememberRepoRoot(key, root) {
|
|
649
|
+
if (!key || !root) return;
|
|
650
|
+
repoRootCache.delete(key);
|
|
651
|
+
repoRootCache.set(key, root);
|
|
652
|
+
while (repoRootCache.size > REPO_ROOT_CACHE_MAX) {
|
|
653
|
+
const oldest = repoRootCache.keys().next().value;
|
|
654
|
+
if (oldest === undefined) break;
|
|
655
|
+
repoRootCache.delete(oldest);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
564
659
|
async function resolveRepo(plan, signal) {
|
|
660
|
+
const key = repoRootCacheKey(plan);
|
|
661
|
+
const hit = cachedRepoRoot(key, plan);
|
|
662
|
+
if (hit) return { root: hit, probe: null };
|
|
565
663
|
const result = await runGit(plan, ['rev-parse', '--show-toplevel'], { signal });
|
|
566
|
-
|
|
664
|
+
if (!succeeded(result)) return { root: null, probe: result };
|
|
665
|
+
const root = cleanText(result.stdout);
|
|
666
|
+
rememberRepoRoot(key, root);
|
|
667
|
+
return { root, probe: result };
|
|
567
668
|
}
|
|
568
669
|
|
|
569
670
|
function localizeConfigPlan(plan) {
|
|
@@ -720,10 +821,22 @@ export async function executeGitTool(input, workDir, options = {}) {
|
|
|
720
821
|
const target = creationTarget(plan);
|
|
721
822
|
return withGitRepoWriteLock(target, () => executeCreation(plan, target, limit, signal), { signal });
|
|
722
823
|
}
|
|
723
|
-
|
|
824
|
+
if (REPO_FREE_OPERATIONS.has(plan.operation)) {
|
|
825
|
+
const result = await runGit(plan, [plan.operation, ...plan.args], { signal });
|
|
826
|
+
if (!succeeded(result)) return commandFailure(plan, result, limit);
|
|
827
|
+
return ok(mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit));
|
|
828
|
+
}
|
|
829
|
+
// Report the command the caller actually ran. Naming the internal probe
|
|
830
|
+
// ("git rev-parse exited 128") for a `git status` in a plain directory hid
|
|
831
|
+
// git's own "fatal: not a git repository" line behind a command the model
|
|
832
|
+
// never issued — and re-ran the probe just to build that message.
|
|
833
|
+
const { root: repo, probe } = await resolveRepo(plan, signal);
|
|
724
834
|
if (!repo) {
|
|
725
|
-
const
|
|
726
|
-
|
|
835
|
+
const missing = probe ?? await runGit(plan, ['rev-parse', '--show-toplevel'], { signal });
|
|
836
|
+
if (isReadOnly(plan) && notARepository(missing)) {
|
|
837
|
+
return ok({ repo: false, cwd: plan.cwd, reason: 'not a git repository' });
|
|
838
|
+
}
|
|
839
|
+
return commandFailure(plan, missing, limit);
|
|
727
840
|
}
|
|
728
841
|
const prepared = prepare(plan, limit);
|
|
729
842
|
if (isReadOnly(plan)) {
|
|
@@ -760,7 +873,17 @@ export async function executeGitTool(input, workDir, options = {}) {
|
|
|
760
873
|
const after = await statusSnapshot(repo, signal);
|
|
761
874
|
invalidateBuiltinResultCache();
|
|
762
875
|
drainCodeGraphCache();
|
|
763
|
-
if (!succeeded(result))
|
|
876
|
+
if (!succeeded(result)) {
|
|
877
|
+
const conflict = conflictOutcome(plan, result);
|
|
878
|
+
if (conflict) {
|
|
879
|
+
return ok({
|
|
880
|
+
...mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit),
|
|
881
|
+
...conflict,
|
|
882
|
+
status: statusDelta(before, after, limit),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
return `${commandFailure(plan, result, limit)}\n${JSON.stringify({ status: statusDelta(before, after, limit) })}`;
|
|
886
|
+
}
|
|
764
887
|
return ok({ ...mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit), status: statusDelta(before, after, limit) });
|
|
765
888
|
})), { signal });
|
|
766
889
|
}
|