@clawdbuilder/jig-bedrock 0.0.0-stage → 0.1.1
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/LICENSE +9 -0
- package/README.md +172 -2
- package/bin/windows-browser +4 -0
- package/index.ts +3 -0
- package/package.json +51 -5
- package/src/aws-discovery.ts +226 -0
- package/src/bedrock-fetch.ts +77 -0
- package/src/budget.ts +129 -0
- package/src/cache.ts +22 -0
- package/src/catalog.ts +514 -0
- package/src/check.ts +14 -0
- package/src/child.ts +7 -0
- package/src/commands.ts +344 -0
- package/src/config.ts +214 -0
- package/src/doctor.ts +172 -0
- package/src/events.ts +46 -0
- package/src/identity.ts +3 -0
- package/src/index.ts +33 -0
- package/src/jig/client.ts +63 -0
- package/src/jig/types.ts +12 -0
- package/src/login.ts +176 -0
- package/src/messages.ts +45 -0
- package/src/pi-host.ts +54 -0
- package/src/prompt-cache.ts +50 -0
- package/src/provider.ts +344 -0
- package/src/sigv4.ts +92 -0
- package/src/sso-check.ts +111 -0
- package/src/sso.ts +339 -0
- package/src/start-sign-in.ts +53 -0
- package/src/statistics.ts +315 -0
- package/src/switch-cost.ts +91 -0
- package/src/usage.ts +138 -0
- package/src/wsl.ts +107 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicCache } from "./cache.ts";
|
|
4
|
+
import { boundedRegular } from "./catalog.ts";
|
|
5
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
6
|
+
import {
|
|
7
|
+
executableExists,
|
|
8
|
+
executableOnPath,
|
|
9
|
+
runUsage,
|
|
10
|
+
UsageError,
|
|
11
|
+
type UsageFailure,
|
|
12
|
+
type UsageRunner,
|
|
13
|
+
usageExecutable,
|
|
14
|
+
} from "./usage.ts";
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed JSON from the AWS CLI or usage program, checked field by field where it is used
|
|
17
|
+
type Json = any;
|
|
18
|
+
|
|
19
|
+
export type UsagePeriod = "daily" | "weekly" | "monthly";
|
|
20
|
+
export const periodLabels: Record<UsagePeriod, string> = {
|
|
21
|
+
daily: "Today",
|
|
22
|
+
weekly: "Last 7 Days",
|
|
23
|
+
monthly: "Current Month",
|
|
24
|
+
};
|
|
25
|
+
export interface UsageTotals {
|
|
26
|
+
requests: number;
|
|
27
|
+
inputFresh: number;
|
|
28
|
+
cacheRead: number;
|
|
29
|
+
cacheWrite: number;
|
|
30
|
+
input: number;
|
|
31
|
+
output: number;
|
|
32
|
+
costUsd: number | null;
|
|
33
|
+
}
|
|
34
|
+
export interface UsageProfile extends UsageTotals {
|
|
35
|
+
model: string;
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
export interface UsageReport {
|
|
39
|
+
user: string;
|
|
40
|
+
period: UsagePeriod;
|
|
41
|
+
totals: UsageTotals;
|
|
42
|
+
profiles: UsageProfile[];
|
|
43
|
+
contract: "v1" | "legacy";
|
|
44
|
+
}
|
|
45
|
+
export interface UsageSnapshot {
|
|
46
|
+
report: UsageReport;
|
|
47
|
+
observedAt: number;
|
|
48
|
+
stale: boolean;
|
|
49
|
+
refreshFailed: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface StatisticsSource {
|
|
52
|
+
load(period: UsagePeriod): UsageSnapshot | undefined;
|
|
53
|
+
refresh(period: UsagePeriod, options?: { force?: boolean; signal?: AbortSignal }): Promise<UsageSnapshot>;
|
|
54
|
+
health(): { usage: boolean; aws: boolean; bash: boolean; jq: boolean };
|
|
55
|
+
}
|
|
56
|
+
const label = (v: unknown, max = 256): v is string =>
|
|
57
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: usage labels reject control characters
|
|
58
|
+
typeof v === "string" && v.length > 0 && v.length <= max && !/[\x00-\x1f\x7f-\x9f]/.test(v);
|
|
59
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
60
|
+
const cost = (v: unknown): v is number | null => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
61
|
+
function totals(value: Json, summary: boolean): UsageTotals {
|
|
62
|
+
const names = summary
|
|
63
|
+
? [
|
|
64
|
+
"total_requests",
|
|
65
|
+
"total_input_tokens_fresh",
|
|
66
|
+
"total_cache_read_tokens",
|
|
67
|
+
"total_cache_write_tokens",
|
|
68
|
+
"total_input_tokens",
|
|
69
|
+
"total_output_tokens",
|
|
70
|
+
"total_estimated_cost_usd",
|
|
71
|
+
]
|
|
72
|
+
: [
|
|
73
|
+
"requests",
|
|
74
|
+
"input_tokens_fresh",
|
|
75
|
+
"cache_read_tokens",
|
|
76
|
+
"cache_write_tokens",
|
|
77
|
+
"total_input_tokens",
|
|
78
|
+
"output_tokens",
|
|
79
|
+
"estimated_cost_usd",
|
|
80
|
+
];
|
|
81
|
+
if (!value || !names.slice(0, 6).every((k) => count(value[k])) || !cost(value[names[6] as string]))
|
|
82
|
+
throw new UsageError("invalid_result");
|
|
83
|
+
const [requests, inputFresh, cacheRead, cacheWrite, input, output, costUsd] = names.map((k) => value[k]);
|
|
84
|
+
if (!Number.isSafeInteger(inputFresh + cacheRead + cacheWrite) || input !== inputFresh + cacheRead + cacheWrite)
|
|
85
|
+
throw new UsageError("invalid_result");
|
|
86
|
+
return { requests, inputFresh, cacheRead, cacheWrite, input, output, costUsd };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* What was spent on the rows that have a price. usage program leaves the summary cost null as soon as one row is unpriced (a model it has
|
|
90
|
+
* no rate for), which would hide hundreds of dollars of known spend behind one unknown row; the footer shows the priced sum
|
|
91
|
+
* and marks it as a lower bound instead.
|
|
92
|
+
*/
|
|
93
|
+
export function pricedSpend(report: UsageReport): { spentUsd: number; unpriced: boolean } {
|
|
94
|
+
return {
|
|
95
|
+
spentUsd: report.profiles.reduce((sum, row) => sum + (row.costUsd ?? 0), 0),
|
|
96
|
+
unpriced: report.profiles.some((row) => row.costUsd === null),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function normalize(value: Json, period: UsagePeriod): UsageReport {
|
|
100
|
+
if (
|
|
101
|
+
!value ||
|
|
102
|
+
typeof value !== "object" ||
|
|
103
|
+
Array.isArray(value) ||
|
|
104
|
+
!label(value.user) ||
|
|
105
|
+
value.period !== periodLabels[period] ||
|
|
106
|
+
!Array.isArray(value.profiles) ||
|
|
107
|
+
value.profiles.length > 256
|
|
108
|
+
)
|
|
109
|
+
throw new UsageError("invalid_result");
|
|
110
|
+
const contract =
|
|
111
|
+
value.schema_version === undefined && value.command === undefined
|
|
112
|
+
? "legacy"
|
|
113
|
+
: value.schema_version === 1 && value.command === "usage"
|
|
114
|
+
? "v1"
|
|
115
|
+
: undefined;
|
|
116
|
+
if (!contract) throw new UsageError("invalid_result");
|
|
117
|
+
const profiles: UsageProfile[] = value.profiles.map((row: Json) => {
|
|
118
|
+
if (
|
|
119
|
+
!row ||
|
|
120
|
+
row.user !== value.user ||
|
|
121
|
+
!(label(row.model_slug) || (row.model_slug === "" && row.estimated_cost_usd === null)) ||
|
|
122
|
+
!label(row.profile_name)
|
|
123
|
+
)
|
|
124
|
+
throw new UsageError("invalid_result");
|
|
125
|
+
return { model: row.model_slug || "Unknown model", name: row.profile_name, ...totals(row, false) };
|
|
126
|
+
});
|
|
127
|
+
const summary =
|
|
128
|
+
value.summary === undefined && !profiles.length && contract === "legacy"
|
|
129
|
+
? { requests: 0, inputFresh: 0, cacheRead: 0, cacheWrite: 0, input: 0, output: 0, costUsd: null }
|
|
130
|
+
: totals(value.summary, true);
|
|
131
|
+
for (const key of ["requests", "inputFresh", "cacheRead", "cacheWrite", "input", "output"] as const) {
|
|
132
|
+
const sum = profiles.reduce((n, p) => n + p[key], 0);
|
|
133
|
+
if (!Number.isSafeInteger(sum) || sum !== summary[key]) throw new UsageError("invalid_result");
|
|
134
|
+
}
|
|
135
|
+
if (profiles.some((p) => p.costUsd === null) && summary.costUsd !== null) throw new UsageError("invalid_result");
|
|
136
|
+
if (summary.costUsd !== null) {
|
|
137
|
+
const sum = profiles.reduce((total, row) => total + (row.costUsd ?? 0), 0);
|
|
138
|
+
// usage program sums priced rows; allow only floating-point representation noise.
|
|
139
|
+
const tolerance = Math.max(1e-8, Number.EPSILON * Math.max(1, sum) * Math.max(1, profiles.length) * 4);
|
|
140
|
+
if (!Number.isFinite(sum) || Math.abs(sum - summary.costUsd) > tolerance)
|
|
141
|
+
throw new UsageError("invalid_result");
|
|
142
|
+
}
|
|
143
|
+
return { user: value.user, period, totals: summary, profiles, contract };
|
|
144
|
+
}
|
|
145
|
+
/** v1 is one JSON object. Only legacy usage program may prefix the object with human-readable text. */
|
|
146
|
+
export function parseUsage(output: string, period: UsagePeriod): UsageReport {
|
|
147
|
+
if (Buffer.byteLength(output) > 2_097_152) throw new UsageError("invalid_result");
|
|
148
|
+
try {
|
|
149
|
+
return normalize(JSON.parse(output), period);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof UsageError) throw error;
|
|
152
|
+
}
|
|
153
|
+
let candidates = 0;
|
|
154
|
+
for (let index = output.indexOf("{"); index >= 0 && candidates++ < 256; index = output.indexOf("{", index + 1)) {
|
|
155
|
+
try {
|
|
156
|
+
const value = JSON.parse(output.slice(index));
|
|
157
|
+
if (value.schema_version !== undefined || value.command !== undefined)
|
|
158
|
+
throw new UsageError("invalid_result");
|
|
159
|
+
return normalize(value, period);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof UsageError) throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw new UsageError("invalid_result");
|
|
165
|
+
}
|
|
166
|
+
function cacheReport(value: Json): UsageReport {
|
|
167
|
+
if (!value || !["v1", "legacy"].includes(value.contract)) throw new UsageError("invalid_result");
|
|
168
|
+
const fields = (t: Json, summary: boolean) =>
|
|
169
|
+
summary
|
|
170
|
+
? {
|
|
171
|
+
total_requests: t.requests,
|
|
172
|
+
total_input_tokens_fresh: t.inputFresh,
|
|
173
|
+
total_cache_read_tokens: t.cacheRead,
|
|
174
|
+
total_cache_write_tokens: t.cacheWrite,
|
|
175
|
+
total_input_tokens: t.input,
|
|
176
|
+
total_output_tokens: t.output,
|
|
177
|
+
total_estimated_cost_usd: t.costUsd,
|
|
178
|
+
}
|
|
179
|
+
: {
|
|
180
|
+
requests: t.requests,
|
|
181
|
+
input_tokens_fresh: t.inputFresh,
|
|
182
|
+
cache_read_tokens: t.cacheRead,
|
|
183
|
+
cache_write_tokens: t.cacheWrite,
|
|
184
|
+
total_input_tokens: t.input,
|
|
185
|
+
output_tokens: t.output,
|
|
186
|
+
estimated_cost_usd: t.costUsd,
|
|
187
|
+
};
|
|
188
|
+
return normalize(
|
|
189
|
+
{
|
|
190
|
+
...(value.contract === "v1" ? { schema_version: 1, command: "usage" } : {}),
|
|
191
|
+
user: value.user,
|
|
192
|
+
period: periodLabels[value.period as UsagePeriod],
|
|
193
|
+
summary: fields(value.totals, true),
|
|
194
|
+
profiles: value.profiles.map((p: Json) => ({
|
|
195
|
+
user: value.user,
|
|
196
|
+
model_slug: p.model,
|
|
197
|
+
profile_name: p.name,
|
|
198
|
+
...fields(p, false),
|
|
199
|
+
})),
|
|
200
|
+
},
|
|
201
|
+
value.period,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
export function createStatistics(
|
|
205
|
+
config: ResolvedConfig,
|
|
206
|
+
runner: UsageRunner = runUsage,
|
|
207
|
+
directory = join(homedir(), ".cache/jig-bedrock"),
|
|
208
|
+
now = Date.now,
|
|
209
|
+
): StatisticsSource {
|
|
210
|
+
const scope = JSON.stringify({
|
|
211
|
+
profile: config.profile,
|
|
212
|
+
team: config.team,
|
|
213
|
+
regions: [...config.regions].sort(),
|
|
214
|
+
executable: usageExecutable(config),
|
|
215
|
+
});
|
|
216
|
+
const file = (period: UsagePeriod) => join(directory, `usage-${period}.json`);
|
|
217
|
+
const window = (period: UsagePeriod, observedAt: number) =>
|
|
218
|
+
new Date(observedAt).toISOString().slice(0, period === "monthly" ? 7 : 10);
|
|
219
|
+
const load = (period: UsagePeriod): UsageSnapshot | undefined => {
|
|
220
|
+
try {
|
|
221
|
+
const value = JSON.parse(boundedRegular(file(period), 1_048_576, true) || "{}");
|
|
222
|
+
if (
|
|
223
|
+
value.schema !== 1 ||
|
|
224
|
+
value.scope !== scope ||
|
|
225
|
+
!Number.isSafeInteger(value.observedAt) ||
|
|
226
|
+
value.observedAt < 0 ||
|
|
227
|
+
value.observedAt > now() ||
|
|
228
|
+
window(period, value.observedAt) !== window(period, now()) ||
|
|
229
|
+
value.report?.period !== period
|
|
230
|
+
)
|
|
231
|
+
return;
|
|
232
|
+
const report = cacheReport(value.report);
|
|
233
|
+
return {
|
|
234
|
+
report,
|
|
235
|
+
observedAt: value.observedAt,
|
|
236
|
+
stale: now() - value.observedAt > 300_000 || value.refreshFailed === true,
|
|
237
|
+
refreshFailed: value.refreshFailed === true,
|
|
238
|
+
};
|
|
239
|
+
} catch {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
let background: Promise<UsageSnapshot> | undefined;
|
|
244
|
+
const perform = async (
|
|
245
|
+
period: UsagePeriod,
|
|
246
|
+
options: { force?: boolean; signal?: AbortSignal },
|
|
247
|
+
): Promise<UsageSnapshot> => {
|
|
248
|
+
if (options.signal?.aborted) throw new UsageError("cancelled");
|
|
249
|
+
const started = now(),
|
|
250
|
+
previous = load(period);
|
|
251
|
+
if (!options.force && previous && !previous.stale) return previous;
|
|
252
|
+
try {
|
|
253
|
+
const output = await runner(
|
|
254
|
+
usageExecutable(config),
|
|
255
|
+
["usage", `--${period}`, "--output", "json", "--aws-profile", config.profile],
|
|
256
|
+
{
|
|
257
|
+
timeout: 120_000,
|
|
258
|
+
maxBuffer: 2_097_152,
|
|
259
|
+
signal: options.signal,
|
|
260
|
+
env: {
|
|
261
|
+
...process.env,
|
|
262
|
+
BEDROCK_REGIONS: config.regions.join(","),
|
|
263
|
+
AWS_PROFILE: config.profile,
|
|
264
|
+
AWS_CLI_AUTO_PROMPT: "off",
|
|
265
|
+
AWS_PAGER: "",
|
|
266
|
+
NO_COLOR: "1",
|
|
267
|
+
PAT_SKIP_UPDATE_CHECK: "1",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
if (options.signal?.aborted) throw new UsageError("cancelled");
|
|
272
|
+
const observedAt = now();
|
|
273
|
+
// Legacy reports have no explicit range, so a UTC rollover cannot be safely relabelled.
|
|
274
|
+
if (window(period, started) !== window(period, observedAt)) throw new UsageError("invalid_result");
|
|
275
|
+
const report = parseUsage(output, period);
|
|
276
|
+
atomicCache(file(period), { schema: 1, scope, report, observedAt, refreshFailed: false });
|
|
277
|
+
return { report, observedAt, stale: false, refreshFailed: false };
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const kind: UsageFailure = error instanceof UsageError ? error.kind : "unavailable";
|
|
280
|
+
if (previous && kind !== "cancelled") {
|
|
281
|
+
try {
|
|
282
|
+
atomicCache(file(period), {
|
|
283
|
+
schema: 1,
|
|
284
|
+
scope,
|
|
285
|
+
report: previous.report,
|
|
286
|
+
observedAt: previous.observedAt,
|
|
287
|
+
refreshFailed: true,
|
|
288
|
+
});
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
throw new UsageError(kind);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
load,
|
|
296
|
+
health: () => ({
|
|
297
|
+
usage: executableExists(usageExecutable(config)),
|
|
298
|
+
aws: executableOnPath("aws"),
|
|
299
|
+
bash: executableOnPath("bash"),
|
|
300
|
+
jq: executableOnPath("jq"),
|
|
301
|
+
}),
|
|
302
|
+
refresh(period, options = {}) {
|
|
303
|
+
// Background monthly display work shares a flight; interactive commands own their cancellation.
|
|
304
|
+
if (period === "monthly" && !options.force && !options.signal) {
|
|
305
|
+
if (background === undefined) {
|
|
306
|
+
background = perform(period, options).finally(() => {
|
|
307
|
+
background = undefined;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return background;
|
|
311
|
+
}
|
|
312
|
+
return perform(period, options);
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isSubagentChild } from "./child.ts";
|
|
3
|
+
import { isJigBedrock } from "./identity.ts";
|
|
4
|
+
import { type Cost, cacheWriteRate, type PromptCache, retentionOf } from "./prompt-cache.ts";
|
|
5
|
+
|
|
6
|
+
/** Below this the rewrite is pocket change and a notice would only be noise (Pi's own cache warming uses the same line). */
|
|
7
|
+
export const NOTICE_FLOOR_USD = 0.05;
|
|
8
|
+
const DEFAULT_TTL_SECONDS = { short: 300, long: 3600 };
|
|
9
|
+
|
|
10
|
+
export const tokensLabel = (tokens: number): string =>
|
|
11
|
+
tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M` : `${Math.round(tokens / 1000)}k`;
|
|
12
|
+
const dollars = (value: number): string => `$${value.toFixed(2)}`;
|
|
13
|
+
|
|
14
|
+
export function switchNotice(
|
|
15
|
+
tokens: number,
|
|
16
|
+
cost: Cost,
|
|
17
|
+
retention: "short" | "long",
|
|
18
|
+
): { text: string; usd: number } | undefined {
|
|
19
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
|
20
|
+
const usd = (tokens * cacheWriteRate(cost, tokens, retention)) / 1_000_000;
|
|
21
|
+
if (!(usd >= NOTICE_FLOOR_USD)) return;
|
|
22
|
+
return {
|
|
23
|
+
usd,
|
|
24
|
+
text: `Switching model rewrites ~${tokensLabel(tokens)} tokens of cache: about ${dollars(usd)} (once). For one hard question, ask the planner subagent instead.`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The cache is per model, so the first request after a switch writes the whole context again at the target's cache-write rate
|
|
30
|
+
* (a session that stays on its model reads it at a fraction of that). One notice per switch, when that is real money and the
|
|
31
|
+
* old cache was still alive: an expired cache is rewritten on the next request whichever model answers it.
|
|
32
|
+
*/
|
|
33
|
+
/** The parts of a session entry the cache check reads. Pi's own entry types are wider. */
|
|
34
|
+
interface SessionEntry {
|
|
35
|
+
type?: string;
|
|
36
|
+
kind?: string;
|
|
37
|
+
timestamp?: string;
|
|
38
|
+
message?: { role?: string; stopReason?: string; timestamp?: number };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerSwitchNotice(pi: Pick<ExtensionAPI, "on">, options: { env?: NodeJS.ProcessEnv } = {}): void {
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
if (isSubagentChild(env)) return;
|
|
44
|
+
pi.on("model_select", (event, ctx) => {
|
|
45
|
+
if (
|
|
46
|
+
event.source === "restore" ||
|
|
47
|
+
!event.previousModel ||
|
|
48
|
+
!isJigBedrock(event.model) ||
|
|
49
|
+
ctx.mode === "print" ||
|
|
50
|
+
ctx.mode === "json"
|
|
51
|
+
)
|
|
52
|
+
return;
|
|
53
|
+
if (event.previousModel.provider === event.model.provider && event.previousModel.id === event.model.id) return;
|
|
54
|
+
const tokens = ctx.getContextUsage?.()?.tokens;
|
|
55
|
+
if (typeof tokens !== "number" || !event.model.cost) return;
|
|
56
|
+
const retention = retentionOf(env);
|
|
57
|
+
// `promptCache` is a field this package adds to the models it registers (see catalog.ts).
|
|
58
|
+
const promptCache = (event.previousModel as { promptCache?: PromptCache }).promptCache;
|
|
59
|
+
if (cacheExpired(ctx.sessionManager.getBranch() as readonly SessionEntry[], promptCache, retention)) return;
|
|
60
|
+
const notice = switchNotice(tokens, event.model.cost, retention);
|
|
61
|
+
if (notice) ctx.ui.notify(notice.text, "info");
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True only when the newest sign of cache activity is known to be older than the previous model's cache lifetime: the last
|
|
67
|
+
* response (its timestamp is when the request started, which is when Bedrock refreshed the entry) or a Pi cache-warming refresh.
|
|
68
|
+
*/
|
|
69
|
+
function cacheExpired(
|
|
70
|
+
entries: readonly SessionEntry[],
|
|
71
|
+
promptCache: PromptCache | undefined,
|
|
72
|
+
retention: "short" | "long",
|
|
73
|
+
now = Date.now(),
|
|
74
|
+
): boolean {
|
|
75
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
76
|
+
const entry = entries[i];
|
|
77
|
+
let at: number | undefined;
|
|
78
|
+
if (entry?.type === "usage" && entry.kind === "cache_warm") at = Date.parse(entry.timestamp ?? "");
|
|
79
|
+
else if (
|
|
80
|
+
entry?.type === "message" &&
|
|
81
|
+
entry.message?.role === "assistant" &&
|
|
82
|
+
entry.message.stopReason !== "error" &&
|
|
83
|
+
entry.message.stopReason !== "aborted"
|
|
84
|
+
)
|
|
85
|
+
at = entry.message.timestamp;
|
|
86
|
+
if (at === undefined) continue;
|
|
87
|
+
if (!Number.isFinite(at)) return false;
|
|
88
|
+
return now - at > (promptCache?.[retention] ?? DEFAULT_TTL_SECONDS[retention]) * 1000;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, join, resolve } from "node:path";
|
|
5
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
export type UsageFailure =
|
|
8
|
+
| "missing"
|
|
9
|
+
| "timeout"
|
|
10
|
+
| "cancelled"
|
|
11
|
+
| "sso_expired"
|
|
12
|
+
| "permission"
|
|
13
|
+
| "network"
|
|
14
|
+
| "invalid_result"
|
|
15
|
+
| "unavailable";
|
|
16
|
+
export class UsageError extends Error {
|
|
17
|
+
readonly kind: UsageFailure;
|
|
18
|
+
constructor(kind: UsageFailure) {
|
|
19
|
+
super(usageHelp(kind));
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.name = "UsageError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function usageHelp(kind: UsageFailure): string {
|
|
25
|
+
switch (kind) {
|
|
26
|
+
case "missing":
|
|
27
|
+
return "usage program statistics unavailable. No usage program: put an executable named usage in usageDir that prints the documented JSON.";
|
|
28
|
+
case "timeout":
|
|
29
|
+
return "usage program statistics timed out. Check AWS network access and retry the command.";
|
|
30
|
+
case "cancelled":
|
|
31
|
+
return "usage program statistics cancelled.";
|
|
32
|
+
case "sso_expired":
|
|
33
|
+
return "usage program statistics need AWS sign-in. Run /aws-login, complete browser sign-in, then retry the statistics command.";
|
|
34
|
+
case "permission":
|
|
35
|
+
return "usage program statistics access denied. Ask your AWS administrator to check the configured profile's read permissions.";
|
|
36
|
+
case "network":
|
|
37
|
+
return "usage program statistics could not reach AWS. Check network access and retry the command.";
|
|
38
|
+
case "invalid_result":
|
|
39
|
+
return "usage program returned an unsupported or invalid statistics report. Update usage program and the Bedrock extension together.";
|
|
40
|
+
default:
|
|
41
|
+
return "usage program statistics unavailable. Run /bedrock-doctor and check usage program setup.";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export interface UsageRunOptions {
|
|
45
|
+
timeout: number;
|
|
46
|
+
maxBuffer: number;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
env?: NodeJS.ProcessEnv;
|
|
49
|
+
}
|
|
50
|
+
export type UsageRunner = (program: string, args: string[], options: UsageRunOptions) => Promise<string>;
|
|
51
|
+
function classify(stderr: string): UsageFailure {
|
|
52
|
+
return /sso|token/i.test(stderr) && /expired|invalid|login|unauthorized|does not exist/i.test(stderr)
|
|
53
|
+
? "sso_expired"
|
|
54
|
+
: /access.?denied|not authorized|unauthorizedoperation/i.test(stderr)
|
|
55
|
+
? "permission"
|
|
56
|
+
: /network|connect|dns|resolve|unreachable|proxy/i.test(stderr)
|
|
57
|
+
? "network"
|
|
58
|
+
: "unavailable";
|
|
59
|
+
}
|
|
60
|
+
/** Run only the configured executable; child output never becomes an exception or transcript. */
|
|
61
|
+
export const runUsage: UsageRunner = (program, args, options) =>
|
|
62
|
+
new Promise((accept, reject) => {
|
|
63
|
+
if (options.signal?.aborted) {
|
|
64
|
+
reject(new UsageError("cancelled"));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let child: ReturnType<typeof spawn>,
|
|
68
|
+
output = "",
|
|
69
|
+
stderr = "",
|
|
70
|
+
bytes = 0,
|
|
71
|
+
settled = false;
|
|
72
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
const stop = () => {
|
|
74
|
+
if (!child?.pid) return;
|
|
75
|
+
try {
|
|
76
|
+
process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
|
|
77
|
+
} catch {}
|
|
78
|
+
};
|
|
79
|
+
const finish = (error?: UsageError) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
if (timer) clearTimeout(timer);
|
|
83
|
+
options.signal?.removeEventListener("abort", abort);
|
|
84
|
+
if (error) {
|
|
85
|
+
stop();
|
|
86
|
+
reject(error);
|
|
87
|
+
} else accept(output);
|
|
88
|
+
};
|
|
89
|
+
const abort = () => finish(new UsageError("cancelled"));
|
|
90
|
+
try {
|
|
91
|
+
child = spawn(program, args, {
|
|
92
|
+
env: options.env ?? process.env,
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
detached: process.platform !== "win32",
|
|
95
|
+
windowsHide: true,
|
|
96
|
+
});
|
|
97
|
+
} catch {
|
|
98
|
+
finish(new UsageError("missing"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
child.stdout!.setEncoding("utf8");
|
|
102
|
+
child.stderr!.setEncoding("utf8");
|
|
103
|
+
child.stdout!.on("data", (chunk: string) => {
|
|
104
|
+
bytes += Buffer.byteLength(chunk);
|
|
105
|
+
if (bytes > options.maxBuffer) finish(new UsageError("invalid_result"));
|
|
106
|
+
else output += chunk;
|
|
107
|
+
});
|
|
108
|
+
child.stderr!.on("data", (chunk: string) => {
|
|
109
|
+
if (stderr.length < 4096) stderr += chunk.slice(0, 4096 - stderr.length);
|
|
110
|
+
});
|
|
111
|
+
child.on("error", (error: NodeJS.ErrnoException) =>
|
|
112
|
+
finish(new UsageError(error.code === "ENOENT" || error.code === "EACCES" ? "missing" : "unavailable")),
|
|
113
|
+
);
|
|
114
|
+
child.on("close", (code) => finish(code === 0 ? undefined : new UsageError(classify(stderr))));
|
|
115
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
116
|
+
timer = setTimeout(() => finish(new UsageError("timeout")), options.timeout);
|
|
117
|
+
timer.unref();
|
|
118
|
+
if (options.signal?.aborted) abort();
|
|
119
|
+
});
|
|
120
|
+
export const usageExecutable = (config: ResolvedConfig): string =>
|
|
121
|
+
join(
|
|
122
|
+
config.usageDir.startsWith("~/") ? join(homedir(), config.usageDir.slice(2)) : resolve(config.usageDir),
|
|
123
|
+
"usage",
|
|
124
|
+
);
|
|
125
|
+
export function executableExists(file: string): boolean {
|
|
126
|
+
try {
|
|
127
|
+
accessSync(file, constants.X_OK);
|
|
128
|
+
return statSync(file).isFile();
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function executableOnPath(program: string, env = process.env): boolean {
|
|
134
|
+
return (env.PATH || "")
|
|
135
|
+
.split(delimiter)
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.some((dir) => executableExists(join(dir, program)));
|
|
138
|
+
}
|
package/src/wsl.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// What the AWS sign-in needs from WSL, where Pi often runs. Two things go wrong there, and both are repaired for the sign-in's
|
|
2
|
+
// child process only, never for Pi itself:
|
|
3
|
+
//
|
|
4
|
+
// The browser. `aws sso login` opens $BROWSER. Inside WSL there is usually none, so the sign-in prints a link nobody sees.
|
|
5
|
+
// With no BROWSER set (by the user, or by bedrock.browser) the child gets bin/windows-browser, which hands the
|
|
6
|
+
// address to the Windows default browser. bedrock.browser names another program and wins everywhere.
|
|
7
|
+
// The interop. WSL_INTEROP names the socket through which Linux starts Windows programs. A long-lived terminal or tmux server
|
|
8
|
+
// can hold the socket of a session that is gone, and then every Windows program, the browser among them, hangs
|
|
9
|
+
// and fails. A live socket answers `cmd.exe /c exit` in well under a second. When it does not, the child gets
|
|
10
|
+
// the distro's own socket, which lives as long as WSL does. This follows the same rule.
|
|
11
|
+
//
|
|
12
|
+
// Outside WSL nothing here changes anything, except that bedrock.browser, which the user wrote, is still used.
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
/** The program that probes the interop socket: any Windows program would do, this one is always there. */
|
|
18
|
+
export const WINDOWS_CMD = "/mnt/c/Windows/System32/cmd.exe";
|
|
19
|
+
/** The distro's own interop socket. */
|
|
20
|
+
export const INTEROP_FALLBACK = "/run/WSL/1_interop";
|
|
21
|
+
/** How long the probe may take: a live socket answers in well under a second. */
|
|
22
|
+
export const PROBE_TIMEOUT_MS = 2_000;
|
|
23
|
+
/** The script that opens an address in the Windows default browser. */
|
|
24
|
+
export const BROWSER_SCRIPT = fileURLToPath(new URL("../bin/windows-browser", import.meta.url));
|
|
25
|
+
|
|
26
|
+
/** The parts of the machine this reads and runs, which a test replaces. */
|
|
27
|
+
export interface WslDeps {
|
|
28
|
+
isWsl(env: NodeJS.ProcessEnv): boolean;
|
|
29
|
+
executable(file: string): boolean;
|
|
30
|
+
exists(file: string): boolean;
|
|
31
|
+
/** Whether Windows answers through the socket `env.WSL_INTEROP` names: runs `cmd.exe /c exit` for at most 2 seconds. */
|
|
32
|
+
interopAnswers(env: NodeJS.ProcessEnv): Promise<boolean>;
|
|
33
|
+
browserScript: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const systemWsl: WslDeps = {
|
|
37
|
+
isWsl(env) {
|
|
38
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
39
|
+
try {
|
|
40
|
+
return /microsoft/i.test(readFileSync("/proc/sys/kernel/osrelease", "utf8"));
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
executable(file) {
|
|
46
|
+
try {
|
|
47
|
+
accessSync(file, constants.X_OK);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
exists: existsSync,
|
|
54
|
+
interopAnswers: (env) =>
|
|
55
|
+
new Promise((resolve) => {
|
|
56
|
+
const child = spawn(WINDOWS_CMD, ["/c", "exit"], { stdio: "ignore", env });
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
child.kill("SIGKILL");
|
|
59
|
+
resolve(false);
|
|
60
|
+
}, PROBE_TIMEOUT_MS);
|
|
61
|
+
child.once("error", () => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
resolve(false);
|
|
64
|
+
});
|
|
65
|
+
child.once("exit", (code) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
resolve(code === 0);
|
|
68
|
+
});
|
|
69
|
+
}),
|
|
70
|
+
browserScript: BROWSER_SCRIPT,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface LoginEnvironment {
|
|
74
|
+
/** The environment for the sign-in's child: `base` with the repairs. `base` itself is not changed. */
|
|
75
|
+
env: NodeJS.ProcessEnv;
|
|
76
|
+
/** One line for the user for each repair that was made. */
|
|
77
|
+
notes: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The environment `aws sso login` starts with: `base`, the browser and the interop socket as described above. */
|
|
81
|
+
export async function loginEnvironment(
|
|
82
|
+
settings: { browser?: string },
|
|
83
|
+
base: NodeJS.ProcessEnv,
|
|
84
|
+
deps: WslDeps = systemWsl,
|
|
85
|
+
): Promise<LoginEnvironment> {
|
|
86
|
+
const env = { ...base };
|
|
87
|
+
const notes: string[] = [];
|
|
88
|
+
// A program the user named wins everywhere: they wrote it in jig.json.
|
|
89
|
+
if (settings.browser) env.BROWSER = settings.browser;
|
|
90
|
+
if (!deps.isWsl(base)) return { env, notes };
|
|
91
|
+
const socket = base.WSL_INTEROP;
|
|
92
|
+
if (
|
|
93
|
+
socket &&
|
|
94
|
+
socket !== INTEROP_FALLBACK &&
|
|
95
|
+
deps.executable(WINDOWS_CMD) &&
|
|
96
|
+
deps.exists(INTEROP_FALLBACK) &&
|
|
97
|
+
!(await deps.interopAnswers(base))
|
|
98
|
+
) {
|
|
99
|
+
env.WSL_INTEROP = INTEROP_FALLBACK;
|
|
100
|
+
notes.push(
|
|
101
|
+
`The Windows interop socket ${socket} does not answer; using ${INTEROP_FALLBACK} for the AWS sign-in.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// A BROWSER the user set is theirs. An unset one, or an empty one, which programs ignore, is filled.
|
|
105
|
+
if (!settings.browser && !base.BROWSER) env.BROWSER = deps.browserScript;
|
|
106
|
+
return { env, notes };
|
|
107
|
+
}
|