incremnt 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -1
- package/package.json +2 -1
- package/src/ask-answer-verifier.js +857 -0
- package/src/ask-coach.js +2634 -0
- package/src/ask-replay.js +358 -0
- package/src/auth.js +169 -15
- package/src/coach-facts.js +14 -1
- package/src/contract.js +160 -3
- package/src/format.js +68 -2
- package/src/lib.js +205 -17
- package/src/mcp.js +88 -24
- package/src/openrouter.js +261 -33
- package/src/plan-changeset.js +132 -0
- package/src/plan-comparison.js +245 -0
- package/src/program-draft.js +230 -0
- package/src/prompt-changelog.js +184 -0
- package/src/promptfoo-evals.js +10 -4
- package/src/promptfoo-langfuse-scores.js +55 -0
- package/src/queries.js +1442 -786
- package/src/remote.js +465 -12
- package/src/score-context.js +14 -7
- package/src/score-prelude.js +113 -0
- package/src/service-url.js +9 -0
- package/src/summary-evals.js +1192 -44
- package/src/sync-service.js +1383 -367
- package/src/transport.js +119 -3
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AI_PROMPT_VERSIONS,
|
|
6
|
+
generateAskAnswerAgentic
|
|
7
|
+
} from './openrouter.js';
|
|
8
|
+
import { verifyAskAnswer } from './ask-answer-verifier.js';
|
|
9
|
+
import {
|
|
10
|
+
buildSummaryEvalContext,
|
|
11
|
+
evaluateSummaryOutputFromSnapshot,
|
|
12
|
+
loadSummaryEvalCases,
|
|
13
|
+
loadSummaryEvalSnapshot
|
|
14
|
+
} from './summary-evals.js';
|
|
15
|
+
|
|
16
|
+
export const ASK_REPLAY_VERSION = 'ask-replay-v0.1';
|
|
17
|
+
|
|
18
|
+
function asArray(value) {
|
|
19
|
+
return Array.isArray(value) ? value : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function patternMatches(text, pattern) {
|
|
23
|
+
return new RegExp(pattern, 'i').test(String(text ?? ''));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function includesAll(actual, required) {
|
|
27
|
+
const actualSet = new Set(asArray(actual));
|
|
28
|
+
return asArray(required).every((item) => actualSet.has(item));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sentenceCount(text) {
|
|
32
|
+
const matches = String(text ?? '').match(/[.!?]+(?:\s|$)/g);
|
|
33
|
+
return matches ? matches.length : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function wordCount(text) {
|
|
37
|
+
const matches = String(text ?? '').trim().match(/\S+/g);
|
|
38
|
+
return matches ? matches.length : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function invocationNames(invocations) {
|
|
42
|
+
return asArray(invocations).map((invocation) => invocation?.name).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function askReplayChecks(testCase, context, output, generationMetadata = {}) {
|
|
46
|
+
const assertions = testCase.replayAssertions ?? {};
|
|
47
|
+
const metadata = context?.routedMetadata ?? {};
|
|
48
|
+
const evidencePlan = metadata.evidencePlan ?? {};
|
|
49
|
+
const checks = [];
|
|
50
|
+
|
|
51
|
+
if (assertions.route) {
|
|
52
|
+
checks.push({
|
|
53
|
+
key: 'ask_replay_route',
|
|
54
|
+
passed: evidencePlan.route === assertions.route,
|
|
55
|
+
reason: evidencePlan.route === assertions.route
|
|
56
|
+
? `Route is ${assertions.route}.`
|
|
57
|
+
: `Expected route ${assertions.route}, got ${evidencePlan.route ?? 'null'}.`
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (assertions.effectiveRoute) {
|
|
62
|
+
checks.push({
|
|
63
|
+
key: 'ask_replay_effective_route',
|
|
64
|
+
passed: evidencePlan.effectiveRoute === assertions.effectiveRoute,
|
|
65
|
+
reason: evidencePlan.effectiveRoute === assertions.effectiveRoute
|
|
66
|
+
? `Effective route is ${assertions.effectiveRoute}.`
|
|
67
|
+
: `Expected effective route ${assertions.effectiveRoute}, got ${evidencePlan.effectiveRoute ?? 'null'}.`
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (assertions.requiredTools) {
|
|
72
|
+
const passed = includesAll(evidencePlan.requiredTools, assertions.requiredTools);
|
|
73
|
+
checks.push({
|
|
74
|
+
key: 'ask_replay_required_tools',
|
|
75
|
+
passed,
|
|
76
|
+
reason: passed
|
|
77
|
+
? 'Required tools include all replay requirements.'
|
|
78
|
+
: `Expected required tools to include ${assertions.requiredTools.join(', ')}; got ${asArray(evidencePlan.requiredTools).join(', ')}.`
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (assertions.routedTools) {
|
|
83
|
+
const passed = includesAll(evidencePlan.executedTools, assertions.routedTools);
|
|
84
|
+
checks.push({
|
|
85
|
+
key: 'ask_replay_routed_tools',
|
|
86
|
+
passed,
|
|
87
|
+
reason: passed
|
|
88
|
+
? 'Routed tools include all replay requirements.'
|
|
89
|
+
: `Expected routed tools to include ${assertions.routedTools.join(', ')}; got ${asArray(evidencePlan.executedTools).join(', ')}.`
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (assertions.executedTools) {
|
|
94
|
+
const actual = generationMetadata.mode === 'live'
|
|
95
|
+
? invocationNames(generationMetadata.toolInvocations)
|
|
96
|
+
: evidencePlan.executedTools;
|
|
97
|
+
const passed = includesAll(actual, assertions.executedTools);
|
|
98
|
+
checks.push({
|
|
99
|
+
key: generationMetadata.mode === 'live' ? 'ask_replay_agentic_tools' : 'ask_replay_executed_tools',
|
|
100
|
+
passed,
|
|
101
|
+
reason: passed
|
|
102
|
+
? 'Executed tools include all replay requirements.'
|
|
103
|
+
: `Expected executed tools to include ${assertions.executedTools.join(', ')}; got ${asArray(actual).join(', ')}.`
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const assertion of asArray(assertions.requiredContextPatterns)) {
|
|
108
|
+
const passed = patternMatches(context?.trainingData, assertion.pattern);
|
|
109
|
+
checks.push({
|
|
110
|
+
key: `ask_replay_context_${assertion.key ?? 'pattern'}`,
|
|
111
|
+
passed,
|
|
112
|
+
reason: passed
|
|
113
|
+
? `Context matched ${assertion.key ?? assertion.pattern}.`
|
|
114
|
+
: `Context did not match ${assertion.key ?? assertion.pattern}.`
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const assertion of asArray(assertions.requiredAnswerPatterns)) {
|
|
119
|
+
const passed = patternMatches(output, assertion.pattern);
|
|
120
|
+
checks.push({
|
|
121
|
+
key: `ask_replay_answer_${assertion.key ?? 'pattern'}`,
|
|
122
|
+
passed,
|
|
123
|
+
reason: passed
|
|
124
|
+
? `Answer matched ${assertion.key ?? assertion.pattern}.`
|
|
125
|
+
: `Answer did not match ${assertion.key ?? assertion.pattern}.`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const assertion of asArray(assertions.forbiddenAnswerPatterns)) {
|
|
130
|
+
const passed = !patternMatches(output, assertion.pattern);
|
|
131
|
+
checks.push({
|
|
132
|
+
key: `ask_replay_forbid_${assertion.key ?? 'pattern'}`,
|
|
133
|
+
passed,
|
|
134
|
+
reason: passed
|
|
135
|
+
? `Answer avoided ${assertion.key ?? assertion.pattern}.`
|
|
136
|
+
: `Answer matched forbidden ${assertion.key ?? assertion.pattern}.`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Number.isFinite(Number(assertions.maxAnswerSentences))) {
|
|
141
|
+
const actual = sentenceCount(output);
|
|
142
|
+
const max = Number(assertions.maxAnswerSentences);
|
|
143
|
+
checks.push({
|
|
144
|
+
key: 'ask_replay_answer_sentence_limit',
|
|
145
|
+
passed: actual <= max,
|
|
146
|
+
reason: actual <= max
|
|
147
|
+
? `Answer is ${actual}/${max} sentences.`
|
|
148
|
+
: `Expected answer to be at most ${max} sentences, got ${actual}.`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (Number.isFinite(Number(assertions.maxAnswerWords))) {
|
|
153
|
+
const actual = wordCount(output);
|
|
154
|
+
const max = Number(assertions.maxAnswerWords);
|
|
155
|
+
checks.push({
|
|
156
|
+
key: 'ask_replay_answer_word_limit',
|
|
157
|
+
passed: actual <= max,
|
|
158
|
+
reason: actual <= max
|
|
159
|
+
? `Answer is ${actual}/${max} words.`
|
|
160
|
+
: `Expected answer to be at most ${max} words, got ${actual}.`
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (assertions.promptVersion) {
|
|
165
|
+
const expected = assertions.promptVersion === 'currentAskAgentic'
|
|
166
|
+
? AI_PROMPT_VERSIONS.askAgentic
|
|
167
|
+
: assertions.promptVersion;
|
|
168
|
+
const actual = generationMetadata.promptVersion ?? AI_PROMPT_VERSIONS.askAgentic;
|
|
169
|
+
checks.push({
|
|
170
|
+
key: 'ask_replay_prompt_version',
|
|
171
|
+
passed: actual === expected,
|
|
172
|
+
reason: actual === expected
|
|
173
|
+
? `Prompt version is ${expected}.`
|
|
174
|
+
: `Expected prompt version ${expected}, got ${actual ?? 'null'}.`
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return checks;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function summaryChecksForAskReplay(summaryEval, { live = false, testCase }) {
|
|
182
|
+
if (!live) return summaryEval.checks;
|
|
183
|
+
const excluded = new Set(testCase.replayAssertions?.liveExcludedSummaryChecks ?? [
|
|
184
|
+
'shape',
|
|
185
|
+
'required_mentions',
|
|
186
|
+
'ask_claims',
|
|
187
|
+
'ask_tool_provenance'
|
|
188
|
+
]);
|
|
189
|
+
return summaryEval.checks.filter((check) => !excluded.has(check.key));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function loadAskReplayCases({ caseSet = 'synthetic', caseIds = [] } = {}) {
|
|
193
|
+
const requested = new Set(caseIds);
|
|
194
|
+
return (await loadSummaryEvalCases(caseSet))
|
|
195
|
+
.filter((testCase) => testCase.surface === 'ask')
|
|
196
|
+
.filter((testCase) => testCase.replayAssertions || testCase.askReplay === true)
|
|
197
|
+
.filter((testCase) => requested.size === 0 || requested.has(testCase.id));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function runAskReplayCase(testCase, {
|
|
201
|
+
live = false,
|
|
202
|
+
apiKey = process.env.OPENROUTER_API_KEY,
|
|
203
|
+
model = null,
|
|
204
|
+
generateAskAnswerAgenticImpl = generateAskAnswerAgentic
|
|
205
|
+
} = {}) {
|
|
206
|
+
const snapshot = await loadSummaryEvalSnapshot(testCase);
|
|
207
|
+
const context = buildSummaryEvalContext(snapshot, testCase);
|
|
208
|
+
if (!context) throw new Error(`Ask replay case ${testCase.id} produced no context.`);
|
|
209
|
+
|
|
210
|
+
let output = testCase.output;
|
|
211
|
+
let generationMetadata = {
|
|
212
|
+
mode: 'stored',
|
|
213
|
+
promptVersion: AI_PROMPT_VERSIONS.askAgentic
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (live) {
|
|
217
|
+
if (!apiKey) throw new Error('OPENROUTER_API_KEY is required for live Ask replay.');
|
|
218
|
+
const result = await generateAskAnswerAgenticImpl(context.trainingData, context.question ?? testCase.question, {
|
|
219
|
+
apiKey,
|
|
220
|
+
model: model ?? context.model,
|
|
221
|
+
history: context.history ?? [],
|
|
222
|
+
tone: context.tone,
|
|
223
|
+
snapshot,
|
|
224
|
+
routingMetadata: context.routedMetadata ?? undefined
|
|
225
|
+
});
|
|
226
|
+
output = result.text;
|
|
227
|
+
generationMetadata = {
|
|
228
|
+
mode: 'live',
|
|
229
|
+
model: result.model,
|
|
230
|
+
durationMs: result.durationMs,
|
|
231
|
+
promptVersion: result.promptVersion,
|
|
232
|
+
promptSurface: result.promptSurface,
|
|
233
|
+
toolInvocations: result.toolInvocations ?? [],
|
|
234
|
+
langfuseTraceId: result.langfuseTraceId,
|
|
235
|
+
langfuseObservationId: result.langfuseObservationId
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const summaryEval = evaluateSummaryOutputFromSnapshot(testCase, snapshot, output);
|
|
240
|
+
const summaryChecks = summaryChecksForAskReplay(summaryEval, { live, testCase });
|
|
241
|
+
const replayChecks = askReplayChecks(testCase, context, output, generationMetadata);
|
|
242
|
+
const answerVerification = verifyAskAnswer({
|
|
243
|
+
answer: output,
|
|
244
|
+
snapshot,
|
|
245
|
+
routingMetadata: context.routedMetadata ?? {},
|
|
246
|
+
today: testCase.context?.today ?? new Date(),
|
|
247
|
+
exclude: testCase.exclude ?? [],
|
|
248
|
+
strictMentionProvenance: true
|
|
249
|
+
});
|
|
250
|
+
const verificationChecks = answerVerification.checks.map((check) => ({
|
|
251
|
+
...check,
|
|
252
|
+
key: `ask_runtime_${check.key}`
|
|
253
|
+
}));
|
|
254
|
+
const checks = [...summaryChecks, ...replayChecks, ...verificationChecks];
|
|
255
|
+
const failedChecks = checks.filter((check) => !check.passed);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
id: testCase.id,
|
|
259
|
+
name: testCase.name,
|
|
260
|
+
replayVersion: ASK_REPLAY_VERSION,
|
|
261
|
+
caseSet: testCase.caseSet,
|
|
262
|
+
snapshotFile: testCase.snapshotFile,
|
|
263
|
+
question: context.question ?? testCase.question,
|
|
264
|
+
mode: generationMetadata.mode,
|
|
265
|
+
route: context.routedMetadata?.evidencePlan?.route ?? null,
|
|
266
|
+
effectiveRoute: context.routedMetadata?.evidencePlan?.effectiveRoute ?? null,
|
|
267
|
+
requiredTools: context.routedMetadata?.evidencePlan?.requiredTools ?? [],
|
|
268
|
+
routedTools: context.routedMetadata?.evidencePlan?.executedTools ?? [],
|
|
269
|
+
agenticTools: invocationNames(generationMetadata.toolInvocations),
|
|
270
|
+
executedTools: context.routedMetadata?.evidencePlan?.executedTools ?? [],
|
|
271
|
+
answerVerification,
|
|
272
|
+
output,
|
|
273
|
+
generationMetadata,
|
|
274
|
+
passed: failedChecks.length === 0,
|
|
275
|
+
checks,
|
|
276
|
+
failedChecks
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function runAskReplayCases(cases, options = {}) {
|
|
281
|
+
const results = [];
|
|
282
|
+
for (const testCase of cases) {
|
|
283
|
+
results.push(await runAskReplayCase(testCase, options));
|
|
284
|
+
}
|
|
285
|
+
const routes = Object.fromEntries([...new Set(results.map((result) => result.effectiveRoute ?? result.route ?? 'unknown'))]
|
|
286
|
+
.sort()
|
|
287
|
+
.map((route) => {
|
|
288
|
+
const routeResults = results.filter((result) => (result.effectiveRoute ?? result.route ?? 'unknown') === route);
|
|
289
|
+
return [route, {
|
|
290
|
+
total: routeResults.length,
|
|
291
|
+
passed: routeResults.filter((result) => result.passed).length,
|
|
292
|
+
failed: routeResults.filter((result) => !result.passed).length
|
|
293
|
+
}];
|
|
294
|
+
}));
|
|
295
|
+
return {
|
|
296
|
+
replayVersion: ASK_REPLAY_VERSION,
|
|
297
|
+
generatedAt: new Date().toISOString(),
|
|
298
|
+
mode: options.live ? 'live' : 'stored',
|
|
299
|
+
summary: {
|
|
300
|
+
total: results.length,
|
|
301
|
+
passed: results.filter((result) => result.passed).length,
|
|
302
|
+
failed: results.filter((result) => !result.passed).length,
|
|
303
|
+
routes
|
|
304
|
+
},
|
|
305
|
+
results
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function formatAskReplayMarkdown(report) {
|
|
310
|
+
const lines = [
|
|
311
|
+
`# Ask Replay ${report.mode}`,
|
|
312
|
+
'',
|
|
313
|
+
`Generated: ${report.generatedAt}`,
|
|
314
|
+
`Harness: ${report.replayVersion}`,
|
|
315
|
+
`Result: ${report.summary.passed}/${report.summary.total} passed`,
|
|
316
|
+
''
|
|
317
|
+
];
|
|
318
|
+
const routeEntries = Object.entries(report.summary.routes ?? {});
|
|
319
|
+
if (routeEntries.length > 0) {
|
|
320
|
+
lines.push('Routes:');
|
|
321
|
+
for (const [route, stats] of routeEntries) {
|
|
322
|
+
lines.push(`- ${route}: ${stats.passed}/${stats.total} passed`);
|
|
323
|
+
}
|
|
324
|
+
lines.push('');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const result of report.results) {
|
|
328
|
+
lines.push(`## ${result.passed ? 'PASS' : 'FAIL'} ${result.id}`);
|
|
329
|
+
lines.push(`Question: ${result.question}`);
|
|
330
|
+
lines.push(`Route: ${result.route}${result.effectiveRoute && result.effectiveRoute !== result.route ? ` -> ${result.effectiveRoute}` : ''}`);
|
|
331
|
+
lines.push(`Routed tools: ${result.routedTools.join(', ') || 'none'}`);
|
|
332
|
+
lines.push(`Verifier: ${result.answerVerification?.status ?? 'unknown'} (${result.answerVerification?.blockingFailureCount ?? 0} blocking, ${result.answerVerification?.advisoryFailureCount ?? 0} advisory)`);
|
|
333
|
+
if (result.mode === 'live') {
|
|
334
|
+
lines.push(`Agentic tools: ${result.agenticTools.join(', ') || 'none'}`);
|
|
335
|
+
}
|
|
336
|
+
lines.push('');
|
|
337
|
+
lines.push(result.output);
|
|
338
|
+
lines.push('');
|
|
339
|
+
if (result.failedChecks.length > 0) {
|
|
340
|
+
lines.push('Failures:');
|
|
341
|
+
for (const check of result.failedChecks) {
|
|
342
|
+
lines.push(`- ${check.key}: ${check.reason}`);
|
|
343
|
+
}
|
|
344
|
+
lines.push('');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return `${lines.join('\n')}\n`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function writeAskReplayReport(report, outDir) {
|
|
352
|
+
await mkdir(outDir, { recursive: true });
|
|
353
|
+
const jsonPath = path.join(outDir, 'ask-replay.json');
|
|
354
|
+
const markdownPath = path.join(outDir, 'ask-replay.md');
|
|
355
|
+
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
356
|
+
await writeFile(markdownPath, formatAskReplayMarkdown(report));
|
|
357
|
+
return { jsonPath, markdownPath };
|
|
358
|
+
}
|
package/src/auth.js
CHANGED
|
@@ -4,6 +4,8 @@ import { writeSessionState } from './state.js';
|
|
|
4
4
|
import { readSnapshot } from './local.js';
|
|
5
5
|
import { resolveServiceUrl } from './service-url.js';
|
|
6
6
|
|
|
7
|
+
const AGENT_TOKEN_MANAGEMENT_TIMEOUT_MS = 15_000;
|
|
8
|
+
|
|
7
9
|
export async function importSessionFile(sessionFilePath) {
|
|
8
10
|
const raw = await fs.readFile(sessionFilePath, 'utf8');
|
|
9
11
|
const session = JSON.parse(raw);
|
|
@@ -113,6 +115,34 @@ export async function bootstrapSessionFromRemoteBaseUrlWithEmail(baseUrl, email,
|
|
|
113
115
|
return bootstrapSessionFromRemoteBaseUrl(baseUrl, devLogin.token, devLogin.account);
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
export async function bootstrapSessionFromRemoteBaseUrlWithAgentToken(baseUrl, agentToken, {
|
|
119
|
+
access = null,
|
|
120
|
+
expiresAt = null
|
|
121
|
+
} = {}) {
|
|
122
|
+
const remoteContract = await fetchRemoteContract(baseUrl, agentToken);
|
|
123
|
+
|
|
124
|
+
return writeSessionState({
|
|
125
|
+
version: 1,
|
|
126
|
+
mode: 'remote',
|
|
127
|
+
account: null,
|
|
128
|
+
auth: {
|
|
129
|
+
accessToken: agentToken,
|
|
130
|
+
refreshToken: null,
|
|
131
|
+
expiresAt,
|
|
132
|
+
type: 'agent-token',
|
|
133
|
+
access: access === 'write' ? 'write' : access === 'read' ? 'read' : null
|
|
134
|
+
},
|
|
135
|
+
sync: {
|
|
136
|
+
verifiedAt: new Date().toISOString()
|
|
137
|
+
},
|
|
138
|
+
transport: {
|
|
139
|
+
baseUrl,
|
|
140
|
+
contractVersion: remoteContract.contractVersion,
|
|
141
|
+
capabilities: remoteContract.capabilities ?? null
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
116
146
|
export async function fetchRemoteAuthConfig(baseUrl) {
|
|
117
147
|
let response;
|
|
118
148
|
|
|
@@ -245,21 +275,14 @@ async function issueRemoteSession(baseUrl, token) {
|
|
|
245
275
|
return response.json();
|
|
246
276
|
}
|
|
247
277
|
|
|
248
|
-
async function fetchRemoteContract(baseUrl, token) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
} catch {
|
|
259
|
-
const error = new Error('Unable to reach incremnt sync service.');
|
|
260
|
-
error.code = 'REMOTE_HTTP_ERROR';
|
|
261
|
-
throw error;
|
|
262
|
-
}
|
|
278
|
+
export async function fetchRemoteContract(baseUrl, token) {
|
|
279
|
+
// Timeout-guarded: this gates every env-agent-token command and every refresh,
|
|
280
|
+
// so an unreachable/cold service must fail fast rather than hang the caller.
|
|
281
|
+
const response = await fetchWithAuthTimeout(resolveServiceUrl(baseUrl, '/cli/contract'), {
|
|
282
|
+
headers: {
|
|
283
|
+
Authorization: `Bearer ${token}`
|
|
284
|
+
}
|
|
285
|
+
});
|
|
263
286
|
|
|
264
287
|
if (response.status === 401 || response.status === 403) {
|
|
265
288
|
const error = new Error('Authentication failed. Check your token and run incremnt login again.');
|
|
@@ -283,6 +306,137 @@ async function fetchRemoteContract(baseUrl, token) {
|
|
|
283
306
|
return payload;
|
|
284
307
|
}
|
|
285
308
|
|
|
309
|
+
export async function refreshRemoteSession(baseUrl, token, previousSession = null) {
|
|
310
|
+
// Timeout-guarded: createTransport awaits this on every expired session, so a
|
|
311
|
+
// hung /auth/refresh must not wedge the CLI or the MCP server.
|
|
312
|
+
const response = await fetchWithAuthTimeout(resolveServiceUrl(baseUrl, '/auth/refresh'), {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: {
|
|
315
|
+
Authorization: `Bearer ${token}`
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (response.status === 401 || response.status === 403) {
|
|
320
|
+
const error = new Error('Session expired. Run incremnt login again.');
|
|
321
|
+
error.code = 'SESSION_EXPIRED';
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
327
|
+
const error = new Error(payload.error ?? 'Unable to refresh incremnt session.');
|
|
328
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const payload = await response.json().catch(() => null);
|
|
333
|
+
if (!payload?.session?.accessToken) {
|
|
334
|
+
// A malformed 200 (partial deploy, proxy returning HTML-as-JSON) must surface
|
|
335
|
+
// a labelled error, not a raw TypeError on the deref below.
|
|
336
|
+
const error = new Error('Unable to refresh incremnt session: malformed response from sync service.');
|
|
337
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
const remoteContract = await fetchRemoteContract(baseUrl, payload.session.accessToken);
|
|
341
|
+
|
|
342
|
+
return writeSessionState({
|
|
343
|
+
version: previousSession?.version ?? 1,
|
|
344
|
+
mode: 'remote',
|
|
345
|
+
account: payload.account ?? previousSession?.account ?? null,
|
|
346
|
+
auth: {
|
|
347
|
+
accessToken: payload.session.accessToken,
|
|
348
|
+
refreshToken: previousSession?.auth?.refreshToken ?? null,
|
|
349
|
+
expiresAt: payload.session.expiresAt
|
|
350
|
+
},
|
|
351
|
+
sync: {
|
|
352
|
+
...(previousSession?.sync ?? {}),
|
|
353
|
+
verifiedAt: new Date().toISOString(),
|
|
354
|
+
refreshedAt: new Date().toISOString()
|
|
355
|
+
},
|
|
356
|
+
transport: {
|
|
357
|
+
...(previousSession?.transport ?? {}),
|
|
358
|
+
baseUrl,
|
|
359
|
+
contractVersion: remoteContract.contractVersion,
|
|
360
|
+
capabilities: remoteContract.capabilities ?? null
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function createRemoteAgentToken(baseUrl, bearerToken, {
|
|
366
|
+
name,
|
|
367
|
+
access = 'read',
|
|
368
|
+
expiresDays = 90
|
|
369
|
+
} = {}) {
|
|
370
|
+
const response = await fetchWithAuthTimeout(resolveServiceUrl(baseUrl, '/cli/agent-tokens'), {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: {
|
|
373
|
+
'content-type': 'application/json',
|
|
374
|
+
Authorization: `Bearer ${bearerToken}`
|
|
375
|
+
},
|
|
376
|
+
body: JSON.stringify({ name, access, expiresDays })
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return parseAgentTokenResponse(response, 'Unable to create agent token.');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function listRemoteAgentTokens(baseUrl, bearerToken) {
|
|
383
|
+
const response = await fetchWithAuthTimeout(resolveServiceUrl(baseUrl, '/cli/agent-tokens'), {
|
|
384
|
+
headers: {
|
|
385
|
+
Authorization: `Bearer ${bearerToken}`
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return parseAgentTokenResponse(response, 'Unable to list agent tokens.');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function revokeRemoteAgentToken(baseUrl, bearerToken, id) {
|
|
393
|
+
const response = await fetchWithAuthTimeout(resolveServiceUrl(baseUrl, `/cli/agent-tokens/${encodeURIComponent(id)}`), {
|
|
394
|
+
method: 'DELETE',
|
|
395
|
+
headers: {
|
|
396
|
+
Authorization: `Bearer ${bearerToken}`
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return parseAgentTokenResponse(response, 'Unable to revoke agent token.');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function fetchWithAuthTimeout(url, init = {}, timeoutMs = AGENT_TOKEN_MANAGEMENT_TIMEOUT_MS) {
|
|
404
|
+
const controller = new AbortController();
|
|
405
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
406
|
+
try {
|
|
407
|
+
return await fetch(url, {
|
|
408
|
+
...init,
|
|
409
|
+
signal: controller.signal
|
|
410
|
+
});
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const message = error?.name === 'AbortError'
|
|
413
|
+
? `Timed out reaching incremnt sync service after ${Math.round(timeoutMs / 1000)} seconds.`
|
|
414
|
+
: 'Unable to reach incremnt sync service.';
|
|
415
|
+
const wrapped = new Error(message);
|
|
416
|
+
wrapped.code = 'REMOTE_HTTP_ERROR';
|
|
417
|
+
throw wrapped;
|
|
418
|
+
} finally {
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function parseAgentTokenResponse(response, fallbackMessage) {
|
|
424
|
+
if (response.status === 401 || response.status === 403) {
|
|
425
|
+
const error = new Error('Agent token management requires a human login. Run incremnt login again.');
|
|
426
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
432
|
+
const error = new Error(payload.error ?? fallbackMessage);
|
|
433
|
+
error.code = response.status === 404 ? 'REMOTE_NOT_FOUND' : 'REMOTE_HTTP_ERROR';
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return response.json();
|
|
438
|
+
}
|
|
439
|
+
|
|
286
440
|
async function issueDevLogin(baseUrl, email, userId) {
|
|
287
441
|
let response;
|
|
288
442
|
|
package/src/coach-facts.js
CHANGED
|
@@ -26,8 +26,21 @@ export function normalizeCoachFactText(value) {
|
|
|
26
26
|
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Recover near-miss kind labels the extraction model commonly emits (casing,
|
|
30
|
+
// plurals, the bare 'goal') instead of dropping the fact wholesale — a dropped
|
|
31
|
+
// 'Injury'/'goal' loses real user-stated context from the coach's memory.
|
|
32
|
+
const COACH_FACT_KIND_ALIASES = new Map([
|
|
33
|
+
['injuries', 'injury'],
|
|
34
|
+
['goal', 'goal_signal'],
|
|
35
|
+
['goals', 'goal_signal'],
|
|
36
|
+
['preferences', 'preference'],
|
|
37
|
+
['constraints', 'constraint'],
|
|
38
|
+
['tones', 'tone']
|
|
39
|
+
]);
|
|
40
|
+
|
|
29
41
|
export function normalizeCoachFactKind(value) {
|
|
30
|
-
|
|
42
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
43
|
+
return COACH_FACT_KIND_ALIASES.get(normalized) ?? normalized;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
export function isCoachFactKind(value) {
|