incremnt 0.8.7 → 0.8.8

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.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +2 -1
  3. package/package.json +4 -1
  4. package/src/ask-answer-verifier.js +23 -2
  5. package/src/ask-coach/contexts.js +1472 -0
  6. package/src/ask-coach/evidence-plan.js +342 -0
  7. package/src/ask-coach/observations.js +8 -0
  8. package/src/ask-coach/orchestrator.js +1641 -0
  9. package/src/ask-coach/renderers.js +4 -0
  10. package/src/ask-coach/routing.js +610 -0
  11. package/src/ask-coach/structured-response.js +5 -0
  12. package/src/ask-coach-routing.js +4 -0
  13. package/src/ask-coach.js +26 -3506
  14. package/src/ask-replay.js +70 -4
  15. package/src/ask-starter-prompts.js +206 -0
  16. package/src/auth.js +26 -0
  17. package/src/coach-advice.js +32 -0
  18. package/src/coach-facts.js +214 -0
  19. package/src/coach-prompt-layers.js +26 -30
  20. package/src/contract.js +27 -7
  21. package/src/exercise-aliases.js +6 -0
  22. package/src/format.js +17 -0
  23. package/src/index.js +1 -1
  24. package/src/lib.js +41 -36
  25. package/src/mcp.js +1 -1
  26. package/src/openrouter.js +279 -161
  27. package/src/plan-changeset.js +14 -8
  28. package/src/program-draft.js +97 -1
  29. package/src/prompt-changelog.js +16 -0
  30. package/src/prompt-security.js +34 -3
  31. package/src/promptfoo-evals.js +2 -0
  32. package/src/promptfoo-langfuse-scores.js +10 -0
  33. package/src/prompts/ask.js +22 -0
  34. package/src/prompts/checkpoint.js +14 -0
  35. package/src/prompts/coach-facts.js +24 -0
  36. package/src/prompts/cycle.js +23 -0
  37. package/src/prompts/starter-graph.js +7 -0
  38. package/src/prompts/vitals.js +9 -0
  39. package/src/prompts/weekly-checkin.js +20 -0
  40. package/src/prompts/workout.js +47 -0
  41. package/src/queries/coach-observations.js +8 -0
  42. package/src/queries/coach-read-tools.js +6 -0
  43. package/src/queries/commands.js +3 -0
  44. package/src/queries/common.js +9 -0
  45. package/src/queries/core.js +6354 -0
  46. package/src/queries/exercise-identity.js +6 -0
  47. package/src/queries/health.js +12 -0
  48. package/src/queries/programs.js +17 -0
  49. package/src/queries/records-progress.js +10 -0
  50. package/src/queries/sessions.js +12 -0
  51. package/src/queries/weekly.js +5 -0
  52. package/src/queries.js +10 -6283
  53. package/src/remote.js +51 -0
  54. package/src/score-context.js +58 -3
  55. package/src/summary-evals.js +201 -45
  56. package/src/sync-service.js +1118 -104
  57. package/src/training-language-public-terms.json +97 -0
  58. package/src/training-language.js +94 -0
  59. package/src/transport.js +7 -1
  60. package/src/validate.js +11 -1
package/src/ask-replay.js CHANGED
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
 
4
4
  import {
5
5
  AI_PROMPT_VERSIONS,
6
- generateAskAnswerAgentic
6
+ generateAskAnswerAgentic,
7
+ generateAskAnswerStarterGraph
7
8
  } from './openrouter.js';
8
9
  import { verifyAskAnswer } from './ask-answer-verifier.js';
9
10
  import {
@@ -42,6 +43,22 @@ function invocationNames(invocations) {
42
43
  return asArray(invocations).map((invocation) => invocation?.name).filter(Boolean);
43
44
  }
44
45
 
46
+ function graphToolNames(generationMetadata = {}, testCase = null) {
47
+ return asArray(
48
+ generationMetadata.graph?.toolNames
49
+ ?? generationMetadata.graphToolNames
50
+ ?? testCase?.expectedStarterGraph?.toolNames
51
+ );
52
+ }
53
+
54
+ function graphNodeNames(generationMetadata = {}, testCase = null) {
55
+ return asArray(
56
+ generationMetadata.graph?.nodes
57
+ ?? generationMetadata.graphNodes
58
+ ?? testCase?.expectedStarterGraph?.nodes
59
+ );
60
+ }
61
+
45
62
  function askReplayChecks(testCase, context, output, generationMetadata = {}) {
46
63
  const assertions = testCase.replayAssertions ?? {};
47
64
  const metadata = context?.routedMetadata ?? {};
@@ -104,6 +121,30 @@ function askReplayChecks(testCase, context, output, generationMetadata = {}) {
104
121
  });
105
122
  }
106
123
 
124
+ if (assertions.graphTools) {
125
+ const actual = graphToolNames(generationMetadata, generationMetadata.mode === 'live' ? null : testCase);
126
+ const passed = includesAll(actual, assertions.graphTools);
127
+ checks.push({
128
+ key: 'ask_replay_graph_tools',
129
+ passed,
130
+ reason: passed
131
+ ? 'Starter graph tools include all replay requirements.'
132
+ : `Expected graph tools to include ${assertions.graphTools.join(', ')}; got ${actual.join(', ')}.`
133
+ });
134
+ }
135
+
136
+ if (assertions.graphNodes) {
137
+ const actual = graphNodeNames(generationMetadata, generationMetadata.mode === 'live' ? null : testCase);
138
+ const passed = includesAll(actual, assertions.graphNodes);
139
+ checks.push({
140
+ key: 'ask_replay_graph_nodes',
141
+ passed,
142
+ reason: passed
143
+ ? 'Starter graph nodes include all replay requirements.'
144
+ : `Expected graph nodes to include ${assertions.graphNodes.join(', ')}; got ${actual.join(', ')}.`
145
+ });
146
+ }
147
+
107
148
  for (const assertion of asArray(assertions.requiredContextPatterns)) {
108
149
  const passed = patternMatches(context?.trainingData, assertion.pattern);
109
150
  checks.push({
@@ -164,6 +205,8 @@ function askReplayChecks(testCase, context, output, generationMetadata = {}) {
164
205
  if (assertions.promptVersion) {
165
206
  const expected = assertions.promptVersion === 'currentAskAgentic'
166
207
  ? AI_PROMPT_VERSIONS.askAgentic
208
+ : assertions.promptVersion === 'currentAskStarterGraph'
209
+ ? AI_PROMPT_VERSIONS.askStarterGraph
167
210
  : assertions.promptVersion;
168
211
  const actual = generationMetadata.promptVersion ?? AI_PROMPT_VERSIONS.askAgentic;
169
212
  checks.push({
@@ -201,7 +244,8 @@ export async function runAskReplayCase(testCase, {
201
244
  live = false,
202
245
  apiKey = process.env.OPENROUTER_API_KEY,
203
246
  model = null,
204
- generateAskAnswerAgenticImpl = generateAskAnswerAgentic
247
+ generateAskAnswerAgenticImpl = generateAskAnswerAgentic,
248
+ generateAskAnswerStarterGraphImpl = generateAskAnswerStarterGraph
205
249
  } = {}) {
206
250
  const snapshot = await loadSummaryEvalSnapshot(testCase);
207
251
  const context = buildSummaryEvalContext(snapshot, testCase);
@@ -210,12 +254,27 @@ export async function runAskReplayCase(testCase, {
210
254
  let output = testCase.output;
211
255
  let generationMetadata = {
212
256
  mode: 'stored',
213
- promptVersion: AI_PROMPT_VERSIONS.askAgentic
257
+ promptVersion: testCase.starterPrompt?.family
258
+ ? AI_PROMPT_VERSIONS.askStarterGraph
259
+ : AI_PROMPT_VERSIONS.askAgentic
214
260
  };
215
261
 
216
262
  if (live) {
217
263
  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, {
264
+ const question = context.question ?? testCase.question;
265
+ const starterPrompt = testCase.starterPrompt;
266
+ const result = starterPrompt?.family
267
+ ? await generateAskAnswerStarterGraphImpl(context.trainingData, question, {
268
+ apiKey,
269
+ model: model ?? context.model,
270
+ history: context.history ?? [],
271
+ tone: context.tone,
272
+ snapshot,
273
+ starterPrompt,
274
+ routingMetadata: context.routedMetadata ?? undefined,
275
+ exclude: testCase.exclude ?? []
276
+ })
277
+ : await generateAskAnswerAgenticImpl(context.trainingData, question, {
219
278
  apiKey,
220
279
  model: model ?? context.model,
221
280
  history: context.history ?? [],
@@ -231,6 +290,7 @@ export async function runAskReplayCase(testCase, {
231
290
  promptVersion: result.promptVersion,
232
291
  promptSurface: result.promptSurface,
233
292
  toolInvocations: result.toolInvocations ?? [],
293
+ graph: result.graph,
234
294
  langfuseTraceId: result.langfuseTraceId,
235
295
  langfuseObservationId: result.langfuseObservationId
236
296
  };
@@ -267,6 +327,9 @@ export async function runAskReplayCase(testCase, {
267
327
  requiredTools: context.routedMetadata?.evidencePlan?.requiredTools ?? [],
268
328
  routedTools: context.routedMetadata?.evidencePlan?.executedTools ?? [],
269
329
  agenticTools: invocationNames(generationMetadata.toolInvocations),
330
+ graphTools: graphToolNames(generationMetadata, testCase),
331
+ graphNodes: graphNodeNames(generationMetadata, testCase),
332
+ starterPrompt: testCase.starterPrompt ?? null,
270
333
  executedTools: context.routedMetadata?.evidencePlan?.executedTools ?? [],
271
334
  answerVerification,
272
335
  output,
@@ -333,6 +396,9 @@ export function formatAskReplayMarkdown(report) {
333
396
  if (result.mode === 'live') {
334
397
  lines.push(`Agentic tools: ${result.agenticTools.join(', ') || 'none'}`);
335
398
  }
399
+ if (result.graphTools.length > 0) {
400
+ lines.push(`Graph tools: ${result.graphTools.join(', ')}`);
401
+ }
336
402
  lines.push('');
337
403
  lines.push(result.output);
338
404
  lines.push('');
@@ -0,0 +1,206 @@
1
+ export const STARTER_PROMPT_FAMILIES = Object.freeze([
2
+ 'recent_session_review',
3
+ 'lift_progress',
4
+ 'pr_review',
5
+ 'training_review',
6
+ 'next_session_prep',
7
+ 'plan_creation',
8
+ 'evergreen_strength',
9
+ 'muscle_coverage',
10
+ 'consistency_review',
11
+ 'comeback_review',
12
+ 'score_review'
13
+ ]);
14
+
15
+ export const STARTER_GRAPH_VERSION = 'ask_starter_langgraph_v1';
16
+
17
+ const STARTER_PROMPT_FAMILY_SET = new Set(STARTER_PROMPT_FAMILIES);
18
+ const CONTEXT_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
19
+ const MAX_CONTEXT_FIELDS = 10;
20
+ const MAX_CONTEXT_VALUE_CHARS = 200;
21
+
22
+ export function starterWorkflowEnabled(env = process.env) {
23
+ return ['1', 'true', 'yes', 'on'].includes(String(env.ASK_STARTER_LANGGRAPH_ENABLED ?? '').trim().toLowerCase());
24
+ }
25
+
26
+ export function normalizeStarterPrompt(raw) {
27
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
28
+
29
+ const family = typeof raw.family === 'string' ? raw.family.trim() : '';
30
+ if (!STARTER_PROMPT_FAMILY_SET.has(family)) return null;
31
+
32
+ const question = typeof raw.question === 'string' ? raw.question.trim() : '';
33
+ if (!question || question.length > 500) return null;
34
+
35
+ const context = normalizeStarterPromptContext(raw.context);
36
+ return {
37
+ family,
38
+ question,
39
+ ...(Object.keys(context).length > 0 ? { context } : {})
40
+ };
41
+ }
42
+
43
+ export function isSupportedStarterPrompt(starterPrompt) {
44
+ return Boolean(starterPrompt && STARTER_PROMPT_FAMILY_SET.has(starterPrompt.family));
45
+ }
46
+
47
+ export function starterGraphFallbackReason(error) {
48
+ const message = error instanceof Error ? error.message : String(error ?? '');
49
+ if (/timeout|abort/i.test(message)) return 'timeout';
50
+ if (/unsupported/i.test(message)) return 'unsupported';
51
+ if (/invalid/i.test(message)) return 'invalid_output';
52
+ return 'error';
53
+ }
54
+
55
+ export function starterPromptToolPlan(starterPrompt) {
56
+ if (!isSupportedStarterPrompt(starterPrompt)) return [];
57
+
58
+ const exercise = starterPrompt.context?.exerciseName
59
+ ?? starterPrompt.context?.exercise
60
+ ?? null;
61
+ const exerciseParams = exercise ? { exercises: [exercise] } : {};
62
+
63
+ switch (starterPrompt.family) {
64
+ case 'lift_progress':
65
+ return [
66
+ { name: 'get_exercise_history', params: { ...exerciseParams, limit: 8, recencyCutoffDays: 90 } },
67
+ { name: 'get_exercise_progress_summary', params: { ...exerciseParams, limit: 12 } },
68
+ { name: 'get_records', params: { ...exerciseParams, limit: exercise ? 5 : 15 } }
69
+ ];
70
+ case 'pr_review':
71
+ case 'evergreen_strength':
72
+ return [
73
+ { name: 'get_records', params: { ...exerciseParams, limit: exercise ? 5 : 15 } },
74
+ { name: 'get_exercise_progress_summary', params: { ...exerciseParams, limit: 10 } }
75
+ ];
76
+ case 'recent_session_review':
77
+ return [
78
+ { name: 'get_recent_sessions', params: { limit: 1, includeStale: true, recencyCutoffDays: 30 } },
79
+ { name: 'compare_session_to_observations', params: { observationLimit: 5, includeOutcomeHistory: true } },
80
+ { name: 'get_next_session', params: { historyLimit: 6, recencyCutoffDays: 90 } }
81
+ ];
82
+ case 'training_review':
83
+ case 'consistency_review':
84
+ case 'comeback_review':
85
+ return [
86
+ { name: 'get_recent_sessions', params: { limit: 8, includeStale: true, recencyCutoffDays: 60 } },
87
+ { name: 'get_weekly_volume', params: {} },
88
+ { name: 'get_records', params: { limit: 15 } },
89
+ { name: 'get_readiness_snapshot', params: { recentDays: 14 } },
90
+ { name: 'get_body_weight_snapshot', params: { recentDays: 30 } },
91
+ ...(isStarterScoreQuestion(starterPrompt) ? [{ name: 'get_increment_score', params: { historyDays: 21 } }] : [])
92
+ ];
93
+ case 'score_review':
94
+ return [
95
+ { name: 'get_increment_score', params: { historyDays: 21 } },
96
+ { name: 'get_recent_sessions', params: { limit: 4, includeStale: true, recencyCutoffDays: 30 } }
97
+ ];
98
+ case 'next_session_prep':
99
+ return [
100
+ { name: 'get_next_session', params: { historyLimit: 8, recencyCutoffDays: 90 } },
101
+ { name: 'get_recent_sessions', params: { limit: 3, includeStale: true, recencyCutoffDays: 45 } },
102
+ { name: 'get_exercise_history', params: { limit: 8, recencyCutoffDays: 90 } }
103
+ ];
104
+ case 'plan_creation':
105
+ return [
106
+ { name: 'get_training_profile', params: {} },
107
+ { name: 'get_recent_sessions', params: { limit: 8, includeStale: true, recencyCutoffDays: 90 } },
108
+ { name: 'get_program_progress', params: { limitExercises: 10 } }
109
+ ];
110
+ case 'muscle_coverage':
111
+ return [
112
+ { name: 'get_muscle_volume_trend', params: { weeks: 4 } },
113
+ { name: 'get_recent_sessions', params: { limit: 8, includeStale: true, recencyCutoffDays: 60 } }
114
+ ];
115
+ default:
116
+ return [];
117
+ }
118
+ }
119
+
120
+ function isStarterScoreQuestion(starterPrompt) {
121
+ return /\b(?:increment\s+)?score\b/i.test(String(starterPrompt?.question ?? ''));
122
+ }
123
+
124
+ export function starterPromptAnswerContract(starterPrompt) {
125
+ switch (starterPrompt?.family) {
126
+ case 'lift_progress':
127
+ return 'Answer with the lift trend, the evidence, and what to do on the next exposure.';
128
+ case 'recent_session_review':
129
+ return 'Answer with one win, one watch item if supported, and the next move.';
130
+ case 'training_review':
131
+ case 'consistency_review':
132
+ case 'comeback_review':
133
+ return 'Answer with the broad trend, the main driver, and the decision question or next action.';
134
+ case 'score_review':
135
+ return 'Explain the current 7-day Score in plain training language: what helped, what held it back, and whether the trend is improving. Do not dump raw sub-scores or tool output.';
136
+ case 'next_session_prep':
137
+ return 'Answer with expected work for the next session and what to watch.';
138
+ case 'plan_creation':
139
+ return 'Draft a concise plan answer and append exactly one trailing <program_draft>{JSON}</program_draft> block if the evidence supports a valid draft. The draft JSON may only use countable reps from 1-30; do not include timed holds, seconds, distance, duration, or extra set keys. Never apply the plan.';
140
+ case 'evergreen_strength':
141
+ case 'pr_review':
142
+ return 'Answer with the relevant record or PR evidence and what it means now.';
143
+ case 'muscle_coverage':
144
+ return 'Answer from effective-set muscle data, naming the main under-trained areas and the next adjustment.';
145
+ default:
146
+ return 'Answer the tapped starter prompt using only the supplied training evidence.';
147
+ }
148
+ }
149
+
150
+ export function starterPromptStructuredIntentHint(starterPrompt) {
151
+ switch (starterPrompt?.family) {
152
+ case 'lift_progress':
153
+ case 'pr_review':
154
+ case 'evergreen_strength':
155
+ return { route: 'exercise_progress_summary' };
156
+ case 'recent_session_review':
157
+ return { route: 'recent_session' };
158
+ case 'training_review':
159
+ case 'consistency_review':
160
+ case 'comeback_review':
161
+ return { route: 'progress_review' };
162
+ case 'score_review':
163
+ return { route: 'score' };
164
+ case 'next_session_prep':
165
+ return { route: 'next_session' };
166
+ case 'plan_creation':
167
+ return { route: 'program_design', requestedAction: 'draft_plan' };
168
+ case 'muscle_coverage':
169
+ return { route: 'volume' };
170
+ default:
171
+ return null;
172
+ }
173
+ }
174
+
175
+ export function applyStarterPromptStructuredIntentHint(routingMetadata, starterPrompt) {
176
+ if (!routingMetadata || typeof routingMetadata !== 'object') return routingMetadata;
177
+ const hint = starterPromptStructuredIntentHint(starterPrompt);
178
+ if (!hint) return routingMetadata;
179
+
180
+ const currentRoute = routingMetadata.intent?.route ?? routingMetadata.route;
181
+ const shouldApplyRoute = !currentRoute || currentRoute === 'general';
182
+ const intent = {
183
+ ...(routingMetadata.intent ?? {}),
184
+ ...(shouldApplyRoute ? { route: hint.route } : {}),
185
+ ...(hint.requestedAction && !routingMetadata.intent?.requestedAction ? { requestedAction: hint.requestedAction } : {})
186
+ };
187
+ return {
188
+ ...routingMetadata,
189
+ ...(shouldApplyRoute ? { route: hint.route } : {}),
190
+ intent
191
+ };
192
+ }
193
+
194
+ function normalizeStarterPromptContext(raw) {
195
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
196
+ const context = {};
197
+ for (const [key, value] of Object.entries(raw)) {
198
+ if (Object.keys(context).length >= MAX_CONTEXT_FIELDS) break;
199
+ if (!CONTEXT_KEY_RE.test(key)) continue;
200
+ if (!['string', 'number', 'boolean'].includes(typeof value)) continue;
201
+ const text = String(value).trim();
202
+ if (!text || text.length > MAX_CONTEXT_VALUE_CHARS) continue;
203
+ context[key] = text;
204
+ }
205
+ return context;
206
+ }
package/src/auth.js CHANGED
@@ -362,6 +362,32 @@ export async function refreshRemoteSession(baseUrl, token, previousSession = nul
362
362
  });
363
363
  }
364
364
 
365
+ export async function refreshRemoteContractMetadata(baseUrl, token, previousSession = null) {
366
+ const remoteContract = await fetchRemoteContract(baseUrl, token);
367
+ const checkedAt = new Date().toISOString();
368
+
369
+ return writeSessionState({
370
+ version: previousSession?.version ?? 1,
371
+ mode: 'remote',
372
+ account: previousSession?.account ?? null,
373
+ auth: previousSession?.auth ?? {
374
+ accessToken: token,
375
+ refreshToken: null,
376
+ expiresAt: null
377
+ },
378
+ sync: {
379
+ ...(previousSession?.sync ?? {}),
380
+ contractCheckedAt: checkedAt
381
+ },
382
+ transport: {
383
+ ...(previousSession?.transport ?? {}),
384
+ baseUrl,
385
+ contractVersion: remoteContract.contractVersion,
386
+ capabilities: remoteContract.capabilities ?? null
387
+ }
388
+ });
389
+ }
390
+
365
391
  export async function createRemoteAgentToken(baseUrl, bearerToken, {
366
392
  name,
367
393
  access = 'read',
@@ -0,0 +1,32 @@
1
+ import { sanitizeCoachAdviceCandidate } from './ask-coach.js';
2
+
3
+ function collapseBlankLines(text) {
4
+ return String(text ?? '')
5
+ .replace(/\n{3,}/g, '\n\n')
6
+ .trim();
7
+ }
8
+
9
+ export function extractCoachAdvice(rawText) {
10
+ const text = String(rawText ?? '');
11
+ const match = text.match(/<coach_advice>\s*([\s\S]*?)\s*<\/coach_advice>/i);
12
+ if (!match) {
13
+ return { answerText: text.trim(), coachAdvice: null };
14
+ }
15
+
16
+ const answerText = collapseBlankLines(text.replace(match[0], ''));
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(match[1]);
20
+ } catch (err) {
21
+ console.warn('askCoach: <coach_advice> JSON parse failed - dropping advice:', err.message);
22
+ return { answerText, coachAdvice: null };
23
+ }
24
+
25
+ const coachAdvice = sanitizeCoachAdviceCandidate(parsed);
26
+ if (!coachAdvice) {
27
+ console.warn('askCoach: <coach_advice> payload failed validation - dropping advice');
28
+ return { answerText, coachAdvice: null };
29
+ }
30
+
31
+ return { answerText, coachAdvice };
32
+ }
@@ -1,5 +1,17 @@
1
1
  export const COACH_FACT_KINDS = Object.freeze(['preference', 'constraint', 'injury', 'goal_signal', 'tone']);
2
2
 
3
+ export const COACH_FACT_PRESENTATION = Object.freeze([
4
+ { kind: 'goal_signal', key: 'goals', title: 'Goals', kindTitle: 'Goal' },
5
+ { kind: 'constraint', key: 'constraints', title: 'Constraints', kindTitle: 'Constraint' },
6
+ { kind: 'injury', key: 'injuries', title: 'Injuries', kindTitle: 'Injury' },
7
+ { kind: 'preference', key: 'preferences', title: 'Preferences', kindTitle: 'Preference' },
8
+ { kind: 'tone', key: 'tone', title: 'Tone', kindTitle: 'Tone' }
9
+ ]);
10
+
11
+ const COACH_FACT_GROUP_KEYS = Object.freeze(
12
+ Object.fromEntries(COACH_FACT_PRESENTATION.map((section) => [section.kind, section.key]))
13
+ );
14
+
3
15
  const COACH_FACT_KIND_SET = new Set(COACH_FACT_KINDS);
4
16
 
5
17
  const PROMPT_INJECTION_PATTERNS = [
@@ -22,6 +34,51 @@ const EPHEMERAL_PATTERNS = [
22
34
  /\b(current|currently|latest|recent)\s+(?:best|e1rm|1rm|volume|pr|record|session|workout)\b/i
23
35
  ];
24
36
 
37
+ const EPHEMERAL_RESPONSE_REQUEST_PATTERNS = [
38
+ /\b(?:i\s+want|i\s+asked\s+for|i\s+ask\s+for|i(?:'d| would)?\s+like|wants|asked for|asks for|would like)\b.{0,80}\b(right now|for this answer|current answer)\b/i,
39
+ /\b(?:i\s+want|i\s+asked\s+for|i\s+ask\s+for|i(?:'d| would)?\s+like|wants|asked for|asks for|would like)\b.{0,80}\b(latest|recent|last|current|today|this week|this month|past|six[-\s]?month|\d+\s+(?:days|weeks|months|years))\b.{0,80}\b(coaching\s+)?(read|take|answer|response|summary|breakdown|overview|feedback)\b/i,
40
+ /\b(?:i\s+want|i\s+asked\s+for|i\s+ask\s+for|i(?:'d| would)?\s+like|wants|asked for|asks for|would like)\b.{0,80}\b(coaching\s+)?(read|take|answer|response|summary|breakdown|overview|feedback)\b.{0,80}\b(latest|recent|last|current|today|this week|this month|past|six[-\s]?month|\d+\s+(?:days|weeks|months|years))\b/i,
41
+ /\bnot just\b.{0,60}\b(latest|recent|last|cards?|session|workout)\b/i,
42
+ /\b(last|past)\s+(?:\d+\s+)?(?:days|weeks|months|years)\b.{0,80}\b(read|take|answer|response|summary|breakdown|overview|feedback)\b/i,
43
+ /\b(?:i\s+want|i\s+asked\s+for|i\s+ask\s+for|i(?:'d| would)?\s+like|wants|asked for|asks for|would like)\b.{0,80}\b(guidance|feedback|recommendation|what to do next|progress checks?|progress updates?|evidence explained|session highlights explained)\b/i,
44
+ /\b(?:i\s+want|i\s+asked\s+for|i\s+ask\s+for|i(?:'d| would)?\s+like|wants|asked for|asks for|would like)\b.{0,80}\b(brand[-\s]?new plan|plan from scratch|program draft|plan artifact|changeset)\b/i
45
+ ];
46
+
47
+ const DURABLE_GOAL_PATTERNS = [
48
+ /\b(?:lose|gain|build|improve|increase|maintain|cut|bulk)\b.{0,80}\b(?:weight|muscle|strength|bench|squat|deadlift|press|pull[-\s]?up|fitness|conditioning|mobility|body fat|hypertrophy)\b/i,
49
+ /\b(?:weight|muscle|strength|bench|squat|deadlift|press|pull[-\s]?up|fitness|conditioning|mobility|body fat|hypertrophy)\b.{0,80}\b(?:goal|target|priority|focus)\b/i,
50
+ /\b(?:goal|target|priority|focus)\b.{0,80}\b(?:lose|gain|build|improve|increase|maintain|cut|bulk)\b/i,
51
+ /\b(?:bench|squat|deadlift|press|pull[-\s]?up|strength|muscle|weight)\b.{0,80}\bprogress\b/i,
52
+ /\bprioriti[sz]e\b.{0,80}\b(?:shoulder[-\s]?friendly|pressing|pulling|strength|hypertrophy|muscle|mobility)\b/i
53
+ ];
54
+
55
+ const ASK_INTENT_ONLY_PATTERNS = [
56
+ /\b(?:what should i do next|what do i do next|what are my goals|what's my goal|explain this|check my progress|how am i progressing|what evidence matters|what matters here)\b/i,
57
+ /\b(?:recommendation|recommend|feedback|guidance|advice)\b.{0,60}\b(?:what to do next|next|now|today|this week|for this)\b/i
58
+ ];
59
+
60
+ const FIRST_PERSON_DURABLE_SIGNAL_PATTERNS = [
61
+ /\bmy goal is\b/i,
62
+ /\bi want to\b.{0,100}\b(?:lose|gain|build|improve|increase|maintain|cut|bulk|avoid)\b/i,
63
+ /\bi(?:'d| would)? prefer\b/i,
64
+ /\bi want to avoid\b/i,
65
+ /\bi can only\b/i,
66
+ /\bi only have\b/i,
67
+ /\bwork sometimes cuts\b/i,
68
+ /\b(?:my|the)\s+(?:shoulder|knee|elbow|hip|back|wrist|ankle|neck)\s+(?:hurts|aches|is sore|is injured)\b/i,
69
+ /\bi can(?:['’]t|not)\b.{0,80}\bbecause\b/i,
70
+ /\bbe concise\b/i,
71
+ /\bdon(?:['’]t| not)\s+be\s+demeaning\b/i
72
+ ];
73
+
74
+ const REMOVAL_INTENT_PATTERNS = [
75
+ /\b(?:remove|delete|clear)\s+(?:that|this|my)?\s*(?:goal|memory|fact|preference|constraint|injury|tone)\b/i,
76
+ /\bforget\s+(?:that|this|my)?\b/i,
77
+ /\bi don(?:['’]t| not)\s+want\b.{0,120}\bas\s+a\s+goal\s+anymore\b/i,
78
+ /\bthat(?:['’]s| is)\s+not\s+true\s+anymore\b/i,
79
+ /\bno\s+longer\s+(?:true|a goal|relevant)\b/i
80
+ ];
81
+
25
82
  export function normalizeCoachFactText(value) {
26
83
  return String(value ?? '').replace(/\s+/g, ' ').trim();
27
84
  }
@@ -57,9 +114,87 @@ export function coachFactPolicyViolation(candidate) {
57
114
  if (PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(fact))) return 'prompt_injection';
58
115
  if (DERIVED_TRAINING_PATTERNS.some((pattern) => pattern.test(fact))) return 'derived_training_fact';
59
116
  if (EPHEMERAL_PATTERNS.some((pattern) => pattern.test(fact))) return 'ephemeral_fact';
117
+ if (EPHEMERAL_RESPONSE_REQUEST_PATTERNS.some((pattern) => pattern.test(fact))) return 'ephemeral_response_request';
118
+ if (kind === 'goal_signal' && !DURABLE_GOAL_PATTERNS.some((pattern) => pattern.test(fact))) return 'non_durable_goal_signal';
119
+ return null;
120
+ }
121
+
122
+ export function coachFactPersistenceDecision(candidate) {
123
+ const reason = coachFactPolicyViolation(candidate);
124
+ return { persist: !reason, reason };
125
+ }
126
+
127
+ export function coachFactRetrievalViolation(candidate) {
128
+ const reason = coachFactPolicyViolation(candidate);
129
+ if (reason) return reason;
60
130
  return null;
61
131
  }
62
132
 
133
+ export function shouldAttemptCoachFactExtraction(currentUserText, { sourceSurface = 'ask' } = {}) {
134
+ const text = normalizeCoachFactText(currentUserText);
135
+ if (!text) return { shouldAttempt: false, skippedReason: 'empty_user_turn' };
136
+ if (detectCoachFactRemovalIntent(text).isRemovalIntent) {
137
+ return { shouldAttempt: false, skippedReason: 'memory_removal_request' };
138
+ }
139
+ if (EPHEMERAL_RESPONSE_REQUEST_PATTERNS.some((pattern) => pattern.test(text))) {
140
+ return { shouldAttempt: false, skippedReason: 'ephemeral_response_request' };
141
+ }
142
+ if (ASK_INTENT_ONLY_PATTERNS.some((pattern) => pattern.test(text))) {
143
+ return { shouldAttempt: false, skippedReason: 'ask_intent_only' };
144
+ }
145
+ if (!FIRST_PERSON_DURABLE_SIGNAL_PATTERNS.some((pattern) => pattern.test(text))) {
146
+ return { shouldAttempt: false, skippedReason: 'no_durable_first_person_signal' };
147
+ }
148
+ return { shouldAttempt: true, sourceSurface };
149
+ }
150
+
151
+ export function detectCoachFactRemovalIntent(currentUserText) {
152
+ const text = normalizeCoachFactText(currentUserText);
153
+ if (!text) return { isRemovalIntent: false, text: '' };
154
+ const isRemovalIntent = REMOVAL_INTENT_PATTERNS.some((pattern) => pattern.test(text));
155
+ const kindPreference = /\bgoal\b/i.test(text)
156
+ ? 'goal_signal'
157
+ : /\bconstraint\b/i.test(text)
158
+ ? 'constraint'
159
+ : /\binjur(?:y|ies)\b/i.test(text)
160
+ ? 'injury'
161
+ : /\btone|concise|demeaning|style\b/i.test(text)
162
+ ? 'tone'
163
+ : /\bprefer|preference\b/i.test(text)
164
+ ? 'preference'
165
+ : null;
166
+ return { isRemovalIntent, text, kindPreference };
167
+ }
168
+
169
+ export function coachFactRemovalMatches(currentUserText, facts, { limit = 3 } = {}) {
170
+ const intent = detectCoachFactRemovalIntent(currentUserText);
171
+ if (!intent.isRemovalIntent) return [];
172
+ const requestTokens = new Set(intent.text.toLowerCase().match(/[a-z0-9]{4,}/g) ?? []);
173
+ const stopTokens = new Set(['remove', 'delete', 'clear', 'forget', 'that', 'this', 'goal', 'memory', 'fact', 'anymore', 'longer', 'true', 'want']);
174
+ const usefulRequestTokens = [...requestTokens].filter((token) => !stopTokens.has(token));
175
+ const scored = [];
176
+ for (const row of Array.isArray(facts) ? facts : []) {
177
+ const kind = normalizeCoachFactKind(row?.kind);
178
+ const fact = normalizeCoachFactText(row?.fact);
179
+ const id = String(row?.id ?? '').trim();
180
+ if (!id || !fact || !isCoachFactKind(kind) || row?.supersededAt || row?.superseded_at) continue;
181
+ const factTokens = new Set(fact.toLowerCase().match(/[a-z0-9]{4,}/g) ?? []);
182
+ const overlap = usefulRequestTokens.filter((token) => factTokens.has(token)).length;
183
+ const quoted = [...String(intent.text).matchAll(/["“”']([^"“”']{3,80})["“”']/g)]
184
+ .some((match) => fact.toLowerCase().includes(String(match[1]).toLowerCase()));
185
+ const kindScore = intent.kindPreference && intent.kindPreference === kind ? 2 : 0;
186
+ const score = (quoted ? 10 : 0) + overlap + kindScore;
187
+ const hasContentTarget = usefulRequestTokens.length > 0 || /["“”']([^"“”']{3,80})["“”']/.test(intent.text);
188
+ if ((hasContentTarget && (quoted || overlap > 0)) || (!hasContentTarget && kindScore > 0)) {
189
+ scored.push({ row, score });
190
+ }
191
+ }
192
+ return scored
193
+ .sort((a, b) => b.score - a.score)
194
+ .slice(0, Math.max(1, Math.min(Number(limit) || 3, 10)))
195
+ .map((item) => item.row);
196
+ }
197
+
63
198
  export function normalizeCoachFactCandidate(candidate) {
64
199
  if (!candidate || typeof candidate !== 'object') return null;
65
200
  const normalized = {
@@ -74,6 +209,60 @@ export function normalizeCoachFactCandidate(candidate) {
74
209
  };
75
210
  }
76
211
 
212
+ export function evaluateCoachFactCandidates(candidates, { limit = 3 } = {}) {
213
+ const proposed = Array.isArray(candidates) ? candidates : [];
214
+ const accepted = [];
215
+ const rejected = [];
216
+
217
+ for (const candidate of proposed) {
218
+ const normalized = {
219
+ kind: normalizeCoachFactKind(candidate?.kind),
220
+ fact: normalizeCoachFactText(candidate?.fact),
221
+ confidence: Number(candidate?.confidence ?? 0.7)
222
+ };
223
+ const reason = coachFactPolicyViolation(normalized);
224
+ if (reason) {
225
+ rejected.push({
226
+ kind: isCoachFactKind(normalized.kind) ? normalized.kind : 'unknown',
227
+ reason
228
+ });
229
+ continue;
230
+ }
231
+ accepted.push({
232
+ ...normalized,
233
+ confidence: Number.isFinite(normalized.confidence) ? Math.max(0, Math.min(1, normalized.confidence)) : 0.7
234
+ });
235
+ }
236
+
237
+ const facts = dedupeCoachFactCandidates(accepted, { limit });
238
+ const rejectionReasonCounts = rejected.reduce((counts, item) => {
239
+ counts[item.reason] = (counts[item.reason] ?? 0) + 1;
240
+ return counts;
241
+ }, {});
242
+ const acceptedKindCounts = facts.reduce((counts, item) => {
243
+ counts[item.kind] = (counts[item.kind] ?? 0) + 1;
244
+ return counts;
245
+ }, {});
246
+ const rejectedKindCounts = rejected.reduce((counts, item) => {
247
+ const kind = isCoachFactKind(item.kind) ? item.kind : 'unknown';
248
+ counts[kind] = (counts[kind] ?? 0) + 1;
249
+ return counts;
250
+ }, {});
251
+
252
+ return {
253
+ facts,
254
+ telemetry: {
255
+ proposedFactCount: proposed.length,
256
+ acceptedFactCount: facts.length,
257
+ rejectedFactCount: rejected.length,
258
+ acceptedKinds: acceptedKindCounts,
259
+ rejectedKinds: rejectedKindCounts,
260
+ rejectionReasons: rejectionReasonCounts,
261
+ rejectedFacts: rejected
262
+ }
263
+ };
264
+ }
265
+
77
266
  export function coachFactConflictKey(candidate) {
78
267
  const kind = normalizeCoachFactKind(candidate?.kind);
79
268
  const fact = normalizeCoachFactText(candidate?.fact).toLowerCase();
@@ -111,3 +300,28 @@ export function dedupeCoachFactCandidates(candidates, { limit = 3 } = {}) {
111
300
  .sort((a, b) => b.confidence - a.confidence)
112
301
  .slice(0, limit);
113
302
  }
303
+
304
+ export function coachFactsResponsePayload(facts) {
305
+ const rows = Array.isArray(facts) ? facts : [];
306
+ const grouped = Object.fromEntries(COACH_FACT_PRESENTATION.map((section) => [section.key, []]));
307
+ for (const fact of rows) {
308
+ const groupKey = COACH_FACT_GROUP_KEYS[fact?.kind];
309
+ if (groupKey) grouped[groupKey].push(fact);
310
+ }
311
+
312
+ return {
313
+ facts: rows,
314
+ grouped,
315
+ sections: COACH_FACT_PRESENTATION.map((section) => ({
316
+ key: section.key,
317
+ kind: section.kind,
318
+ title: section.title,
319
+ facts: grouped[section.key] ?? []
320
+ })),
321
+ availableKinds: COACH_FACT_PRESENTATION.map((section) => ({
322
+ kind: section.kind,
323
+ title: section.kindTitle,
324
+ sectionTitle: section.title
325
+ }))
326
+ };
327
+ }