@inixiative/foundry-core 0.4.0 → 0.5.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/package.json +1 -1
- package/src/action-prompt.ts +15 -16
- package/src/adapters/file-memory.ts +3 -2
- package/src/adapters/index.ts +37 -1
- package/src/adapters/markdown-docs.ts +243 -5
- package/src/adapters/markdown-hierarchy.ts +904 -0
- package/src/capability.ts +36 -28
- package/src/context-layer.ts +42 -4
- package/src/context-stack.ts +13 -4
- package/src/executor.ts +46 -3
- package/src/feedback/correction.ts +366 -0
- package/src/harness.ts +16 -0
- package/src/index.ts +73 -9
- package/src/messages.ts +7 -1
- package/src/middleware.ts +5 -0
- package/src/model-registry.ts +57 -6
- package/src/native-evidence.ts +11 -1
- package/src/scope.ts +2 -0
- package/src/session-delivery.ts +76 -0
- package/src/thread.ts +18 -3
- package/src/types.ts +8 -0
- package/src/permission-middleware.ts +0 -80
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// The Prospector's correction detection (moved from Oracle's session miner), shared by Oracle's
|
|
4
|
+
// `oracle mine`, Foundry's live feedback capture and `foundry repeats`. Data and pure functions only:
|
|
5
|
+
// callers bring the transcript turns and the model call.
|
|
6
|
+
|
|
7
|
+
/** Why a message became a candidate, strongest first. */
|
|
8
|
+
export type CorrectionSignal = 'repeat' | 'explicit' | 'interruption' | 'opener';
|
|
9
|
+
|
|
10
|
+
/** One turn of a transcript, whatever harness wrote it. */
|
|
11
|
+
export interface TranscriptTurn {
|
|
12
|
+
role: 'user' | 'assistant';
|
|
13
|
+
/** The harness's record id for the turn. */
|
|
14
|
+
id?: string;
|
|
15
|
+
/** ISO time, or '' when the record has none. */
|
|
16
|
+
timestamp: string;
|
|
17
|
+
/** What the person typed (user) or the agent's prose (assistant). */
|
|
18
|
+
text: string;
|
|
19
|
+
/** The person interrupted the agent just before this turn. */
|
|
20
|
+
interrupted?: boolean;
|
|
21
|
+
/** Where the turn sits in its source, e.g. `line:42`. */
|
|
22
|
+
sourceRef?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CorrectionEvent {
|
|
26
|
+
/** Stable identity: `<sessionId>:<messageId>`, or a content digest when the record has no id. */
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly sessionId: string;
|
|
29
|
+
readonly messageId?: string;
|
|
30
|
+
readonly sourceRef?: string;
|
|
31
|
+
readonly timestamp: string;
|
|
32
|
+
/** What the agent said right before the correction ('' at the start of a session). */
|
|
33
|
+
readonly before: string;
|
|
34
|
+
/** The person's message. */
|
|
35
|
+
readonly correction: string;
|
|
36
|
+
/** The strongest signal, and every signal that fired. */
|
|
37
|
+
readonly signal: CorrectionSignal;
|
|
38
|
+
readonly signals: readonly CorrectionSignal[];
|
|
39
|
+
/** The matched phrase of the strongest pattern signal. */
|
|
40
|
+
readonly phrase?: string;
|
|
41
|
+
/** 0–1 rank among candidates. */
|
|
42
|
+
readonly score: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CorrectionDetection {
|
|
46
|
+
hit: boolean;
|
|
47
|
+
signal?: CorrectionSignal;
|
|
48
|
+
signals: CorrectionSignal[];
|
|
49
|
+
phrase?: string;
|
|
50
|
+
score: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Data, so every caller filters with exactly these. Tuned on a 19.8k-message scan against an Opus read. */
|
|
54
|
+
export const CORRECTION_PATTERNS: Readonly<
|
|
55
|
+
Record<Exclude<CorrectionSignal, 'interruption'>, RegExp>
|
|
56
|
+
> = {
|
|
57
|
+
/** The person says this already failed. A bare "again" is handled by `AGAIN_CORRECTION`, not here. */
|
|
58
|
+
repeat:
|
|
59
|
+
/\b(?:i thought we|didn'?t we|did(?:n'?t| not) (?:i|we) (?:say|tell|ask|agree|decide)|i (?:already )?(?:told|asked) you|once and for all|how many times|(?:keeps?|kept) (?:doing|adding|making|asking|forgetting|happening)|i keep (?:saying|telling|having|asking)|same (?:mistake|lessons?)|as i said|like i said|we(?:'ve| have) (?:talked|been over|discussed|decided)|over and over|who keeps|still (?:seeing|doing|using|adding)|gentle reminder|reminder:)/i,
|
|
60
|
+
/** A standing instruction, a challenge, a reuse hint or a verdict on what the agent did. */
|
|
61
|
+
explicit:
|
|
62
|
+
/\b(?:from now on|going forward|in the future|always|never|remember (?:that|to|this)|make sure (?:you|to|that)|stop \w+ing|do not|don'?t (?:ever|make|add|do|use|ask|touch|create|build|write|need|be|put|bother)|please (?:don'?t|use|run|just)|you (?:should|shouldn'?t|need to|have to|must|can (?:just|always)|don'?t (?:need|have) to)|we (?:should(?:n'?t)?|don'?t|never|always)|should (?:just|not|never|always)|shouldn'?t|no need to|just (?:merge|use|do|call|fix|throw|delete|go|run|ship)|stop asking|why (?:would|did|are|do) (?:you|u)|why not just|why can'?t we just|get rid of|(?:is|are)n'?t needed|(?:does|do)n'?t make sense|i (?:really )?don'?t like|weird pattern|we (?:already )?have (?:a |an |the )?(?:native |existing |shared )?\w+(?: \w+)? (?:for|that|utils?|helpers?)|isn'?t there (?:already )?a|already (?:exists|have|has)|use (?:the )?(?:existing|internal|shared)|follow (?:what|how) \w+ (?:is doing|does)|are (?:you|u) assuming|did (?:you|u) (?:actually|read|check|test|run|verify)|i don'?t trust|have (?:you|u) (?:restarted|tested|run|checked|verified)|in both repos|backport|backprop|use (?:the )?logger)\b/i,
|
|
63
|
+
/** A message that opens by pushing back (the Prospector's corrective openers). */
|
|
64
|
+
opener:
|
|
65
|
+
/^\s*(?:no\b|no,|nope|don'?t|dont\b|stop\b|wait\b|wrong\b|not what|that'?s not|thats not|actually,?\s|instead\b|undo\b|revert\b|why (?:did|would|are|is|do) (?:you|u)|why not|what the (?:fuck|hell)|wtf|that'?s wrong|you'?re wrong|i said)/i,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* "Again" marks a repeat only beside a negation or a challenge in the same sentence ("again, we
|
|
70
|
+
* shouldn't…", "not again"). Retries ("try it again", "run again") and filler don't count.
|
|
71
|
+
*/
|
|
72
|
+
export const AGAIN_CORRECTION = {
|
|
73
|
+
word: /\bagain\b/i,
|
|
74
|
+
retry:
|
|
75
|
+
/\b(?:try|run|do|check|show|send|ask|say|test|start|restart|look|read|load|open|build|deploy|push|pull|merge|call|post|go)(?: (?:it|that|this|them|me|the \w+))? again\b|^\W*again\W*$/i,
|
|
76
|
+
opening: /^\W*(?:no,?\s+)?again\s*[,:;—-]\s*\S/i,
|
|
77
|
+
context:
|
|
78
|
+
/\b(?:don'?t|do not|not|never|shouldn'?t|stop|why|wrong|told|said|no longer|keep|still|same|we'?re|you'?re|you)\b/i,
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
/** What a signal says, most telling first: the `signal` a caller acts on (a repeat can block). */
|
|
82
|
+
const MEANING: readonly CorrectionSignal[] = ['repeat', 'explicit', 'interruption', 'opener'];
|
|
83
|
+
/** Measured precision per signal, for `score`: openers hold best, interruptions worst. */
|
|
84
|
+
const WEIGHT: Record<CorrectionSignal, number> = {
|
|
85
|
+
opener: 0.8,
|
|
86
|
+
repeat: 0.7,
|
|
87
|
+
explicit: 0.6,
|
|
88
|
+
interruption: 0.5,
|
|
89
|
+
};
|
|
90
|
+
/** The agent ended on a question, so a bare "no" answers it rather than corrects it. */
|
|
91
|
+
const ASKED = /\?\s*[)*`]*\s*$/;
|
|
92
|
+
const INTERRUPTED = /^\s*\[Request interrupted by user/;
|
|
93
|
+
const INTERRUPTED_MARKER = /^\s*\[Request interrupted by user[^\]]*\]\s*/;
|
|
94
|
+
|
|
95
|
+
/** The person's own prose: pasted code, quotes and log lines are someone else's words. */
|
|
96
|
+
export function proseOf(text: string): string {
|
|
97
|
+
return text
|
|
98
|
+
.replace(/```[\s\S]*?(?:```|$)/g, ' ')
|
|
99
|
+
.split('\n')
|
|
100
|
+
.filter(
|
|
101
|
+
(line) =>
|
|
102
|
+
!/^\s*>/.test(line) &&
|
|
103
|
+
!/^\s*(?:\[?\d{4}-\d{2}-\d{2}[T ]\d|\d{1,2}:\d{2}(?::\d{2})?\s|[\w.-]+:\d+(?::\d+)?\s)/.test(
|
|
104
|
+
line,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function againPhrase(prose: string): string | undefined {
|
|
111
|
+
for (const sentence of prose.split(/(?<=[.!?\n])\s+/)) {
|
|
112
|
+
if (!AGAIN_CORRECTION.word.test(sentence) || AGAIN_CORRECTION.retry.test(sentence)) continue;
|
|
113
|
+
// "Again, merge it yourself." opens with the marker: a repeat whatever follows.
|
|
114
|
+
if (AGAIN_CORRECTION.opening.test(sentence)) return 'again';
|
|
115
|
+
if (AGAIN_CORRECTION.context.test(sentence.replace(AGAIN_CORRECTION.word, ''))) return 'again';
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function detectCorrection(
|
|
121
|
+
text: string,
|
|
122
|
+
before = '',
|
|
123
|
+
options: { interrupted?: boolean } = {},
|
|
124
|
+
): CorrectionDetection {
|
|
125
|
+
const prose = proseOf(text);
|
|
126
|
+
const found = new Map<CorrectionSignal, string | undefined>();
|
|
127
|
+
for (const signal of ['opener', 'repeat', 'explicit'] as const) {
|
|
128
|
+
const match = CORRECTION_PATTERNS[signal].exec(prose);
|
|
129
|
+
if (match) found.set(signal, match[0].trim());
|
|
130
|
+
}
|
|
131
|
+
const again = againPhrase(prose);
|
|
132
|
+
if (again && !found.has('repeat')) found.set('repeat', again);
|
|
133
|
+
if (options.interrupted) found.set('interruption', undefined);
|
|
134
|
+
const signals = MEANING.filter((signal) => found.has(signal));
|
|
135
|
+
const [strongest] = signals;
|
|
136
|
+
if (!strongest) return { hit: false, signals, score: 0 };
|
|
137
|
+
let score = Math.max(...signals.map((signal) => WEIGHT[signal])) + 0.1 * (signals.length - 1);
|
|
138
|
+
if (signals.length === 1 && strongest === 'opener' && ASKED.test(before)) score -= 0.4;
|
|
139
|
+
const phrase = found.get(strongest) ?? signals.map((signal) => found.get(signal)).find(Boolean);
|
|
140
|
+
return {
|
|
141
|
+
hit: true,
|
|
142
|
+
signal: strongest,
|
|
143
|
+
signals,
|
|
144
|
+
...(phrase ? { phrase } : {}),
|
|
145
|
+
score: Math.round(Math.min(1, score) * 100) / 100,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function correctionId(
|
|
150
|
+
sessionId: string,
|
|
151
|
+
event: Pick<CorrectionEvent, 'messageId' | 'timestamp' | 'before' | 'correction'>,
|
|
152
|
+
): string {
|
|
153
|
+
if (event.messageId) return `${sessionId}:${event.messageId}`;
|
|
154
|
+
const digest = createHash('sha256')
|
|
155
|
+
.update(`${event.timestamp}\0${event.before}\0${event.correction}`)
|
|
156
|
+
.digest('hex')
|
|
157
|
+
.slice(0, 16);
|
|
158
|
+
return `${sessionId}:sha256-${digest}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Correction candidates in one session's turns, secrets scrubbed. */
|
|
162
|
+
export function extractCorrections(
|
|
163
|
+
sessionId: string,
|
|
164
|
+
turns: Iterable<TranscriptTurn>,
|
|
165
|
+
options: { minScore?: number } = {},
|
|
166
|
+
): { corrections: CorrectionEvent[]; secretsScrubbed: number } {
|
|
167
|
+
const minScore = options.minScore ?? 0;
|
|
168
|
+
const corrections: CorrectionEvent[] = [];
|
|
169
|
+
let before = '';
|
|
170
|
+
let pendingInterruption = false;
|
|
171
|
+
let secretsScrubbed = 0;
|
|
172
|
+
// An identical message resent within ten minutes is one correction, not a repeat.
|
|
173
|
+
const recent = new Map<string, number>();
|
|
174
|
+
for (const turn of turns) {
|
|
175
|
+
if (turn.role === 'assistant') {
|
|
176
|
+
if (turn.text.trim()) before = turn.text;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (turn.interrupted || INTERRUPTED.test(turn.text)) pendingInterruption = true;
|
|
180
|
+
const text = turn.text.replace(INTERRUPTED_MARKER, '');
|
|
181
|
+
if (!text.trim()) continue;
|
|
182
|
+
const detection = detectCorrection(text, before, { interrupted: pendingInterruption });
|
|
183
|
+
pendingInterruption = false;
|
|
184
|
+
const key = text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
185
|
+
const at = Date.parse(turn.timestamp);
|
|
186
|
+
const sent = recent.get(key);
|
|
187
|
+
recent.set(key, at);
|
|
188
|
+
if (sent !== undefined && Number.isFinite(at) && at - sent < 600_000) continue;
|
|
189
|
+
if (!detection.hit || !detection.signal || detection.score < minScore) continue;
|
|
190
|
+
// Scrub before truncating, so a cut can't leave part of a secret unmatched.
|
|
191
|
+
const scrubbedBefore = scrubSecrets(before);
|
|
192
|
+
const scrubbedText = scrubSecrets(text.trim());
|
|
193
|
+
secretsScrubbed += scrubbedBefore.hits + scrubbedText.hits;
|
|
194
|
+
const event = {
|
|
195
|
+
...(turn.id ? { messageId: turn.id } : {}),
|
|
196
|
+
...(turn.sourceRef ? { sourceRef: turn.sourceRef } : {}),
|
|
197
|
+
timestamp: turn.timestamp,
|
|
198
|
+
before: scrubbedBefore.text.slice(-2000),
|
|
199
|
+
correction: scrubbedText.text.slice(0, 4000),
|
|
200
|
+
signal: detection.signal,
|
|
201
|
+
signals: detection.signals,
|
|
202
|
+
...(detection.phrase ? { phrase: detection.phrase } : {}),
|
|
203
|
+
score: detection.score,
|
|
204
|
+
};
|
|
205
|
+
corrections.push({ id: correctionId(sessionId, event), sessionId, ...event });
|
|
206
|
+
}
|
|
207
|
+
return { corrections, secretsScrubbed };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
211
|
+
[/sk-ant-[A-Za-z0-9_-]{8,}/g, '[REDACTED:anthropic-key]'],
|
|
212
|
+
[/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED:api-key]'],
|
|
213
|
+
[/gh[pousr]_[A-Za-z0-9]{20,}/g, '[REDACTED:github-token]'],
|
|
214
|
+
[/github_pat_[A-Za-z0-9_]{20,}/g, '[REDACTED:github-token]'],
|
|
215
|
+
[/AIza[A-Za-z0-9_-]{30,}/g, '[REDACTED:google-key]'],
|
|
216
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack-token]'],
|
|
217
|
+
[/AKIA[A-Z0-9]{16}/g, '[REDACTED:aws-key]'],
|
|
218
|
+
[
|
|
219
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
220
|
+
'[REDACTED:private-key]',
|
|
221
|
+
],
|
|
222
|
+
[/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[REDACTED:jwt]'],
|
|
223
|
+
[/\b[a-z]+_(?:live|test|prod|runtime)_[A-Za-z0-9]{16,}/g, '[REDACTED:token]'],
|
|
224
|
+
[/\b(?:npm_|glpat-|hf_)[A-Za-z0-9_-]{20,}/g, '[REDACTED:token]'],
|
|
225
|
+
[/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, '[REDACTED:slack-webhook]'],
|
|
226
|
+
[/\bASIA[A-Z0-9]{16}/g, '[REDACTED:aws-key]'],
|
|
227
|
+
[/\b(Bearer|Basic|DPoP)\s+[A-Za-z0-9._~+/=-]{16,}/g, '$1 [REDACTED:credential]'],
|
|
228
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1[REDACTED:password]@'],
|
|
229
|
+
[
|
|
230
|
+
/\b([A-Z][A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD)[A-Z0-9_]*\s*[=:]\s*)["']?[^\s"']{8,}["']?/g,
|
|
231
|
+
'$1[REDACTED:value]',
|
|
232
|
+
],
|
|
233
|
+
[
|
|
234
|
+
/(["']?)\b(password|passwd|api[_-]?key|secret|(?:access|auth|refresh)?[_-]?token)\1(\s*[:=]\s*)["']?[^\s"',}]{8,}["']?/gi,
|
|
235
|
+
'$1$2$1$3[REDACTED:value]',
|
|
236
|
+
],
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/** Replace credential-shaped substrings. Transcripts are exactly where leaked keys live. */
|
|
240
|
+
export function scrubSecrets(text: string): { text: string; hits: number } {
|
|
241
|
+
let hits = 0;
|
|
242
|
+
let out = text;
|
|
243
|
+
for (const [pattern, replacement] of SECRET_PATTERNS)
|
|
244
|
+
out = out.replace(pattern, (...match: unknown[]) => {
|
|
245
|
+
hits++;
|
|
246
|
+
return replacement.replace(/\$(\d)/g, (_, group: string) => {
|
|
247
|
+
const value = match[Number(group)];
|
|
248
|
+
return typeof value === 'string' ? value : '';
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
return { text: out, hits };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** FEEDBACK-LOG's tags plus process: the closed list feedback items and repeat rules are tagged with. */
|
|
255
|
+
export const FEEDBACK_TAGS = [
|
|
256
|
+
'hygiene',
|
|
257
|
+
'naming',
|
|
258
|
+
'merge',
|
|
259
|
+
'reuse',
|
|
260
|
+
'overbuild',
|
|
261
|
+
'architecture',
|
|
262
|
+
'data',
|
|
263
|
+
'product',
|
|
264
|
+
'coordination',
|
|
265
|
+
'communication',
|
|
266
|
+
'tooling',
|
|
267
|
+
'security',
|
|
268
|
+
'process',
|
|
269
|
+
] as const;
|
|
270
|
+
export type FeedbackTag = (typeof FEEDBACK_TAGS)[number];
|
|
271
|
+
|
|
272
|
+
export const CORRECTION_CATEGORIES = ['approach', 'scope', 'convention', 'fact', 'other'] as const;
|
|
273
|
+
export type CorrectionCategory = (typeof CORRECTION_CATEGORIES)[number];
|
|
274
|
+
|
|
275
|
+
export interface CorrectionClassification {
|
|
276
|
+
isCorrection: boolean;
|
|
277
|
+
category: CorrectionCategory;
|
|
278
|
+
/** Imperative rules, each at most 160 characters and saying when it applies; empty when none. */
|
|
279
|
+
statements: string[];
|
|
280
|
+
/** False when it is a correction but no crisp rule can be stated. */
|
|
281
|
+
sharp: boolean;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const CORRECTION_CLASSIFY_PROMPT = `You classify messages a person typed to an AI coding agent. Each message comes with the agent's previous message, and each is judged on its own.
|
|
285
|
+
|
|
286
|
+
A CORRECTION is feedback about how the agent works: the person redirects, fixes or rejects what the agent did, or states a rule or preference the agent should keep following beyond this task. These are NOT corrections: a new task or question, an answer to the agent's question, approving or reviewing one specific item, design discussion, status chatter.
|
|
287
|
+
|
|
288
|
+
For a correction, write each rule it carries as a statement: one imperative sentence of at most 160 characters that says when it applies ("When X, do Y." or "Never X."), general enough to hold next time, in plain words without quoting the message. At most three statements per message. Use names (products, tools, terms) only when the rule is about them.
|
|
289
|
+
|
|
290
|
+
Respond with JSON only, one result per message id:
|
|
291
|
+
{ "results": { "<id>": { "isCorrection": boolean, "category": "approach" | "scope" | "convention" | "fact" | "other", "statements": string[], "sharp": boolean } } }
|
|
292
|
+
category: approach = how to do the work; scope = what to do or not do; convention = naming, style, process or tooling rules; fact = something about the world or the codebase the agent got wrong; other = anything else.
|
|
293
|
+
sharp: false when it is a correction but no crisp rule can be stated; then statements is empty.`;
|
|
294
|
+
|
|
295
|
+
/** The stable start of every classify input: providers that prime a session reuse it (`stablePrefix`). */
|
|
296
|
+
export const CORRECTION_CLASSIFY_PREFIX = 'Classify each message below.\n\n';
|
|
297
|
+
|
|
298
|
+
/** The messages every caller sends for a batch of candidates (one is a batch of one). Ids are M1..Mn. */
|
|
299
|
+
export function correctionClassifyMessages(
|
|
300
|
+
events: readonly Pick<CorrectionEvent, 'before' | 'correction'>[],
|
|
301
|
+
limits: { before?: number; correction?: number } = {},
|
|
302
|
+
): { role: 'system' | 'user'; content: string }[] {
|
|
303
|
+
const before = limits.before ?? 1200;
|
|
304
|
+
const correction = limits.correction ?? 3000;
|
|
305
|
+
const body = events
|
|
306
|
+
.map(
|
|
307
|
+
(event, index) =>
|
|
308
|
+
`## M${index + 1}\n### Agent's prior message\n${event.before.slice(-before) || '(start of session)'}\n\n### Person's message\n${event.correction.slice(0, correction)}`,
|
|
309
|
+
)
|
|
310
|
+
.join('\n\n');
|
|
311
|
+
return [
|
|
312
|
+
{ role: 'system', content: CORRECTION_CLASSIFY_PROMPT },
|
|
313
|
+
{ role: 'user', content: `${CORRECTION_CLASSIFY_PREFIX}${body}` },
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function classification(value: unknown): CorrectionClassification | undefined {
|
|
318
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
319
|
+
const item = value as Record<string, unknown>;
|
|
320
|
+
if (typeof item.isCorrection !== 'boolean') return undefined;
|
|
321
|
+
const category = CORRECTION_CATEGORIES.find((entry) => entry === item.category) ?? 'other';
|
|
322
|
+
const statements = Array.isArray(item.statements)
|
|
323
|
+
? item.statements
|
|
324
|
+
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
|
325
|
+
.map((entry) => entry.trim().slice(0, 160))
|
|
326
|
+
.slice(0, 3)
|
|
327
|
+
: [];
|
|
328
|
+
if (!item.isCorrection) return { isCorrection: false, category, statements: [], sharp: false };
|
|
329
|
+
const sharp = item.sharp !== false && statements.length > 0;
|
|
330
|
+
return { isCorrection: true, category, statements: sharp ? statements : [], sharp };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The first balanced `{...}` in a model answer, so prose or braces after it don't break parsing. */
|
|
334
|
+
function firstJsonObject(content: string): string | undefined {
|
|
335
|
+
const start = content.indexOf('{');
|
|
336
|
+
if (start < 0) return undefined;
|
|
337
|
+
let depth = 0;
|
|
338
|
+
let quoted = false;
|
|
339
|
+
for (let i = start; i < content.length; i++) {
|
|
340
|
+
const char = content[i];
|
|
341
|
+
if (quoted) {
|
|
342
|
+
if (char === '\\') i++;
|
|
343
|
+
else if (char === '"') quoted = false;
|
|
344
|
+
} else if (char === '"') quoted = true;
|
|
345
|
+
else if (char === '{') depth++;
|
|
346
|
+
else if (char === '}' && --depth === 0) return content.slice(start, i + 1);
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One answer per message of the batch, in order; undefined where the answer is missing or malformed. */
|
|
352
|
+
export function parseCorrectionClassifications(
|
|
353
|
+
content: string,
|
|
354
|
+
count: number,
|
|
355
|
+
): (CorrectionClassification | undefined)[] {
|
|
356
|
+
let raw: unknown;
|
|
357
|
+
try {
|
|
358
|
+
raw = JSON.parse(firstJsonObject(content) ?? '');
|
|
359
|
+
} catch {
|
|
360
|
+
return Array.from({ length: count }, () => undefined);
|
|
361
|
+
}
|
|
362
|
+
const results =
|
|
363
|
+
raw && typeof raw === 'object' ? (raw as { results?: unknown }).results : undefined;
|
|
364
|
+
const byId = results && typeof results === 'object' ? (results as Record<string, unknown>) : {};
|
|
365
|
+
return Array.from({ length: count }, (_, index) => classification(byId[`M${index + 1}`]));
|
|
366
|
+
}
|
package/src/harness.ts
CHANGED
|
@@ -137,6 +137,13 @@ interface SendOptions {
|
|
|
137
137
|
onDelta?: (text: string) => void;
|
|
138
138
|
/** Final trace for this attempt, independent of the bounded history cache. */
|
|
139
139
|
onTrace?: (trace: Trace) => void;
|
|
140
|
+
/**
|
|
141
|
+
* The executor has prepared its input after every middleware ran and is handing it to its
|
|
142
|
+
* provider. Fires once per message, before any provider call.
|
|
143
|
+
*/
|
|
144
|
+
onExecutorInput?: () => void;
|
|
145
|
+
/** The payload the execute stage's agent receives instead of `message.payload` (see DispatchOptions.agentPayload). */
|
|
146
|
+
executorPayload?: unknown;
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
/**
|
|
@@ -480,6 +487,7 @@ export class Harness {
|
|
|
480
487
|
const requestedAgents = new Set<string>();
|
|
481
488
|
const requestedLayers = new Set<string>();
|
|
482
489
|
const stageResults = new Map<string, ExecutionResult>();
|
|
490
|
+
let executorInput = false;
|
|
483
491
|
|
|
484
492
|
let classification: Decision<Classification> | undefined;
|
|
485
493
|
let route: Decision<Route> | undefined;
|
|
@@ -655,11 +663,19 @@ export class Harness {
|
|
|
655
663
|
// classifier/router/enricher output isn't user-visible content.
|
|
656
664
|
const result = await this.thread.dispatch(agentId, stagePayload, layerFilter, {
|
|
657
665
|
onDelta: stage.role === 'execute' ? opts?.onDelta : undefined,
|
|
666
|
+
requestLayer: (layerId) => requestedLayers.add(layerId),
|
|
658
667
|
role: stage.role,
|
|
659
668
|
messageId: message.id,
|
|
660
669
|
invocation: stage.invocation,
|
|
670
|
+
...(stage.role === 'execute' && opts?.executorPayload !== undefined
|
|
671
|
+
? { agentPayload: opts.executorPayload }
|
|
672
|
+
: {}),
|
|
661
673
|
recordInjection: (artifact) => {
|
|
662
674
|
stageSpan.annotations.injection = artifact;
|
|
675
|
+
if (stage.role === 'execute' && !executorInput) {
|
|
676
|
+
executorInput = true;
|
|
677
|
+
opts?.onExecutorInput?.();
|
|
678
|
+
}
|
|
663
679
|
},
|
|
664
680
|
nativeObservation: opts?.nativeObservation,
|
|
665
681
|
recordNative: (evidence) => {
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,45 @@ export {
|
|
|
33
33
|
validateMemorySelection,
|
|
34
34
|
} from './adapters/file-memory';
|
|
35
35
|
export { HttpMemory } from './adapters/http-memory';
|
|
36
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
claudemdSource,
|
|
38
|
+
formatInstructionFiles,
|
|
39
|
+
type InstructionFile,
|
|
40
|
+
type InstructionReadOptions,
|
|
41
|
+
instructionImports,
|
|
42
|
+
MarkdownDocs,
|
|
43
|
+
readInstructionFile,
|
|
44
|
+
resolveInstructionImport,
|
|
45
|
+
secretInstructionPath,
|
|
46
|
+
stripInstructionComments,
|
|
47
|
+
withinAny,
|
|
48
|
+
withinPath,
|
|
49
|
+
} from './adapters/markdown-docs';
|
|
50
|
+
export {
|
|
51
|
+
dedupeHierarchy,
|
|
52
|
+
dedupeHierarchySlots,
|
|
53
|
+
discoverHierarchy,
|
|
54
|
+
expandHierarchyPath,
|
|
55
|
+
type HierarchyDedupeOptions,
|
|
56
|
+
type HierarchyDiscoverOptions,
|
|
57
|
+
type HierarchyDuplicate,
|
|
58
|
+
type HierarchyDuplicateKind,
|
|
59
|
+
type HierarchyFile,
|
|
60
|
+
type HierarchyFileSource,
|
|
61
|
+
type HierarchyLocation,
|
|
62
|
+
type HierarchyMatch,
|
|
63
|
+
type HierarchyNode,
|
|
64
|
+
type HierarchyRefusal,
|
|
65
|
+
type HierarchySlotDuplicate,
|
|
66
|
+
type HierarchySource,
|
|
67
|
+
type HierarchySpan,
|
|
68
|
+
type HierarchyStack,
|
|
69
|
+
type HierarchyWalkSource,
|
|
70
|
+
hierarchyFingerprint,
|
|
71
|
+
markdownSections,
|
|
72
|
+
type SelectedHierarchyNode,
|
|
73
|
+
selectHierarchyNodes,
|
|
74
|
+
} from './adapters/markdown-hierarchy';
|
|
37
75
|
export { type SqliteEntry, SqliteMemory } from './adapters/sqlite-memory';
|
|
38
76
|
// Agent primitives
|
|
39
77
|
export {
|
|
@@ -60,6 +98,7 @@ export {
|
|
|
60
98
|
type GateContext,
|
|
61
99
|
type PermissionLevel,
|
|
62
100
|
type PermissionPolicy,
|
|
101
|
+
permissionLevel,
|
|
63
102
|
RESTRICTED_POLICY,
|
|
64
103
|
} from './capability';
|
|
65
104
|
export {
|
|
@@ -122,6 +161,29 @@ export {
|
|
|
122
161
|
// Observability
|
|
123
162
|
export { EventStream, type SessionEvent, type StreamEvent } from './event-stream';
|
|
124
163
|
export { type ExecuteHandler, type ExecuteMeta, Executor, type ExecutorConfig } from './executor';
|
|
164
|
+
// Feedback: correction detection (the Prospector's), shared by Oracle, live capture and `foundry repeats`
|
|
165
|
+
export {
|
|
166
|
+
AGAIN_CORRECTION,
|
|
167
|
+
CORRECTION_CATEGORIES,
|
|
168
|
+
CORRECTION_CLASSIFY_PREFIX,
|
|
169
|
+
CORRECTION_CLASSIFY_PROMPT,
|
|
170
|
+
CORRECTION_PATTERNS,
|
|
171
|
+
type CorrectionCategory,
|
|
172
|
+
type CorrectionClassification,
|
|
173
|
+
type CorrectionDetection,
|
|
174
|
+
type CorrectionEvent,
|
|
175
|
+
type CorrectionSignal,
|
|
176
|
+
correctionClassifyMessages,
|
|
177
|
+
correctionId,
|
|
178
|
+
detectCorrection,
|
|
179
|
+
extractCorrections,
|
|
180
|
+
FEEDBACK_TAGS,
|
|
181
|
+
type FeedbackTag,
|
|
182
|
+
parseCorrectionClassifications,
|
|
183
|
+
proseOf,
|
|
184
|
+
scrubSecrets,
|
|
185
|
+
type TranscriptTurn,
|
|
186
|
+
} from './feedback/correction';
|
|
125
187
|
export {
|
|
126
188
|
type FlowConfig,
|
|
127
189
|
type FlowStage,
|
|
@@ -132,7 +194,6 @@ export {
|
|
|
132
194
|
type PipelineStep,
|
|
133
195
|
type RequestContext,
|
|
134
196
|
} from './harness';
|
|
135
|
-
|
|
136
197
|
// Lifecycle Hooks (registry only — built-in hooks in @inixiative/foundry)
|
|
137
198
|
export {
|
|
138
199
|
type HookContext,
|
|
@@ -205,20 +266,23 @@ export {
|
|
|
205
266
|
type RuntimeKind,
|
|
206
267
|
registryModel,
|
|
207
268
|
resolveDecisionModel,
|
|
269
|
+
SUBSCRIPTION_DECISION_MODELS,
|
|
208
270
|
} from './model-registry';
|
|
209
271
|
export * from './native-evidence';
|
|
210
|
-
// Permission middleware
|
|
211
|
-
export {
|
|
212
|
-
type PermissionCheck,
|
|
213
|
-
type PermissionCheckResult,
|
|
214
|
-
type PermissionMiddlewareConfig,
|
|
215
|
-
permissionMiddleware,
|
|
216
|
-
} from './permission-middleware';
|
|
217
272
|
// Retry middleware
|
|
218
273
|
export { type RetryConfig, retryMiddleware } from './retry';
|
|
219
274
|
export { type Route, type RouteHandler, Router, type RouterConfig } from './router';
|
|
220
275
|
// Ownership scope — which thread/project owns a source read or tool call
|
|
221
276
|
export { normalizeScope, type OwnershipScope } from './scope';
|
|
277
|
+
export {
|
|
278
|
+
type DeliveredPart,
|
|
279
|
+
layerPart,
|
|
280
|
+
type SessionDelivery,
|
|
281
|
+
type SessionDeliveryReport,
|
|
282
|
+
type StandingPart,
|
|
283
|
+
standingPart,
|
|
284
|
+
standingParts,
|
|
285
|
+
} from './session-delivery';
|
|
222
286
|
// Signals
|
|
223
287
|
export { type Signal, SignalBus, type SignalHandler, type SignalKind } from './signal';
|
|
224
288
|
// Orchestration
|
package/src/messages.ts
CHANGED
|
@@ -96,7 +96,7 @@ export interface DeliveryRecord {
|
|
|
96
96
|
readonly assessedHash?: string;
|
|
97
97
|
/** Owner revision the decorator assessed, for owner-versioned layers. */
|
|
98
98
|
readonly assessedRevision?: number;
|
|
99
|
-
/** Hash the executor assembled
|
|
99
|
+
/** Hash the executor assembled; a session provider sends only what `session` lists. */
|
|
100
100
|
readonly deliveredHash: string;
|
|
101
101
|
/** Owner revision of the bytes actually assembled, from the layer's own version mark at assembly. */
|
|
102
102
|
readonly deliveredRevision?: number;
|
|
@@ -110,6 +110,8 @@ export interface DeliveryRecord {
|
|
|
110
110
|
}>;
|
|
111
111
|
/** Layer ids committed to the delivery ledger. */
|
|
112
112
|
readonly committed: string[];
|
|
113
|
+
/** What a session provider delivered (its base context and this turn's sent parts), when one ran. */
|
|
114
|
+
readonly session?: import('./session-delivery').SessionDeliveryReport;
|
|
113
115
|
/**
|
|
114
116
|
* How the turn treated learning still pending from earlier work:
|
|
115
117
|
* `pending` means preparation continued immediately with committed knowledge.
|
|
@@ -143,6 +145,10 @@ export interface InjectionArtifact {
|
|
|
143
145
|
readonly executorContext?: string;
|
|
144
146
|
/** Initial provider messages, before native-runtime wrapping or tool followups. */
|
|
145
147
|
readonly providerMessages?: LLMMessage[];
|
|
148
|
+
/** What a session provider delivered: its system prompt parts and the parts sent with this turn. */
|
|
149
|
+
readonly sessionDelivery?: import('./session-delivery').SessionDeliveryReport;
|
|
150
|
+
/** Every standing part the executor assembled (layer with its prompt), by id and hash. */
|
|
151
|
+
readonly standingParts?: Array<import('./session-delivery').DeliveredPart>;
|
|
146
152
|
readonly layers?: Array<
|
|
147
153
|
LayerInstanceState & {
|
|
148
154
|
id: string;
|
package/src/middleware.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface DispatchContext<TPayload = unknown> {
|
|
|
18
18
|
readonly messageId?: string;
|
|
19
19
|
/** Identity of this dispatch; tool observations for it carry the same id. */
|
|
20
20
|
readonly dispatchId?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Include an on-demand layer in this dispatch's context (the routing plan asked for it). Present
|
|
23
|
+
* when a Harness stage dispatched it; layers of other activation modes are unaffected.
|
|
24
|
+
*/
|
|
25
|
+
readonly requestLayer?: (layerId: string) => void;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export interface DispatchOutcome<TResult = unknown> {
|
package/src/model-registry.ts
CHANGED
|
@@ -225,11 +225,17 @@ export interface ModelSweepOption {
|
|
|
225
225
|
export const MODEL_REGISTRY_UPDATED_AT = '2026-09-24';
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
|
-
* Decision default: the cheapest current model tagged `judgment`. Held here
|
|
229
|
-
* rather than in an adapter so one map answers "what runs decisions".
|
|
228
|
+
* Decision default: Claude Haiku 5.5, the cheapest current Claude model tagged `judgment`. Held here
|
|
229
|
+
* rather than in an adapter so one map answers "what runs decisions". DECISION_PROVIDER is the
|
|
230
|
+
* API-token path; subscription decisions pick their model per runtime from SUBSCRIPTION_DECISION_MODELS.
|
|
230
231
|
*/
|
|
231
|
-
export const DECISION_PROVIDER = '
|
|
232
|
-
export const DECISION_MODEL = '
|
|
232
|
+
export const DECISION_PROVIDER = 'anthropic';
|
|
233
|
+
export const DECISION_MODEL = 'claude-haiku-5-5';
|
|
234
|
+
/** Subscription decision default per native runtime; settings choose the runtime. */
|
|
235
|
+
export const SUBSCRIPTION_DECISION_MODELS = {
|
|
236
|
+
claude: DECISION_MODEL,
|
|
237
|
+
codex: 'gpt-6-luna',
|
|
238
|
+
} as const;
|
|
233
239
|
|
|
234
240
|
/** Tag rule: every model judges. Cheap models review and route; capable models execute and advise. */
|
|
235
241
|
const FAST = ['judgment', 'learning-review', 'tool-use'] as ModelCapability[];
|
|
@@ -270,7 +276,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
|
|
|
270
276
|
},
|
|
271
277
|
{
|
|
272
278
|
id: 'opus',
|
|
273
|
-
label: 'Opus 5',
|
|
279
|
+
label: 'Opus 5.5',
|
|
274
280
|
tier: 'powerful',
|
|
275
281
|
costTier: 'high',
|
|
276
282
|
contextWindow: 1_000_000,
|
|
@@ -278,10 +284,11 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
|
|
|
278
284
|
nativeAlias: true,
|
|
279
285
|
capabilities: POWERFUL_REASONING,
|
|
280
286
|
reasoning: CLAUDE_REASONING,
|
|
287
|
+
notes: 'Claude Code alias for claude-opus-5-5.',
|
|
281
288
|
},
|
|
282
289
|
{
|
|
283
290
|
id: 'sonnet',
|
|
284
|
-
label: 'Sonnet 5',
|
|
291
|
+
label: 'Sonnet 5.5',
|
|
285
292
|
tier: 'standard',
|
|
286
293
|
costTier: 'medium',
|
|
287
294
|
contextWindow: 1_000_000,
|
|
@@ -289,6 +296,39 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
|
|
|
289
296
|
nativeAlias: true,
|
|
290
297
|
capabilities: STANDARD_REASONING,
|
|
291
298
|
reasoning: CLAUDE_REASONING,
|
|
299
|
+
notes: 'Claude Code alias for claude-sonnet-5-5.',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: 'claude-haiku-5-5',
|
|
303
|
+
label: 'Haiku 5.5',
|
|
304
|
+
tier: 'fast',
|
|
305
|
+
costTier: 'low',
|
|
306
|
+
contextWindow: 1_000_000,
|
|
307
|
+
runtimeKind: 'native-harness',
|
|
308
|
+
capabilities: FAST_REASONING,
|
|
309
|
+
reasoning: { ...CLAUDE_REASONING, efforts: ['low', 'medium', 'high'] },
|
|
310
|
+
notes: "Foundry's decision model; the CLI reports this id as the served model.",
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'claude-opus-5-5',
|
|
314
|
+
label: 'Opus 5.5',
|
|
315
|
+
tier: 'powerful',
|
|
316
|
+
costTier: 'high',
|
|
317
|
+
contextWindow: 1_000_000,
|
|
318
|
+
runtimeKind: 'native-harness',
|
|
319
|
+
capabilities: POWERFUL_REASONING,
|
|
320
|
+
reasoning: CLAUDE_REASONING,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: 'claude-fable-5-1',
|
|
324
|
+
label: 'Fable 5.1',
|
|
325
|
+
tier: 'powerful',
|
|
326
|
+
costTier: 'high',
|
|
327
|
+
contextWindow: 1_000_000,
|
|
328
|
+
maxOutputTokens: 128_000,
|
|
329
|
+
runtimeKind: 'native-harness',
|
|
330
|
+
capabilities: POWERFUL_REASONING,
|
|
331
|
+
reasoning: CLAUDE_REASONING,
|
|
292
332
|
},
|
|
293
333
|
{
|
|
294
334
|
id: 'haiku',
|
|
@@ -325,6 +365,17 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
|
|
|
325
365
|
reasoning: CLAUDE_REASONING,
|
|
326
366
|
notes: 'Thinking is always on; steer with effort.',
|
|
327
367
|
},
|
|
368
|
+
{
|
|
369
|
+
id: 'claude-opus-5-5',
|
|
370
|
+
label: 'Claude Opus 5.5',
|
|
371
|
+
tier: 'powerful',
|
|
372
|
+
costTier: 'high',
|
|
373
|
+
contextWindow: 1_000_000,
|
|
374
|
+
maxOutputTokens: 128_000,
|
|
375
|
+
runtimeKind: 'api',
|
|
376
|
+
capabilities: POWERFUL_REASONING,
|
|
377
|
+
reasoning: CLAUDE_REASONING,
|
|
378
|
+
},
|
|
328
379
|
{
|
|
329
380
|
id: 'claude-opus-5',
|
|
330
381
|
label: 'Claude Opus 5',
|