@firstpick/pi-utils 0.1.7 → 0.1.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/README.md +9 -0
- package/index.ts +10 -0
- package/package.json +15 -1
- package/src/cli.ts +56 -0
- package/src/initial-prompt-estimate-service.ts +202 -0
- package/src/initial-prompt-estimate-state.ts +48 -0
- package/src/json.ts +34 -0
- package/src/local-wiki.ts +141 -15
- package/src/markdown.ts +77 -0
- package/src/paths.ts +38 -0
- package/src/process.ts +152 -0
- package/src/prompt-export-estimate.ts +198 -0
- package/src/release-log.ts +65 -0
- package/src/tool-result.ts +17 -0
- package/src/ui/live-output.ts +48 -0
package/README.md
CHANGED
|
@@ -26,8 +26,17 @@ Shared helper utilities used by `@firstpick/pi-extension-*` packages.
|
|
|
26
26
|
- `buildInitialPromptCalibrationRecord(args)`
|
|
27
27
|
- `appendInitialPromptCalibrationRecord(appendEntry, record)`
|
|
28
28
|
- `delay(ms)`
|
|
29
|
+
- `tokenizeArgs(input)` / `takeValue(tokens, index, flag)`
|
|
30
|
+
- `readJsonFile(path)` / `readJsonIfExists(path, fallback)` / `writeJsonFile(path, data)`
|
|
31
|
+
- `runCommand(command, args, options?)` / `runShellCommand(cwd, command, options?)`
|
|
32
|
+
- `shellQuote(value)` / `stripAnsi(input)` / `resolveExecutableFromPath(name)`
|
|
33
|
+
- `jsonToolResult(payload)` / `textToolResult(text, details?)`
|
|
34
|
+
- `createRunLog(cwd)` / `appendRunLog(log, chunk)` / `saveRunLog(log, options)` / `listRunLogs(dir)`
|
|
35
|
+
- `parseChecklistLine(line)` / `extractChecklist(text)` / `stripChecklistLines(text)` / `countChecklistProgress(textOrItems)`
|
|
36
|
+
- `expandTilde(input)` / `resolveUserPath(input, cwd?)` / `safeResolveInside(base, ref)` / `formatUserPath(path)`
|
|
29
37
|
- `createExtensionWorkingIndicator(ctx, initialMessage, options?)`
|
|
30
38
|
- `withExtensionWorkingIndicator(ctx, initialMessage, run, options?)`
|
|
39
|
+
- `appendDisplayChunk(lines, chunk)` / `outputLinesFromDisplay(lines)` / `formatElapsed(startMs)`
|
|
31
40
|
- `createLocalWikiEngine(config)`
|
|
32
41
|
|
|
33
42
|
`createExtensionWorkingIndicator` renders a reusable extension-owned spinner using `ctx.ui.setWidget` plus footer `setStatus`, so it works inside slash-command handlers where Pi's built-in model-streaming working row is not shown.
|
package/index.ts
CHANGED
|
@@ -3,8 +3,18 @@ export * from "./src/env";
|
|
|
3
3
|
export * from "./src/text";
|
|
4
4
|
export * from "./src/tokens";
|
|
5
5
|
export * from "./src/prompt-calibration";
|
|
6
|
+
export * from "./src/prompt-export-estimate";
|
|
7
|
+
export * from "./src/initial-prompt-estimate-state";
|
|
8
|
+
export * from "./src/initial-prompt-estimate-service";
|
|
6
9
|
export * from "./src/async";
|
|
10
|
+
export * from "./src/cli";
|
|
11
|
+
export * from "./src/json";
|
|
12
|
+
export * from "./src/process";
|
|
13
|
+
export * from "./src/tool-result";
|
|
14
|
+
export * from "./src/markdown";
|
|
15
|
+
export * from "./src/release-log";
|
|
7
16
|
export * from "./src/ui/working-indicator";
|
|
17
|
+
export * from "./src/ui/live-output";
|
|
8
18
|
export * from "./src/local-wiki";
|
|
9
19
|
|
|
10
20
|
export { default } from "./src/extension";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Shared utilities for Firstpick Pi extension packages.",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -10,8 +10,18 @@
|
|
|
10
10
|
"./text": "./src/text.ts",
|
|
11
11
|
"./tokens": "./src/tokens.ts",
|
|
12
12
|
"./prompt-calibration": "./src/prompt-calibration.ts",
|
|
13
|
+
"./prompt-export-estimate": "./src/prompt-export-estimate.ts",
|
|
14
|
+
"./initial-prompt-estimate-state": "./src/initial-prompt-estimate-state.ts",
|
|
15
|
+
"./initial-prompt-estimate-service": "./src/initial-prompt-estimate-service.ts",
|
|
13
16
|
"./async": "./src/async.ts",
|
|
17
|
+
"./cli": "./src/cli.ts",
|
|
18
|
+
"./json": "./src/json.ts",
|
|
19
|
+
"./process": "./src/process.ts",
|
|
20
|
+
"./tool-result": "./src/tool-result.ts",
|
|
21
|
+
"./markdown": "./src/markdown.ts",
|
|
22
|
+
"./release-log": "./src/release-log.ts",
|
|
14
23
|
"./ui": "./src/ui/working-indicator.ts",
|
|
24
|
+
"./ui/live-output": "./src/ui/live-output.ts",
|
|
15
25
|
"./local-wiki": "./src/local-wiki.ts"
|
|
16
26
|
},
|
|
17
27
|
"license": "MIT",
|
|
@@ -26,6 +36,10 @@
|
|
|
26
36
|
"./index.ts"
|
|
27
37
|
]
|
|
28
38
|
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"check": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types tests/initial-prompt-estimate-state.test.mjs",
|
|
41
|
+
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types tests/initial-prompt-estimate-state.test.mjs"
|
|
42
|
+
},
|
|
29
43
|
"peerDependencies": {
|
|
30
44
|
"@earendil-works/pi-coding-agent": "*"
|
|
31
45
|
},
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function tokenizeArgs(input: string): string[] {
|
|
2
|
+
const tokens: string[] = [];
|
|
3
|
+
let current = "";
|
|
4
|
+
let quote: '"' | "'" | undefined;
|
|
5
|
+
let escaped = false;
|
|
6
|
+
|
|
7
|
+
for (const char of input) {
|
|
8
|
+
if (escaped) {
|
|
9
|
+
current += char;
|
|
10
|
+
escaped = false;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (char === "\\") {
|
|
15
|
+
escaped = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (quote) {
|
|
20
|
+
if (char === quote) quote = undefined;
|
|
21
|
+
else current += char;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (char === '"' || char === "'") {
|
|
26
|
+
quote = char;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (/\s/.test(char)) {
|
|
31
|
+
if (current) {
|
|
32
|
+
tokens.push(current);
|
|
33
|
+
current = "";
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
current += char;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (escaped) current += "\\";
|
|
42
|
+
if (quote) throw new Error(`Unclosed ${quote} quote`);
|
|
43
|
+
if (current) tokens.push(current);
|
|
44
|
+
return tokens;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function takeValue(tokens: string[], index: number, flag: string): string {
|
|
48
|
+
const value = tokens[index + 1];
|
|
49
|
+
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function takeOptionalValue(tokens: string[], index: number): string | undefined {
|
|
54
|
+
const value = tokens[index + 1];
|
|
55
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { InitialPromptCalibration, InitialPromptInputEstimate, InitialPromptToolInfo } from "./tokens";
|
|
3
|
+
import {
|
|
4
|
+
estimateInitialPromptForPiContext,
|
|
5
|
+
estimateInitialPromptFromPiExport,
|
|
6
|
+
getActiveInitialPromptToolInfos,
|
|
7
|
+
type ExportBackedInitialPromptEstimate,
|
|
8
|
+
} from "./prompt-export-estimate";
|
|
9
|
+
import {
|
|
10
|
+
buildInitialPromptEstimateKey,
|
|
11
|
+
resolveInitialPromptEstimateRefreshDecision,
|
|
12
|
+
} from "./initial-prompt-estimate-state";
|
|
13
|
+
|
|
14
|
+
export type InitialPromptEstimatePiApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools">;
|
|
15
|
+
export type InitialPromptEstimateContext = Pick<ExtensionContext, "getSystemPrompt" | "sessionManager">;
|
|
16
|
+
export type InitialPromptEstimateSource = ExportBackedInitialPromptEstimate["source"] | "fallback";
|
|
17
|
+
|
|
18
|
+
export type InitialPromptEstimateSnapshot = {
|
|
19
|
+
key: string;
|
|
20
|
+
estimate: InitialPromptInputEstimate;
|
|
21
|
+
systemPrompt: string;
|
|
22
|
+
tools: InitialPromptToolInfo[];
|
|
23
|
+
source: InitialPromptEstimateSource;
|
|
24
|
+
settled: boolean;
|
|
25
|
+
attempts: number;
|
|
26
|
+
warning?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type InitialPromptCalibrationGetter<Ctx extends InitialPromptEstimateContext> = (
|
|
30
|
+
ctx: Ctx,
|
|
31
|
+
) => InitialPromptCalibration | null | undefined;
|
|
32
|
+
|
|
33
|
+
export type StableInitialPromptEstimateOptions = {
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type InitialPromptEstimateServiceOptions<Ctx extends InitialPromptEstimateContext> = StableInitialPromptEstimateOptions & {
|
|
38
|
+
pi: InitialPromptEstimatePiApi;
|
|
39
|
+
getCalibration: InitialPromptCalibrationGetter<Ctx>;
|
|
40
|
+
/** Publish provisional fallback snapshots while export-backed estimation is still running. */
|
|
41
|
+
publishFallback?: boolean;
|
|
42
|
+
onUpdate?: (snapshot: InitialPromptEstimateSnapshot, ctx: Ctx) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type InitialPromptEstimateRefreshResult =
|
|
46
|
+
| { status: "updated"; snapshot: InitialPromptEstimateSnapshot }
|
|
47
|
+
| { status: "unsettled"; snapshot: InitialPromptEstimateSnapshot }
|
|
48
|
+
| { status: "stale"; snapshot: null };
|
|
49
|
+
|
|
50
|
+
const DEFAULT_STABLE_ESTIMATE_ATTEMPTS = 3;
|
|
51
|
+
|
|
52
|
+
function resolveMaxAttempts(value: number | undefined): number {
|
|
53
|
+
const attempts = Math.floor(Number(value ?? DEFAULT_STABLE_ESTIMATE_ATTEMPTS));
|
|
54
|
+
return Number.isFinite(attempts) && attempts > 0 ? attempts : DEFAULT_STABLE_ESTIMATE_ATTEMPTS;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function appendWarning(existing: string | undefined, warning: string): string {
|
|
58
|
+
return existing ? `${existing} ${warning}` : warning;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildInitialPromptFallbackSnapshot(
|
|
62
|
+
pi: InitialPromptEstimatePiApi,
|
|
63
|
+
ctx: InitialPromptEstimateContext,
|
|
64
|
+
calibration?: InitialPromptCalibration | null,
|
|
65
|
+
attempts = 0,
|
|
66
|
+
): InitialPromptEstimateSnapshot {
|
|
67
|
+
const systemPrompt = ctx.getSystemPrompt();
|
|
68
|
+
const tools = getActiveInitialPromptToolInfos(pi);
|
|
69
|
+
const estimate = estimateInitialPromptForPiContext(pi, systemPrompt, calibration, tools);
|
|
70
|
+
return {
|
|
71
|
+
key: buildInitialPromptEstimateKey(estimate),
|
|
72
|
+
estimate,
|
|
73
|
+
systemPrompt,
|
|
74
|
+
tools,
|
|
75
|
+
source: "fallback",
|
|
76
|
+
settled: false,
|
|
77
|
+
attempts,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function snapshotFromExportEstimate(
|
|
82
|
+
key: string,
|
|
83
|
+
promptEstimate: ExportBackedInitialPromptEstimate,
|
|
84
|
+
attempts: number,
|
|
85
|
+
): InitialPromptEstimateSnapshot {
|
|
86
|
+
return {
|
|
87
|
+
key,
|
|
88
|
+
estimate: promptEstimate.estimate,
|
|
89
|
+
systemPrompt: promptEstimate.systemPrompt,
|
|
90
|
+
tools: promptEstimate.tools,
|
|
91
|
+
source: promptEstimate.source,
|
|
92
|
+
settled: true,
|
|
93
|
+
attempts,
|
|
94
|
+
warning: promptEstimate.warning,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function estimateStableInitialPromptFromPiContext<Ctx extends InitialPromptEstimateContext>(
|
|
99
|
+
pi: InitialPromptEstimatePiApi,
|
|
100
|
+
ctx: Ctx,
|
|
101
|
+
getCalibration: InitialPromptCalibrationGetter<Ctx>,
|
|
102
|
+
options: StableInitialPromptEstimateOptions = {},
|
|
103
|
+
): Promise<InitialPromptEstimateSnapshot> {
|
|
104
|
+
const maxAttempts = resolveMaxAttempts(options.maxAttempts);
|
|
105
|
+
let latestFallback = buildInitialPromptFallbackSnapshot(pi, ctx, getCalibration(ctx) ?? null, 0);
|
|
106
|
+
|
|
107
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
108
|
+
const calibration = getCalibration(ctx) ?? null;
|
|
109
|
+
const fallback = buildInitialPromptFallbackSnapshot(pi, ctx, calibration, attempt - 1);
|
|
110
|
+
const promptEstimate = await estimateInitialPromptFromPiExport(pi, ctx, calibration);
|
|
111
|
+
latestFallback = buildInitialPromptFallbackSnapshot(pi, ctx, getCalibration(ctx) ?? null, attempt);
|
|
112
|
+
|
|
113
|
+
const decision = resolveInitialPromptEstimateRefreshDecision({
|
|
114
|
+
requestId: attempt,
|
|
115
|
+
currentRequestId: attempt,
|
|
116
|
+
initialKey: fallback.key,
|
|
117
|
+
latestKey: latestFallback.key,
|
|
118
|
+
});
|
|
119
|
+
if (decision === "accept-exported-estimate") {
|
|
120
|
+
return snapshotFromExportEstimate(fallback.key, promptEstimate, attempt);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
...latestFallback,
|
|
126
|
+
attempts: maxAttempts,
|
|
127
|
+
warning: appendWarning(
|
|
128
|
+
latestFallback.warning,
|
|
129
|
+
"Initial prompt inputs changed while estimating; used live context fallback.",
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createInitialPromptEstimateService<Ctx extends InitialPromptEstimateContext>(
|
|
135
|
+
options: InitialPromptEstimateServiceOptions<Ctx>,
|
|
136
|
+
) {
|
|
137
|
+
let snapshot: InitialPromptEstimateSnapshot | null = null;
|
|
138
|
+
let activeRequestId = 0;
|
|
139
|
+
|
|
140
|
+
const publish = (nextSnapshot: InitialPromptEstimateSnapshot, ctx: Ctx): InitialPromptEstimateSnapshot => {
|
|
141
|
+
snapshot = nextSnapshot;
|
|
142
|
+
options.onUpdate?.(nextSnapshot, ctx);
|
|
143
|
+
return nextSnapshot;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const getFallbackSnapshot = (ctx: Ctx, attempts = 0): InitialPromptEstimateSnapshot => {
|
|
147
|
+
return buildInitialPromptFallbackSnapshot(options.pi, ctx, options.getCalibration(ctx) ?? null, attempts);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const refresh = async (ctx: Ctx): Promise<InitialPromptEstimateRefreshResult> => {
|
|
151
|
+
const requestId = ++activeRequestId;
|
|
152
|
+
const maxAttempts = resolveMaxAttempts(options.maxAttempts);
|
|
153
|
+
let latestFallback = getFallbackSnapshot(ctx, 0);
|
|
154
|
+
|
|
155
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
156
|
+
const calibration = options.getCalibration(ctx) ?? null;
|
|
157
|
+
const fallback = buildInitialPromptFallbackSnapshot(options.pi, ctx, calibration, attempt - 1);
|
|
158
|
+
latestFallback = fallback;
|
|
159
|
+
if (options.publishFallback) publish(fallback, ctx);
|
|
160
|
+
|
|
161
|
+
const promptEstimate = await estimateInitialPromptFromPiExport(options.pi, ctx, calibration);
|
|
162
|
+
latestFallback = getFallbackSnapshot(ctx, attempt);
|
|
163
|
+
|
|
164
|
+
const decision = resolveInitialPromptEstimateRefreshDecision({
|
|
165
|
+
requestId,
|
|
166
|
+
currentRequestId: activeRequestId,
|
|
167
|
+
initialKey: fallback.key,
|
|
168
|
+
latestKey: latestFallback.key,
|
|
169
|
+
});
|
|
170
|
+
if (decision === "ignore-stale-request") return { status: "stale", snapshot: null };
|
|
171
|
+
if (decision === "restart-inputs-changed") continue;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
status: "updated",
|
|
175
|
+
snapshot: publish(snapshotFromExportEstimate(fallback.key, promptEstimate, attempt), ctx),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const unsettled = {
|
|
180
|
+
...latestFallback,
|
|
181
|
+
attempts: maxAttempts,
|
|
182
|
+
warning: appendWarning(
|
|
183
|
+
latestFallback.warning,
|
|
184
|
+
"Initial prompt inputs changed while estimating; kept the previous settled estimate.",
|
|
185
|
+
),
|
|
186
|
+
};
|
|
187
|
+
if (options.publishFallback) publish(unsettled, ctx);
|
|
188
|
+
return { status: "unsettled", snapshot: unsettled };
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
clear() {
|
|
193
|
+
activeRequestId++;
|
|
194
|
+
snapshot = null;
|
|
195
|
+
},
|
|
196
|
+
getSnapshot() {
|
|
197
|
+
return snapshot;
|
|
198
|
+
},
|
|
199
|
+
getFallbackSnapshot,
|
|
200
|
+
refresh,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type InitialPromptEstimateRefreshDecision =
|
|
2
|
+
| "accept-exported-estimate"
|
|
3
|
+
| "ignore-stale-request"
|
|
4
|
+
| "restart-inputs-changed";
|
|
5
|
+
|
|
6
|
+
export type InitialPromptEstimateRefreshDecisionInput = {
|
|
7
|
+
requestId: number;
|
|
8
|
+
currentRequestId: number;
|
|
9
|
+
initialKey: string;
|
|
10
|
+
latestKey: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type InitialPromptEstimateKeyInput = {
|
|
14
|
+
uncalibratedTotal: number;
|
|
15
|
+
promptText: number;
|
|
16
|
+
toolSchemas: number;
|
|
17
|
+
framing: number;
|
|
18
|
+
toolCount: number;
|
|
19
|
+
calibrationMultiplier: number;
|
|
20
|
+
calibrationSamples: number;
|
|
21
|
+
low: number;
|
|
22
|
+
high: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function buildInitialPromptEstimateKey(estimate: InitialPromptEstimateKeyInput): string {
|
|
26
|
+
return [
|
|
27
|
+
estimate.uncalibratedTotal,
|
|
28
|
+
estimate.promptText,
|
|
29
|
+
estimate.toolSchemas,
|
|
30
|
+
estimate.framing,
|
|
31
|
+
estimate.toolCount,
|
|
32
|
+
estimate.calibrationMultiplier,
|
|
33
|
+
estimate.calibrationSamples,
|
|
34
|
+
estimate.low,
|
|
35
|
+
estimate.high,
|
|
36
|
+
].join(":");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveInitialPromptEstimateRefreshDecision({
|
|
40
|
+
requestId,
|
|
41
|
+
currentRequestId,
|
|
42
|
+
initialKey,
|
|
43
|
+
latestKey,
|
|
44
|
+
}: InitialPromptEstimateRefreshDecisionInput): InitialPromptEstimateRefreshDecision {
|
|
45
|
+
if (requestId !== currentRequestId) return "ignore-stale-request";
|
|
46
|
+
if (latestKey !== initialKey) return "restart-inputs-changed";
|
|
47
|
+
return "accept-exported-estimate";
|
|
48
|
+
}
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function readJsonFile<T = unknown>(filePath: string): T {
|
|
5
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function readJsonIfExists<T>(filePath: string, fallback: T): T {
|
|
9
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
10
|
+
return readJsonFile<T>(filePath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeJsonFile(filePath: string, data: unknown, options: { mode?: number } = {}): void {
|
|
14
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: options.mode });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readJsonFileAsync<T = unknown>(filePath: string): Promise<T> {
|
|
19
|
+
return JSON.parse(await fs.promises.readFile(filePath, "utf8")) as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readJsonIfExistsAsync<T>(filePath: string, fallback: T): Promise<T> {
|
|
23
|
+
try {
|
|
24
|
+
return await readJsonFileAsync<T>(filePath);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeJsonFileAsync(filePath: string, data: unknown, options: { mode?: number } = {}): Promise<void> {
|
|
32
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
33
|
+
await fs.promises.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: options.mode });
|
|
34
|
+
}
|
package/src/local-wiki.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
|
|
4
|
-
export type LocalWikiFormat = "markdown" | "html";
|
|
4
|
+
export type LocalWikiFormat = "markdown" | "html" | "asciidoc";
|
|
5
5
|
|
|
6
6
|
export interface LocalWikiSection {
|
|
7
7
|
title: string;
|
|
@@ -68,6 +68,9 @@ export interface LocalWikiEngineConfig {
|
|
|
68
68
|
statusExtra?: () => Promise<Record<string, unknown>>;
|
|
69
69
|
transformText?: (text: string, title: string, filePath: string) => string;
|
|
70
70
|
titleFromHtml?: (html: string, filePath: string, fallback: string) => string;
|
|
71
|
+
/** Expand AsciiDoc include:: directives before parsing text/sections. Defaults to true for asciidoc format. */
|
|
72
|
+
expandIncludes?: boolean;
|
|
73
|
+
maxIncludeDepth?: number;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
@@ -130,6 +133,31 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
130
133
|
.trim();
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
function stripAsciidocDecorators(input: string): string {
|
|
137
|
+
return input
|
|
138
|
+
.replace(/^={1,6}\s+/, "")
|
|
139
|
+
.replace(/^\[\[[^\]]+\]\]\s*/, "")
|
|
140
|
+
.replace(/xref:([^\[]+)\[([^\]]*)\]/g, (_m, target: string, label: string) => label || titleFromPath(target))
|
|
141
|
+
.replace(/https?:[^\[]+\[([^\]]+)\]/g, "$1")
|
|
142
|
+
.replace(/(?:kbd|btn|menu):\[([^\]]+)\]/g, "$1")
|
|
143
|
+
.replace(/[*_`]/g, "")
|
|
144
|
+
.trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function asciidocToText(asciidoc: string): string {
|
|
148
|
+
return normalizeWhitespace(asciidoc
|
|
149
|
+
.replace(/^\s*:[^:\n]+:.*$/gm, "")
|
|
150
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
151
|
+
.replace(/^\[\[[^\]]+\]\]\s*$/gm, "")
|
|
152
|
+
.replace(/^\[(?:source|console|bash|python|json|ini|subs|NOTE|TIP|IMPORTANT|WARNING|CAUTION)[^\]]*\]\s*$/gim, "")
|
|
153
|
+
.replace(/^(?:----|====|\+{4}|`{3})\s*$/gm, "")
|
|
154
|
+
.replace(/^image::[^\[]+\[[^\]]*\]\s*$/gm, "")
|
|
155
|
+
.replace(/include::([^\[]+)\[[^\]]*\]/g, "")
|
|
156
|
+
.replace(/xref:([^\[]+)\[([^\]]*)\]/g, (_m, target: string, label: string) => label || titleFromPath(target))
|
|
157
|
+
.replace(/https?:[^\[]+\[([^\]]+)\]/g, "$1")
|
|
158
|
+
.replace(/(?:kbd|btn|menu):\[([^\]]+)\]/g, "$1"));
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
function stripYamlFrontmatter(markdown: string): string {
|
|
134
162
|
return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
|
|
135
163
|
}
|
|
@@ -195,6 +223,33 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
195
223
|
return sections;
|
|
196
224
|
}
|
|
197
225
|
|
|
226
|
+
function asciidocTitle(asciidoc: string, filePath: string): string {
|
|
227
|
+
for (const line of asciidoc.split(/\n/)) {
|
|
228
|
+
const match = line.match(/^(={1,6})\s+(.+)$/);
|
|
229
|
+
if (match) return stripAsciidocDecorators(match[2]);
|
|
230
|
+
}
|
|
231
|
+
return titleFromPath(filePath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function asciidocSections(asciidoc: string, fallbackTitle: string): LocalWikiSection[] {
|
|
235
|
+
const sections: LocalWikiSection[] = [];
|
|
236
|
+
let current: LocalWikiSection | undefined;
|
|
237
|
+
for (const line of asciidoc.split(/\n/)) {
|
|
238
|
+
const match = line.match(/^(={1,6})\s+(.+)$/);
|
|
239
|
+
if (match) {
|
|
240
|
+
if (current) current.text = asciidocToText(current.text);
|
|
241
|
+
const title = stripAsciidocDecorators(match[2]);
|
|
242
|
+
current = { title, level: match[1].length, anchor: anchorFromHeading(title), text: "" };
|
|
243
|
+
sections.push(current);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (current) current.text += `${line}\n`;
|
|
247
|
+
}
|
|
248
|
+
if (!current) sections.push({ title: fallbackTitle, level: 1, anchor: anchorFromHeading(fallbackTitle), text: asciidocToText(asciidoc) });
|
|
249
|
+
else current.text = asciidocToText(current.text);
|
|
250
|
+
return sections;
|
|
251
|
+
}
|
|
252
|
+
|
|
198
253
|
function htmlToText(html: string): string {
|
|
199
254
|
let body = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i)?.[1] ?? html;
|
|
200
255
|
body = body.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ");
|
|
@@ -215,15 +270,35 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
215
270
|
return (config.titleFromHtml?.(html, filePath, fallback) ?? stripTags(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "")) || fallback;
|
|
216
271
|
}
|
|
217
272
|
|
|
273
|
+
function candidateLocalPaths(currentFile: string, href: string): string[] {
|
|
274
|
+
if (/^(https?:|mailto:|#)/i.test(href)) return [];
|
|
275
|
+
const cleanHref = decodeEntities(href).split("#")[0].split("?")[0].trim();
|
|
276
|
+
if (!cleanHref) return [];
|
|
277
|
+
const variants = [...new Set([cleanHref, cleanHref.replace(/^\.\/+/g, "")].filter(Boolean))];
|
|
278
|
+
const expand = (candidate: string): string[] => {
|
|
279
|
+
if (path.extname(candidate)) return [candidate];
|
|
280
|
+
if (config.format === "html") return [`${candidate}.html`, `${candidate}.htm`, path.join(candidate, "index.html")];
|
|
281
|
+
if (config.format === "asciidoc") return [`${candidate}.adoc`, `${candidate}.asciidoc`, `${candidate}.asc`, path.join(candidate, "index.adoc")];
|
|
282
|
+
return [`${candidate}.md`, `${candidate}.mdx`, `${candidate}.rst`, path.join(candidate, "index.md")];
|
|
283
|
+
};
|
|
284
|
+
const bases = [path.dirname(currentFile), path.dirname(path.dirname(currentFile)), config.docsPath];
|
|
285
|
+
const resolved: string[] = [];
|
|
286
|
+
for (const variant of variants) {
|
|
287
|
+
for (const expanded of expand(variant)) {
|
|
288
|
+
if (path.isAbsolute(expanded)) resolved.push(path.normalize(expanded));
|
|
289
|
+
for (const base of bases) resolved.push(path.normalize(path.resolve(base, expanded)));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return [...new Set(resolved)].filter((candidate) => candidate === config.docsPath || candidate.startsWith(`${config.docsPath}${path.sep}`));
|
|
293
|
+
}
|
|
294
|
+
|
|
218
295
|
function resolveLocalPath(currentFile: string, href: string): string | undefined {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const resolved = path.normalize(path.resolve(path.dirname(currentFile), candidate));
|
|
226
|
-
if (resolved.startsWith(config.docsPath)) return resolved;
|
|
296
|
+
return candidateLocalPaths(currentFile, href)[0];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function resolveExistingLocalPath(currentFile: string, href: string): Promise<string | undefined> {
|
|
300
|
+
for (const candidate of candidateLocalPaths(currentFile, href)) {
|
|
301
|
+
if (await localExists(candidate)) return candidate;
|
|
227
302
|
}
|
|
228
303
|
return undefined;
|
|
229
304
|
}
|
|
@@ -238,6 +313,38 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
238
313
|
return [...links.values()];
|
|
239
314
|
}
|
|
240
315
|
|
|
316
|
+
function asciidocLinks(asciidoc: string, currentFile: string): LocalWikiLink[] {
|
|
317
|
+
const links = new Map<string, LocalWikiLink>();
|
|
318
|
+
const add = (href: string, label: string) => {
|
|
319
|
+
const resolved = resolveLocalPath(currentFile, href.trim());
|
|
320
|
+
if (!resolved) return;
|
|
321
|
+
links.set(resolved, { title: stripAsciidocDecorators(label) || titleFromPath(resolved), path: resolved });
|
|
322
|
+
};
|
|
323
|
+
for (const match of asciidoc.matchAll(/xref:([^\[]+)\[([^\]]*)\]/g)) add(match[1], match[2]);
|
|
324
|
+
for (const match of asciidoc.matchAll(/^include::([^\[]+)\[([^\]]*)\]/gm)) add(match[1], match[2]);
|
|
325
|
+
return [...links.values()];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function expandAsciidocIncludes(raw: string, currentFile: string, depth = 0, seen = new Set<string>()): Promise<string> {
|
|
329
|
+
const maxDepth = Math.max(0, config.maxIncludeDepth ?? 4);
|
|
330
|
+
if (depth >= maxDepth) return raw;
|
|
331
|
+
const includeRe = /^include::([^\[]+)\[[^\]]*\]\s*$/gm;
|
|
332
|
+
const replacements = await Promise.all([...raw.matchAll(includeRe)].map(async (match) => {
|
|
333
|
+
const resolved = await resolveExistingLocalPath(currentFile, match[1].trim());
|
|
334
|
+
if (!resolved || seen.has(resolved)) return { from: match[0], to: "" };
|
|
335
|
+
try {
|
|
336
|
+
seen.add(resolved);
|
|
337
|
+
const included = await fsp.readFile(resolved, "utf8");
|
|
338
|
+
return { from: match[0], to: await expandAsciidocIncludes(included, resolved, depth + 1, seen) };
|
|
339
|
+
} catch {
|
|
340
|
+
return { from: match[0], to: "" };
|
|
341
|
+
}
|
|
342
|
+
}));
|
|
343
|
+
let expanded = raw;
|
|
344
|
+
for (const { from, to } of replacements) expanded = expanded.replace(from, to);
|
|
345
|
+
return expanded;
|
|
346
|
+
}
|
|
347
|
+
|
|
241
348
|
function htmlLinks(html: string, currentFile: string): LocalWikiLink[] {
|
|
242
349
|
const links = new Map<string, LocalWikiLink>();
|
|
243
350
|
for (const match of html.matchAll(/<a\s+[^>]*href=["']([^"'#?]+)(?:#[^"']*)?["'][^>]*>([\s\S]*?)<\/a>/gi)) {
|
|
@@ -248,13 +355,29 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
248
355
|
return [...links.values()];
|
|
249
356
|
}
|
|
250
357
|
|
|
251
|
-
function parsePage(raw: string, filePath: string, mtimeMs: number): LocalWikiPage {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
358
|
+
function parsePage(raw: string, filePath: string, mtimeMs: number, sourceRaw = raw): LocalWikiPage {
|
|
359
|
+
if (config.format === "html") {
|
|
360
|
+
const title = htmlTitle(sourceRaw, filePath);
|
|
361
|
+
const baseText = htmlToText(raw);
|
|
362
|
+
const text = config.transformText?.(baseText, title, filePath) ?? baseText;
|
|
363
|
+
const sections = markdownSections(text, title);
|
|
364
|
+
return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: htmlLinks(sourceRaw, filePath), text, mtimeMs };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (config.format === "asciidoc") {
|
|
368
|
+
const title = asciidocTitle(sourceRaw, filePath);
|
|
369
|
+
const baseText = asciidocToText(raw);
|
|
370
|
+
const text = config.transformText?.(baseText, title, filePath) ?? baseText;
|
|
371
|
+
const sections = asciidocSections(raw, title);
|
|
372
|
+
return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: asciidocLinks(sourceRaw, filePath), text, mtimeMs };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const title = markdownTitle(sourceRaw, filePath);
|
|
376
|
+
const markdownBody = stripYamlFrontmatter(raw);
|
|
377
|
+
const baseText = normalizeWhitespace(markdownBody);
|
|
255
378
|
const text = config.transformText?.(baseText, title, filePath) ?? baseText;
|
|
256
379
|
const sections = markdownSections(text, title);
|
|
257
|
-
return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links:
|
|
380
|
+
return { title, slug: path.relative(config.docsPath, filePath).replace(config.fileExtensions, ""), path: filePath, source: config.sourceName?.(filePath, config.docsPath), headings: sections.map((s) => s.title), sections, links: markdownLinks(markdownBody, filePath), text, mtimeMs };
|
|
258
381
|
}
|
|
259
382
|
|
|
260
383
|
function limitText(text: string, maxChars = 12000): { text: string; truncated: boolean } {
|
|
@@ -270,7 +393,10 @@ export function createLocalWikiEngine(config: LocalWikiEngineConfig) {
|
|
|
270
393
|
for (const file of files) {
|
|
271
394
|
const stat = await fsp.stat(file);
|
|
272
395
|
newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
|
|
273
|
-
|
|
396
|
+
const raw = await fsp.readFile(file, "utf8");
|
|
397
|
+
const shouldExpandIncludes = config.format === "asciidoc" && config.expandIncludes !== false;
|
|
398
|
+
const expanded = shouldExpandIncludes ? await expandAsciidocIncludes(raw, file) : raw;
|
|
399
|
+
pages.push(parsePage(expanded, file, stat.mtimeMs, raw));
|
|
274
400
|
}
|
|
275
401
|
const metadata: LocalWikiCacheMetadata = { schemaVersion, docsPath: config.docsPath, generatedAt: new Date().toISOString(), pageCount: pages.length, newestMtimeMs, extra: await config.metadataExtra?.() };
|
|
276
402
|
await fsp.writeFile(pagesCache, JSON.stringify(pages, null, 2));
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type ChecklistStatus = "todo" | "partial" | "done";
|
|
2
|
+
|
|
3
|
+
export type ChecklistItem = {
|
|
4
|
+
text: string;
|
|
5
|
+
status: ChecklistStatus;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ChecklistProgress = {
|
|
9
|
+
total: number;
|
|
10
|
+
done: number;
|
|
11
|
+
partial: number;
|
|
12
|
+
remaining: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const CHECKLIST_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.+)$/;
|
|
16
|
+
|
|
17
|
+
export function parseChecklistLine(line: string): ChecklistItem | undefined {
|
|
18
|
+
const match = CHECKLIST_LINE_REGEX.exec(line);
|
|
19
|
+
if (!match) return undefined;
|
|
20
|
+
|
|
21
|
+
const mark = (match[1] || " ").toLowerCase();
|
|
22
|
+
const label = (match[2] || "").trim().replace(/\s+/g, " ");
|
|
23
|
+
if (!label) return undefined;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
status: mark === "x" ? "done" : mark === "-" ? "partial" : "todo",
|
|
27
|
+
text: label,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractChecklist(text: string): ChecklistItem[] {
|
|
32
|
+
const checklist: ChecklistItem[] = [];
|
|
33
|
+
let inFence = false;
|
|
34
|
+
|
|
35
|
+
for (const line of text.split(/\r?\n/)) {
|
|
36
|
+
if (/^\s*```/.test(line)) {
|
|
37
|
+
inFence = !inFence;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (inFence) continue;
|
|
41
|
+
|
|
42
|
+
const item = parseChecklistLine(line);
|
|
43
|
+
if (item) checklist.push(item);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return checklist;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function stripChecklistLines(text: string): string {
|
|
50
|
+
let inFence = false;
|
|
51
|
+
const kept: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const line of text.split(/\r?\n/)) {
|
|
54
|
+
if (/^\s*```/.test(line)) {
|
|
55
|
+
inFence = !inFence;
|
|
56
|
+
kept.push(line);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!inFence && parseChecklistLine(line)) continue;
|
|
61
|
+
kept.push(line);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function countChecklistProgress(textOrItems: string | ChecklistItem[]): ChecklistProgress {
|
|
68
|
+
const items = typeof textOrItems === "string" ? extractChecklist(textOrItems) : textOrItems;
|
|
69
|
+
const done = items.filter((item) => item.status === "done").length;
|
|
70
|
+
const partial = items.filter((item) => item.status === "partial").length;
|
|
71
|
+
return {
|
|
72
|
+
total: items.length,
|
|
73
|
+
done,
|
|
74
|
+
partial,
|
|
75
|
+
remaining: Math.max(0, items.length - done),
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -26,3 +26,41 @@ export function getAgentSettingsPath(): string {
|
|
|
26
26
|
export function getWorkspaceEnvPath(cwd = process.cwd()): string {
|
|
27
27
|
return path.join(cwd, ".env");
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
export function expandTilde(input: string, homeDir = os.homedir()): string {
|
|
31
|
+
if (input === "~") return homeDir;
|
|
32
|
+
if (input.startsWith("~/")) return path.join(homeDir, input.slice(2));
|
|
33
|
+
if (input === "$HOME") return homeDir;
|
|
34
|
+
if (input.startsWith("$HOME/")) return path.join(homeDir, input.slice(6));
|
|
35
|
+
return input;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function stripAtPathPrefix(input: string): string {
|
|
39
|
+
return input.trim().replace(/^@+/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveUserPath(input: string, cwd = process.cwd(), options: { stripAtPrefix?: boolean } = {}): string {
|
|
43
|
+
const cleaned = options.stripAtPrefix === false ? input.trim() : stripAtPathPrefix(input);
|
|
44
|
+
const expanded = expandTilde(cleaned);
|
|
45
|
+
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(cwd, expanded);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isPathInside(basePath: string, candidatePath: string): boolean {
|
|
49
|
+
const relative = path.relative(path.resolve(basePath), path.resolve(candidatePath));
|
|
50
|
+
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function safeResolveInside(basePath: string, reference: string): string {
|
|
54
|
+
const base = path.resolve(basePath);
|
|
55
|
+
const candidate = resolveUserPath(reference, base);
|
|
56
|
+
if (!isPathInside(base, candidate)) throw new Error(`Path escapes base directory: ${reference}`);
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatUserPath(filePath: string, homeDir = os.homedir()): string {
|
|
61
|
+
const normalized = path.resolve(filePath);
|
|
62
|
+
const home = path.resolve(homeDir);
|
|
63
|
+
if (normalized === home) return "~";
|
|
64
|
+
if (normalized.startsWith(`${home}${path.sep}`)) return `~/${normalized.slice(home.length + 1).split(path.sep).join("/")}`;
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|
package/src/process.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile, spawn, type ChildProcessByStdio } from "node:child_process";
|
|
5
|
+
import type { Readable } from "node:stream";
|
|
6
|
+
|
|
7
|
+
export const ANSI_ESCAPE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
8
|
+
|
|
9
|
+
export type CommandResult = {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
exitCode?: number;
|
|
14
|
+
signal?: NodeJS.Signals | null;
|
|
15
|
+
error?: string;
|
|
16
|
+
timedOut?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type RunCommandOptions = {
|
|
20
|
+
cwd?: string;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
maxStdoutChars?: number;
|
|
24
|
+
maxStderrChars?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type AbortableProcess = ChildProcessByStdio<null, Readable, Readable> & {
|
|
28
|
+
abortProcessGroup?: () => void;
|
|
29
|
+
abortReleaseStep?: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function shellQuote(value: string): string {
|
|
33
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stripAnsi(input: string): string {
|
|
37
|
+
return input.replace(ANSI_ESCAPE_RE, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveExecutableFromPath(binName: string, envPath = process.env.PATH ?? ""): string | undefined {
|
|
41
|
+
const candidates = os.platform() === "win32" && !binName.toLowerCase().endsWith(".exe") ? [binName, `${binName}.exe`] : [binName];
|
|
42
|
+
for (const dir of envPath.split(path.delimiter).filter(Boolean)) {
|
|
43
|
+
for (const name of candidates) {
|
|
44
|
+
const candidate = path.join(dir, name);
|
|
45
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function commandExists(command: string, args: string[] = ["--version"], timeoutMs = 3000): Promise<boolean> {
|
|
52
|
+
const result = await runCommand(command, args, { timeoutMs });
|
|
53
|
+
return result.ok;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function trimBuffer(value: string, maxChars: number | undefined): string {
|
|
57
|
+
if (!maxChars || value.length <= maxChars) return value;
|
|
58
|
+
return value.slice(-maxChars);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function runCommand(command: string, args: string[] = [], options: RunCommandOptions = {}): Promise<CommandResult> {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const child = execFile(command, args, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs }, (error, stdout, stderr) => {
|
|
64
|
+
const exitCode = error && "code" in error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
65
|
+
resolve({
|
|
66
|
+
ok: !error,
|
|
67
|
+
stdout: trimBuffer(String(stdout ?? ""), options.maxStdoutChars),
|
|
68
|
+
stderr: trimBuffer(String(stderr ?? ""), options.maxStderrChars),
|
|
69
|
+
exitCode,
|
|
70
|
+
signal: error && "signal" in error ? (error.signal as NodeJS.Signals | null) : null,
|
|
71
|
+
error: error instanceof Error ? error.message : undefined,
|
|
72
|
+
timedOut: error && "killed" in error ? Boolean(error.killed) : false,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
child.on("error", (error) => {
|
|
76
|
+
resolve({ ok: false, stdout: "", stderr: "", error: error.message, exitCode: 1 });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function runShellCommand(cwd: string, command: string, options: RunCommandOptions = {}): Promise<CommandResult> {
|
|
82
|
+
return runCommand("bash", ["-lc", command], { ...options, cwd });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function runLiveShellCommand(args: {
|
|
86
|
+
cwd: string;
|
|
87
|
+
command: string;
|
|
88
|
+
onChunk: (chunk: string) => void;
|
|
89
|
+
onChild?: (child: AbortableProcess) => void;
|
|
90
|
+
timeoutMs?: number;
|
|
91
|
+
detached?: boolean;
|
|
92
|
+
}): Promise<CommandResult & { output: string; aborted: boolean }> {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const child = spawn("bash", ["-lc", args.command], {
|
|
95
|
+
cwd: args.cwd,
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
detached: args.detached ?? true,
|
|
98
|
+
}) as AbortableProcess;
|
|
99
|
+
let output = "";
|
|
100
|
+
let aborted = false;
|
|
101
|
+
let settled = false;
|
|
102
|
+
let timer: NodeJS.Timeout | undefined;
|
|
103
|
+
|
|
104
|
+
const abort = () => {
|
|
105
|
+
aborted = true;
|
|
106
|
+
try {
|
|
107
|
+
if (child.pid && child.pid > 0) process.kill(-child.pid, "SIGINT");
|
|
108
|
+
} catch {
|
|
109
|
+
child.kill("SIGINT");
|
|
110
|
+
}
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
113
|
+
try {
|
|
114
|
+
if (child.pid && child.pid > 0) process.kill(-child.pid, "SIGTERM");
|
|
115
|
+
} catch {
|
|
116
|
+
child.kill("SIGTERM");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}, 1500).unref();
|
|
120
|
+
};
|
|
121
|
+
child.abortProcessGroup = abort;
|
|
122
|
+
child.abortReleaseStep = abort;
|
|
123
|
+
|
|
124
|
+
const finish = (result: CommandResult & { output: string; aborted: boolean }) => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
settled = true;
|
|
127
|
+
if (timer) clearTimeout(timer);
|
|
128
|
+
resolve(result);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (args.timeoutMs && args.timeoutMs > 0) {
|
|
132
|
+
timer = setTimeout(() => {
|
|
133
|
+
abort();
|
|
134
|
+
finish({ ok: false, stdout: output, stderr: "", output, aborted: true, timedOut: true });
|
|
135
|
+
}, args.timeoutMs);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
args.onChild?.(child);
|
|
139
|
+
child.stdout.on("data", (d) => {
|
|
140
|
+
const chunk = String(d);
|
|
141
|
+
output += chunk;
|
|
142
|
+
args.onChunk(chunk);
|
|
143
|
+
});
|
|
144
|
+
child.stderr.on("data", (d) => {
|
|
145
|
+
const chunk = String(d);
|
|
146
|
+
output += chunk;
|
|
147
|
+
args.onChunk(chunk);
|
|
148
|
+
});
|
|
149
|
+
child.on("error", (error) => finish({ ok: false, stdout: output, stderr: error.message, output, aborted, error: error.message }));
|
|
150
|
+
child.on("close", (code, signal) => finish({ ok: code === 0 && !aborted, stdout: output, stderr: "", output, aborted, exitCode: code ?? undefined, signal }));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { InitialPromptCalibration, InitialPromptInputEstimate, InitialPromptToolInfo } from "./tokens";
|
|
8
|
+
import { estimateInitialPromptInput } from "./tokens";
|
|
9
|
+
|
|
10
|
+
export type ExportBackedInitialPromptEstimate = {
|
|
11
|
+
estimate: InitialPromptInputEstimate;
|
|
12
|
+
systemPrompt: string;
|
|
13
|
+
/** Active tool schemas used for the estimate, preferably decoded from the temporary export HTML. */
|
|
14
|
+
tools: InitialPromptToolInfo[];
|
|
15
|
+
source: "export-html" | "direct";
|
|
16
|
+
warning?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ExportSessionData = {
|
|
20
|
+
systemPrompt?: unknown;
|
|
21
|
+
tools?: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ExportSessionToHtml = (
|
|
25
|
+
sm: ExtensionContext["sessionManager"],
|
|
26
|
+
state?: { systemPrompt?: string; tools?: InitialPromptToolInfo[] },
|
|
27
|
+
options?: { outputPath?: string; themeName?: string } | string,
|
|
28
|
+
) => Promise<string>;
|
|
29
|
+
|
|
30
|
+
type PromptEstimatePiApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools">;
|
|
31
|
+
type PromptEstimateContext = Pick<ExtensionContext, "getSystemPrompt" | "sessionManager">;
|
|
32
|
+
|
|
33
|
+
let exportSessionToHtmlPromise: Promise<ExportSessionToHtml | null> | null = null;
|
|
34
|
+
|
|
35
|
+
function resolvePiExportHtmlModuleUrl(): string | null {
|
|
36
|
+
try {
|
|
37
|
+
const basePath = typeof __filename === "string" && path.isAbsolute(__filename) ? __filename : path.join(process.cwd(), "package.json");
|
|
38
|
+
const requireFromHere = createRequire(basePath);
|
|
39
|
+
const candidateDirs = requireFromHere.resolve.paths("@earendil-works/pi-coding-agent") ?? [];
|
|
40
|
+
for (const dir of candidateDirs) {
|
|
41
|
+
const candidate = path.join(dir, "@earendil-works", "pi-coding-agent", "dist", "core", "export-html", "index.js");
|
|
42
|
+
if (fs.existsSync(candidate)) return pathToFileURL(candidate).href;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadPiExportSessionToHtml(): Promise<ExportSessionToHtml | null> {
|
|
51
|
+
exportSessionToHtmlPromise ??= (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const exportModuleUrl = resolvePiExportHtmlModuleUrl();
|
|
54
|
+
if (!exportModuleUrl) return null;
|
|
55
|
+
const mod = (await import(exportModuleUrl)) as { exportSessionToHtml?: unknown };
|
|
56
|
+
return typeof mod.exportSessionToHtml === "function" ? (mod.exportSessionToHtml as ExportSessionToHtml) : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return exportSessionToHtmlPromise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeTempExportPath(sessionId: string | undefined): string {
|
|
65
|
+
const safeSessionId = (sessionId || "session").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
|
66
|
+
const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
67
|
+
return path.join(os.tmpdir(), `pi-prompt-estimate-export-${safeSessionId}-${nonce}.html`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function decodeSessionDataFromExportHtml(html: string): ExportSessionData | null {
|
|
71
|
+
const match = html.match(/<script[^>]*id=["']session-data["'][^>]*>([^<]*)<\/script>/i);
|
|
72
|
+
const encoded = match?.[1]?.trim();
|
|
73
|
+
if (!encoded) return null;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
|
77
|
+
return parsed && typeof parsed === "object" ? (parsed as ExportSessionData) : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeExportedTools(tools: unknown): InitialPromptToolInfo[] {
|
|
84
|
+
if (!Array.isArray(tools)) return [];
|
|
85
|
+
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
const normalized: InitialPromptToolInfo[] = [];
|
|
88
|
+
for (const tool of tools) {
|
|
89
|
+
const record = (tool && typeof tool === "object" ? tool : {}) as Record<string, unknown>;
|
|
90
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
91
|
+
if (!name || seen.has(name)) continue;
|
|
92
|
+
seen.add(name);
|
|
93
|
+
normalized.push({
|
|
94
|
+
name,
|
|
95
|
+
description: typeof record.description === "string" ? record.description : undefined,
|
|
96
|
+
parameters: record.parameters,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getActiveInitialPromptToolInfos(pi: PromptEstimatePiApi): InitialPromptToolInfo[] {
|
|
103
|
+
let activeTools: string[] = [];
|
|
104
|
+
let allTools: InitialPromptToolInfo[] = [];
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
activeTools = pi.getActiveTools();
|
|
108
|
+
} catch {
|
|
109
|
+
activeTools = [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
allTools = pi.getAllTools().map((tool) => ({
|
|
114
|
+
name: tool.name,
|
|
115
|
+
description: tool.description,
|
|
116
|
+
parameters: tool.parameters,
|
|
117
|
+
}));
|
|
118
|
+
} catch {
|
|
119
|
+
allTools = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (allTools.length === 0) return [];
|
|
123
|
+
|
|
124
|
+
const toolsByName = new Map<string, InitialPromptToolInfo>();
|
|
125
|
+
for (const tool of allTools) {
|
|
126
|
+
if (tool.name && !toolsByName.has(tool.name)) toolsByName.set(tool.name, tool);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const orderedNames = activeTools.length > 0 ? activeTools : Array.from(toolsByName.keys()).sort();
|
|
130
|
+
return orderedNames.map((name) => toolsByName.get(name)).filter((tool): tool is InitialPromptToolInfo => !!tool);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function estimateInitialPromptForPiContext(
|
|
134
|
+
pi: PromptEstimatePiApi,
|
|
135
|
+
systemPrompt: string,
|
|
136
|
+
calibration?: InitialPromptCalibration | null,
|
|
137
|
+
exportedTools?: InitialPromptToolInfo[],
|
|
138
|
+
): InitialPromptInputEstimate {
|
|
139
|
+
const tools = exportedTools ?? getActiveInitialPromptToolInfos(pi);
|
|
140
|
+
return estimateInitialPromptInput({
|
|
141
|
+
systemPrompt,
|
|
142
|
+
activeTools: tools.map((tool) => tool.name),
|
|
143
|
+
allTools: tools,
|
|
144
|
+
calibration,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function estimateInitialPromptFromPiExport(
|
|
149
|
+
pi: PromptEstimatePiApi,
|
|
150
|
+
ctx: PromptEstimateContext,
|
|
151
|
+
calibration?: InitialPromptCalibration | null,
|
|
152
|
+
): Promise<ExportBackedInitialPromptEstimate> {
|
|
153
|
+
const fallbackSystemPrompt = ctx.getSystemPrompt();
|
|
154
|
+
const fallbackTools = getActiveInitialPromptToolInfos(pi);
|
|
155
|
+
const exportSessionToHtml = await loadPiExportSessionToHtml();
|
|
156
|
+
if (!exportSessionToHtml) {
|
|
157
|
+
return {
|
|
158
|
+
estimate: estimateInitialPromptForPiContext(pi, fallbackSystemPrompt, calibration, fallbackTools),
|
|
159
|
+
systemPrompt: fallbackSystemPrompt,
|
|
160
|
+
tools: fallbackTools,
|
|
161
|
+
source: "direct",
|
|
162
|
+
warning: "Pi HTML export API unavailable; used live context fallback.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const outputPath = makeTempExportPath(ctx.sessionManager.getSessionId());
|
|
167
|
+
let writtenPath = outputPath;
|
|
168
|
+
try {
|
|
169
|
+
writtenPath = await exportSessionToHtml(
|
|
170
|
+
ctx.sessionManager,
|
|
171
|
+
{ systemPrompt: fallbackSystemPrompt, tools: fallbackTools },
|
|
172
|
+
{ outputPath },
|
|
173
|
+
);
|
|
174
|
+
const html = fs.readFileSync(writtenPath, "utf8");
|
|
175
|
+
const sessionData = decodeSessionDataFromExportHtml(html);
|
|
176
|
+
const exportedSystemPrompt = typeof sessionData?.systemPrompt === "string" ? sessionData.systemPrompt : fallbackSystemPrompt;
|
|
177
|
+
const exportedTools = normalizeExportedTools(sessionData?.tools);
|
|
178
|
+
const tools = exportedTools.length > 0 ? exportedTools : fallbackTools;
|
|
179
|
+
const estimate = estimateInitialPromptForPiContext(pi, exportedSystemPrompt, calibration, tools);
|
|
180
|
+
return { estimate, systemPrompt: exportedSystemPrompt, tools, source: "export-html" };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
estimate: estimateInitialPromptForPiContext(pi, fallbackSystemPrompt, calibration, fallbackTools),
|
|
184
|
+
systemPrompt: fallbackSystemPrompt,
|
|
185
|
+
tools: fallbackTools,
|
|
186
|
+
source: "direct",
|
|
187
|
+
warning: `Pi HTML export failed; used live context fallback (${error instanceof Error ? error.message : String(error)}).`,
|
|
188
|
+
};
|
|
189
|
+
} finally {
|
|
190
|
+
for (const filePath of new Set([outputPath, writtenPath])) {
|
|
191
|
+
try {
|
|
192
|
+
fs.rmSync(filePath, { force: true });
|
|
193
|
+
} catch {
|
|
194
|
+
// Best-effort cleanup only.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type RunLog = {
|
|
5
|
+
id: string;
|
|
6
|
+
startedAt: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
chunks: string[];
|
|
9
|
+
saved: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type RunLogEntry = {
|
|
13
|
+
file: string;
|
|
14
|
+
title: string;
|
|
15
|
+
mtimeMs: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function sanitizeLogId(value: string): string {
|
|
19
|
+
return value.replace(/[^0-9A-Za-z._-]/g, "-");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createRunLog(cwd: string, now = new Date()): RunLog {
|
|
23
|
+
const startedAt = now.toISOString();
|
|
24
|
+
return { id: sanitizeLogId(startedAt), startedAt, cwd, chunks: [], saved: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function appendRunLog(runLog: RunLog, chunk: string): void {
|
|
28
|
+
runLog.chunks.push(chunk);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveRunLog(runLog: RunLog, args: { logDir: string; title: string; status: string; summary?: string; now?: Date }): string | undefined {
|
|
32
|
+
if (runLog.saved) return undefined;
|
|
33
|
+
runLog.saved = true;
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(args.logDir, { recursive: true });
|
|
36
|
+
const filePath = join(args.logDir, `${runLog.id}-${sanitizeLogId(args.status)}.log`);
|
|
37
|
+
const content = [
|
|
38
|
+
args.title,
|
|
39
|
+
`started_at=${runLog.startedAt}`,
|
|
40
|
+
`finished_at=${(args.now ?? new Date()).toISOString()}`,
|
|
41
|
+
`status=${args.status}`,
|
|
42
|
+
`cwd=${runLog.cwd}`,
|
|
43
|
+
args.summary ? `summary=${args.summary.replace(/\r?\n/g, " | ")}` : undefined,
|
|
44
|
+
"",
|
|
45
|
+
"--- output ---",
|
|
46
|
+
runLog.chunks.join(""),
|
|
47
|
+
].filter((line): line is string => line !== undefined).join("\n");
|
|
48
|
+
writeFileSync(filePath, content, "utf8");
|
|
49
|
+
return filePath;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listRunLogs(logDir: string): RunLogEntry[] {
|
|
56
|
+
if (!existsSync(logDir)) return [];
|
|
57
|
+
return readdirSync(logDir)
|
|
58
|
+
.filter((file) => file.endsWith(".log"))
|
|
59
|
+
.map((file) => {
|
|
60
|
+
const filePath = join(logDir, file);
|
|
61
|
+
const stat = statSync(filePath);
|
|
62
|
+
return { file: filePath, title: file.replace(/\.log$/, ""), mtimeMs: stat.mtimeMs };
|
|
63
|
+
})
|
|
64
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
65
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TextToolResult<T = unknown> = {
|
|
2
|
+
content: Array<{ type: "text"; text: string }>;
|
|
3
|
+
details?: T;
|
|
4
|
+
isError?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function textToolResult<T = unknown>(text: string, details?: T, options: { isError?: boolean } = {}): TextToolResult<T> {
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: "text", text }],
|
|
10
|
+
...(details === undefined ? {} : { details }),
|
|
11
|
+
...(options.isError === undefined ? {} : { isError: options.isError }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function jsonToolResult<T = unknown>(payload: T, options: { space?: number; isError?: boolean } = {}): TextToolResult<T> {
|
|
16
|
+
return textToolResult(JSON.stringify(payload, null, options.space ?? 2), payload, { isError: options.isError });
|
|
17
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function isCtrlO(data: string): boolean {
|
|
2
|
+
const key = data.toLowerCase();
|
|
3
|
+
return data === "\x0f" || key === "ctrl+o" || data === "\x1b[111;5u" || data === "\x1b[27;5;111~";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isCtrlC(data: string): boolean {
|
|
7
|
+
const key = data.toLowerCase();
|
|
8
|
+
return data === "\x03" || key === "ctrl+c" || data === "\x1b[99;5u" || data === "\x1b[27;5;99~";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function appendDisplayChunk(lines: string[], chunk: string): void {
|
|
12
|
+
if (lines.length === 0) lines.push("");
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
15
|
+
const char = chunk[i];
|
|
16
|
+
if (char === "\r") {
|
|
17
|
+
if (chunk[i + 1] === "\n") {
|
|
18
|
+
lines.push("");
|
|
19
|
+
i++;
|
|
20
|
+
} else {
|
|
21
|
+
lines[lines.length - 1] = "";
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (char === "\n") {
|
|
26
|
+
lines.push("");
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
lines[lines.length - 1] += char;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function outputLinesFromDisplay(lines: string[]): string[] {
|
|
34
|
+
const visible = lines.slice();
|
|
35
|
+
while (visible.length > 0 && visible[visible.length - 1] === "") visible.pop();
|
|
36
|
+
return visible;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatElapsed(startMs: number, nowMs = Date.now()): string {
|
|
40
|
+
const seconds = Math.max(0, Math.floor((nowMs - startMs) / 1000));
|
|
41
|
+
const minutes = Math.floor(seconds / 60);
|
|
42
|
+
const remainder = seconds % 60;
|
|
43
|
+
return minutes > 0 ? `${minutes}m${String(remainder).padStart(2, "0")}s` : `${remainder}s`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function truncateLine(line: string, width: number): string {
|
|
47
|
+
return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line;
|
|
48
|
+
}
|