@oh-my-pi/pi-coding-agent 15.2.3 → 15.3.0
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 +41 -0
- package/dist/types/config/settings-schema.d.ts +34 -1
- package/dist/types/config/settings.d.ts +6 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/goals/runtime.d.ts +4 -0
- package/dist/types/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/hash.d.ts +13 -39
- package/dist/types/hashline/parser.d.ts +2 -6
- package/dist/types/modes/components/status-line/types.d.ts +10 -0
- package/dist/types/modes/components/status-line.d.ts +10 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/shared.d.ts +9 -0
- package/dist/types/modes/theme/shimmer.d.ts +6 -3
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +17 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -0
- package/dist/types/tools/bash-command-fixup.d.ts +0 -5
- package/dist/types/utils/clipboard.d.ts +3 -1
- package/dist/types/utils/image-resize.d.ts +4 -1
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +1 -8
- package/src/config/settings-schema.ts +29 -1
- package/src/config/settings.ts +19 -0
- package/src/discovery/helpers.ts +5 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +5 -7
- package/src/edit/streaming.ts +24 -12
- package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
- package/src/goals/runtime.ts +35 -13
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/grammar.lark +7 -8
- package/src/hashline/hash.ts +21 -43
- package/src/hashline/input.ts +15 -13
- package/src/hashline/parser.ts +62 -161
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/main.ts +1 -1
- package/src/modes/components/model-selector.ts +53 -22
- package/src/modes/components/status-line/segments.ts +53 -0
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +147 -12
- package/src/modes/controllers/command-controller.ts +9 -0
- package/src/modes/controllers/event-controller.ts +10 -1
- package/src/modes/interactive-mode.ts +74 -18
- package/src/modes/shared.ts +16 -0
- package/src/modes/theme/shimmer.ts +15 -6
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/context-usage.ts +25 -2
- package/src/modes/utils/ui-helpers.ts +11 -1
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/prompts/tools/hashline.md +62 -81
- package/src/sdk.ts +24 -0
- package/src/session/agent-session.ts +58 -0
- package/src/session/session-manager.ts +54 -1
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +50 -1
- package/src/task/index.ts +11 -0
- package/src/task/render.ts +26 -2
- package/src/task/types.ts +35 -0
- package/src/tools/bash-command-fixup.ts +0 -10
- package/src/tools/bash.ts +1 -9
- package/src/utils/clipboard.ts +68 -3
- package/src/utils/commit-message-generator.ts +6 -1
- package/src/utils/image-resize.ts +51 -26
- package/src/utils/title-generator.ts +45 -13
- package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
- package/src/modes/components/status-line-segment-editor.ts +0 -359
|
@@ -81,7 +81,8 @@ export type StatusLineSegmentId =
|
|
|
81
81
|
| "hostname"
|
|
82
82
|
| "cache_read"
|
|
83
83
|
| "cache_write"
|
|
84
|
-
| "session_name"
|
|
84
|
+
| "session_name"
|
|
85
|
+
| "usage";
|
|
85
86
|
|
|
86
87
|
/** Submenu choice metadata. */
|
|
87
88
|
export type SubmenuOption<V extends string = string> = {
|
|
@@ -836,6 +837,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
836
837
|
},
|
|
837
838
|
|
|
838
839
|
"retry.baseDelayMs": { type: "number", default: 2000 },
|
|
840
|
+
"retry.maxDelayMs": {
|
|
841
|
+
type: "number",
|
|
842
|
+
default: 5 * 60 * 1000,
|
|
843
|
+
ui: {
|
|
844
|
+
tab: "model",
|
|
845
|
+
label: "Max Retry Delay",
|
|
846
|
+
description:
|
|
847
|
+
"Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
|
|
848
|
+
},
|
|
849
|
+
},
|
|
839
850
|
"retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
|
|
840
851
|
"retry.fallbackRevertPolicy": {
|
|
841
852
|
type: "enum",
|
|
@@ -2612,6 +2623,22 @@ export const SETTINGS_SCHEMA = {
|
|
|
2612
2623
|
description: "Use Parallel extract API for URL fetching when credentials are available",
|
|
2613
2624
|
},
|
|
2614
2625
|
},
|
|
2626
|
+
"provider.appendOnlyContext": {
|
|
2627
|
+
type: "enum",
|
|
2628
|
+
values: ["auto", "on", "off"] as const,
|
|
2629
|
+
default: "auto",
|
|
2630
|
+
ui: {
|
|
2631
|
+
tab: "providers",
|
|
2632
|
+
label: "Append-Only Context",
|
|
2633
|
+
description:
|
|
2634
|
+
"Cache system prompt + tool specs and keep an append-only message log so provider prefix caches (DeepSeek, Anthropic) hit at maximum rate. Auto enables for DeepSeek.",
|
|
2635
|
+
options: [
|
|
2636
|
+
{ value: "auto", label: "Auto", description: "Enable for DeepSeek (recommended)" },
|
|
2637
|
+
{ value: "on", label: "On", description: "Always enable append-only context" },
|
|
2638
|
+
{ value: "off", label: "Off", description: "Disable append-only context" },
|
|
2639
|
+
],
|
|
2640
|
+
},
|
|
2641
|
+
},
|
|
2615
2642
|
|
|
2616
2643
|
// Exa
|
|
2617
2644
|
"exa.enabled": {
|
|
@@ -2843,6 +2870,7 @@ export interface RetrySettings {
|
|
|
2843
2870
|
enabled: boolean;
|
|
2844
2871
|
maxRetries: number;
|
|
2845
2872
|
baseDelayMs: number;
|
|
2873
|
+
maxDelayMs: number;
|
|
2846
2874
|
}
|
|
2847
2875
|
|
|
2848
2876
|
export interface MemoriesSettings {
|
package/src/config/settings.ts
CHANGED
|
@@ -856,7 +856,26 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
|
|
|
856
856
|
setDefaultTabWidth(value);
|
|
857
857
|
}
|
|
858
858
|
},
|
|
859
|
+
"provider.appendOnlyContext": value => {
|
|
860
|
+
if (typeof value === "string") {
|
|
861
|
+
for (const cb of appendOnlyModeCallbacks) cb(value);
|
|
862
|
+
}
|
|
863
|
+
},
|
|
859
864
|
};
|
|
865
|
+
/** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
|
|
866
|
+
const appendOnlyModeCallbacks = new Set<(value: string) => void>();
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Subscribe to append-only mode setting changes.
|
|
870
|
+
* Returns an unsubscribe function. Multiple sessions (main + subagents)
|
|
871
|
+
* can register independently without overwriting each other.
|
|
872
|
+
*/
|
|
873
|
+
export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void {
|
|
874
|
+
appendOnlyModeCallbacks.add(cb);
|
|
875
|
+
return () => {
|
|
876
|
+
appendOnlyModeCallbacks.delete(cb);
|
|
877
|
+
};
|
|
878
|
+
}
|
|
860
879
|
|
|
861
880
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
862
881
|
// Global Singleton
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -211,6 +211,7 @@ export interface ParsedAgentFields {
|
|
|
211
211
|
model?: string[];
|
|
212
212
|
output?: unknown;
|
|
213
213
|
thinkingLevel?: ThinkingLevel;
|
|
214
|
+
autoloadSkills?: string[];
|
|
214
215
|
blocking?: boolean;
|
|
215
216
|
}
|
|
216
217
|
|
|
@@ -264,7 +265,10 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
|
|
|
264
265
|
const thinkingLevel = parseThinkingLevel(rawThinkingLevel);
|
|
265
266
|
const model = parseModelList(frontmatter.model);
|
|
266
267
|
const blocking = parseBoolean(frontmatter.blocking);
|
|
267
|
-
|
|
268
|
+
const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills)
|
|
269
|
+
?.map(s => s.trim())
|
|
270
|
+
.filter(Boolean);
|
|
271
|
+
return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills };
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
async function globIf(
|
package/src/edit/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ export * from "./apply-patch";
|
|
|
35
35
|
export * from "./diff";
|
|
36
36
|
export * from "./file-read-cache";
|
|
37
37
|
|
|
38
|
-
// Resolve the `$HFMT
|
|
38
|
+
// Resolve the `$HFMT$`, `$HOP_*$`, `$HOP_CHARS$`, and `$HFILE$` placeholders in the hashline Lark grammar.
|
|
39
39
|
const hashlineGrammar = resolveHashlineGrammarPlaceholders(hashlineGrammarTemplate);
|
|
40
40
|
|
|
41
41
|
export * from "../hashline";
|
package/src/edit/renderer.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
6
6
|
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
9
|
+
import { HL_FILE_PREFIX } from "../hashline/hash";
|
|
9
10
|
import type { FileDiagnosticsResult } from "../lsp";
|
|
10
11
|
import { renderDiff as renderDiffColored } from "../modes/components/diff";
|
|
11
12
|
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
|
@@ -328,7 +329,6 @@ function getCallPreview(
|
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** End Patch'";
|
|
331
|
-
const HL_INPUT_HEADER_PREFIX = "@";
|
|
332
332
|
|
|
333
333
|
function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
334
334
|
const trimmed = rawPath.trim();
|
|
@@ -342,13 +342,11 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
function parseHashlineInputPreviewHeader(line: string): string | null {
|
|
345
|
-
if (!line.startsWith(
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// (and stray "@ PATH" / "@@@ PATH" runs) all route to the same file. Mirror
|
|
349
|
-
// that here so the renderer doesn't surface a literal "@ " in the title.
|
|
345
|
+
if (!line.startsWith(HL_FILE_PREFIX)) return null;
|
|
346
|
+
// Mirror hashline/input.ts: strip every leading file marker so canonical
|
|
347
|
+
// `§ PATH` headers and stray `§§ PATH` / `§§§PATH` runs render clean paths.
|
|
350
348
|
let prefixEnd = 0;
|
|
351
|
-
while (prefixEnd < line.length && line[prefixEnd] ===
|
|
349
|
+
while (prefixEnd < line.length && line[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
|
352
350
|
const body = line.slice(prefixEnd).trim();
|
|
353
351
|
const previewPath = normalizeHashlineInputPreviewPath(body);
|
|
354
352
|
return previewPath.length > 0 ? previewPath : null;
|
package/src/edit/streaming.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
containsRecognizableHashlineOperations,
|
|
23
23
|
END_PATCH_MARKER,
|
|
24
24
|
type HashlineInputSection,
|
|
25
|
+
HL_FILE_PREFIX,
|
|
26
|
+
HL_OP_CHARS,
|
|
25
27
|
splitHashlineInputs,
|
|
26
28
|
} from "../hashline";
|
|
27
29
|
import type { Theme } from "../modes/theme/theme";
|
|
@@ -77,8 +79,19 @@ const STREAMING_FALLBACK_LINES = 12;
|
|
|
77
79
|
const STREAMING_FALLBACK_WIDTH = 80;
|
|
78
80
|
|
|
79
81
|
function isHashlineHeaderLine(line: string): boolean {
|
|
82
|
+
return line.trimEnd().startsWith(HL_FILE_PREFIX);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseHashlineHeaderPath(line: string): string {
|
|
80
86
|
const trimmed = line.trimEnd();
|
|
81
|
-
|
|
87
|
+
let prefixEnd = 0;
|
|
88
|
+
while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
|
89
|
+
return trimmed.slice(prefixEnd).trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isHashlineOpLine(line: string): boolean {
|
|
93
|
+
const first = line[0];
|
|
94
|
+
return first !== undefined && HL_OP_CHARS.includes(first);
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
function isHashlineEnvelopeMarkerLine(line: string): boolean {
|
|
@@ -358,11 +371,11 @@ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[
|
|
|
358
371
|
}
|
|
359
372
|
|
|
360
373
|
/**
|
|
361
|
-
* Hashline equivalent: emit each
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
374
|
+
* Hashline equivalent: emit each payload line as a `+added` line in the
|
|
375
|
+
* order the model typed it. We deliberately omit op headers and removal
|
|
376
|
+
* targets from the streaming preview because their content lives in the file
|
|
377
|
+
* and would require a costly re-apply per tick; the complete unified diff is
|
|
378
|
+
* shown once streaming finishes.
|
|
366
379
|
*/
|
|
367
380
|
function buildHashlineNaturalOrderPreviews(
|
|
368
381
|
input: string,
|
|
@@ -382,13 +395,12 @@ function buildHashlineNaturalOrderPreviews(
|
|
|
382
395
|
for (const raw of lines) {
|
|
383
396
|
if (isHashlineEnvelopeMarkerLine(raw)) continue;
|
|
384
397
|
if (isHashlineHeaderLine(raw)) {
|
|
385
|
-
currentPath = raw
|
|
398
|
+
currentPath = parseHashlineHeaderPath(raw);
|
|
386
399
|
if (currentPath) ensure(currentPath);
|
|
387
400
|
continue;
|
|
388
401
|
}
|
|
389
|
-
if (raw
|
|
390
|
-
|
|
391
|
-
}
|
|
402
|
+
if (isHashlineOpLine(raw) || !currentPath) continue;
|
|
403
|
+
ensure(currentPath).push(`+${raw}`);
|
|
392
404
|
}
|
|
393
405
|
if (groups.size === 0) return null;
|
|
394
406
|
const previews: PerFileDiffPreview[] = [];
|
|
@@ -409,7 +421,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
409
421
|
if (input.length === 0) return null;
|
|
410
422
|
if (ctx.isStreaming) {
|
|
411
423
|
// Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
|
|
412
|
-
// reordering by showing
|
|
424
|
+
// reordering by showing payload lines in input order.
|
|
413
425
|
return buildHashlineNaturalOrderPreviews(input, args.path);
|
|
414
426
|
}
|
|
415
427
|
ctx.signal.throwIfAborted();
|
|
@@ -419,7 +431,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
419
431
|
sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
|
|
420
432
|
} catch {
|
|
421
433
|
// Single-section fallback keeps the original error rendering for the
|
|
422
|
-
// "haven't typed
|
|
434
|
+
// "haven't typed `§ PATH` yet" case.
|
|
423
435
|
const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
|
|
424
436
|
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
425
437
|
});
|
|
@@ -98,7 +98,16 @@ function rewriteLegacyPiImports(source: string): string {
|
|
|
98
98
|
return match;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
try {
|
|
102
|
+
return `${prefix}${toImportSpecifier(getResolvedSpecifier(remappedSpecifier))}${suffix}`;
|
|
103
|
+
} catch {
|
|
104
|
+
// Resolution failed — typically in compiled binary mode where
|
|
105
|
+
// Bun.resolveSync cannot walk up from /$bunfs/root to find the
|
|
106
|
+
// bundled node_modules. Return the original specifier unchanged so
|
|
107
|
+
// rewriteBareImportsForLegacyExtension can resolve it against the
|
|
108
|
+
// plugin's own installed peer deps instead.
|
|
109
|
+
return match;
|
|
110
|
+
}
|
|
102
111
|
},
|
|
103
112
|
);
|
|
104
113
|
}
|
|
@@ -232,15 +241,28 @@ function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
|
|
|
232
241
|
return "js";
|
|
233
242
|
}
|
|
234
243
|
|
|
235
|
-
function resolveLegacyPiSpecifier(args: { path: string }): { path: string } | undefined {
|
|
244
|
+
function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { path: string } | undefined {
|
|
236
245
|
const remappedSpecifier = remapLegacyPiSpecifier(args.path);
|
|
237
246
|
if (!remappedSpecifier) {
|
|
238
247
|
return undefined;
|
|
239
248
|
}
|
|
240
249
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
// Primary: resolve the canonical @oh-my-pi/* specifier from the host binary
|
|
251
|
+
// location. Works in dev mode and in source-link installs.
|
|
252
|
+
try {
|
|
253
|
+
return { path: getResolvedSpecifier(remappedSpecifier) };
|
|
254
|
+
} catch {
|
|
255
|
+
// Fallback for compiled binary mode: the bundled packages live inside
|
|
256
|
+
// /$bunfs/root and aren't reachable by filesystem resolution. Try the
|
|
257
|
+
// original (pre-remap) specifier against the importing file's directory,
|
|
258
|
+
// which resolves to the plugin's installed peer dep.
|
|
259
|
+
const importerDir = path.dirname(args.importer);
|
|
260
|
+
try {
|
|
261
|
+
return { path: Bun.resolveSync(args.path, importerDir) };
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
244
266
|
}
|
|
245
267
|
|
|
246
268
|
function resolveTypeBoxSpecifier(): { path: string } {
|
package/src/goals/runtime.ts
CHANGED
|
@@ -373,6 +373,21 @@ export class GoalRuntime {
|
|
|
373
373
|
await this.#withAccounting(() => this.#flushUsageLocked(steering, currentUsage));
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
#createGoalState(objective: string, tokenBudget: number | undefined): GoalModeState {
|
|
377
|
+
const now = this.#now();
|
|
378
|
+
const goal: Goal = {
|
|
379
|
+
id: String(Snowflake.next()),
|
|
380
|
+
objective,
|
|
381
|
+
status: "active",
|
|
382
|
+
tokenBudget,
|
|
383
|
+
tokensUsed: 0,
|
|
384
|
+
timeUsedSeconds: 0,
|
|
385
|
+
createdAt: now,
|
|
386
|
+
updatedAt: now,
|
|
387
|
+
};
|
|
388
|
+
return { enabled: true, mode: "active", goal };
|
|
389
|
+
}
|
|
390
|
+
|
|
376
391
|
async createGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
|
|
377
392
|
const objective = input.objective.trim();
|
|
378
393
|
if (!objective) throw new Error("objective is required when op=create");
|
|
@@ -382,20 +397,27 @@ export class GoalRuntime {
|
|
|
382
397
|
if (existing?.goal && existing.goal.status !== "dropped" && existing.goal.status !== "complete") {
|
|
383
398
|
throw new Error("cannot create a new goal because this session already has a goal");
|
|
384
399
|
}
|
|
385
|
-
const
|
|
386
|
-
const goal: Goal = {
|
|
387
|
-
id: String(Snowflake.next()),
|
|
388
|
-
objective,
|
|
389
|
-
status: "active",
|
|
390
|
-
tokenBudget: input.tokenBudget,
|
|
391
|
-
tokensUsed: 0,
|
|
392
|
-
timeUsedSeconds: 0,
|
|
393
|
-
createdAt: now,
|
|
394
|
-
updatedAt: now,
|
|
395
|
-
};
|
|
396
|
-
const state: GoalModeState = { enabled: true, mode: "active", goal };
|
|
400
|
+
const state = this.#createGoalState(objective, input.tokenBudget);
|
|
397
401
|
this.#budgetReportedFor = undefined;
|
|
398
|
-
this.#markActiveAccounting(goal);
|
|
402
|
+
this.#markActiveAccounting(state.goal);
|
|
403
|
+
await this.#commitState(state, { persist: "goal" });
|
|
404
|
+
return state;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async replaceGoal(input: { objective: string; tokenBudget?: number }): Promise<GoalModeState> {
|
|
409
|
+
const objective = input.objective.trim();
|
|
410
|
+
if (!objective) throw new Error("objective is required when op=replace");
|
|
411
|
+
validateTokenBudget(input.tokenBudget);
|
|
412
|
+
return await this.#withAccounting(async () => {
|
|
413
|
+
const existing = this.#host.getState();
|
|
414
|
+
if (!existing?.enabled || !isAccountingStatus(existing.goal)) {
|
|
415
|
+
throw new Error("cannot replace goal because no goal is active");
|
|
416
|
+
}
|
|
417
|
+
await this.#flushUsageLocked("suppressed");
|
|
418
|
+
const state = this.#createGoalState(objective, input.tokenBudget);
|
|
419
|
+
this.#budgetReportedFor = undefined;
|
|
420
|
+
this.#markActiveAccounting(state.goal);
|
|
399
421
|
await this.#commitState(state, { persist: "goal" });
|
|
400
422
|
return state;
|
|
401
423
|
});
|
|
@@ -4,9 +4,6 @@ export const MISMATCH_CONTEXT = 2;
|
|
|
4
4
|
/** Filler hash used for the interior of a multi-line range; not validated. */
|
|
5
5
|
export const RANGE_INTERIOR_HASH = "**";
|
|
6
6
|
|
|
7
|
-
/** Header marker introducing a new file section in multi-section input. */
|
|
8
|
-
export const FILE_HEADER_PREFIX = "@";
|
|
9
|
-
|
|
10
7
|
/** Optional patch envelope start marker; silently consumed when present. */
|
|
11
8
|
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
12
9
|
|
package/src/hashline/diff.ts
CHANGED
|
@@ -30,7 +30,7 @@ export async function computeHashlineSectionDiff(
|
|
|
30
30
|
const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
|
|
31
31
|
const { text: content } = stripBom(rawContent);
|
|
32
32
|
const normalized = normalizeToLF(content);
|
|
33
|
-
const result = applyHashlineEdits(normalized, parseHashline(section.diff
|
|
33
|
+
const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
|
|
34
34
|
if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
|
|
35
35
|
return generateDiffString(normalized, result.lines);
|
|
36
36
|
} catch (err) {
|
package/src/hashline/execute.ts
CHANGED
|
@@ -106,7 +106,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
|
|
|
106
106
|
const { session, path: sectionPath, diff } = options;
|
|
107
107
|
|
|
108
108
|
const absolutePath = resolvePlanPath(session, sectionPath);
|
|
109
|
-
const { edits } = parseHashlineWithWarnings(diff
|
|
109
|
+
const { edits } = parseHashlineWithWarnings(diff);
|
|
110
110
|
enforcePlanModeWrite(session, sectionPath, { op: "update" });
|
|
111
111
|
|
|
112
112
|
const source = await readHashlineFile(absolutePath, sectionPath);
|
|
@@ -139,7 +139,7 @@ async function executeHashlineSection(
|
|
|
139
139
|
} = options;
|
|
140
140
|
|
|
141
141
|
const absolutePath = resolvePlanPath(session, sourcePath);
|
|
142
|
-
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff
|
|
142
|
+
const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
|
|
143
143
|
enforcePlanModeWrite(session, sourcePath, { op: "update" });
|
|
144
144
|
|
|
145
145
|
const source = await readHashlineFile(absolutePath, sourcePath);
|
|
@@ -3,20 +3,19 @@ begin_patch: "*** Begin Patch" LF
|
|
|
3
3
|
end_patch: "*** End Patch" LF?
|
|
4
4
|
|
|
5
5
|
hunk: update_hunk
|
|
6
|
-
update_hunk: "
|
|
6
|
+
update_hunk: "$HFILE$" filename LF line_op*
|
|
7
7
|
|
|
8
8
|
filename: /(.+)/
|
|
9
9
|
|
|
10
|
-
line_op: insert_before | insert_after | replace |
|
|
11
|
-
insert_before:
|
|
12
|
-
insert_after:
|
|
13
|
-
replace:
|
|
14
|
-
|
|
15
|
-
payload: $HSEP$ /(.*)/ LF
|
|
10
|
+
line_op: insert_before | insert_after | replace | blank
|
|
11
|
+
insert_before: "$HOP_INSERT_BEFORE$" anchor LF payload+
|
|
12
|
+
insert_after: "$HOP_INSERT_AFTER$" anchor LF payload+
|
|
13
|
+
replace: "$HOP_REPLACE$" range LF payload*
|
|
14
|
+
payload: /[^$HOP_CHARS$$HFILE$\n][^\n]*/ LF | LF
|
|
16
15
|
blank: LF
|
|
17
16
|
|
|
18
17
|
anchor: LID | "EOF" | "BOF"
|
|
19
|
-
range: LID ".." LID
|
|
18
|
+
range: LID (".." LID)?
|
|
20
19
|
LID: /[1-9]\d*$HFMT$/
|
|
21
20
|
|
|
22
21
|
%import common.LF
|
package/src/hashline/hash.ts
CHANGED
|
@@ -75,7 +75,13 @@ export function describeAnchorExamples(linePrefix = ""): string {
|
|
|
75
75
|
* pass through unchanged.
|
|
76
76
|
*/
|
|
77
77
|
export function resolveHashlineGrammarPlaceholders(grammar: string): string {
|
|
78
|
-
return grammar
|
|
78
|
+
return grammar
|
|
79
|
+
.replaceAll("$HFMT$", "[a-z]{2}")
|
|
80
|
+
.replaceAll("$HOP_INSERT_BEFORE$", HL_OP_INSERT_BEFORE)
|
|
81
|
+
.replaceAll("$HOP_INSERT_AFTER$", HL_OP_INSERT_AFTER)
|
|
82
|
+
.replaceAll("$HOP_REPLACE$", HL_OP_REPLACE)
|
|
83
|
+
.replaceAll("$HOP_CHARS$", HL_OP_CHARS)
|
|
84
|
+
.replaceAll("$HFILE$", HL_FILE_PREFIX);
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/** @deprecated Use {@link resolveHashlineGrammarPlaceholders}. */
|
|
@@ -84,51 +90,23 @@ export const resolveLarkLidPlaceholders = resolveHashlineGrammarPlaceholders;
|
|
|
84
90
|
const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
85
91
|
|
|
86
92
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
93
|
+
* Hashline edit input markers. File section headers start with {@link HL_FILE_PREFIX};
|
|
94
|
+
* op lines start with a direction/action sigil: {@link HL_OP_INSERT_BEFORE},
|
|
95
|
+
* {@link HL_OP_INSERT_AFTER}, or {@link HL_OP_REPLACE}. Payload lines are
|
|
96
|
+
* verbatim file content and have no per-line marker.
|
|
91
97
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* the edit grammar, prompt helper, and edit parser derive from it.
|
|
95
|
-
*
|
|
96
|
-
* Default is `~`, chosen empirically. Benchmark across 8 candidate separators
|
|
97
|
-
* x 3 models (glm-4.7:nitro, gpt-5.4-nano, claude-sonnet-4-6), 24-48 runs per
|
|
98
|
-
* cell, hashline variant, 12 sampled tasks per run:
|
|
99
|
-
*
|
|
100
|
-
* sep | task ✓ | edit ✓ | patch fail | tok/run
|
|
101
|
-
* ----|--------|--------|-----------------|--------
|
|
102
|
-
* + | 70.8% | 78.0% | 27/125 (21.6%) | 32,127
|
|
103
|
-
* ÷ | 70.7% | 90.6% | 22/211 (10.4%) | 31,666
|
|
104
|
-
* ~ | 69.4% | 94.9% | 6/107 ( 5.6%) | 30,529 <-- default
|
|
105
|
-
* > | 69.2% | 91.5% | 21/219 ( 9.6%) | 30,777
|
|
106
|
-
* : | 66.7% | 86.4% | 20/126 (15.9%) | 33,900
|
|
107
|
-
* | | 65.9% | 86.9% | 20/127 (15.7%) | 34,589
|
|
108
|
-
* \ | 65.5% | 89.8% | 16/124 (12.9%) | 36,010
|
|
109
|
-
* % | 63.9% | 92.8% | 11/125 ( 8.8%) | 36,530
|
|
110
|
-
*
|
|
111
|
-
* `~` wins because:
|
|
112
|
-
* - highest edit-tool success rate (94.9%) of any tested separator
|
|
113
|
-
* - lowest patch-failure rate (5.6%) — model rarely emits a malformed payload
|
|
114
|
-
* - cheapest in tokens alongside `>` (no retry overhead from format collisions)
|
|
115
|
-
* - no line-leading role in any mainstream language, markdown, diff, regex,
|
|
116
|
-
* or shell, so payload lines are unambiguous to both the parser and models
|
|
117
|
-
* - task-success is statistically tied with `>` and `÷` (within run-to-run
|
|
118
|
-
* noise), so the edit-reliability win is free
|
|
119
|
-
*
|
|
120
|
-
* `+` and `÷` lead on raw task-success but at the cost of ~2-4x more patch
|
|
121
|
-
* failures (the model retries until it lands a valid edit). `:`, `|`, `\`
|
|
122
|
-
* collide with line-leading syntax (label/object-key, body separator, escape)
|
|
123
|
-
* and degrade both edit reliability and intent-match.
|
|
98
|
+
* These constants are the single source of truth for the edit parser, grammar,
|
|
99
|
+
* renderer, and prompt.
|
|
124
100
|
*/
|
|
125
|
-
export const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
101
|
+
export const HL_OP_INSERT_BEFORE = "«";
|
|
102
|
+
export const HL_OP_INSERT_AFTER = "»";
|
|
103
|
+
export const HL_OP_REPLACE = "≔";
|
|
104
|
+
|
|
105
|
+
/** All hashline edit op sigils, concatenated for fast membership tests. */
|
|
106
|
+
export const HL_OP_CHARS = `${HL_OP_INSERT_BEFORE}${HL_OP_INSERT_AFTER}${HL_OP_REPLACE}`;
|
|
129
107
|
|
|
130
|
-
/**
|
|
131
|
-
export const
|
|
108
|
+
/** Hashline edit file section header marker. */
|
|
109
|
+
export const HL_FILE_PREFIX = "§";
|
|
132
110
|
|
|
133
111
|
/** Stable separator for read/search/hashline display output. Intentionally not configurable. */
|
|
134
112
|
export const HL_BODY_SEP = "|";
|
package/src/hashline/input.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER
|
|
3
|
-
import {
|
|
2
|
+
import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants";
|
|
3
|
+
import { HL_FILE_PREFIX, HL_OP_CHARS } from "./hash";
|
|
4
4
|
import type { SplitHashlineOptions } from "./types";
|
|
5
5
|
|
|
6
|
+
const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
const HASHLINE_OP_LINE_RE = new RegExp(`^[${regexEscape(HL_OP_CHARS)}]`);
|
|
8
|
+
|
|
6
9
|
export interface HashlineInputSection {
|
|
7
10
|
path: string;
|
|
8
11
|
diff: string;
|
|
@@ -26,19 +29,18 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
|
|
|
26
29
|
|
|
27
30
|
function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
|
|
28
31
|
const trimmed = line.trimEnd();
|
|
29
|
-
if (!trimmed.startsWith(
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// stray headers still route to the right file.
|
|
32
|
+
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
|
|
33
|
+
// Strip a run of leading header markers so canonical `§PATH` and
|
|
34
|
+
// runaway-prefix forms like `§§PATH` / `§§§PATH` route to the same file.
|
|
33
35
|
let prefixEnd = 0;
|
|
34
|
-
while (prefixEnd < trimmed.length && trimmed[prefixEnd] ===
|
|
36
|
+
while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
|
|
35
37
|
const rest = trimmed.slice(prefixEnd);
|
|
36
38
|
if (rest.trim().length === 0) {
|
|
37
|
-
throw new Error(`Input header "${
|
|
39
|
+
throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
|
|
38
40
|
}
|
|
39
41
|
const parsedPath = normalizeHashlinePath(rest, cwd);
|
|
40
42
|
if (parsedPath.length === 0) {
|
|
41
|
-
throw new Error(`Input header "${
|
|
43
|
+
throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
|
|
42
44
|
}
|
|
43
45
|
return { path: parsedPath, diff: "" };
|
|
44
46
|
}
|
|
@@ -64,7 +66,7 @@ function stripLeadingBlankLines(input: string): string {
|
|
|
64
66
|
|
|
65
67
|
export function containsRecognizableHashlineOperations(input: string): boolean {
|
|
66
68
|
for (const line of input.split(/\r?\n/)) {
|
|
67
|
-
if (
|
|
69
|
+
if (HASHLINE_OP_LINE_RE.test(line)) return true;
|
|
68
70
|
}
|
|
69
71
|
return false;
|
|
70
72
|
}
|
|
@@ -79,7 +81,7 @@ function normalizeFallbackInput(input: string, options: SplitHashlineOptions): s
|
|
|
79
81
|
if (!options.path || !containsRecognizableHashlineOperations(input)) return input;
|
|
80
82
|
const fallbackPath = normalizeHashlinePath(options.path, options.cwd);
|
|
81
83
|
if (fallbackPath.length === 0) return input;
|
|
82
|
-
return `${
|
|
84
|
+
return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
|
|
@@ -95,8 +97,8 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
|
|
|
95
97
|
if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
|
|
96
98
|
const preview = JSON.stringify(firstLine.slice(0, 120));
|
|
97
99
|
throw new Error(
|
|
98
|
-
`input must begin with "
|
|
99
|
-
`Example: "
|
|
100
|
+
`input must begin with "${HL_FILE_PREFIX}PATH" on the first non-blank line; got: ${preview}. ` +
|
|
101
|
+
`Example: "${HL_FILE_PREFIX}src/foo.ts" then edit ops.`,
|
|
100
102
|
);
|
|
101
103
|
}
|
|
102
104
|
|