@siuver/omp-debug-mode 0.1.5 → 0.1.7

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/src/evidence.ts CHANGED
@@ -1,169 +1,325 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import type { EvidenceArtifact, EvidenceMethod, EvidenceRequest, EvidenceView } from "./state";
4
-
5
- /** Upper bound on accepted plan entries so one model reply cannot flood the gate. */
6
- export const MAX_EVIDENCE_REQUESTS = 12;
7
-
8
- const METHODS: readonly EvidenceMethod[] = ["agent_inspection", "runtime_probe", "user_report", "user_artifact"];
9
-
10
- export interface EvidencePlanParseResult {
11
- found: boolean;
12
- valid: boolean;
13
- requests: EvidenceRequest[];
14
- error?: string;
15
- }
16
-
17
- function isNonEmptyString(value: unknown): value is string {
18
- return typeof value === "string" && value.trim().length > 0;
19
- }
20
-
21
- function isNonEmptyStringArray(value: unknown): value is string[] {
22
- return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
23
- }
24
-
25
- function hasOnlyUniqueValues(values: readonly string[]): boolean {
26
- return new Set(values).size === values.length;
27
- }
28
-
29
- /**
30
- * Parse the `<evidence_plan>` block from a model reply. Validation is
31
- * all-or-nothing: any malformed or invalid entry rejects the whole plan so a
32
- * partial plan can never silently drop a hypothesis.
33
- */
34
- export function parseEvidencePlan(text: string): EvidencePlanParseResult {
35
- const match = /<evidence_plan>([\s\S]*?)<\/evidence_plan>/.exec(text);
36
- if (!match) return { found: false, valid: false, requests: [] };
37
- const fail = (error: string): EvidencePlanParseResult => ({ found: true, valid: false, requests: [], error });
38
-
39
- let parsed: unknown;
40
- try {
41
- parsed = JSON.parse(match[1].trim());
42
- } catch (error) {
43
- return fail(`malformed JSON in <evidence_plan>: ${(error as Error).message}`);
44
- }
45
- if (!Array.isArray(parsed)) return fail("<evidence_plan> must contain a JSON array");
46
- if (parsed.length === 0) return fail("<evidence_plan> array must not be empty");
47
- if (parsed.length > MAX_EVIDENCE_REQUESTS) {
48
- return fail(`<evidence_plan> has ${parsed.length} entries; at most ${MAX_EVIDENCE_REQUESTS} are allowed`);
49
- }
50
-
51
- const requests: EvidenceRequest[] = [];
52
- const seenIds = new Set<string>();
53
- for (const entry of parsed) {
54
- if (typeof entry !== "object" || entry === null) return fail("every evidence plan entry must be an object");
55
- const record = entry as Record<string, unknown>;
56
- if (!isNonEmptyString(record.id)) return fail("every evidence plan entry needs a non-empty id");
57
- if (seenIds.has(record.id)) return fail(`duplicate evidence request id ${record.id}`);
58
- seenIds.add(record.id);
59
- if (!isNonEmptyStringArray(record.hypothesisIds) || !hasOnlyUniqueValues(record.hypothesisIds)) {
60
- return fail(`request ${record.id} needs one or more unique non-empty hypothesisIds`);
61
- }
62
- if (!METHODS.includes(record.method as EvidenceMethod)) {
63
- return fail(`request ${record.id} has unknown method ${JSON.stringify(record.method)}`);
64
- }
65
- if (!isNonEmptyString(record.title)) return fail(`request ${record.id} needs a non-empty title`);
66
- if (!isNonEmptyString(record.rationale)) {
67
- return fail(`request ${record.id} needs a non-empty rationale explaining why this method is decisive`);
68
- }
69
- if (!isNonEmptyStringArray(record.instructions)) {
70
- return fail(`request ${record.id} needs one or more non-empty instructions`);
71
- }
72
- if (record.artifactHint !== undefined && !isNonEmptyString(record.artifactHint)) {
73
- return fail(`request ${record.id} has an empty artifactHint`);
74
- }
75
- requests.push({
76
- id: record.id,
77
- hypothesisIds: record.hypothesisIds,
78
- method: record.method as EvidenceMethod,
79
- title: record.title,
80
- rationale: record.rationale,
81
- instructions: record.instructions,
82
- ...(record.artifactHint === undefined ? {} : { artifactHint: record.artifactHint }),
83
- });
84
- }
85
- return { found: true, valid: true, requests };
86
- }
87
-
88
- export type ArtifactValidation =
89
- | { ok: true; artifact: Omit<EvidenceArtifact, "id" | "addedAt" | "requestId"> }
90
- | { ok: false; reason: string };
91
-
92
- /**
93
- * Resolve a user-supplied path and capture filesystem metadata only. The file
94
- * is referenced in place: its contents are never read, copied or deleted here.
95
- */
96
- export function validateEvidenceArtifact(rawPath: string, cwd: string): ArtifactValidation {
97
- const trimmed = rawPath.trim();
98
- if (trimmed.length === 0) return { ok: false, reason: "no evidence file path provided" };
99
- const absolute = path.resolve(cwd, trimmed);
100
- let stats: fs.Stats;
101
- try {
102
- stats = fs.statSync(absolute);
103
- } catch (error) {
104
- return { ok: false, reason: `cannot stat ${absolute}: ${(error as Error).message}` };
105
- }
106
- if (!stats.isFile()) return { ok: false, reason: `${absolute} is not a regular file` };
107
- try {
108
- fs.accessSync(absolute, fs.constants.R_OK);
109
- } catch {
110
- return { ok: false, reason: `${absolute} is not readable` };
111
- }
112
- return {
113
- ok: true,
114
- artifact: { path: absolute, name: path.basename(absolute), size: stats.size, mtimeMs: stats.mtimeMs },
115
- };
116
- }
117
-
118
- /**
119
- * Human-readable evidence description for the blackboard and the model-facing
120
- * tool. Availability is re-checked on every call: a missing or unreadable file
121
- * is never treated as captured evidence.
122
- */
123
- export function describeEvidence(view: EvidenceView): string {
124
- const { requests, artifacts, observations } = view;
125
- if (requests.length === 0 && artifacts.length === 0 && observations.length === 0) {
126
- return "(none)";
127
- }
128
- const lines: string[] = [];
129
- for (const request of requests) {
130
- const linked = artifacts.filter(artifact => artifact.requestId === request.id);
131
- const reports = observations.filter(observation => observation.requestIds.includes(request.id));
132
- const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
133
- const coverage =
134
- request.method === "user_artifact"
135
- ? linked.length > 0
136
- ? "artifact attached"
137
- : "PENDING artifact"
138
- : request.method === "user_report"
139
- ? reports.length > 0
140
- ? "report submitted"
141
- : "PENDING report"
142
- : "agent-collected";
143
- lines.push(
144
- `- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
145
- );
146
- }
147
- for (const observation of observations) {
148
- lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
149
- }
150
- for (const artifact of artifacts) {
151
- let stats: fs.Stats;
152
- try {
153
- stats = fs.statSync(artifact.path);
154
- fs.accessSync(artifact.path, fs.constants.R_OK);
155
- } catch {
156
- lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (file missing or unreadable)`);
157
- continue;
158
- }
159
- if (!stats.isFile()) {
160
- lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (no longer a regular file)`);
161
- continue;
162
- }
163
- const linked = artifact.requestId ? `, request ${artifact.requestId}` : ", unlinked";
164
- lines.push(
165
- `- ${artifact.id} [artifact] ${artifact.path}available${linked}, ${stats.size} bytes, mtime ${new Date(stats.mtimeMs).toISOString()}`,
166
- );
167
- }
168
- return lines.join("\n");
169
- }
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import {
4
+ type DebugSession,
5
+ type DebugState,
6
+ type EvidenceArtifact,
7
+ type EvidenceMethod,
8
+ type EvidenceRequest,
9
+ type EvidenceView,
10
+ currentRound,
11
+ pendingRequests,
12
+ } from "./state";
13
+
14
+ /** Upper bound on accepted plan entries so one model reply cannot flood the gate. */
15
+ export const MAX_EVIDENCE_REQUESTS = 12;
16
+
17
+ const METHODS: readonly EvidenceMethod[] = ["agent_inspection", "runtime_probe", "user_report", "user_artifact"];
18
+
19
+ export interface EvidencePlanParseResult {
20
+ found: boolean;
21
+ valid: boolean;
22
+ requests: EvidenceRequest[];
23
+ error?: string;
24
+ }
25
+
26
+ function isNonEmptyString(value: unknown): value is string {
27
+ return typeof value === "string" && value.trim().length > 0;
28
+ }
29
+
30
+ function isNonEmptyStringArray(value: unknown): value is string[] {
31
+ return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
32
+ }
33
+
34
+ function hasOnlyUniqueValues(values: readonly string[]): boolean {
35
+ return new Set(values).size === values.length;
36
+ }
37
+
38
+ export type EvidenceRequestsValidation =
39
+ | { ok: true; requests: EvidenceRequest[] }
40
+ | { ok: false; error: string };
41
+
42
+ /**
43
+ * Validate evidence plan entries. All-or-nothing: any malformed or invalid
44
+ * entry rejects the whole plan so a partial plan can never silently drop a
45
+ * hypothesis. Shared by the `hand_off_to_user` tool and the legacy
46
+ * `<evidence_plan>` tag parser, so both accept exactly the same shape and a
47
+ * rejection always says the same thing.
48
+ */
49
+ export function validateEvidenceRequests(entries: unknown): EvidenceRequestsValidation {
50
+ const fail = (error: string): EvidenceRequestsValidation => ({ ok: false, error });
51
+ if (!Array.isArray(entries)) return fail("the evidence plan must be a JSON array");
52
+ if (entries.length === 0) return fail("the evidence plan array must not be empty");
53
+ if (entries.length > MAX_EVIDENCE_REQUESTS) {
54
+ return fail(`the evidence plan has ${entries.length} entries; at most ${MAX_EVIDENCE_REQUESTS} are allowed`);
55
+ }
56
+
57
+ const requests: EvidenceRequest[] = [];
58
+ const seenIds = new Set<string>();
59
+ for (const entry of entries) {
60
+ if (typeof entry !== "object" || entry === null) return fail("every evidence plan entry must be an object");
61
+ const record = entry as Record<string, unknown>;
62
+ if (!isNonEmptyString(record.id)) return fail("every evidence plan entry needs a non-empty id");
63
+ if (seenIds.has(record.id)) return fail(`duplicate evidence request id ${record.id}`);
64
+ seenIds.add(record.id);
65
+ if (!isNonEmptyStringArray(record.hypothesisIds) || !hasOnlyUniqueValues(record.hypothesisIds)) {
66
+ return fail(`request ${record.id} needs one or more unique non-empty hypothesisIds`);
67
+ }
68
+ if (!METHODS.includes(record.method as EvidenceMethod)) {
69
+ return fail(`request ${record.id} has unknown method ${JSON.stringify(record.method)}`);
70
+ }
71
+ if (!isNonEmptyString(record.title)) return fail(`request ${record.id} needs a non-empty title`);
72
+ if (!isNonEmptyString(record.rationale)) {
73
+ return fail(`request ${record.id} needs a non-empty rationale explaining why this method is decisive`);
74
+ }
75
+ if (!isNonEmptyStringArray(record.instructions)) {
76
+ return fail(`request ${record.id} needs one or more non-empty instructions`);
77
+ }
78
+ if (record.artifactHint !== undefined && !isNonEmptyString(record.artifactHint)) {
79
+ return fail(`request ${record.id} has an empty artifactHint`);
80
+ }
81
+ requests.push({
82
+ id: record.id,
83
+ hypothesisIds: record.hypothesisIds,
84
+ method: record.method as EvidenceMethod,
85
+ title: record.title,
86
+ rationale: record.rationale,
87
+ instructions: record.instructions,
88
+ ...(record.artifactHint === undefined ? {} : { artifactHint: record.artifactHint }),
89
+ });
90
+ }
91
+ return { ok: true, requests };
92
+ }
93
+
94
+ /**
95
+ * Parse the legacy `<evidence_plan>` block from a model reply. Kept so a plan
96
+ * described in prose is still recorded rather than lost — but a parsed block no
97
+ * longer closes a round: the turn is sent back for the `hand_off_to_user` call,
98
+ * which is the only closure.
99
+ */
100
+ export function parseEvidencePlan(text: string): EvidencePlanParseResult {
101
+ const match = /<evidence_plan>([\s\S]*?)<\/evidence_plan>/.exec(text);
102
+ if (!match) return { found: false, valid: false, requests: [] };
103
+
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(match[1].trim());
107
+ } catch (error) {
108
+ return {
109
+ found: true,
110
+ valid: false,
111
+ requests: [],
112
+ error: `malformed JSON in <evidence_plan>: ${(error as Error).message}`,
113
+ };
114
+ }
115
+ const validation = validateEvidenceRequests(parsed);
116
+ if (!validation.ok) return { found: true, valid: false, requests: [], error: validation.error };
117
+ return { found: true, valid: true, requests: validation.requests };
118
+ }
119
+
120
+ /** First argument of `/debug-evidence` when the file answers no request. */
121
+ export const UNLINKED_SELECTOR = "-";
122
+
123
+ export type EvidenceArgument =
124
+ | { ok: true; requestId: string | null; rawPath: string | undefined }
125
+ | { ok: false; error: string };
126
+
127
+ /** A request as a link target: which round declared it, and whether it is settled. */
128
+ interface RequestTarget {
129
+ request: EvidenceRequest;
130
+ round: number;
131
+ current: boolean;
132
+ pending: boolean;
133
+ }
134
+
135
+ /**
136
+ * Every request that can be linked to, best candidate first: this round before
137
+ * older rounds, and within this round the ones still waiting on the user.
138
+ */
139
+ function requestTargets(session: DebugSession): RequestTarget[] {
140
+ const round = currentRound(session);
141
+ const pending = new Set(pendingRequests(session, round).map(request => request.id));
142
+ const targets: RequestTarget[] = [];
143
+ for (const candidate of session.rounds) {
144
+ for (const request of candidate.plan ?? []) {
145
+ targets.push({
146
+ request,
147
+ round: candidate.index,
148
+ current: candidate.index === round.index,
149
+ pending: pending.has(request.id),
150
+ });
151
+ }
152
+ }
153
+ return targets.sort((a, b) => {
154
+ if (a.current !== b.current) return a.current ? -1 : 1;
155
+ if (a.pending !== b.pending) return a.pending ? -1 : 1;
156
+ return b.round - a.round;
157
+ });
158
+ }
159
+
160
+ function knownIds(session: DebugSession): string[] {
161
+ return requestTargets(session).map(target => target.request.id);
162
+ }
163
+
164
+ function describeKnownIds(ids: readonly string[]): string {
165
+ return ids.length > 0 ? ids.join(", ") : "(none no evidence request exists yet)";
166
+ }
167
+
168
+ /**
169
+ * Parse `/debug-evidence <request-id|-> [path]`.
170
+ *
171
+ * The first token is always a link selector, never part of the path. Deciding
172
+ * that by looking the token up in session state is what used to make
173
+ * `/debug-evidence E1 frame.rdc` resolve `<cwd>/E1 frame.rdc` as one path
174
+ * whenever E1 was not a currently pending artifact request, and the failure was
175
+ * reported as a missing file rather than as an unrecognised id.
176
+ */
177
+ export function parseEvidenceArgument(args: string, session: DebugSession): EvidenceArgument {
178
+ const trimmed = args.trim();
179
+ const ids = knownIds(session);
180
+ if (!trimmed) {
181
+ return {
182
+ ok: false,
183
+ error:
184
+ `name what the file answers first: /debug-evidence <request-id|${UNLINKED_SELECTOR}> [path]. ` +
185
+ `Known request ids: ${describeKnownIds(ids)}.`,
186
+ };
187
+ }
188
+ const token = trimmed.split(/\s+/, 1)[0];
189
+ const rest = trimmed.slice(token.length).trim();
190
+ const rawPath = rest.length > 0 ? rest : undefined;
191
+ if (token === UNLINKED_SELECTOR) return { ok: true, requestId: null, rawPath };
192
+ if (ids.includes(token)) return { ok: true, requestId: token, rawPath };
193
+ return {
194
+ ok: false,
195
+ error:
196
+ `${JSON.stringify(token)} is not an evidence request id. Known request ids: ${describeKnownIds(ids)}. ` +
197
+ `Pass ${UNLINKED_SELECTOR} as the first argument to attach a file that answers no request.`,
198
+ };
199
+ }
200
+
201
+ /** Structurally an `AutocompleteItem`, declared here so no host type is vendored. */
202
+ export interface EvidenceCompletion {
203
+ value: string;
204
+ label: string;
205
+ description?: string;
206
+ hint?: string;
207
+ }
208
+
209
+ function describeTarget(target: RequestTarget): string {
210
+ const status = target.current ? (target.pending ? "pending" : "satisfied") : `round ${target.round}`;
211
+ return `${target.request.method} · ${target.request.title} — ${status}`;
212
+ }
213
+
214
+ /**
215
+ * Complete the link selector under the editor. Must stay synchronous and free
216
+ * of side effects: the host calls it on every keystroke.
217
+ *
218
+ * Whitespace in the prefix means the selector is settled, so returning null
219
+ * hands the rest of the argument to the host's own file-path completion.
220
+ */
221
+ export function evidenceCompletions(argumentPrefix: string, state: DebugState): EvidenceCompletion[] | null {
222
+ if (/\s/.test(argumentPrefix) || !state.active) return null;
223
+ const prefix = argumentPrefix.trim().toLowerCase();
224
+ const items: EvidenceCompletion[] = [
225
+ {
226
+ value: `${UNLINKED_SELECTOR} `,
227
+ label: UNLINKED_SELECTOR,
228
+ description: "Attach a file that answers no request",
229
+ hint: "<path>",
230
+ },
231
+ ];
232
+ for (const target of requestTargets(state)) {
233
+ items.push({
234
+ value: `${target.request.id} `,
235
+ label: target.request.id,
236
+ description: describeTarget(target),
237
+ hint: "<path>",
238
+ });
239
+ }
240
+ const matched = items.filter(item => item.label.toLowerCase().startsWith(prefix));
241
+ return matched.length > 0 ? matched : null;
242
+ }
243
+
244
+ export type ArtifactValidation =
245
+ | { ok: true; artifact: Omit<EvidenceArtifact, "id" | "addedAt" | "requestId"> }
246
+ | { ok: false; reason: string };
247
+
248
+ /**
249
+ * Resolve a user-supplied path and capture filesystem metadata only. The file
250
+ * is referenced in place: its contents are never read, copied or deleted here.
251
+ */
252
+ export function validateEvidenceArtifact(rawPath: string, cwd: string): ArtifactValidation {
253
+ const trimmed = rawPath.trim();
254
+ if (trimmed.length === 0) return { ok: false, reason: "no evidence file path provided" };
255
+ const absolute = path.resolve(cwd, trimmed);
256
+ let stats: fs.Stats;
257
+ try {
258
+ stats = fs.statSync(absolute);
259
+ } catch (error) {
260
+ return { ok: false, reason: `cannot stat ${absolute}: ${(error as Error).message}` };
261
+ }
262
+ if (!stats.isFile()) return { ok: false, reason: `${absolute} is not a regular file` };
263
+ try {
264
+ fs.accessSync(absolute, fs.constants.R_OK);
265
+ } catch {
266
+ return { ok: false, reason: `${absolute} is not readable` };
267
+ }
268
+ return {
269
+ ok: true,
270
+ artifact: { path: absolute, name: path.basename(absolute), size: stats.size, mtimeMs: stats.mtimeMs },
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Human-readable evidence description for the blackboard and the model-facing
276
+ * tool. Availability is re-checked on every call: a missing or unreadable file
277
+ * is never treated as captured evidence.
278
+ */
279
+ export function describeEvidence(view: EvidenceView): string {
280
+ const { requests, artifacts, observations } = view;
281
+ if (requests.length === 0 && artifacts.length === 0 && observations.length === 0) {
282
+ return "(none)";
283
+ }
284
+ const lines: string[] = [];
285
+ for (const request of requests) {
286
+ const linked = artifacts.filter(artifact => artifact.requestId === request.id);
287
+ const reports = observations.filter(observation => observation.requestIds.includes(request.id));
288
+ const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
289
+ const coverage =
290
+ request.method === "user_artifact"
291
+ ? linked.length > 0
292
+ ? "artifact attached"
293
+ : "PENDING artifact"
294
+ : request.method === "user_report"
295
+ ? reports.length > 0
296
+ ? "report submitted"
297
+ : "PENDING report"
298
+ : "agent-collected";
299
+ lines.push(
300
+ `- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
301
+ );
302
+ }
303
+ for (const observation of observations) {
304
+ lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
305
+ }
306
+ for (const artifact of artifacts) {
307
+ let stats: fs.Stats;
308
+ try {
309
+ stats = fs.statSync(artifact.path);
310
+ fs.accessSync(artifact.path, fs.constants.R_OK);
311
+ } catch {
312
+ lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (file missing or unreadable)`);
313
+ continue;
314
+ }
315
+ if (!stats.isFile()) {
316
+ lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (no longer a regular file)`);
317
+ continue;
318
+ }
319
+ const linked = artifact.requestId ? `, request ${artifact.requestId}` : ", unlinked";
320
+ lines.push(
321
+ `- ${artifact.id} [artifact] ${artifact.path} — available${linked}, ${stats.size} bytes, mtime ${new Date(stats.mtimeMs).toISOString()}`,
322
+ );
323
+ }
324
+ return lines.join("\n");
325
+ }