omp-llm-role 1.0.0 → 1.2.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/README.md +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- package/docs/dev/spec.md +0 -633
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in reporting of **unexpected** plugin errors as GitHub issues on the
|
|
3
|
+
* plugin's own tracker (`Gott50/omp-llm-role`).
|
|
4
|
+
*
|
|
5
|
+
* This module is the feature's single home and the plugin's only GitHub client.
|
|
6
|
+
* It owns the fingerprint, the redaction rules, the issue title/body, the local
|
|
7
|
+
* ledger (`llm-role-error-reports.json` in the agent dir), the token path
|
|
8
|
+
* (search → dedupe → caps → create/comment), the prefilled-URL path, the `ask`
|
|
9
|
+
* posture and the whole-call containment.
|
|
10
|
+
*
|
|
11
|
+
* The entry point is `reportUnexpectedError(input, deps)`. The injected `deps`
|
|
12
|
+
* object is the feature's one test seam: production callers pass
|
|
13
|
+
* `defaultErrorReportDeps()`, tests inject every field (no real network, clock,
|
|
14
|
+
* home dir or `gh` invocation).
|
|
15
|
+
*
|
|
16
|
+
* Reporting is best-effort: the whole call is wrapped, every request carries a
|
|
17
|
+
* timeout, and a failure only adds one notify line. It never throws and never
|
|
18
|
+
* changes the caller's run result.
|
|
19
|
+
*
|
|
20
|
+
* Dual runtime: only `node:` builtins and the global `fetch` (Bun under omp,
|
|
21
|
+
* Node for the CLI). No runtime dependency.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { isRecord } from "./guards.ts";
|
|
29
|
+
import { readPluginSettingsMap, resolveSettings, type ResolvedSettings } from "./settings.ts";
|
|
30
|
+
import { agentDir as defaultAgentDir } from "./state.ts";
|
|
31
|
+
|
|
32
|
+
/** The plugin's tracker coordinates — the one home for the repo slug. */
|
|
33
|
+
const REPO = "Gott50/omp-llm-role";
|
|
34
|
+
const API_BASE = "https://api.github.com";
|
|
35
|
+
const WEB_BASE = "https://github.com";
|
|
36
|
+
const USER_AGENT = "omp-llm-role";
|
|
37
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
38
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
39
|
+
const MAX_URL_BYTES = 8 * 1024;
|
|
40
|
+
const MAX_TITLE_LINE = 80;
|
|
41
|
+
const MAX_CREATIONS_PER_DAY = 3;
|
|
42
|
+
const TRUNCATION_MARKER = "… truncated";
|
|
43
|
+
|
|
44
|
+
/** Ledger file name inside the agent dir. */
|
|
45
|
+
export const ERROR_REPORT_LEDGER_FILE = "llm-role-error-reports.json";
|
|
46
|
+
|
|
47
|
+
export type ErrorReportInput = {
|
|
48
|
+
/** The command name (`session-start`, `refresh-roles`, `explore-roles`,
|
|
49
|
+
* `create-agent`, `remove-agent`, `project-roles`) or the updater abort label. */
|
|
50
|
+
label: string;
|
|
51
|
+
error: unknown;
|
|
52
|
+
hasUI: boolean;
|
|
53
|
+
/** The caller's UI line sink. */
|
|
54
|
+
notify: (line: string, level: "info" | "warning") => void;
|
|
55
|
+
/** The caller's yes/no prompt (built from `ctx.ui.select?`); absent in
|
|
56
|
+
* print/RPC/subagent sessions. */
|
|
57
|
+
confirm?: (title: string, payload: string) => Promise<boolean>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ErrorReportDeps = {
|
|
61
|
+
settings: ResolvedSettings;
|
|
62
|
+
fetch: typeof fetch;
|
|
63
|
+
env: Record<string, string | undefined>;
|
|
64
|
+
now(): Date;
|
|
65
|
+
agentDir(): string;
|
|
66
|
+
readVersion(): string;
|
|
67
|
+
ghToken?(): string | null;
|
|
68
|
+
openUrl?(url: string): void;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ErrorReportOutcome = {
|
|
72
|
+
action: "off" | "created" | "commented" | "offered" | "suppressed" | "failed";
|
|
73
|
+
url?: string;
|
|
74
|
+
reason?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The ledger's action vocabulary — what a ledger entry records. Narrower than
|
|
78
|
+
* `ErrorReportOutcome["action"]`: `off` and `failed` are outcomes, never
|
|
79
|
+
* ledgered. */
|
|
80
|
+
type ReportAction = "created" | "commented" | "offered" | "suppressed";
|
|
81
|
+
|
|
82
|
+
type Report = {
|
|
83
|
+
fingerprint: string;
|
|
84
|
+
label: string;
|
|
85
|
+
errorName: string;
|
|
86
|
+
message: string;
|
|
87
|
+
stack: string;
|
|
88
|
+
title: string;
|
|
89
|
+
body: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type LedgerEntry = {
|
|
93
|
+
fingerprint: string;
|
|
94
|
+
label: string;
|
|
95
|
+
title: string;
|
|
96
|
+
firstSeen: string;
|
|
97
|
+
lastSeen: string;
|
|
98
|
+
createdDay?: string;
|
|
99
|
+
offeredDay?: string;
|
|
100
|
+
count: number;
|
|
101
|
+
/** Untrusted on read — a hand-edited or corrupt file may hold anything, so
|
|
102
|
+
* the read path preserves the raw string; `recordLedger`'s patch is the
|
|
103
|
+
* typed write path. */
|
|
104
|
+
action: string;
|
|
105
|
+
issueNumber?: number;
|
|
106
|
+
issueUrl?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type Ledger = { reports: LedgerEntry[]; dayCount: { day: string; count: number } };
|
|
110
|
+
|
|
111
|
+
type GhResult = { kind: "ok"; data: unknown } | { kind: "denied"; status: number } | { kind: "error"; reason: string };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Per-process guard: a crash loop in one session must not fire N requests.
|
|
115
|
+
* Exported reset is a test hook for process-global state.
|
|
116
|
+
*/
|
|
117
|
+
const reportedFingerprints = new Set<string>();
|
|
118
|
+
|
|
119
|
+
/** Clear the in-process guard (test hook). */
|
|
120
|
+
export function resetErrorReportGuard(): void {
|
|
121
|
+
reportedFingerprints.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Report an unexpected plugin error. Never throws, never rejects, and never
|
|
126
|
+
* changes the caller's run result. A no-op (`action: "off"`) when
|
|
127
|
+
* `errorReporting` is `off`.
|
|
128
|
+
*/
|
|
129
|
+
export async function reportUnexpectedError(input: ErrorReportInput, deps: ErrorReportDeps): Promise<ErrorReportOutcome> {
|
|
130
|
+
try {
|
|
131
|
+
// Fail closed: only the two explicit opt-ins report. Anything else — `off`,
|
|
132
|
+
// a typo, a value from a hand-edited lock file — is a no-op, so a privacy
|
|
133
|
+
// setting can never fail open into filing an issue.
|
|
134
|
+
const posture = deps.settings.errorReporting;
|
|
135
|
+
if (posture !== "ask" && posture !== "auto") return { action: "off" };
|
|
136
|
+
const report = buildReport(input, deps);
|
|
137
|
+
if (reportedFingerprints.has(report.fingerprint)) {
|
|
138
|
+
return { action: "suppressed", reason: "already reported in this process" };
|
|
139
|
+
}
|
|
140
|
+
reportedFingerprints.add(report.fingerprint);
|
|
141
|
+
return await dispatch(report, input, deps);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const reason = errText(err);
|
|
144
|
+
try {
|
|
145
|
+
input.notify(`llm-role: error reporting failed: ${reason}`, "warning");
|
|
146
|
+
} catch {
|
|
147
|
+
// the notify sink itself failed; nothing left to do
|
|
148
|
+
}
|
|
149
|
+
return { action: "failed", reason };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Production deps: resolved plugin settings, the global fetch, the real clock,
|
|
154
|
+
* the agent dir, the shipped manifest, `gh auth token` and the OS opener. */
|
|
155
|
+
export function defaultErrorReportDeps(): ErrorReportDeps {
|
|
156
|
+
return {
|
|
157
|
+
settings: resolveSettings(readPluginSettingsMap()).settings,
|
|
158
|
+
fetch: globalThis.fetch,
|
|
159
|
+
env: process.env,
|
|
160
|
+
now: () => new Date(),
|
|
161
|
+
agentDir: defaultAgentDir,
|
|
162
|
+
readVersion: readPluginVersion,
|
|
163
|
+
ghToken: resolveGhToken,
|
|
164
|
+
openUrl: defaultOpenUrl,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function dispatch(report: Report, input: ErrorReportInput, deps: ErrorReportDeps): Promise<ErrorReportOutcome> {
|
|
169
|
+
if (deps.settings.errorReporting === "ask") {
|
|
170
|
+
if (input.hasUI && input.confirm) {
|
|
171
|
+
const approved = await input.confirm(report.title, report.body);
|
|
172
|
+
if (!approved) return offerUrl(report, input, deps);
|
|
173
|
+
} else {
|
|
174
|
+
// No UI (print/RPC/subagent) or no prompt: never upload without asking.
|
|
175
|
+
return offerUrl(report, input, deps);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const token = resolveToken(deps);
|
|
179
|
+
if (!token) return offerUrl(report, input, deps);
|
|
180
|
+
return tokenPath(report, input, deps, token);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function tokenPath(report: Report, input: ErrorReportInput, deps: ErrorReportDeps, token: string): Promise<ErrorReportOutcome> {
|
|
184
|
+
const query = `repo:${REPO} in:body "omp-llm-role-error-report: ${report.fingerprint}"`;
|
|
185
|
+
const search = await ghRequest(deps, token, "GET", `/search/issues?q=${encodeURIComponent(query)}`);
|
|
186
|
+
if (search.kind === "denied") return offerUrl(report, input, deps);
|
|
187
|
+
if (search.kind === "error") return failed(input, search.reason);
|
|
188
|
+
const items = searchItems(search.data);
|
|
189
|
+
const open = items.find((item) => item.state === "open");
|
|
190
|
+
if (open) {
|
|
191
|
+
const comment = await ghRequest(deps, token, "POST", `/repos/${REPO}/issues/${open.number}/comments`, { body: commentBody(report) });
|
|
192
|
+
if (comment.kind === "denied") return offerUrl(report, input, deps);
|
|
193
|
+
if (comment.kind === "error") return failed(input, comment.reason);
|
|
194
|
+
recordLedger(deps, report, { action: "commented", issueNumber: open.number, issueUrl: open.html_url });
|
|
195
|
+
input.notify(`llm-role: commented on ${open.html_url}`, "info");
|
|
196
|
+
return { action: "commented", url: open.html_url };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const closed = items.filter((item) => item.state === "closed");
|
|
200
|
+
const today = dayKey(deps.now());
|
|
201
|
+
const ledger = readLedger(deps.agentDir());
|
|
202
|
+
const entry = ledgerEntry(ledger, report.fingerprint);
|
|
203
|
+
if (entry?.createdDay === today) {
|
|
204
|
+
recordLedger(deps, report, { action: "suppressed" });
|
|
205
|
+
return { action: "suppressed", reason: "already created today" };
|
|
206
|
+
}
|
|
207
|
+
if (ledger.dayCount.day === today && ledger.dayCount.count >= MAX_CREATIONS_PER_DAY) {
|
|
208
|
+
recordLedger(deps, report, { action: "suppressed" });
|
|
209
|
+
return { action: "suppressed", reason: "daily creation cap reached" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const body = closed.length > 0 ? `${report.body}\n\n---\nReappearance of ${closed.map((c) => `#${c.number}`).join(", ")}.` : report.body;
|
|
213
|
+
const create = await ghRequest(deps, token, "POST", `/repos/${REPO}/issues`, { title: report.title, body, labels: ["bug", "needs-triage"] });
|
|
214
|
+
if (create.kind === "denied") return offerUrl(report, input, deps);
|
|
215
|
+
if (create.kind === "error") return failed(input, create.reason);
|
|
216
|
+
const issue = createIssue(create.data);
|
|
217
|
+
if (!issue) return failed(input, "malformed create response");
|
|
218
|
+
recordLedger(deps, report, { action: "created", issueNumber: issue.number, issueUrl: issue.html_url, createdDay: today, incrementDay: true });
|
|
219
|
+
input.notify(`llm-role: filed ${issue.html_url}`, "info");
|
|
220
|
+
return { action: "created", url: issue.html_url };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function offerUrl(report: Report, input: ErrorReportInput, deps: ErrorReportDeps): ErrorReportOutcome {
|
|
224
|
+
const today = dayKey(deps.now());
|
|
225
|
+
const entry = ledgerEntry(readLedger(deps.agentDir()), report.fingerprint);
|
|
226
|
+
if (entry?.offeredDay === today) {
|
|
227
|
+
return { action: "suppressed", reason: "already offered today" };
|
|
228
|
+
}
|
|
229
|
+
const url = buildIssueUrl(report);
|
|
230
|
+
recordLedger(deps, report, { action: "offered", offeredDay: today });
|
|
231
|
+
try {
|
|
232
|
+
(deps.openUrl ?? defaultOpenUrl)(url);
|
|
233
|
+
} catch {
|
|
234
|
+
// opening the browser is best-effort; the URL is still notified
|
|
235
|
+
}
|
|
236
|
+
input.notify(`llm-role: report this at ${url}`, "info");
|
|
237
|
+
return { action: "offered", url };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function failed(input: ErrorReportInput, reason: string): ErrorReportOutcome {
|
|
241
|
+
input.notify(`llm-role: error reporting failed: ${reason}`, "warning");
|
|
242
|
+
return { action: "failed", reason };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- report construction ---------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function buildReport(input: ErrorReportInput, deps: ErrorReportDeps): Report {
|
|
248
|
+
const { errorName, message, stack } = describeError(input.error);
|
|
249
|
+
const redact = makeRedactor(deps.env);
|
|
250
|
+
const rMessage = redact(message);
|
|
251
|
+
const rStack = redact(stack);
|
|
252
|
+
const failureSite = firstFrame(rStack) ?? collapseWhitespace(rMessage);
|
|
253
|
+
const fingerprint = sha256(`${input.label}\n${errorName}\n${failureSite}`);
|
|
254
|
+
const title = buildTitle(input.label, errorName, rMessage);
|
|
255
|
+
const body = buildBody({ fingerprint, label: input.label, errorName, message: rMessage, stack: rStack }, deps);
|
|
256
|
+
return { fingerprint, label: input.label, errorName, message: rMessage, stack: rStack, title, body };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function describeError(error: unknown): { errorName: string; message: string; stack: string } {
|
|
260
|
+
if (error instanceof Error) {
|
|
261
|
+
return { errorName: error.name || "Error", message: error.message ?? "", stack: error.stack ?? "" };
|
|
262
|
+
}
|
|
263
|
+
return { errorName: "Error", message: typeof error === "string" ? error : safeStringify(error), stack: "" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildTitle(label: string, errorName: string, message: string): string {
|
|
267
|
+
const firstLine = (message.split("\n")[0] ?? "").trim();
|
|
268
|
+
const clipped = firstLine.length > MAX_TITLE_LINE ? `${firstLine.slice(0, MAX_TITLE_LINE - 1)}…` : firstLine;
|
|
269
|
+
return `[auto] ${label}: ${errorName}: ${clipped}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildBody(report: { fingerprint: string; label: string; errorName: string; message: string; stack: string }, deps: ErrorReportDeps): string {
|
|
273
|
+
const versions = process.versions as Record<string, string | undefined>;
|
|
274
|
+
const runtime = versions.bun ? `bun ${versions.bun}` : `node ${versions.node}`;
|
|
275
|
+
const sections = [
|
|
276
|
+
`<!-- omp-llm-role-error-report: ${report.fingerprint} -->`,
|
|
277
|
+
"",
|
|
278
|
+
"### Metadata",
|
|
279
|
+
`- Plugin version: ${deps.readVersion()}`,
|
|
280
|
+
`- Runtime: ${runtime}`,
|
|
281
|
+
`- Platform: ${process.platform} ${process.arch}`,
|
|
282
|
+
`- Command: ${report.label}`,
|
|
283
|
+
`- Timestamp: ${deps.now().toISOString()}`,
|
|
284
|
+
"",
|
|
285
|
+
"### Error",
|
|
286
|
+
`\`${report.errorName}: ${report.message}\``,
|
|
287
|
+
"",
|
|
288
|
+
"```",
|
|
289
|
+
report.stack || "(no stack)",
|
|
290
|
+
"```",
|
|
291
|
+
];
|
|
292
|
+
const body = sections.join("\n");
|
|
293
|
+
// The cap is a byte budget: `body.length` counts UTF-16 code units, so a
|
|
294
|
+
// non-ASCII stack could otherwise exceed 64 KB by up to ~4×.
|
|
295
|
+
if (Buffer.byteLength(body, "utf8") <= MAX_BODY_BYTES) return body;
|
|
296
|
+
const truncated = Buffer.from(body, "utf8").subarray(0, MAX_BODY_BYTES).toString("utf8");
|
|
297
|
+
return `${truncated}\n${TRUNCATION_MARKER}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function commentBody(report: Report): string {
|
|
301
|
+
return `Another occurrence of this error (fingerprint \`${report.fingerprint}\`).\n\n\`\`\`\n${report.stack || report.message}\n\`\`\``;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Replace lone surrogates with U+FFFD so `encodeURIComponent` cannot throw. */
|
|
305
|
+
function toWellFormed(s: string): string {
|
|
306
|
+
return s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildIssueUrl(report: Report): string {
|
|
310
|
+
const base = `${WEB_BASE}/${REPO}/issues/new`;
|
|
311
|
+
const labels = "bug,needs-triage";
|
|
312
|
+
const title = toWellFormed(report.title);
|
|
313
|
+
const make = (body: string): string => `${base}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(toWellFormed(body))}&labels=${labels}`;
|
|
314
|
+
let body = report.body;
|
|
315
|
+
let url = make(body);
|
|
316
|
+
while (url.length > MAX_URL_BYTES && body.length > 0) {
|
|
317
|
+
// Truncate on code points, not UTF-16 code units: a code-unit cut can split
|
|
318
|
+
// a surrogate pair, and `encodeURIComponent` then throws on the lone half.
|
|
319
|
+
const points = Array.from(body);
|
|
320
|
+
body = points.slice(0, Math.floor(points.length * 0.8)).join("");
|
|
321
|
+
url = make(body);
|
|
322
|
+
}
|
|
323
|
+
return url;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- redaction -------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
/** The named redaction rules, applied in order to the message and the stack
|
|
329
|
+
* before either reaches the title, the body, the URL or the fingerprint. */
|
|
330
|
+
function makeRedactor(env: Record<string, string | undefined>): (s: string) => string {
|
|
331
|
+
const home = env.HOME || env.USERPROFILE || "";
|
|
332
|
+
return (s: string): string => {
|
|
333
|
+
let out = s;
|
|
334
|
+
if (home) out = out.split(home).join("~");
|
|
335
|
+
out = out.replace(/[^\s()]*node_modules\//g, "<pkg>/");
|
|
336
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]+/g, "<redacted>");
|
|
337
|
+
out = out.replace(/\bgh[pousr]_[A-Za-z0-9]+/g, "<redacted>");
|
|
338
|
+
out = out.replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "<redacted>");
|
|
339
|
+
out = out.replace(/Bearer\s+\S+/g, "<redacted>");
|
|
340
|
+
return out;
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- fingerprint helpers ---------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function firstFrame(stack: string): string | null {
|
|
347
|
+
for (const line of stack.split("\n")) {
|
|
348
|
+
const match = /^\s*at\s+(.+)$/.exec(line);
|
|
349
|
+
if (match) return collapseWhitespace(match[1]);
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function collapseWhitespace(s: string): string {
|
|
355
|
+
return s.replace(/\s+/g, " ").trim();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function sha256(s: string): string {
|
|
359
|
+
return createHash("sha256").update(s).digest("hex");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function safeStringify(value: unknown): string {
|
|
363
|
+
try {
|
|
364
|
+
return JSON.stringify(value) ?? String(value);
|
|
365
|
+
} catch {
|
|
366
|
+
return String(value);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function errText(err: unknown): string {
|
|
371
|
+
return err instanceof Error ? err.message : String(err);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- GitHub API ------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
async function ghRequest(deps: ErrorReportDeps, token: string, method: string, path: string, body?: unknown): Promise<GhResult> {
|
|
377
|
+
let res: Response;
|
|
378
|
+
try {
|
|
379
|
+
res = await deps.fetch(`${API_BASE}${path}`, {
|
|
380
|
+
method,
|
|
381
|
+
headers: {
|
|
382
|
+
Authorization: `Bearer ${token}`,
|
|
383
|
+
Accept: "application/vnd.github+json",
|
|
384
|
+
"User-Agent": USER_AGENT,
|
|
385
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
386
|
+
},
|
|
387
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
388
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
389
|
+
});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return { kind: "error", reason: errText(err) };
|
|
392
|
+
}
|
|
393
|
+
// 401/403/404 all mean "this token cannot write here" (a fine-grained PAT
|
|
394
|
+
// without Issues:write answers 404): fall through to the prefilled URL.
|
|
395
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) return { kind: "denied", status: res.status };
|
|
396
|
+
if (!res.ok) return { kind: "error", reason: `HTTP ${res.status}` };
|
|
397
|
+
try {
|
|
398
|
+
return { kind: "ok", data: await res.json() };
|
|
399
|
+
} catch (err) {
|
|
400
|
+
return { kind: "error", reason: `malformed response: ${errText(err)}` };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
type SearchItem = { number: number; state: string; html_url: string };
|
|
405
|
+
|
|
406
|
+
function searchItems(data: unknown): SearchItem[] {
|
|
407
|
+
if (!isRecord(data) || !Array.isArray(data.items)) return [];
|
|
408
|
+
const out: SearchItem[] = [];
|
|
409
|
+
for (const item of data.items) {
|
|
410
|
+
if (!isRecord(item) || typeof item.number !== "number") continue;
|
|
411
|
+
out.push({
|
|
412
|
+
number: item.number,
|
|
413
|
+
state: typeof item.state === "string" ? item.state : "",
|
|
414
|
+
html_url: typeof item.html_url === "string" ? item.html_url : "",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function createIssue(data: unknown): { number: number; html_url: string } | null {
|
|
421
|
+
if (isRecord(data) && typeof data.number === "number") {
|
|
422
|
+
return { number: data.number, html_url: typeof data.html_url === "string" ? data.html_url : "" };
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- token resolution ------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
function resolveToken(deps: ErrorReportDeps): string | null {
|
|
430
|
+
const fromEnv = deps.env.GITHUB_TOKEN || deps.env.GH_TOKEN;
|
|
431
|
+
if (fromEnv) return fromEnv;
|
|
432
|
+
try {
|
|
433
|
+
return deps.ghToken?.() ?? null;
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let ghTokenCache: string | null | undefined;
|
|
440
|
+
|
|
441
|
+
function resolveGhToken(): string | null {
|
|
442
|
+
if (ghTokenCache !== undefined) return ghTokenCache;
|
|
443
|
+
let resolved: string | null = null;
|
|
444
|
+
try {
|
|
445
|
+
const out = execFileSync("gh", ["auth", "token"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
446
|
+
resolved = out.length > 0 ? out : null;
|
|
447
|
+
} catch {
|
|
448
|
+
resolved = null;
|
|
449
|
+
}
|
|
450
|
+
ghTokenCache = resolved;
|
|
451
|
+
return resolved;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readPluginVersion(): string {
|
|
455
|
+
try {
|
|
456
|
+
const pkg: unknown = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
457
|
+
return isRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
458
|
+
} catch {
|
|
459
|
+
return "unknown";
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function defaultOpenUrl(url: string): void {
|
|
464
|
+
try {
|
|
465
|
+
if (process.platform === "darwin") execFileSync("open", [url], { stdio: "ignore" });
|
|
466
|
+
else console.error(url);
|
|
467
|
+
} catch {
|
|
468
|
+
console.error(url);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- ledger ----------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
function emptyLedger(): Ledger {
|
|
475
|
+
return { reports: [], dayCount: { day: "", count: 0 } };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Read the ledger. Missing, unparseable, or unexpected-shape → empty. */
|
|
479
|
+
function readLedger(dir: string): Ledger {
|
|
480
|
+
let parsed: unknown;
|
|
481
|
+
try {
|
|
482
|
+
parsed = JSON.parse(readFileSync(join(dir, ERROR_REPORT_LEDGER_FILE), "utf8"));
|
|
483
|
+
} catch {
|
|
484
|
+
return emptyLedger();
|
|
485
|
+
}
|
|
486
|
+
if (!isRecord(parsed)) return emptyLedger();
|
|
487
|
+
const reports: LedgerEntry[] = [];
|
|
488
|
+
if (Array.isArray(parsed.reports)) {
|
|
489
|
+
for (const entry of parsed.reports) {
|
|
490
|
+
if (!isRecord(entry) || typeof entry.fingerprint !== "string") continue;
|
|
491
|
+
reports.push({
|
|
492
|
+
fingerprint: entry.fingerprint,
|
|
493
|
+
label: typeof entry.label === "string" ? entry.label : "",
|
|
494
|
+
title: typeof entry.title === "string" ? entry.title : "",
|
|
495
|
+
firstSeen: typeof entry.firstSeen === "string" ? entry.firstSeen : "",
|
|
496
|
+
lastSeen: typeof entry.lastSeen === "string" ? entry.lastSeen : "",
|
|
497
|
+
createdDay: typeof entry.createdDay === "string" ? entry.createdDay : undefined,
|
|
498
|
+
offeredDay: typeof entry.offeredDay === "string" ? entry.offeredDay : undefined,
|
|
499
|
+
count: typeof entry.count === "number" ? entry.count : 0,
|
|
500
|
+
action: typeof entry.action === "string" ? entry.action : "",
|
|
501
|
+
issueNumber: typeof entry.issueNumber === "number" ? entry.issueNumber : undefined,
|
|
502
|
+
issueUrl: typeof entry.issueUrl === "string" ? entry.issueUrl : undefined,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const dayCount = isRecord(parsed.dayCount) ? parsed.dayCount : {};
|
|
507
|
+
return {
|
|
508
|
+
reports,
|
|
509
|
+
dayCount: { day: typeof dayCount.day === "string" ? dayCount.day : "", count: typeof dayCount.count === "number" ? dayCount.count : 0 },
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The ledger entry for a fingerprint, or undefined. The one lookup both the
|
|
514
|
+
* token path and the URL path use. */
|
|
515
|
+
function ledgerEntry(ledger: Ledger, fingerprint: string): LedgerEntry | undefined {
|
|
516
|
+
return ledger.reports.find((e) => e.fingerprint === fingerprint);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Atomic replace (temp + rename) so concurrent writers cannot corrupt the file. */
|
|
520
|
+
function writeLedger(ledger: Ledger, dir: string): void {
|
|
521
|
+
mkdirSync(dir, { recursive: true });
|
|
522
|
+
const path = join(dir, ERROR_REPORT_LEDGER_FILE);
|
|
523
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
524
|
+
writeFileSync(tmp, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
525
|
+
renameSync(tmp, path);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function recordLedger(
|
|
529
|
+
deps: ErrorReportDeps,
|
|
530
|
+
report: Report,
|
|
531
|
+
patch: { action: ReportAction; issueNumber?: number; issueUrl?: string; createdDay?: string; offeredDay?: string; incrementDay?: boolean },
|
|
532
|
+
): void {
|
|
533
|
+
try {
|
|
534
|
+
const dir = deps.agentDir();
|
|
535
|
+
const ledger = readLedger(dir);
|
|
536
|
+
const iso = deps.now().toISOString();
|
|
537
|
+
let entry = ledger.reports.find((e) => e.fingerprint === report.fingerprint);
|
|
538
|
+
if (!entry) {
|
|
539
|
+
entry = { fingerprint: report.fingerprint, label: report.label, title: report.title, firstSeen: iso, lastSeen: iso, count: 0, action: patch.action };
|
|
540
|
+
ledger.reports.push(entry);
|
|
541
|
+
}
|
|
542
|
+
entry.lastSeen = iso;
|
|
543
|
+
entry.count += 1;
|
|
544
|
+
entry.action = patch.action;
|
|
545
|
+
if (patch.issueNumber !== undefined) entry.issueNumber = patch.issueNumber;
|
|
546
|
+
if (patch.issueUrl !== undefined) entry.issueUrl = patch.issueUrl;
|
|
547
|
+
if (patch.createdDay !== undefined) entry.createdDay = patch.createdDay;
|
|
548
|
+
if (patch.offeredDay !== undefined) entry.offeredDay = patch.offeredDay;
|
|
549
|
+
if (patch.incrementDay) {
|
|
550
|
+
const day = dayKey(deps.now());
|
|
551
|
+
if (ledger.dayCount.day === day) ledger.dayCount.count += 1;
|
|
552
|
+
else ledger.dayCount = { day, count: 1 };
|
|
553
|
+
}
|
|
554
|
+
writeLedger(ledger, dir);
|
|
555
|
+
} catch {
|
|
556
|
+
// the ledger is an audit aid, never a run prerequisite
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function dayKey(date: Date): string {
|
|
561
|
+
return date.toISOString().slice(0, 10);
|
|
562
|
+
}
|
package/src/explorer/boot.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared in-process launcher for the ranking explorer. Both entry points feed
|
|
3
|
-
* it today's ranking data plus an omp catalog
|
|
3
|
+
* it today's ranking data, the key-availability overlay, plus an omp catalog
|
|
4
|
+
* and get back a loopback server:
|
|
4
5
|
*
|
|
5
6
|
* - the omp extension's `/explore-roles` command (catalog via the session's
|
|
6
7
|
* model registry, so no subprocess)
|
|
@@ -12,9 +13,11 @@
|
|
|
12
13
|
|
|
13
14
|
import { execFile } from "node:child_process";
|
|
14
15
|
import type { Server } from "node:http";
|
|
15
|
-
import { enrichThinkingLevels, type CatalogEntry } from "../availability.ts";
|
|
16
|
-
import type { RankData
|
|
17
|
-
import { DEFAULT_ROLES, PLUGIN_SETTINGS_PATH
|
|
16
|
+
import { enrichThinkingLevels, type CatalogEntry, type KeyAvailability } from "../availability.ts";
|
|
17
|
+
import type { RankData } from "../engine.ts";
|
|
18
|
+
import { DEFAULT_ROLES, PLUGIN_SETTINGS_PATH } from "../settings.ts";
|
|
19
|
+
import { agentDir } from "../state.ts";
|
|
20
|
+
import { readScopeRoles, resolveScope, resolveScopes, type Scope, type ScopeInputs } from "./scopes.ts";
|
|
18
21
|
import { createExplorerServer } from "./server.ts";
|
|
19
22
|
|
|
20
23
|
/** Preferred loopback port; a busy port falls back to an OS-assigned one. */
|
|
@@ -30,14 +33,22 @@ export type ExplorerHandle = {
|
|
|
30
33
|
export type ExplorerBootOpts = {
|
|
31
34
|
/** Directory holding the SPA (`web/`). */
|
|
32
35
|
webDir: string;
|
|
33
|
-
/**
|
|
36
|
+
/** User-level lock file (the user scope's read/export target; default: the plugin lock file). */
|
|
34
37
|
lockPath?: string;
|
|
38
|
+
/** Session cwd; its project joins the scope list when it has a project role config. */
|
|
39
|
+
cwd?: string;
|
|
40
|
+
/** Agent dir holding the project registry (default: `agentDir()`). */
|
|
41
|
+
registryDir?: string;
|
|
35
42
|
/** Today's ranking data, already fetched. */
|
|
36
43
|
rank: RankData;
|
|
37
44
|
/** omp catalog rows gating the thinking price factor (empty = OR-flag fallback). */
|
|
38
45
|
catalog: CatalogEntry[];
|
|
46
|
+
/** Today's key-availability overlay (key-usable vs key-blocked models). */
|
|
47
|
+
availability: KeyAvailability;
|
|
39
48
|
/** Refetch for `POST /api/refresh`. */
|
|
40
49
|
reload(refresh: boolean): Promise<RankData>;
|
|
50
|
+
/** Re-derive the key-availability overlay for `POST /api/refresh` (best-effort). */
|
|
51
|
+
reloadAvailability?: () => Promise<KeyAvailability>;
|
|
41
52
|
port?: number;
|
|
42
53
|
/** Open the URL in the default browser (macOS only). */
|
|
43
54
|
open?: boolean;
|
|
@@ -77,34 +88,35 @@ function listen(server: Server, port: number, onLog: (line: string) => void): Pr
|
|
|
77
88
|
* must `close()` it on shutdown (the extension does so in `session_shutdown`). */
|
|
78
89
|
export async function startExplorer(opts: ExplorerBootOpts): Promise<ExplorerHandle> {
|
|
79
90
|
const onLog = opts.onLog ?? (() => {});
|
|
80
|
-
const
|
|
91
|
+
const userLockPath = opts.lockPath ?? PLUGIN_SETTINGS_PATH;
|
|
92
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
93
|
+
const scopeInputs: ScopeInputs = { userLockPath, cwd, registryDir: opts.registryDir };
|
|
81
94
|
let rank = opts.rank;
|
|
95
|
+
let availability = opts.availability;
|
|
82
96
|
enrichThinkingLevels(rank.models, opts.catalog);
|
|
83
97
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
for (const e of errors) onLog(`settings warning: ${e}`);
|
|
93
|
-
// The resolved set is what the plugin does today; the universe adds the roles
|
|
94
|
-
// it knows but does not currently rank (shipped opt-ins, lock-file-only roles).
|
|
95
|
-
return { roles: settings.roles, universe: roleUniverse(raw, settings.roles) };
|
|
96
|
-
};
|
|
98
|
+
// Scope resolution is boot's job: the user-level scope, the registry's
|
|
99
|
+
// projects, and the session's project. Re-resolved on every request so a
|
|
100
|
+
// project registered by another session appears without a restart, and the
|
|
101
|
+
// active scope's roles/universe are re-read from its lock file so a page
|
|
102
|
+
// reload after Export reflects the write (the lock file is the source of
|
|
103
|
+
// truth). A project scope merges the project lock over the user-level lock —
|
|
104
|
+
// the exact read the updater performs.
|
|
105
|
+
const listScopes = (): Scope[] => resolveScopes(scopeInputs);
|
|
97
106
|
|
|
98
107
|
const server = createExplorerServer({
|
|
99
108
|
webDir: opts.webDir,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
listScopes,
|
|
110
|
+
resolveScope: (requestedId) => resolveScope(listScopes(), requestedId, cwd),
|
|
111
|
+
getState: (scope) => {
|
|
112
|
+
const { roles, universe, features, errors } = readScopeRoles(scope, userLockPath);
|
|
113
|
+
for (const e of errors) onLog(`settings warning: ${e}`);
|
|
114
|
+
return { rank, roles, universe, defaults: DEFAULT_ROLES, availability, features };
|
|
104
115
|
},
|
|
105
116
|
refresh: async () => {
|
|
106
117
|
rank = await opts.reload(true);
|
|
107
118
|
enrichThinkingLevels(rank.models, opts.catalog);
|
|
119
|
+
if (opts.reloadAvailability) availability = await opts.reloadAvailability();
|
|
108
120
|
},
|
|
109
121
|
});
|
|
110
122
|
// Optional detach (extension only): never hold the host process open on the
|
|
@@ -120,7 +132,7 @@ export async function startExplorer(opts: ExplorerBootOpts): Promise<ExplorerHan
|
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
const url = `http://127.0.0.1:${port}`;
|
|
123
|
-
onLog(`explorer: ${url} (models: ${rank.models.length}, fetched: ${rank.fetchedAt.slice(0, 10)}, lock: ${
|
|
135
|
+
onLog(`explorer: ${url} (models: ${rank.models.length}, fetched: ${rank.fetchedAt.slice(0, 10)}, scopes: ${listScopes().length}, user lock: ${userLockPath})`);
|
|
124
136
|
if (opts.open === true && process.platform === "darwin") execFile("open", [url], () => {});
|
|
125
137
|
|
|
126
138
|
return {
|