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.
@@ -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
- let response;
250
-
251
- try {
252
- const url = resolveServiceUrl(baseUrl, '/cli/contract');
253
- response = await fetch(url, {
254
- headers: {
255
- Authorization: `Bearer ${token}`
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
 
@@ -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
- return String(value ?? '').trim();
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) {