@oh-my-pi/pi-coding-agent 15.5.7 → 15.5.9
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/CHANGELOG.md +53 -1
- package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +10 -10
- package/dist/types/edit/file-snapshot-store.d.ts +9 -6
- package/dist/types/edit/hashline/diff.d.ts +4 -5
- package/dist/types/edit/streaming.d.ts +2 -1
- package/dist/types/eval/py/index.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
- package/dist/types/mcp/transports/http.d.ts +9 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/tools/match-line-format.d.ts +2 -2
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +2 -0
- package/package.json +8 -8
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-broker-cli.ts +2 -1
- package/src/cli/auth-gateway-cli.ts +210 -9
- package/src/commands/auth-gateway.ts +7 -1
- package/src/config/settings-schema.ts +12 -11
- package/src/edit/file-snapshot-store.ts +9 -6
- package/src/edit/hashline/diff.ts +26 -13
- package/src/edit/hashline/execute.ts +13 -9
- package/src/edit/renderer.ts +9 -9
- package/src/edit/streaming.ts +4 -6
- package/src/eval/py/index.ts +1 -1
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/shared-events.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +2 -0
- package/src/internal-urls/vault-protocol.ts +936 -0
- package/src/main.ts +1 -2
- package/src/mcp/transports/http.ts +29 -2
- package/src/modes/components/tool-execution.ts +6 -4
- package/src/modes/controllers/event-controller.ts +10 -3
- package/src/modes/interactive-mode.ts +10 -2
- package/src/modes/utils/ui-helpers.ts +2 -1
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +3 -3
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +26 -1
- package/src/session/agent-session.ts +82 -11
- package/src/system-prompt.ts +2 -0
- package/src/tools/ast-edit.ts +10 -7
- package/src/tools/ast-grep.ts +12 -11
- package/src/tools/eval.ts +28 -3
- package/src/tools/match-line-format.ts +2 -2
- package/src/tools/path-utils.ts +2 -0
- package/src/tools/plan-mode-guard.ts +6 -1
- package/src/tools/read.ts +70 -55
- package/src/tools/render-utils.ts +15 -0
- package/src/tools/search.ts +12 -12
- package/src/tools/write.ts +61 -6
- package/src/utils/file-mentions.ts +11 -5
- package/src/web/search/providers/codex.ts +2 -1
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
type Api,
|
|
20
20
|
AuthBrokerClient,
|
|
21
21
|
AuthStorage,
|
|
22
|
+
type CompletionProbe,
|
|
23
|
+
type CompletionProbeInput,
|
|
24
|
+
type CredentialCompletionResult,
|
|
25
|
+
completeSimple,
|
|
22
26
|
DEFAULT_AUTH_GATEWAY_BIND,
|
|
23
27
|
type GeneratedProvider,
|
|
24
28
|
getBundledModels,
|
|
@@ -46,6 +50,14 @@ export interface AuthGatewayCommandArgs {
|
|
|
46
50
|
* to wire token-paste plumbing into every local client.
|
|
47
51
|
*/
|
|
48
52
|
noAuth?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Strict mode for `check` — additionally exercise every credential
|
|
55
|
+
* against its provider's chat-completion endpoint. The usage probe (run
|
|
56
|
+
* unconditionally) can pass while the chat endpoint still 401s the same
|
|
57
|
+
* bearer, so strict mode is the definitive "is this credential
|
|
58
|
+
* actually usable" signal. Slower and consumes a tiny amount of quota.
|
|
59
|
+
*/
|
|
60
|
+
strict?: boolean;
|
|
49
61
|
};
|
|
50
62
|
}
|
|
51
63
|
|
|
@@ -342,12 +354,185 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
|
|
|
342
354
|
}
|
|
343
355
|
}
|
|
344
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Providers whose chat endpoint expects a JSON-serialized credential blob
|
|
359
|
+
* (`{ token, projectId, refreshToken, expiresAt, … }`) rather than the raw
|
|
360
|
+
* access token. Mirrors `getOAuthApiKey` in `packages/ai/src/utils/oauth`.
|
|
361
|
+
*/
|
|
362
|
+
const STRUCTURED_API_KEY_PROVIDERS: ReadonlySet<string> = new Set([
|
|
363
|
+
"github-copilot",
|
|
364
|
+
"google-gemini-cli",
|
|
365
|
+
"google-antigravity",
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Provider API types that strict-mode chat probes intentionally skip:
|
|
370
|
+
* - `bedrock-converse-stream` resolves credentials from the AWS env/profile, not the broker bearer.
|
|
371
|
+
* - `google-vertex` uses Application Default Credentials; the broker bearer is not the right key.
|
|
372
|
+
* - `cursor-agent` and `pi-native` (gateway forwarding) have transport quirks
|
|
373
|
+
* that make a bearer-only "ping" a poor signal.
|
|
374
|
+
*/
|
|
375
|
+
const STRICT_PROBE_SKIPPED_APIS: ReadonlySet<Api> = new Set<Api>([
|
|
376
|
+
"bedrock-converse-stream",
|
|
377
|
+
"google-vertex",
|
|
378
|
+
"cursor-agent",
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
/** Max chat models to try per credential before reporting failure. */
|
|
382
|
+
const STRICT_PROBE_MAX_CANDIDATES = 4;
|
|
383
|
+
|
|
384
|
+
/** Per-attempt deadline. Each candidate gets its own slice instead of sharing one budget. */
|
|
385
|
+
const STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS = 15_000;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Overall per-credential budget passed to {@link AuthStorage.checkCredentials}.
|
|
389
|
+
* Big enough to walk every candidate at the per-attempt cap with a small
|
|
390
|
+
* margin for refresh/network overhead.
|
|
391
|
+
*/
|
|
392
|
+
const STRICT_PROBE_OVERALL_TIMEOUT_MS = STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS * (STRICT_PROBE_MAX_CANDIDATES + 1);
|
|
393
|
+
|
|
394
|
+
/** Match upstream errors that mean "this model is gone, try a different one" so we walk the catalog instead of declaring the credential bad. */
|
|
395
|
+
const RETRYABLE_MODEL_ERROR_RE =
|
|
396
|
+
/not[_ -]found|invalid[_ -]model|model[_ -]is[_ -]not[_ -]valid|no longer supported|deprecated|404|decommissioned/i;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Rank bundled models for a provider in probe order: cheapest first, then by
|
|
400
|
+
* id for determinism. Filters out non-bearer-auth APIs (Vertex/Bedrock),
|
|
401
|
+
* pi-native transport (would loop through the gateway), and placeholder /
|
|
402
|
+
* router entries with negative/missing cost.
|
|
403
|
+
*/
|
|
404
|
+
function pickProbeCandidates(provider: string): Model<Api>[] {
|
|
405
|
+
const bundled = getBundledModels(provider as GeneratedProvider);
|
|
406
|
+
if (bundled.length === 0) return [];
|
|
407
|
+
const candidates = bundled.filter(model => {
|
|
408
|
+
if (model.transport === "pi-native") return false;
|
|
409
|
+
if (STRICT_PROBE_SKIPPED_APIS.has(model.api)) return false;
|
|
410
|
+
if (!model.input.includes("text")) return false;
|
|
411
|
+
const totalCost = (model.cost?.input ?? 0) + (model.cost?.output ?? 0);
|
|
412
|
+
if (!Number.isFinite(totalCost) || totalCost < 0) return false;
|
|
413
|
+
if (model.maxTokens <= 0) return false;
|
|
414
|
+
return true;
|
|
415
|
+
});
|
|
416
|
+
candidates.sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output) || a.id.localeCompare(b.id));
|
|
417
|
+
return candidates;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Compose the apiKey bytes a provider's chat endpoint expects, given a
|
|
422
|
+
* post-refresh probe credential. Mirrors `getOAuthApiKey` for the providers
|
|
423
|
+
* that require a structured blob; otherwise returns the raw access token /
|
|
424
|
+
* API key.
|
|
425
|
+
*/
|
|
426
|
+
function composeProbeApiKey(provider: string, credential: CompletionProbeInput["credential"]): string {
|
|
427
|
+
if (credential.type === "api_key") return credential.apiKey;
|
|
428
|
+
if (!STRUCTURED_API_KEY_PROVIDERS.has(provider)) return credential.accessToken;
|
|
429
|
+
return JSON.stringify({
|
|
430
|
+
token: credential.accessToken,
|
|
431
|
+
enterpriseUrl: credential.enterpriseUrl,
|
|
432
|
+
projectId: credential.projectId,
|
|
433
|
+
refreshToken: credential.refreshToken,
|
|
434
|
+
expiresAt: credential.expiresAt,
|
|
435
|
+
email: credential.email,
|
|
436
|
+
accountId: credential.accountId,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function probeOneModel(
|
|
441
|
+
model: Model<Api>,
|
|
442
|
+
apiKey: string,
|
|
443
|
+
outerSignal: AbortSignal,
|
|
444
|
+
): Promise<CredentialCompletionResult> {
|
|
445
|
+
const start = Date.now();
|
|
446
|
+
const attemptTimeoutSignal = AbortSignal.timeout(STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS);
|
|
447
|
+
const attemptSignal = AbortSignal.any([outerSignal, attemptTimeoutSignal]);
|
|
448
|
+
// `systemPrompt` is mandatory for some providers (Codex 400s "Instructions
|
|
449
|
+
// are required" without it). `disableReasoning` is intentionally NOT set:
|
|
450
|
+
// providers like Fireworks reject the "none" effort it maps to, and we'd
|
|
451
|
+
// rather burn 16 reasoning tokens than misdiagnose a healthy credential.
|
|
452
|
+
const response = await completeSimple(
|
|
453
|
+
model,
|
|
454
|
+
{
|
|
455
|
+
systemPrompt: ["Connectivity check. Reply with the single word 'pong'."],
|
|
456
|
+
messages: [{ role: "user", content: "ping", timestamp: start }],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
apiKey,
|
|
460
|
+
maxTokens: 32,
|
|
461
|
+
signal: attemptSignal,
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
const latencyMs = Date.now() - start;
|
|
465
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
reason: response.errorMessage ?? `chat probe ended with stopReason=${response.stopReason}`,
|
|
469
|
+
modelId: model.id,
|
|
470
|
+
latencyMs,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return { ok: true, modelId: model.id, latencyMs };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Build the {@link CompletionProbe} consumed by
|
|
478
|
+
* {@link AuthStorage.checkCredentials} in `--strict` mode. Walks the cheapest
|
|
479
|
+
* candidates per provider, retrying on "model not found / invalid model"
|
|
480
|
+
* errors so a stale catalog entry doesn't masquerade as a bad credential.
|
|
481
|
+
* Stops as soon as one model returns a successful response (the credential
|
|
482
|
+
* authenticated against at least one model in the catalog).
|
|
483
|
+
*/
|
|
484
|
+
function createStrictCompletionProbe(): CompletionProbe {
|
|
485
|
+
return async (input: CompletionProbeInput): Promise<CredentialCompletionResult> => {
|
|
486
|
+
const candidates = pickProbeCandidates(input.provider).slice(0, STRICT_PROBE_MAX_CANDIDATES);
|
|
487
|
+
if (candidates.length === 0) {
|
|
488
|
+
return { ok: null, reason: `no bearer-compatible probe model bundled for provider ${input.provider}` };
|
|
489
|
+
}
|
|
490
|
+
const apiKey = composeProbeApiKey(input.provider, input.credential);
|
|
491
|
+
let lastFailure: CredentialCompletionResult | undefined;
|
|
492
|
+
for (const model of candidates) {
|
|
493
|
+
if (input.signal.aborted) {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
reason: "aborted",
|
|
497
|
+
modelId: model.id,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const result = await probeOneModel(model, apiKey, input.signal);
|
|
501
|
+
if (result.ok === true) return result;
|
|
502
|
+
lastFailure = result;
|
|
503
|
+
if (!RETRYABLE_MODEL_ERROR_RE.test(result.reason ?? "")) {
|
|
504
|
+
// Non-model error (401, 403, 5xx, network) — the credential is the
|
|
505
|
+
// issue, not the catalog. Stop walking.
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return (
|
|
510
|
+
lastFailure ?? {
|
|
511
|
+
ok: false,
|
|
512
|
+
reason: `all ${candidates.length} probe models failed for provider ${input.provider}`,
|
|
513
|
+
}
|
|
514
|
+
);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function formatCompletionStatus(completion: CredentialCompletionResult | undefined): string {
|
|
519
|
+
if (!completion) return "";
|
|
520
|
+
if (completion.ok === true) return chalk.green(" [chat: ok]");
|
|
521
|
+
if (completion.ok === false) return chalk.red(" [chat: FAIL]");
|
|
522
|
+
return chalk.yellow(" [chat: skip]");
|
|
523
|
+
}
|
|
524
|
+
|
|
345
525
|
/**
|
|
346
526
|
* `omp auth-gateway check` — probe each broker-supplied credential and print
|
|
347
527
|
* per-credential auth health. Use this when the gateway is returning 401s and
|
|
348
528
|
* you need to find which row in a multi-account pool is the bad one. The
|
|
349
529
|
* aggregate `/v1/usage` endpoint silently drops failed credentials, so a
|
|
350
530
|
* dedicated diagnostic is the only way to see which credentials failed.
|
|
531
|
+
*
|
|
532
|
+
* Strict mode (`--strict`) additionally exercises each credential against a
|
|
533
|
+
* cheap chat model from its provider's bundled catalog. This catches the case
|
|
534
|
+
* where the usage endpoint reports 200 but the chat endpoint 401s the same
|
|
535
|
+
* bearer (revoked OAuth scope, mislabeled provider row, etc).
|
|
351
536
|
*/
|
|
352
537
|
async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
353
538
|
const brokerConfig = await resolveAuthBrokerConfig();
|
|
@@ -363,10 +548,16 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
363
548
|
const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
|
|
364
549
|
try {
|
|
365
550
|
await storage.reload();
|
|
366
|
-
const results = await storage.checkCredentials(
|
|
551
|
+
const results = await storage.checkCredentials(
|
|
552
|
+
flags.strict
|
|
553
|
+
? { completionProbe: createStrictCompletionProbe(), completionTimeoutMs: STRICT_PROBE_OVERALL_TIMEOUT_MS }
|
|
554
|
+
: undefined,
|
|
555
|
+
);
|
|
367
556
|
|
|
368
557
|
if (flags.json) {
|
|
369
|
-
process.stdout.write(
|
|
558
|
+
process.stdout.write(
|
|
559
|
+
`${JSON.stringify({ broker: brokerConfig.url, strict: flags.strict === true, credentials: results }, null, 2)}\n`,
|
|
560
|
+
);
|
|
370
561
|
} else {
|
|
371
562
|
const grouped = new Map<string, typeof results>();
|
|
372
563
|
for (const row of results) {
|
|
@@ -375,7 +566,7 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
375
566
|
grouped.set(row.provider, list);
|
|
376
567
|
}
|
|
377
568
|
const providers = [...grouped.keys()].sort();
|
|
378
|
-
process.stdout.write(`broker: ${brokerConfig.url}\n`);
|
|
569
|
+
process.stdout.write(`broker: ${brokerConfig.url}${flags.strict ? chalk.dim(" [strict]") : ""}\n`);
|
|
379
570
|
for (const provider of providers) {
|
|
380
571
|
const rows = grouped.get(provider) ?? [];
|
|
381
572
|
process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
|
|
@@ -389,19 +580,29 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
|
|
|
389
580
|
const identity =
|
|
390
581
|
row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
|
|
391
582
|
const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
|
|
392
|
-
const
|
|
583
|
+
const reasonParts: string[] = [];
|
|
584
|
+
if (row.reason) reasonParts.push(row.reason);
|
|
585
|
+
if (row.completion?.reason) reasonParts.push(`chat: ${row.completion.reason}`);
|
|
586
|
+
const reason = reasonParts.length > 0 ? chalk.dim(` — ${reasonParts.join("; ")}`) : "";
|
|
587
|
+
const chat = formatCompletionStatus(row.completion);
|
|
393
588
|
process.stdout.write(
|
|
394
|
-
` ${status} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
|
|
589
|
+
` ${status}${chat} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
|
|
395
590
|
);
|
|
396
591
|
}
|
|
397
592
|
}
|
|
398
593
|
const failed = results.filter(row => row.ok === false).length;
|
|
399
594
|
const unverifiable = results.filter(row => row.ok === null).length;
|
|
400
595
|
const passing = results.filter(row => row.ok === true).length;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
596
|
+
const chatFailed = flags.strict ? results.filter(row => row.completion?.ok === false).length : 0;
|
|
597
|
+
const summaryParts = [
|
|
598
|
+
chalk.green(`${passing} ok`),
|
|
599
|
+
chalk.red(`${failed} failed`),
|
|
600
|
+
chalk.yellow(`${unverifiable} unverifiable`),
|
|
601
|
+
];
|
|
602
|
+
if (flags.strict) summaryParts.push(chalk.red(`${chatFailed} chat-failed`));
|
|
603
|
+
summaryParts.push(`${results.length} total`);
|
|
604
|
+
process.stdout.write(`\n${summaryParts.join(", ")}\n`);
|
|
605
|
+
if (failed > 0 || chatFailed > 0) process.exitCode = 1;
|
|
405
606
|
}
|
|
406
607
|
} finally {
|
|
407
608
|
storage.close();
|
|
@@ -22,13 +22,17 @@ export default class AuthGateway extends Command {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
static flags = {
|
|
25
|
-
json: Flags.boolean({ description: "Output JSON (token/status)" }),
|
|
25
|
+
json: Flags.boolean({ description: "Output JSON (token/status/check)" }),
|
|
26
26
|
bind: Flags.string({ description: "Bind address for `serve` (host:port)", char: "b" }),
|
|
27
27
|
regenerate: Flags.boolean({ description: "Regenerate the gateway bearer token (token)" }),
|
|
28
28
|
"no-auth": Flags.boolean({
|
|
29
29
|
description:
|
|
30
30
|
"Disable inbound bearer-token auth (serve). Useful when bound to loopback — any caller is allowed.",
|
|
31
31
|
}),
|
|
32
|
+
strict: Flags.boolean({
|
|
33
|
+
description:
|
|
34
|
+
"For `check`: additionally probe each credential against its provider's chat-completion endpoint. Slower; consumes a tiny amount of quota per credential.",
|
|
35
|
+
}),
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
static examples = [
|
|
@@ -40,6 +44,7 @@ export default class AuthGateway extends Command {
|
|
|
40
44
|
"# Show local gateway + broker config status\n omp auth-gateway status",
|
|
41
45
|
"# Probe each broker credential to see which one is producing 401s\n omp auth-gateway check",
|
|
42
46
|
"# Same, machine-readable for scripts\n omp auth-gateway check --json",
|
|
47
|
+
"# Strict check — also exercises each credential with a real chat-completion ping\n omp auth-gateway check --strict",
|
|
43
48
|
];
|
|
44
49
|
|
|
45
50
|
async run(): Promise<void> {
|
|
@@ -55,6 +60,7 @@ export default class AuthGateway extends Command {
|
|
|
55
60
|
bind: flags.bind,
|
|
56
61
|
regenerate: flags.regenerate,
|
|
57
62
|
noAuth: flags["no-auth"],
|
|
63
|
+
strict: flags.strict,
|
|
58
64
|
},
|
|
59
65
|
};
|
|
60
66
|
await initTheme();
|
|
@@ -1568,16 +1568,6 @@ export const SETTINGS_SCHEMA = {
|
|
|
1568
1568
|
},
|
|
1569
1569
|
},
|
|
1570
1570
|
|
|
1571
|
-
"edit.hashlineAutoDropPureInsertDuplicates": {
|
|
1572
|
-
type: "boolean",
|
|
1573
|
-
default: false,
|
|
1574
|
-
ui: {
|
|
1575
|
-
tab: "editing",
|
|
1576
|
-
label: "Hashline Duplicate Insert Drop",
|
|
1577
|
-
description:
|
|
1578
|
-
"Drop payload lines that duplicate adjacent file context — 2+-line context echoes on `↑`/`↓` inserts, and a single boundary line at either edge of an `A-B:` replacement",
|
|
1579
|
-
},
|
|
1580
|
-
},
|
|
1581
1571
|
"edit.blockAutoGenerated": {
|
|
1582
1572
|
type: "boolean",
|
|
1583
1573
|
default: true,
|
|
@@ -1605,7 +1595,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1605
1595
|
tab: "editing",
|
|
1606
1596
|
label: "Hash Lines",
|
|
1607
1597
|
description:
|
|
1608
|
-
"Include
|
|
1598
|
+
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (¶PATH#tag plus LINE:content)",
|
|
1609
1599
|
},
|
|
1610
1600
|
},
|
|
1611
1601
|
|
|
@@ -2083,6 +2073,17 @@ export const SETTINGS_SCHEMA = {
|
|
|
2083
2073
|
ui: { tab: "tools", label: "Read URLs", description: "Allow the read tool to fetch and process URLs" },
|
|
2084
2074
|
},
|
|
2085
2075
|
|
|
2076
|
+
"vault.enabled": {
|
|
2077
|
+
type: "boolean",
|
|
2078
|
+
default: false,
|
|
2079
|
+
ui: {
|
|
2080
|
+
tab: "tools",
|
|
2081
|
+
label: "Obsidian Vault",
|
|
2082
|
+
description:
|
|
2083
|
+
"Enable the vault:// internal URL for reading and editing Obsidian vault content via the Obsidian CLI. When disabled, vault:// resolution is refused and the vault:// entry is omitted from the system prompt.",
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
|
|
2086
2087
|
"github.enabled": {
|
|
2087
2088
|
type: "boolean",
|
|
2088
2089
|
default: false,
|
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
* Session-bound file snapshot store.
|
|
3
3
|
*
|
|
4
4
|
* Used by `read` and `search` to record exactly what the model saw, and by
|
|
5
|
-
* the hashline patcher to recover from stale section
|
|
6
|
-
* externally between read and edit, or a prior in-session edit
|
|
7
|
-
* the
|
|
5
|
+
* the hashline patcher to verify or recover from stale section tags (file
|
|
6
|
+
* changed externally between read and edit, or a prior in-session edit
|
|
7
|
+
* advanced the tag). The store is the {@link InMemorySnapshotStore}
|
|
8
8
|
* from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
|
|
9
|
-
* is wiring it onto the per-session
|
|
9
|
+
* is wiring it onto the per-session owner object.
|
|
10
10
|
*/
|
|
11
11
|
import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
interface FileSnapshotStoreOwner {
|
|
14
|
+
fileSnapshotStore?: InMemorySnapshotStore;
|
|
15
|
+
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Look up (or lazily create) the file snapshot store attached to a session.
|
|
16
19
|
* Storage lives on `session.fileSnapshotStore` so it ages out exactly with
|
|
17
20
|
* the session itself.
|
|
18
21
|
*/
|
|
19
|
-
export function getFileSnapshotStore(session:
|
|
22
|
+
export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemorySnapshotStore {
|
|
20
23
|
if (!session.fileSnapshotStore) session.fileSnapshotStore = new InMemorySnapshotStore();
|
|
21
24
|
return session.fileSnapshotStore;
|
|
22
25
|
}
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* pair to {@link generateDiffString} so the renderer can show the diff
|
|
6
6
|
* while the tool call is still streaming.
|
|
7
7
|
*
|
|
8
|
-
* Validation is intentionally light: only the section
|
|
8
|
+
* Validation is intentionally light: only the section snapshot tag is checked
|
|
9
9
|
* (so the preview goes red when anchors are stale), no plan-mode guards
|
|
10
10
|
* and no auto-generated-file refusal — those belong on the write path.
|
|
11
11
|
*/
|
|
12
12
|
import {
|
|
13
|
-
computeFileHash,
|
|
14
13
|
Patch as HashlinePatch,
|
|
15
14
|
normalizeToLF,
|
|
16
15
|
type Patch,
|
|
17
16
|
type PatchSection,
|
|
17
|
+
type Snapshot,
|
|
18
|
+
type SnapshotStore,
|
|
18
19
|
stripBom,
|
|
19
20
|
} from "@oh-my-pi/hashline";
|
|
20
21
|
import { resolveToCwd } from "../../tools/path-utils";
|
|
@@ -22,7 +23,6 @@ import { generateDiffString } from "../diff";
|
|
|
22
23
|
import { readEditFileText } from "../read-file";
|
|
23
24
|
|
|
24
25
|
export interface HashlineDiffOptions {
|
|
25
|
-
autoDropPureInsertDuplicates?: boolean;
|
|
26
26
|
/**
|
|
27
27
|
* Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
|
|
28
28
|
* so trailing in-flight ops do not throw or emit phantom edits. Streaming
|
|
@@ -44,20 +44,34 @@ function hasAnchorScoped(section: PatchSection): boolean {
|
|
|
44
44
|
return section.hasAnchorScopedEdit;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
47
|
+
function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string, anchorLines: readonly number[]): boolean {
|
|
48
|
+
if (snapshot.fullText !== undefined) return snapshot.fullText === currentText;
|
|
49
|
+
for (const lineNumber of anchorLines) {
|
|
50
|
+
if (snapshot.get(lineNumber) === undefined) return false;
|
|
51
|
+
}
|
|
52
|
+
return snapshot.matchesLiveFile(currentText.split("\n"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateSectionHash(
|
|
56
|
+
section: PatchSection,
|
|
57
|
+
absolutePath: string,
|
|
58
|
+
text: string,
|
|
59
|
+
snapshots: SnapshotStore,
|
|
60
|
+
): string | null {
|
|
48
61
|
if (section.fileHash === undefined) {
|
|
49
62
|
return hasAnchorScoped(section)
|
|
50
|
-
? `Missing hashline
|
|
63
|
+
? `Missing hashline snapshot tag for anchored edit to ${section.path}; use \`¶${section.path}#tag\` from your latest read.`
|
|
51
64
|
: null;
|
|
52
65
|
}
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
return `Hashline
|
|
66
|
+
const snapshot = snapshots.byHash(absolutePath, section.fileHash);
|
|
67
|
+
if (snapshot && snapshotMatchesCurrent(snapshot, text, section.collectAnchorLines())) return null;
|
|
68
|
+
return `Hashline snapshot tag mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file does not match that snapshot; re-read and try again.`;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
export async function computeHashlineSectionDiff(
|
|
59
72
|
section: PatchSection,
|
|
60
73
|
cwd: string,
|
|
74
|
+
snapshots: SnapshotStore,
|
|
61
75
|
options: HashlineDiffOptions = {},
|
|
62
76
|
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
63
77
|
try {
|
|
@@ -65,11 +79,9 @@ export async function computeHashlineSectionDiff(
|
|
|
65
79
|
const rawContent = await readSectionText(absolutePath, section.path);
|
|
66
80
|
const { text: content } = stripBom(rawContent);
|
|
67
81
|
const normalized = normalizeToLF(content);
|
|
68
|
-
const hashError = validateSectionHash(section, normalized);
|
|
82
|
+
const hashError = validateSectionHash(section, absolutePath, normalized, snapshots);
|
|
69
83
|
if (hashError) return { error: hashError };
|
|
70
|
-
const result = options.streaming
|
|
71
|
-
? section.applyPartialTo(normalized, options)
|
|
72
|
-
: section.applyTo(normalized, options);
|
|
84
|
+
const result = options.streaming ? section.applyPartialTo(normalized) : section.applyTo(normalized);
|
|
73
85
|
if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
|
|
74
86
|
return generateDiffString(normalized, result.text);
|
|
75
87
|
} catch (err) {
|
|
@@ -80,6 +92,7 @@ export async function computeHashlineSectionDiff(
|
|
|
80
92
|
export async function computeHashlineDiff(
|
|
81
93
|
input: { input: string },
|
|
82
94
|
cwd: string,
|
|
95
|
+
snapshots: SnapshotStore,
|
|
83
96
|
options: HashlineDiffOptions = {},
|
|
84
97
|
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
85
98
|
let patch: Patch;
|
|
@@ -91,5 +104,5 @@ export async function computeHashlineDiff(
|
|
|
91
104
|
if (patch.sections.length !== 1) {
|
|
92
105
|
return { error: "Streaming diff preview supports exactly one hashline section." };
|
|
93
106
|
}
|
|
94
|
-
return computeHashlineSectionDiff(patch.sections[0], cwd, options);
|
|
107
|
+
return computeHashlineSectionDiff(patch.sections[0], cwd, snapshots, options);
|
|
95
108
|
}
|
|
@@ -37,14 +37,19 @@ export interface ExecuteHashlineSingleOptions {
|
|
|
37
37
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function getHashlineApplyOptions(session: ToolSession): { autoDropPureInsertDuplicates: boolean } {
|
|
41
|
-
return {
|
|
42
|
-
autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
function noChangeDiagnostic(path: string): string {
|
|
47
|
-
|
|
41
|
+
// The patch parsed and applied cleanly but produced no change — the
|
|
42
|
+
// `|literal` body rows matched the file content at the targeted lines
|
|
43
|
+
// byte-for-byte. The model usually misreads this as "wrong anchor, try
|
|
44
|
+
// again with a bigger payload" and starts duplicating content; the
|
|
45
|
+
// message below names the cause directly so the next turn can re-read
|
|
46
|
+
// instead of expanding the patch.
|
|
47
|
+
return (
|
|
48
|
+
`Edits to ${path} parsed and applied cleanly, but produced no change: ` +
|
|
49
|
+
`your body row(s) are byte-identical to the file at the targeted lines. ` +
|
|
50
|
+
`The bug is somewhere else — re-read the file before issuing another edit. ` +
|
|
51
|
+
`Do NOT widen the payload or add lines; verify the anchor first.`
|
|
52
|
+
);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
|
|
@@ -128,8 +133,7 @@ export async function executeHashlineSingle(
|
|
|
128
133
|
batchRequest: options.batchRequest,
|
|
129
134
|
});
|
|
130
135
|
const snapshots = getFileSnapshotStore(options.session);
|
|
131
|
-
const
|
|
132
|
-
const patcher = new Patcher({ fs, snapshots, applyOptions });
|
|
136
|
+
const patcher = new Patcher({ fs, snapshots });
|
|
133
137
|
|
|
134
138
|
// Single-section fast path: prepare, commit, render.
|
|
135
139
|
if (patch.sections.length === 1) {
|
package/src/edit/renderer.ts
CHANGED
|
@@ -235,24 +235,24 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
|
|
|
235
235
|
|
|
236
236
|
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
|
|
237
237
|
if (!diff) return "";
|
|
238
|
-
// Hunk-aware truncation keeps the change rows themselves visible
|
|
239
|
-
//
|
|
240
|
-
//
|
|
238
|
+
// Hunk-aware truncation keeps the change rows themselves visible. Tail-mode
|
|
239
|
+
// pins the visible window to the bottom of the diff so newly streamed
|
|
240
|
+
// hunks stay on screen as more arrives, instead of leaving the user stuck
|
|
241
|
+
// staring at the head of the file while the tail scrolls offscreen.
|
|
241
242
|
const {
|
|
242
243
|
text: truncatedDiff,
|
|
243
244
|
hiddenHunks,
|
|
244
245
|
hiddenLines,
|
|
245
|
-
} = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES);
|
|
246
|
+
} = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES, { fromTail: true });
|
|
246
247
|
let text = "\n\n";
|
|
247
|
-
text += renderDiffColored(truncatedDiff, { filePath: rawPath });
|
|
248
248
|
if (hiddenHunks > 0 || hiddenLines > 0) {
|
|
249
249
|
const remainder: string[] = [];
|
|
250
250
|
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
|
|
251
251
|
if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
|
|
252
|
-
text += uiTheme.fg("dim",
|
|
253
|
-
} else {
|
|
254
|
-
text += uiTheme.fg("dim", `\n(${label})`);
|
|
252
|
+
text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
|
|
255
253
|
}
|
|
254
|
+
text += renderDiffColored(truncatedDiff, { filePath: rawPath });
|
|
255
|
+
text += uiTheme.fg("dim", `\n(${label})`);
|
|
256
256
|
return text;
|
|
257
257
|
}
|
|
258
258
|
|
|
@@ -312,7 +312,7 @@ const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** E
|
|
|
312
312
|
|
|
313
313
|
function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
314
314
|
const trimmed = rawPath.trim();
|
|
315
|
-
const hashStart = /#[0-9a-
|
|
315
|
+
const hashStart = /#[0-9a-fA-F]{3}$/u.exec(trimmed)?.index;
|
|
316
316
|
const withoutHash = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
|
|
317
317
|
if (withoutHash.length < 2) return withoutHash;
|
|
318
318
|
const first = withoutHash[0];
|
package/src/edit/streaming.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
END_PATCH_MARKER,
|
|
21
21
|
type PatchSection as HashlineInputSection,
|
|
22
22
|
Patch as HashlinePatch,
|
|
23
|
+
type SnapshotStore,
|
|
23
24
|
} from "@oh-my-pi/hashline";
|
|
24
25
|
import type { Theme } from "../modes/theme/theme";
|
|
25
26
|
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
@@ -39,9 +40,9 @@ export interface PerFileDiffPreview {
|
|
|
39
40
|
export interface StreamingDiffContext {
|
|
40
41
|
cwd: string;
|
|
41
42
|
signal: AbortSignal;
|
|
43
|
+
snapshots: SnapshotStore;
|
|
42
44
|
fuzzyThreshold?: number;
|
|
43
45
|
allowFuzzy?: boolean;
|
|
44
|
-
hashlineAutoDropPureInsertDuplicates?: boolean;
|
|
45
46
|
/**
|
|
46
47
|
* True while the tool's arguments are still streaming in. Strategies that
|
|
47
48
|
* accept free-form text input (apply_patch, hashline) trim the trailing
|
|
@@ -325,9 +326,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
325
326
|
// to parse; suppress until the next chunk arrives. Once args are
|
|
326
327
|
// complete, surface the error so the model sees what went wrong.
|
|
327
328
|
if (ctx.isStreaming) return null;
|
|
328
|
-
const result = await computeHashlineDiff({ input }, ctx.cwd,
|
|
329
|
-
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
330
|
-
});
|
|
329
|
+
const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots);
|
|
331
330
|
ctx.signal.throwIfAborted();
|
|
332
331
|
return [toPerFilePreview("", result)];
|
|
333
332
|
}
|
|
@@ -346,8 +345,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
346
345
|
for (let i = 0; i < sectionsToProcess.length; i++) {
|
|
347
346
|
ctx.signal.throwIfAborted();
|
|
348
347
|
const section = sectionsToProcess[i];
|
|
349
|
-
const result = await computeHashlineSectionDiff(section, ctx.cwd, {
|
|
350
|
-
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
348
|
+
const result = await computeHashlineSectionDiff(section, ctx.cwd, ctx.snapshots, {
|
|
351
349
|
streaming: ctx.isStreaming,
|
|
352
350
|
});
|
|
353
351
|
ctx.signal.throwIfAborted();
|
package/src/eval/py/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { checkPythonKernelAvailability } from "./kernel";
|
|
|
5
5
|
|
|
6
6
|
const PYTHON_SESSION_PREFIX = "python:";
|
|
7
7
|
|
|
8
|
-
function namespaceSessionId(sessionId: string): string {
|
|
8
|
+
export function namespaceSessionId(sessionId: string): string {
|
|
9
9
|
return sessionId.startsWith(PYTHON_SESSION_PREFIX) ? sessionId : `${PYTHON_SESSION_PREFIX}${sessionId}`;
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -100,7 +100,7 @@ export type CustomToolSessionEvent =
|
|
|
100
100
|
}
|
|
101
101
|
| {
|
|
102
102
|
reason: "auto_compaction_start";
|
|
103
|
-
trigger: "threshold" | "overflow" | "idle";
|
|
103
|
+
trigger: "threshold" | "overflow" | "idle" | "incomplete";
|
|
104
104
|
action: "context-full" | "handoff";
|
|
105
105
|
}
|
|
106
106
|
| {
|
|
@@ -203,7 +203,7 @@ export interface TurnEndEvent {
|
|
|
203
203
|
/** Fired when auto-compaction starts */
|
|
204
204
|
export interface AutoCompactionStartEvent {
|
|
205
205
|
type: "auto_compaction_start";
|
|
206
|
-
reason: "threshold" | "overflow" | "idle";
|
|
206
|
+
reason: "threshold" | "overflow" | "idle" | "incomplete";
|
|
207
207
|
action: "context-full" | "handoff";
|
|
208
208
|
}
|
|
209
209
|
|