fidelis 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/README.md +2 -2
- package/dist/auditor.d.ts +54 -0
- package/dist/auditor.js +278 -0
- package/dist/compiler.d.ts +45 -0
- package/dist/compiler.js +138 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +44 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/judge.d.ts +32 -0
- package/dist/judge.js +249 -0
- package/dist/machine.d.ts +4 -0
- package/dist/machine.js +15 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.js +42 -0
- package/dist/providers.d.ts +75 -0
- package/dist/providers.js +137 -0
- package/dist/redact.d.ts +1 -0
- package/dist/redact.js +10 -0
- package/dist/store.d.ts +27 -0
- package/dist/store.js +171 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.js +1 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fidelis
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Core of [Fidelis](https://github.com/insign/fidelis): instruction resolution, checklist cache, judge pipeline and session state machine. Use a host adapter unless you are writing one.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AuditRecord, Config, Evidence, ExecEvent, Finding, Host, InstructionSource, Outcome, SessionState, StateName } from "./types.ts";
|
|
2
|
+
import type { DecisionModel, GenerateText } from "./providers.ts";
|
|
3
|
+
import { type Store } from "./store.ts";
|
|
4
|
+
export declare const FIDELIS_MARKER = "[Fidelis audit: automated message, not from the user]";
|
|
5
|
+
export type MessageOrigin = "human" | "sdk" | "synthetic" | "fidelis" | "agent" | "unknown";
|
|
6
|
+
export declare function correctionReport(auditID: string, findings: Finding[]): string;
|
|
7
|
+
export declare function userNotice(findings: Finding[]): string;
|
|
8
|
+
export interface CompletionInput {
|
|
9
|
+
/** null means the host did not reveal what it loaded: never read that as "no rules". */
|
|
10
|
+
sources: InstructionSource[] | null;
|
|
11
|
+
evidence: Evidence;
|
|
12
|
+
/** Host identity of the final turn, e.g. the last assistant message ID. */
|
|
13
|
+
boundaryKey: string;
|
|
14
|
+
}
|
|
15
|
+
export interface AuditorOptions {
|
|
16
|
+
store: Store;
|
|
17
|
+
host: Host;
|
|
18
|
+
config: Config;
|
|
19
|
+
options?: Record<string, unknown>;
|
|
20
|
+
compiler: GenerateText | null;
|
|
21
|
+
judge: DecisionModel | null;
|
|
22
|
+
onChange?: (state: SessionState, transition: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare class Auditor {
|
|
25
|
+
private readonly o;
|
|
26
|
+
constructor(options: AuditorOptions);
|
|
27
|
+
private save;
|
|
28
|
+
/** Only a verified human message opens a turn or releases a wait. A known ID never counts twice. */
|
|
29
|
+
userMessage(sessionID: string, message: {
|
|
30
|
+
id: string;
|
|
31
|
+
origin: MessageOrigin;
|
|
32
|
+
}): boolean;
|
|
33
|
+
/** A turn that failed or was interrupted has no completed boundary to audit: nothing is running any more. */
|
|
34
|
+
executionEnded(sessionID: string): void;
|
|
35
|
+
/** The agent never received the correction, so nothing is being corrected. */
|
|
36
|
+
deliveryFailed(sessionID: string, reason: string): void;
|
|
37
|
+
/** Merge host events with the ledger so compaction cannot erase evidence the audit still needs. */
|
|
38
|
+
retain(sessionID: string, events: ExecEvent[]): ExecEvent[];
|
|
39
|
+
complete(sessionID: string, input: CompletionInput): Promise<Outcome>;
|
|
40
|
+
/** Deterministic routing. Model output only suggests; these branches decide. */
|
|
41
|
+
private route;
|
|
42
|
+
status(sessionID: string): {
|
|
43
|
+
sessionID: string;
|
|
44
|
+
state: StateName;
|
|
45
|
+
phase: import("./types.ts").Phase;
|
|
46
|
+
reason: string | undefined;
|
|
47
|
+
revision: number;
|
|
48
|
+
updatedAt: number;
|
|
49
|
+
auditID: string | null;
|
|
50
|
+
corrections: number;
|
|
51
|
+
actionable: number;
|
|
52
|
+
audits: AuditRecord[];
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/auditor.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { consentProblem } from "./config.js";
|
|
3
|
+
import { getChecklist, resolveSources } from "./compiler.js";
|
|
4
|
+
import { judge, LEDGER_MARK } from "./judge.js";
|
|
5
|
+
import { transition } from "./machine.js";
|
|
6
|
+
import { redact } from "./redact.js";
|
|
7
|
+
import { canonical, sha256 } from "./store.js";
|
|
8
|
+
export const FIDELIS_MARKER = "[Fidelis audit: automated message, not from the user]";
|
|
9
|
+
const AUDIT_STALE_MS = 15 * 60_000;
|
|
10
|
+
const MAX_TRACKED = 500;
|
|
11
|
+
const LEDGER_CLIP = 2000;
|
|
12
|
+
const LEDGER_MAX = 1000;
|
|
13
|
+
export function correctionReport(auditID, findings) {
|
|
14
|
+
const lines = [FIDELIS_MARKER, `Audit ${auditID}. This is not new user authorization.`, ""];
|
|
15
|
+
findings.forEach((finding, index) => {
|
|
16
|
+
lines.push(`${index + 1}. Rule ${finding.ruleID} (${finding.obligation}) from ${finding.source}: "${finding.quote}"`);
|
|
17
|
+
lines.push(` Observed: ${finding.explanation}`);
|
|
18
|
+
if (finding.repair === "unrepairable")
|
|
19
|
+
lines.push(" This cannot be undone. State it plainly in your reply and do any feasible compensating work; do not pretend the order of events changed.");
|
|
20
|
+
else
|
|
21
|
+
lines.push(` Allowed repair: ${finding.repairHint || "the smallest change that satisfies the rule"}.`);
|
|
22
|
+
});
|
|
23
|
+
lines.push("", "Fix only what is listed. If a repair needs something the user has not authorized, ask the user and stop instead.");
|
|
24
|
+
return lines.join("\n");
|
|
25
|
+
}
|
|
26
|
+
export function userNotice(findings) {
|
|
27
|
+
return findings.map((finding) => `${finding.ruleID}: ${finding.explanation}`).join("\n");
|
|
28
|
+
}
|
|
29
|
+
export class Auditor {
|
|
30
|
+
o;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.o = options;
|
|
33
|
+
}
|
|
34
|
+
save(sessionID, change, label = "update") {
|
|
35
|
+
const state = this.o.store.update(sessionID, change);
|
|
36
|
+
this.o.onChange?.(state, label);
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
39
|
+
/** Only a verified human message opens a turn or releases a wait. A known ID never counts twice. */
|
|
40
|
+
userMessage(sessionID, message) {
|
|
41
|
+
const trusted = message.origin === "human" || (message.origin === "sdk" && this.o.config.trustSdkPrompts);
|
|
42
|
+
const current = this.o.store.read(sessionID);
|
|
43
|
+
// An SDK prompt may open a turn but can never release a wait or a stop.
|
|
44
|
+
const opensTurn = message.origin === "sdk" && current.state !== "WAITING_USER" && current.state !== "STOPPED";
|
|
45
|
+
if ((!trusted && !opensTurn) || current.humanIDs.includes(message.id))
|
|
46
|
+
return false;
|
|
47
|
+
this.save(sessionID, (state) => {
|
|
48
|
+
if (state.humanIDs.includes(message.id))
|
|
49
|
+
return;
|
|
50
|
+
state.humanIDs = [...state.humanIDs, message.id].slice(-MAX_TRACKED);
|
|
51
|
+
state.state = transition(state.state, "human");
|
|
52
|
+
state.turn += 1;
|
|
53
|
+
state.corrections = 0;
|
|
54
|
+
state.disclosed = [];
|
|
55
|
+
state.findings = [];
|
|
56
|
+
state.violationKey = undefined;
|
|
57
|
+
state.audit = undefined;
|
|
58
|
+
state.reason = undefined;
|
|
59
|
+
state.phase = null;
|
|
60
|
+
}, "human");
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/** A turn that failed or was interrupted has no completed boundary to audit: nothing is running any more. */
|
|
64
|
+
executionEnded(sessionID) {
|
|
65
|
+
if (this.o.store.read(sessionID).state !== "RUNNING")
|
|
66
|
+
return;
|
|
67
|
+
this.save(sessionID, (state) => {
|
|
68
|
+
if (state.state === "RUNNING")
|
|
69
|
+
state.state = "READY";
|
|
70
|
+
}, "ended");
|
|
71
|
+
}
|
|
72
|
+
/** The agent never received the correction, so nothing is being corrected. */
|
|
73
|
+
deliveryFailed(sessionID, reason) {
|
|
74
|
+
this.save(sessionID, (state) => {
|
|
75
|
+
if (state.state !== "CORRECTING")
|
|
76
|
+
return;
|
|
77
|
+
state.state = "AUDIT_INCOMPLETE";
|
|
78
|
+
state.reason = `The correction could not be delivered: ${reason}`;
|
|
79
|
+
}, "incomplete");
|
|
80
|
+
}
|
|
81
|
+
/** Merge host events with the ledger so compaction cannot erase evidence the audit still needs. */
|
|
82
|
+
retain(sessionID, events) {
|
|
83
|
+
const store = this.o.store;
|
|
84
|
+
const raw = this.o.config.storeRawTranscript;
|
|
85
|
+
const merged = new Map(store.readLedger(sessionID).map((event) => [event.id, event]));
|
|
86
|
+
for (const event of events)
|
|
87
|
+
merged.set(event.id, event);
|
|
88
|
+
const ordered = [...merged.values()].sort((a, b) => (a.at ?? 0) - (b.at ?? 0) || a.seq - b.seq).map((event, seq) => ({ ...event, seq }));
|
|
89
|
+
const clipField = (value) => {
|
|
90
|
+
if (value === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
const clean = redact(value);
|
|
93
|
+
return raw || clean.length <= LEDGER_CLIP ? clean : clean.slice(0, LEDGER_CLIP) + LEDGER_MARK;
|
|
94
|
+
};
|
|
95
|
+
store.writeLedger(sessionID, ordered.slice(-LEDGER_MAX).map((event) => ({ ...event, text: clipField(event.text), output: clipField(event.output), input: clipField(typeof event.input === "string" || event.input === undefined ? event.input : JSON.stringify(event.input)) })));
|
|
96
|
+
return ordered;
|
|
97
|
+
}
|
|
98
|
+
async complete(sessionID, input) {
|
|
99
|
+
const { store, config } = this.o;
|
|
100
|
+
if (!config.enabled || !config.auditOnCompletion)
|
|
101
|
+
return { action: "none", state: store.read(sessionID).state, reason: "auditing disabled" };
|
|
102
|
+
const started = Date.now();
|
|
103
|
+
// Hash what identifies the turn, not timestamps or transcript flush order, so a replayed callback matches.
|
|
104
|
+
const acted = input.evidence.events.filter((event) => event.kind === "tool" || event.kind === "fidelis").map((event) => [event.id, event.status ?? ""]);
|
|
105
|
+
const boundaryID = sha256(canonical({ key: input.boundaryKey, acted, reply: input.evidence.finalReply }));
|
|
106
|
+
const auditID = randomUUID();
|
|
107
|
+
let skip;
|
|
108
|
+
const begin = this.save(sessionID, (state) => {
|
|
109
|
+
const inFlight = state.audit && state.audit.boundaryID === boundaryID && started - state.audit.startedAt < AUDIT_STALE_MS;
|
|
110
|
+
if (state.boundaries.includes(boundaryID) || inFlight) {
|
|
111
|
+
skip = { action: "none", state: state.state, duplicate: true };
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const next = transition(state.state, "complete");
|
|
115
|
+
if (next !== "AUDITING") {
|
|
116
|
+
// Locked: no audit and no push until a verified human turn.
|
|
117
|
+
state.boundaries = [...state.boundaries, boundaryID].slice(-MAX_TRACKED);
|
|
118
|
+
skip = { action: "none", state: next, reason: state.reason };
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
state.state = next;
|
|
122
|
+
state.phase = "judging";
|
|
123
|
+
state.audit = { id: auditID, boundaryID, turn: state.turn, startedAt: started };
|
|
124
|
+
}, "auditing");
|
|
125
|
+
if (skip)
|
|
126
|
+
return skip;
|
|
127
|
+
// Spend is saved as it happens, so a superseded audit still counts against the session.
|
|
128
|
+
const charge = (costUSD) => {
|
|
129
|
+
if (costUSD)
|
|
130
|
+
this.save(sessionID, (state) => void (state.spentUSD += costUSD), "usage");
|
|
131
|
+
};
|
|
132
|
+
const metrics = { cacheHit: false, compilerCalls: 0, judgeCalls: 0, tokens: 0, costUSD: 0, latencyMs: 0, uncertain: 0 };
|
|
133
|
+
const finish = (event, reason, findings, extra = {}) => {
|
|
134
|
+
metrics.latencyMs = Date.now() - started;
|
|
135
|
+
metrics.uncertain = findings.filter((finding) => finding.status === "UNKNOWN").length;
|
|
136
|
+
let current = false;
|
|
137
|
+
const final = this.save(sessionID, (state) => {
|
|
138
|
+
current = state.audit?.id === auditID;
|
|
139
|
+
if (!current)
|
|
140
|
+
return;
|
|
141
|
+
Object.assign(state, extra);
|
|
142
|
+
state.state = transition("AUDITING", event);
|
|
143
|
+
state.phase = null;
|
|
144
|
+
state.audit = undefined;
|
|
145
|
+
state.reason = reason;
|
|
146
|
+
state.findings = findings;
|
|
147
|
+
state.lastAuditID = auditID;
|
|
148
|
+
state.boundaries = [...state.boundaries, boundaryID].slice(-MAX_TRACKED);
|
|
149
|
+
}, event);
|
|
150
|
+
if (!current)
|
|
151
|
+
return null;
|
|
152
|
+
const record = { auditID, boundaryID, turn: begin.turn, at: Date.now(), state: final.state, reason, findings, metrics, corrections: final.corrections };
|
|
153
|
+
store.appendAudit(sessionID, record);
|
|
154
|
+
return final.state;
|
|
155
|
+
};
|
|
156
|
+
const stale = () => ({ action: "none", state: store.read(sessionID).state, reason: "audit superseded by a newer turn" });
|
|
157
|
+
const incomplete = (reason, findings = []) => {
|
|
158
|
+
const state = finish("incomplete", reason, findings);
|
|
159
|
+
return state ? { action: "none", state, reason, notice: `Fidelis audit incomplete: ${reason}` } : stale();
|
|
160
|
+
};
|
|
161
|
+
if (config.maxAuditCostUSD !== null && begin.spentUSD >= config.maxAuditCostUSD) {
|
|
162
|
+
const reason = `Audit budget of $${config.maxAuditCostUSD} is exhausted.`;
|
|
163
|
+
const state = finish("stop", reason, []);
|
|
164
|
+
return state ? { action: "none", state, reason, notice: `Fidelis stopped: ${reason}` } : stale();
|
|
165
|
+
}
|
|
166
|
+
if (!input.sources)
|
|
167
|
+
return incomplete("The host did not reveal which instructions it loaded.");
|
|
168
|
+
const sources = resolveSources(input.sources);
|
|
169
|
+
if (sources.length === 0) {
|
|
170
|
+
const reason = "No effective instructions to audit.";
|
|
171
|
+
const state = finish("clean", reason, []);
|
|
172
|
+
return state ? { action: "none", state, reason } : stale();
|
|
173
|
+
}
|
|
174
|
+
const options = this.o.options ?? {};
|
|
175
|
+
const compileConsent = consentProblem(config.compilerModel, options, config);
|
|
176
|
+
const compiled = await getChecklist({
|
|
177
|
+
store,
|
|
178
|
+
host: this.o.host,
|
|
179
|
+
sources,
|
|
180
|
+
config,
|
|
181
|
+
generate: compileConsent ? null : this.o.compiler,
|
|
182
|
+
onCompile: () => this.save(sessionID, (state) => void (state.phase = "compiling"), "compiling"),
|
|
183
|
+
});
|
|
184
|
+
metrics.cacheHit = compiled.cacheHit;
|
|
185
|
+
metrics.compilerCalls = compiled.calls;
|
|
186
|
+
metrics.tokens = compiled.tokens;
|
|
187
|
+
metrics.costUSD = compiled.costUSD;
|
|
188
|
+
charge(compiled.costUSD);
|
|
189
|
+
if (!compiled.checklist)
|
|
190
|
+
return incomplete(compileConsent && !compiled.cacheHit ? compileConsent : (compiled.reason ?? "Checklist unavailable."));
|
|
191
|
+
this.save(sessionID, (state) => void (state.phase = "judging"), "judging");
|
|
192
|
+
const judgeConsent = consentProblem(config.judgeModel, options, config);
|
|
193
|
+
if (judgeConsent)
|
|
194
|
+
return incomplete(judgeConsent);
|
|
195
|
+
if (!this.o.judge)
|
|
196
|
+
return incomplete("No decision model is available.");
|
|
197
|
+
let verdict;
|
|
198
|
+
try {
|
|
199
|
+
verdict = await judge({
|
|
200
|
+
checklist: compiled.checklist,
|
|
201
|
+
evidence: input.evidence,
|
|
202
|
+
prior: begin.findings.filter((finding) => finding.status === "VIOLATED"),
|
|
203
|
+
model: this.o.judge,
|
|
204
|
+
config,
|
|
205
|
+
stopReason: () => {
|
|
206
|
+
const current = store.read(sessionID);
|
|
207
|
+
if (current.audit?.id !== auditID)
|
|
208
|
+
return "the audit was superseded by a newer turn";
|
|
209
|
+
return config.maxAuditCostUSD !== null && current.spentUSD >= config.maxAuditCostUSD ? "the audit budget is exhausted" : null;
|
|
210
|
+
},
|
|
211
|
+
onUsage: (tokens, costUSD) => {
|
|
212
|
+
metrics.tokens += tokens;
|
|
213
|
+
metrics.costUSD += costUSD;
|
|
214
|
+
charge(costUSD);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return incomplete(`Judge failed: ${error.message}`);
|
|
220
|
+
}
|
|
221
|
+
metrics.judgeCalls = verdict.calls;
|
|
222
|
+
return this.route(verdict.findings, verdict.waiting, compiled.checklist.dropped.length, begin, auditID, finish, stale, incomplete);
|
|
223
|
+
}
|
|
224
|
+
/** Deterministic routing. Model output only suggests; these branches decide. */
|
|
225
|
+
route(findings, waiting, dropped, begin, auditID, finish, stale, incomplete) {
|
|
226
|
+
const violated = findings.filter((finding) => finding.status === "VIOLATED");
|
|
227
|
+
const open = violated.filter((finding) => !finding.resolved);
|
|
228
|
+
const forUser = open.filter((finding) => finding.repair === "user");
|
|
229
|
+
if (waiting || forUser.length) {
|
|
230
|
+
const reason = forUser.length ? `Repair needs user authorization: ${forUser.map((finding) => finding.ruleID).join(", ")}.` : "The agent is waiting for a user decision.";
|
|
231
|
+
const state = finish("wait", reason, findings);
|
|
232
|
+
return state ? { action: "wait", state, auditID, reason, notice: violated.length ? `Fidelis findings:\n${userNotice(violated)}` : undefined } : stale();
|
|
233
|
+
}
|
|
234
|
+
// A rule disclosed as unrepairable is never corrected again, even if the model later calls it repairable.
|
|
235
|
+
const undisclosed = open.filter((finding) => !begin.disclosed.includes(finding.ruleID));
|
|
236
|
+
if (undisclosed.length) {
|
|
237
|
+
if (begin.corrections >= this.o.config.maxCorrections) {
|
|
238
|
+
const reason = `Correction limit of ${this.o.config.maxCorrections} reached with unresolved violations.`;
|
|
239
|
+
const state = finish("stop", reason, findings);
|
|
240
|
+
return state ? { action: "none", state, reason, notice: `Fidelis stopped: ${reason}\n${userNotice(undisclosed)}` } : stale();
|
|
241
|
+
}
|
|
242
|
+
const state = finish("correct", undefined, findings, {
|
|
243
|
+
corrections: begin.corrections + 1,
|
|
244
|
+
disclosed: [...begin.disclosed, ...undisclosed.filter((finding) => finding.repair === "unrepairable").map((finding) => finding.ruleID)],
|
|
245
|
+
});
|
|
246
|
+
return state ? { action: "correct", state, auditID, text: correctionReport(auditID, undisclosed) } : stale();
|
|
247
|
+
}
|
|
248
|
+
if (open.length) {
|
|
249
|
+
const reason = "Only already disclosed unrepairable violations remain.";
|
|
250
|
+
const state = finish("stop", reason, findings);
|
|
251
|
+
return state ? { action: "none", state, reason, notice: `Fidelis findings:\n${userNotice(open)}` } : stale();
|
|
252
|
+
}
|
|
253
|
+
const unknown = findings.filter((finding) => finding.status === "UNKNOWN");
|
|
254
|
+
if (unknown.length)
|
|
255
|
+
return incomplete(`${unknown.length} rule(s) could not be assessed.`, findings);
|
|
256
|
+
// A rule the compiler produced but validation rejected was never judged, so this cannot be a clean pass.
|
|
257
|
+
if (dropped)
|
|
258
|
+
return incomplete(`${dropped} compiled rule(s) failed validation and were not assessed.`, findings);
|
|
259
|
+
const reason = violated.length ? `${violated.length} violation(s) on record, repaired or acknowledged after correction.` : undefined;
|
|
260
|
+
const state = finish("clean", reason, findings);
|
|
261
|
+
return state ? { action: "none", state, reason, notice: violated.length ? `Fidelis findings:\n${userNotice(violated)}` : undefined } : stale();
|
|
262
|
+
}
|
|
263
|
+
status(sessionID) {
|
|
264
|
+
const state = this.o.store.read(sessionID);
|
|
265
|
+
return {
|
|
266
|
+
sessionID,
|
|
267
|
+
state: state.state,
|
|
268
|
+
phase: state.phase,
|
|
269
|
+
reason: state.reason,
|
|
270
|
+
revision: state.revision,
|
|
271
|
+
updatedAt: state.updatedAt,
|
|
272
|
+
auditID: state.audit?.id ?? state.lastAuditID ?? null,
|
|
273
|
+
corrections: state.corrections,
|
|
274
|
+
actionable: state.findings.filter((finding) => finding.status === "VIOLATED" && !finding.resolved).length,
|
|
275
|
+
audits: this.o.store.audits(sessionID, 20),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Checklist, Config, Host, InstructionSource, Rule } from "./types.ts";
|
|
2
|
+
import { type GenerateText } from "./providers.ts";
|
|
3
|
+
import { type Store } from "./store.ts";
|
|
4
|
+
export declare const SCHEMA_VERSION = 1;
|
|
5
|
+
export declare const COMPILER_VERSION = 1;
|
|
6
|
+
/** Ordered by the host's load order, without double-counting a file reached twice. */
|
|
7
|
+
export declare function resolveSources(sources: InstructionSource[]): InstructionSource[];
|
|
8
|
+
export declare function compilerPrompt(config: Pick<Config, "compilerPromptPath">): string;
|
|
9
|
+
export declare function checklistFingerprint(host: Host, sources: InstructionSource[], config: Pick<Config, "compilerModel" | "compilerEffort">, prompt: string): string;
|
|
10
|
+
/** Schema check plus traceability: a rule whose quote is not verbatim in its source is rejected. */
|
|
11
|
+
export declare function validateRules(raw: any, sources: InstructionSource[]): {
|
|
12
|
+
rules: Rule[];
|
|
13
|
+
dropped: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare const CHECKLIST_SCHEMA: {
|
|
16
|
+
type: string;
|
|
17
|
+
properties: {
|
|
18
|
+
confidence: {
|
|
19
|
+
type: string;
|
|
20
|
+
};
|
|
21
|
+
rules: {
|
|
22
|
+
type: string;
|
|
23
|
+
items: {
|
|
24
|
+
type: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
required: string[];
|
|
29
|
+
};
|
|
30
|
+
export interface CompileResult {
|
|
31
|
+
checklist?: Checklist;
|
|
32
|
+
cacheHit: boolean;
|
|
33
|
+
calls: number;
|
|
34
|
+
tokens: number;
|
|
35
|
+
costUSD: number;
|
|
36
|
+
reason?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function getChecklist(input: {
|
|
39
|
+
store: Store;
|
|
40
|
+
host: Host;
|
|
41
|
+
sources: InstructionSource[];
|
|
42
|
+
config: Config;
|
|
43
|
+
generate: GenerateText | null;
|
|
44
|
+
onCompile?: () => void;
|
|
45
|
+
}): Promise<CompileResult>;
|
package/dist/compiler.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { OBLIGATIONS } from "./types.js";
|
|
3
|
+
import { COMPILER_PROMPT } from "./prompts.js";
|
|
4
|
+
import { generated, parseJson, withRetry } from "./providers.js";
|
|
5
|
+
import { redact } from "./redact.js";
|
|
6
|
+
import { canonical, sha256 } from "./store.js";
|
|
7
|
+
export const SCHEMA_VERSION = 1;
|
|
8
|
+
export const COMPILER_VERSION = 1;
|
|
9
|
+
const MIN_CONFIDENCE = 0.5;
|
|
10
|
+
/** Ordered by the host's load order, without double-counting a file reached twice. */
|
|
11
|
+
export function resolveSources(sources) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
return [...sources]
|
|
14
|
+
.sort((a, b) => a.priority - b.priority)
|
|
15
|
+
.filter((source) => {
|
|
16
|
+
const key = source.path || sha256(source.text);
|
|
17
|
+
if (seen.has(key) || !source.text.trim())
|
|
18
|
+
return false;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
return true;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function compilerPrompt(config) {
|
|
24
|
+
return config.compilerPromptPath ? readFileSync(config.compilerPromptPath, "utf8") : COMPILER_PROMPT;
|
|
25
|
+
}
|
|
26
|
+
export function checklistFingerprint(host, sources, config, prompt) {
|
|
27
|
+
return sha256(canonical({
|
|
28
|
+
schema: SCHEMA_VERSION,
|
|
29
|
+
compiler: COMPILER_VERSION,
|
|
30
|
+
host,
|
|
31
|
+
sources: sources.map((source) => ({ path: source.path, scope: source.scope, priority: source.priority, text: source.text })),
|
|
32
|
+
model: config.compilerModel,
|
|
33
|
+
effort: config.compilerEffort,
|
|
34
|
+
prompt: sha256(prompt),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
const normalize = (text) => text.replace(/\s+/g, " ").trim();
|
|
38
|
+
const isString = (value) => typeof value === "string";
|
|
39
|
+
/** Schema check plus traceability: a rule whose quote is not verbatim in its source is rejected. */
|
|
40
|
+
export function validateRules(raw, sources) {
|
|
41
|
+
if (!raw || !Array.isArray(raw.rules))
|
|
42
|
+
throw new Error("compiler output lacks rules");
|
|
43
|
+
const dropped = [];
|
|
44
|
+
const byQuote = new Map();
|
|
45
|
+
const localToStable = new Map();
|
|
46
|
+
const pending = [];
|
|
47
|
+
raw.rules.forEach((item, index) => {
|
|
48
|
+
const label = String(item?.id ?? index);
|
|
49
|
+
const valid = item &&
|
|
50
|
+
isString(item.quote) &&
|
|
51
|
+
isString(item.source) &&
|
|
52
|
+
OBLIGATIONS.includes(item.obligation) &&
|
|
53
|
+
["applicability", "deadline", "expectedEvidence", "counterEvidence"].every((key) => isString(item[key]));
|
|
54
|
+
if (!valid)
|
|
55
|
+
return void dropped.push(`${label}: invalid shape`);
|
|
56
|
+
// The verbatim quote is the traceability anchor. Models often shorten a path, so it may correct the source.
|
|
57
|
+
const key = normalize(item.quote);
|
|
58
|
+
const holds = (candidate) => normalize(candidate.text).includes(key);
|
|
59
|
+
const source = sources.find((candidate) => candidate.path === item.source && holds(candidate)) ?? sources.find(holds);
|
|
60
|
+
if (!source || !key)
|
|
61
|
+
return void dropped.push(`${label}: quote not verbatim`);
|
|
62
|
+
const existing = byQuote.get(key);
|
|
63
|
+
if (existing)
|
|
64
|
+
return void localToStable.set(label, existing.id);
|
|
65
|
+
const rule = {
|
|
66
|
+
id: `R-${sha256(`${source.path}\0${key}`).slice(0, 10)}`,
|
|
67
|
+
quote: item.quote,
|
|
68
|
+
source: source.path,
|
|
69
|
+
obligation: item.obligation,
|
|
70
|
+
applicability: item.applicability,
|
|
71
|
+
trigger: isString(item.trigger) ? item.trigger : "",
|
|
72
|
+
deadline: item.deadline,
|
|
73
|
+
exceptions: Array.isArray(item.exceptions) ? item.exceptions.filter(isString) : [],
|
|
74
|
+
expectedEvidence: item.expectedEvidence,
|
|
75
|
+
counterEvidence: item.counterEvidence,
|
|
76
|
+
repairHint: isString(item.repairHint) ? item.repairHint : "",
|
|
77
|
+
priority: source.priority,
|
|
78
|
+
supersedable: item.supersedable === true,
|
|
79
|
+
supersededBy: null,
|
|
80
|
+
conflictsWith: [],
|
|
81
|
+
};
|
|
82
|
+
byQuote.set(key, rule);
|
|
83
|
+
localToStable.set(label, rule.id);
|
|
84
|
+
pending.push({ rule, raw: item });
|
|
85
|
+
});
|
|
86
|
+
for (const { rule, raw: item } of pending) {
|
|
87
|
+
rule.supersededBy = localToStable.get(String(item.supersededBy)) ?? null;
|
|
88
|
+
rule.conflictsWith = (Array.isArray(item.conflictsWith) ? item.conflictsWith : []).flatMap((id) => localToStable.get(String(id)) ?? []);
|
|
89
|
+
}
|
|
90
|
+
const known = new Set(pending.map(({ rule }) => rule.id));
|
|
91
|
+
for (const { rule } of pending)
|
|
92
|
+
for (const other of rule.conflictsWith)
|
|
93
|
+
if (known.has(other))
|
|
94
|
+
byId(pending, other).conflictsWith = [...new Set([...byId(pending, other).conflictsWith, rule.id])];
|
|
95
|
+
return { rules: pending.map(({ rule }) => rule), dropped };
|
|
96
|
+
}
|
|
97
|
+
const byId = (pending, id) => pending.find(({ rule }) => rule.id === id).rule;
|
|
98
|
+
export const CHECKLIST_SCHEMA = {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: { confidence: { type: "number" }, rules: { type: "array", items: { type: "object" } } },
|
|
101
|
+
required: ["confidence", "rules"],
|
|
102
|
+
};
|
|
103
|
+
export async function getChecklist(input) {
|
|
104
|
+
const { store, host, config, generate } = input;
|
|
105
|
+
const sources = resolveSources(input.sources);
|
|
106
|
+
const prompt = compilerPrompt(config);
|
|
107
|
+
const fingerprint = checklistFingerprint(host, sources, config, prompt);
|
|
108
|
+
const cached = config.cacheChecklist ? store.getChecklist(fingerprint) : undefined;
|
|
109
|
+
const miss = { cacheHit: false, calls: 1, tokens: 0, costUSD: 0 };
|
|
110
|
+
if (cached?.schema === SCHEMA_VERSION && cached.dropped.length === 0)
|
|
111
|
+
return { checklist: cached, cacheHit: true, calls: 0, tokens: 0, costUSD: 0 };
|
|
112
|
+
if (!generate)
|
|
113
|
+
return { ...miss, calls: 0, reason: "No compiler model is available." };
|
|
114
|
+
input.onCompile?.();
|
|
115
|
+
const full = prompt + sources.map((source) => `\n--- source: ${source.path} (scope ${source.scope}) ---\n${redact(source.text)}`).join("\n");
|
|
116
|
+
try {
|
|
117
|
+
const output = generated(await withRetry(() => generate(full, CHECKLIST_SCHEMA), config));
|
|
118
|
+
miss.tokens = output.tokens ?? 0;
|
|
119
|
+
miss.costUSD = output.costUSD ?? 0;
|
|
120
|
+
const raw = parseJson(output.text);
|
|
121
|
+
const confidence = raw?.confidence;
|
|
122
|
+
if (typeof confidence !== "number" || !(confidence >= 0 && confidence <= 1))
|
|
123
|
+
return { ...miss, reason: "Compiler output has no valid confidence." };
|
|
124
|
+
const { rules, dropped } = validateRules(raw, sources);
|
|
125
|
+
if (confidence < MIN_CONFIDENCE)
|
|
126
|
+
return { ...miss, reason: `Compiler confidence ${confidence} is too low.` };
|
|
127
|
+
if (rules.length === 0)
|
|
128
|
+
return { ...miss, reason: `Compiler produced no valid rules from the instructions (${dropped.length} rejected).` };
|
|
129
|
+
const checklist = { schema: SCHEMA_VERSION, fingerprint, model: config.compilerModel, compiledAt: Date.now(), rules, dropped };
|
|
130
|
+
// Only a fully validated checklist is reused; a partial one is retried on the next turn.
|
|
131
|
+
if (config.cacheChecklist && dropped.length === 0)
|
|
132
|
+
store.putChecklist(checklist);
|
|
133
|
+
return { ...miss, checklist };
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return { ...miss, reason: `Checklist compilation failed: ${error.message}` };
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Config, Host } from "./types.ts";
|
|
2
|
+
export declare const HOST_DEFAULTS: Record<Host, {
|
|
3
|
+
compilerModel: string;
|
|
4
|
+
compilerEffort: string;
|
|
5
|
+
judgeModel: string;
|
|
6
|
+
judgeEffort: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** Default models whose free tier may use submitted data for training. Needs explicit consent. */
|
|
9
|
+
export declare const DATA_USE_MODELS: Set<string>;
|
|
10
|
+
export declare function resolveConfig(host: Host, options?: Record<string, unknown>): Config;
|
|
11
|
+
/** Consent is implied only when the user named the model; the built-in default must be opted in. */
|
|
12
|
+
export declare function consentProblem(model: string, options: Record<string, unknown>, config: Config): string | null;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const HOST_DEFAULTS = {
|
|
2
|
+
opencode: { compilerModel: "opencode/big-pickle", compilerEffort: "default", judgeModel: "opencode/jev-1.13-free", judgeEffort: "default" },
|
|
3
|
+
"claude-code": { compilerModel: "claude-haiku-5-5", compilerEffort: "xhigh", judgeModel: "claude-haiku-5-5", judgeEffort: "xhigh" },
|
|
4
|
+
};
|
|
5
|
+
/** Default models whose free tier may use submitted data for training. Needs explicit consent. */
|
|
6
|
+
export const DATA_USE_MODELS = new Set(["opencode/big-pickle"]);
|
|
7
|
+
const BASE = {
|
|
8
|
+
enabled: true,
|
|
9
|
+
compilerPromptPath: null,
|
|
10
|
+
judgePromptPath: null,
|
|
11
|
+
auditOnCompletion: true,
|
|
12
|
+
maxCorrections: 3,
|
|
13
|
+
minViolationConfidence: 0.9,
|
|
14
|
+
maxAuditTokens: 12000,
|
|
15
|
+
maxAuditCostUSD: null,
|
|
16
|
+
cacheChecklist: true,
|
|
17
|
+
storeRawTranscript: false,
|
|
18
|
+
allowFreeModelDataUse: false,
|
|
19
|
+
trustSdkPrompts: false,
|
|
20
|
+
timeoutMs: 240_000,
|
|
21
|
+
retries: 2,
|
|
22
|
+
retentionDays: 30,
|
|
23
|
+
};
|
|
24
|
+
const UI = { enabled: true, footer: true, notifications: "important", auditPanel: true, plainTextIcons: false };
|
|
25
|
+
export function resolveConfig(host, options = {}) {
|
|
26
|
+
const merged = { ...BASE, ...HOST_DEFAULTS[host] };
|
|
27
|
+
for (const [key, value] of Object.entries(options)) {
|
|
28
|
+
if (value === undefined || value === "auto" || key === "ui" || !(key in merged))
|
|
29
|
+
continue;
|
|
30
|
+
const expected = BASE[key];
|
|
31
|
+
if (expected !== null && expected !== undefined && typeof value !== typeof expected)
|
|
32
|
+
throw new Error(`fidelis: option ${key} must be ${typeof expected}`);
|
|
33
|
+
merged[key] = value;
|
|
34
|
+
}
|
|
35
|
+
merged.ui = { ...UI, ...options.ui };
|
|
36
|
+
return merged;
|
|
37
|
+
}
|
|
38
|
+
/** Consent is implied only when the user named the model; the built-in default must be opted in. */
|
|
39
|
+
export function consentProblem(model, options, config) {
|
|
40
|
+
const named = Object.values(options).includes(model);
|
|
41
|
+
if (!DATA_USE_MODELS.has(model) || named || config.allowFreeModelDataUse)
|
|
42
|
+
return null;
|
|
43
|
+
return `Default model ${model} may use submitted data for training during its free period. Set allowFreeModelDataUse: true or configure another model.`;
|
|
44
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./types.ts";
|
|
2
|
+
export * from "./config.ts";
|
|
3
|
+
export * from "./store.ts";
|
|
4
|
+
export * from "./redact.ts";
|
|
5
|
+
export * from "./machine.ts";
|
|
6
|
+
export * from "./providers.ts";
|
|
7
|
+
export * from "./compiler.ts";
|
|
8
|
+
export * from "./judge.ts";
|
|
9
|
+
export * from "./auditor.ts";
|
|
10
|
+
export * from "./prompts.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./config.js";
|
|
3
|
+
export * from "./store.js";
|
|
4
|
+
export * from "./redact.js";
|
|
5
|
+
export * from "./machine.js";
|
|
6
|
+
export * from "./providers.js";
|
|
7
|
+
export * from "./compiler.js";
|
|
8
|
+
export * from "./judge.js";
|
|
9
|
+
export * from "./auditor.js";
|
|
10
|
+
export * from "./prompts.js";
|
package/dist/judge.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Checklist, Config, Evidence, Finding } from "./types.ts";
|
|
2
|
+
import { type DecisionModel } from "./providers.ts";
|
|
3
|
+
/** Marks text the ledger cut. An event read back from it can no longer prove what it does not show. */
|
|
4
|
+
export declare const LEDGER_MARK = "\u2026[clipped in ledger]";
|
|
5
|
+
export declare class EvidenceTooLarge extends Error {
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The turn is clipped loudly and never loses an event: over budget means an incomplete audit. Older context may be
|
|
9
|
+
* omitted to fit, and the digest then says so, because a missing event can no longer prove anything.
|
|
10
|
+
*/
|
|
11
|
+
export declare function digest(evidence: Evidence, prior: Finding[], budgetChars: number, rawIDs?: ReadonlySet<string>, preamble?: string): {
|
|
12
|
+
text: string;
|
|
13
|
+
clipped: boolean;
|
|
14
|
+
complete: boolean;
|
|
15
|
+
};
|
|
16
|
+
export interface JudgeResult {
|
|
17
|
+
findings: Finding[];
|
|
18
|
+
waiting: boolean;
|
|
19
|
+
calls: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
costUSD: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function judgePreamble(config: Pick<Config, "judgePromptPath">): string;
|
|
24
|
+
export declare function judge(input: {
|
|
25
|
+
checklist: Checklist;
|
|
26
|
+
evidence: Evidence;
|
|
27
|
+
prior: Finding[];
|
|
28
|
+
model: DecisionModel;
|
|
29
|
+
config: Config;
|
|
30
|
+
stopReason?: () => string | null;
|
|
31
|
+
onUsage?: (tokens: number, costUSD: number) => void;
|
|
32
|
+
}): Promise<JudgeResult>;
|