@isparling/engram-coach 0.1.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 +116 -0
- package/engram-coach-domain.ts +236 -0
- package/engram-coach-extractor.ts +295 -0
- package/engram-coach-pack.ts +61 -0
- package/engram-coach-presentation.ts +187 -0
- package/engram-coach-reconciliation.ts +327 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# engram-coach
|
|
2
|
+
|
|
3
|
+
## What it does
|
|
4
|
+
|
|
5
|
+
engram-coach is a document-driven endurance-coaching plugin for Claude Code. It combines athlete-approved coaching records with Intervals.icu activity and wellness data to adapt training, review blocks and seasons, analyze races, and preserve durable lessons in Markdown.
|
|
6
|
+
|
|
7
|
+
The plugin provides coaching workflows and deterministic analysis tools. Athlete records, credentials, and local configuration remain outside this repository.
|
|
8
|
+
|
|
9
|
+
## Included skills
|
|
10
|
+
|
|
11
|
+
- `adapt-plan` — assess readiness after a key workout and adapt the next prescription.
|
|
12
|
+
- `block-review` — synthesize an end-of-block summary.
|
|
13
|
+
- `consult` — provide advice within an active plan.
|
|
14
|
+
- `intake` — configure a new athlete and coaching workspace.
|
|
15
|
+
- `lactate-analyze` — query lactate tests and threshold estimates.
|
|
16
|
+
- `lessons-rollup` — curate durable patterns from coaching records.
|
|
17
|
+
- `monitoring-rollup` — maintain longitudinal monitoring records.
|
|
18
|
+
- `race-analysis` — synthesize a completed race.
|
|
19
|
+
- `season-retrospective` — review a completed season.
|
|
20
|
+
- `set-goal` — establish a new goal arc and prescriptions.
|
|
21
|
+
|
|
22
|
+
## Coaching personas
|
|
23
|
+
|
|
24
|
+
Built-in personas are generic coaching policies, not named-coach reproductions:
|
|
25
|
+
|
|
26
|
+
- `conservative` — recovery-first with an HRV veto.
|
|
27
|
+
- `aggressive` — progressive overload with weighted readiness.
|
|
28
|
+
- `polarized` — high low-intensity volume plus high-intensity work.
|
|
29
|
+
- `volume` — long-horizon aerobic volume with a 14-day CTL trend.
|
|
30
|
+
|
|
31
|
+
You can define a custom policy with [`PERSONA_SCHEMA.md`](PERSONA_SCHEMA.md).
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Follow [`SETUP.md`](SETUP.md) to install the plugin, configure Intervals.icu access, and create a local coaching workspace.
|
|
36
|
+
|
|
37
|
+
For direct OMP integration, install the published Engram packages and bind the OMP extension:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm install @isparling/engram-coach @isparling/engram-harness @isparling/engram-cli @isparling/engram-omp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
extensions:
|
|
45
|
+
- ./node_modules/@isparling/engram-omp/omp-extension.ts
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This does not include core Engram onboarding. The OMP extension resolves
|
|
49
|
+
`engram-coach` by declaring it in the `installed_packs` of a space's binding
|
|
50
|
+
inside an **existing, active Engram binding registry** — with a session-aware
|
|
51
|
+
active space already registered and selected. That registry is an external
|
|
52
|
+
prerequisite; this package neither creates nor configures one. Set one up
|
|
53
|
+
through your own Engram deployment, then add the pack declaration:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"installed_packs": [
|
|
58
|
+
{
|
|
59
|
+
"id": "engram-coach",
|
|
60
|
+
"version": "0.1.0",
|
|
61
|
+
"from": "@isparling/engram-coach",
|
|
62
|
+
"extract": true
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Set `ENGRAM_BINDING_REGISTRY`** to the absolute path of that binding
|
|
69
|
+
registry file before starting OMP. It is required, not optional: without it
|
|
70
|
+
the adapter disables knowledge capture entirely for the whole session. See
|
|
71
|
+
[`SETUP.md`](SETUP.md#6-install-the-plugin) (Alternative: Direct OMP
|
|
72
|
+
integration) for the full walkthrough.
|
|
73
|
+
|
|
74
|
+
### Verifying the direct OMP integration
|
|
75
|
+
|
|
76
|
+
Bind the pack, start an OMP session, and complete one full agent turn — the
|
|
77
|
+
adapter resolves the session's active space and pack only inside its first
|
|
78
|
+
`agent_end` handler, not at session start. After that turn settles, call the
|
|
79
|
+
`engram_status` tool. It reports the binding-selected pack identity and CLI
|
|
80
|
+
mode:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{ "mode": "cli", "pack_id": "engram-coach", "pack_version": "0.1.0" }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`mode` is always `"cli"` — the adapter shells out to the Engram CLI and
|
|
87
|
+
never injects knowledge directly into context. `pack_id: null` before the
|
|
88
|
+
first turn has settled is expected, not a binding failure — call
|
|
89
|
+
`engram_status` again after a turn completes.
|
|
90
|
+
If `pack_id` is still `null` after that, the active space's
|
|
91
|
+
binding has not resolved `engram-coach`; recheck `ENGRAM_BINDING_REGISTRY`
|
|
92
|
+
and the `installed_packs` declaration above.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
Copy [`config.json.example`](config.json.example) to your local configuration path and replace every placeholder. Keep credentials and athlete records outside this repository. The example config defaults to the generic `conservative` persona.
|
|
97
|
+
|
|
98
|
+
## Privacy boundary
|
|
99
|
+
|
|
100
|
+
This repository ships no athlete records, medical information, real event data, personal narratives, credentials, or historical coaching artifacts. Examples and fixtures are synthetic. Do not commit local `config.json`, `.env`, generated data, or athlete-owned coaching documents.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
Install tool dependencies and run the public test suite:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install --prefix tools
|
|
108
|
+
npm test --prefix tools
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
For the lactate package:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm install --prefix lactate
|
|
115
|
+
npm run build --prefix lactate
|
|
116
|
+
```
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engram-coach domain model — coaching ontology types and constants.
|
|
3
|
+
*
|
|
4
|
+
* Captures the LLM-driven semantics of engram-coach's 10 skills as a
|
|
5
|
+
* structured type system. Used by the pack's extractor, validator, and
|
|
6
|
+
* reconciler to produce coaching-aware knowledge candidates instead of
|
|
7
|
+
* generic keyword-match blobs.
|
|
8
|
+
*
|
|
9
|
+
* This file has no Engram harness imports — it is a pure domain vocabulary
|
|
10
|
+
* that the pack module imports and the extension does not need to see.
|
|
11
|
+
*
|
|
12
|
+
* @module engram-coach-domain
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Entity types — what kind of coaching artifact or concept this is about.
|
|
17
|
+
// Every extraction candidate carries one entity type in details.entityType.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export const ENGRAM_COACH_ENTITY_TYPES = [
|
|
21
|
+
"workout-adaptation", // adapt-plan: per-session adjustment
|
|
22
|
+
"consultation", // consult: mid-arc advice seeking
|
|
23
|
+
"block-review", // block-review: end-of-block SUMMARY.md
|
|
24
|
+
"race-report", // race-analysis: post-race RACE_REPORT.md
|
|
25
|
+
"season-review", // season-retrospective: SEASON_REVIEW.md
|
|
26
|
+
"arc-plan", // set-goal: new training arc definition
|
|
27
|
+
"lactate-test", // lactate-analyze: LT1/LT2/FTP/FTHR test
|
|
28
|
+
"monitoring-capture", // monitoring-rollup: longitudinal concern row
|
|
29
|
+
"intake-record", // intake: one-time coaching setup
|
|
30
|
+
"prescription", // prescription YAML file
|
|
31
|
+
"persona-fit", // persona selection or change
|
|
32
|
+
"calibration-point", // lessons-rollup: durable athlete-specific pattern
|
|
33
|
+
"methodology", // set-goal: per-sub-block methodology doc
|
|
34
|
+
"session-execution", // raw session data (prescription vs actual)
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export type EngramCoachEntityType = (typeof ENGRAM_COACH_ENTITY_TYPES)[number];
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Decision kinds — the type of coaching judgment being rendered.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export const ENGRAM_COACH_DECISION_KINDS = [
|
|
44
|
+
"workout-adaptation", // adjusting a specific session's prescription
|
|
45
|
+
"consultation-advice", // mid-arc advisory response
|
|
46
|
+
"block-restructure", // changing a block's structure mid-cycle
|
|
47
|
+
"arc-planning", // establishing or revising a goal arc
|
|
48
|
+
"persona-change", // switching coaching philosophy
|
|
49
|
+
"recovery-intervention", // pulling back based on signal picture
|
|
50
|
+
"threshold-update", // updating FTP/FTHR from lactate data
|
|
51
|
+
"monitoring-capture", // recording a monitoring concern row
|
|
52
|
+
"profile-claim", // promoting a calibration point to profile
|
|
53
|
+
"setup-decision", // intake/configuration decisions
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type EngramCoachDecisionKind = (typeof ENGRAM_COACH_DECISION_KINDS)[number];
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Training signals — physiological metrics the skills track and reason about.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export const ENGRAM_COACH_TRAINING_SIGNALS = [
|
|
63
|
+
"ctl", // chronic training load
|
|
64
|
+
"atl", // acute training load
|
|
65
|
+
"tsb", // training stress balance
|
|
66
|
+
"hrv", // heart rate variability (rMSSD)
|
|
67
|
+
"rhr", // resting heart rate
|
|
68
|
+
"decoupling", // aerobic decoupling (pace/power drift)
|
|
69
|
+
"time-in-zones", // intensity distribution
|
|
70
|
+
"interval-execution", // interval fade, compliance, CV
|
|
71
|
+
"power-curve", // best efforts at standard durations
|
|
72
|
+
"strength-ratio", // neuromuscular fatigue indicator
|
|
73
|
+
"lactate-threshold", // LT1, LT2, OBLA values
|
|
74
|
+
"rpe", // rate of perceived exertion
|
|
75
|
+
"nutrition", // fueling / hydration
|
|
76
|
+
"sleep", // sleep quality and duration
|
|
77
|
+
"stress", // external life stress
|
|
78
|
+
"illness", // sickness / immune status
|
|
79
|
+
"injury", // physical injury / discomfort
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
export type EngramCoachTrainingSignal = (typeof ENGRAM_COACH_TRAINING_SIGNALS)[number];
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Training phases — position within a training cycle.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export const ENGRAM_COACH_TRAINING_PHASES = [
|
|
89
|
+
"base",
|
|
90
|
+
"build-1",
|
|
91
|
+
"build-2",
|
|
92
|
+
"race-specificity",
|
|
93
|
+
"peak",
|
|
94
|
+
"recovery",
|
|
95
|
+
"transition",
|
|
96
|
+
"unknown",
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
export type EngramCoachTrainingPhase = (typeof ENGRAM_COACH_TRAINING_PHASES)[number];
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Personas — coaching philosophies.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export const ENGRAM_COACH_PERSONAS = [
|
|
106
|
+
"conservative",
|
|
107
|
+
"aggressive",
|
|
108
|
+
"polarized",
|
|
109
|
+
"volume",
|
|
110
|
+
] as const;
|
|
111
|
+
|
|
112
|
+
export type EngramCoachPersona = (typeof ENGRAM_COACH_PERSONAS)[number];
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Skills — the 10 engram-coach skills that generate knowledge.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export const ENGRAM_COACH_SKILLS = [
|
|
119
|
+
"adapt-plan",
|
|
120
|
+
"consult",
|
|
121
|
+
"block-review",
|
|
122
|
+
"race-analysis",
|
|
123
|
+
"season-retrospective",
|
|
124
|
+
"lessons-rollup",
|
|
125
|
+
"monitoring-rollup",
|
|
126
|
+
"lactate-analyze",
|
|
127
|
+
"intake",
|
|
128
|
+
"set-goal",
|
|
129
|
+
] as const;
|
|
130
|
+
|
|
131
|
+
export type EngramCoachSkill = (typeof ENGRAM_COACH_SKILLS)[number];
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Entity-type to skill mapping — which skill produces which entity types.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export const ENTITY_TYPE_TO_SKILL: Record<EngramCoachEntityType, EngramCoachSkill> = {
|
|
138
|
+
"workout-adaptation": "adapt-plan",
|
|
139
|
+
consultation: "consult",
|
|
140
|
+
"block-review": "block-review",
|
|
141
|
+
"race-report": "race-analysis",
|
|
142
|
+
"season-review": "season-retrospective",
|
|
143
|
+
"arc-plan": "set-goal",
|
|
144
|
+
"lactate-test": "lactate-analyze",
|
|
145
|
+
"monitoring-capture": "monitoring-rollup",
|
|
146
|
+
"intake-record": "intake",
|
|
147
|
+
prescription: "set-goal",
|
|
148
|
+
"persona-fit": "intake",
|
|
149
|
+
"calibration-point": "lessons-rollup",
|
|
150
|
+
methodology: "set-goal",
|
|
151
|
+
"session-execution": "adapt-plan",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// EngramCoachDetails — the structured `details` payload carried in every
|
|
156
|
+
// engram-coach extraction candidate.
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export type EngramCoachDetails = {
|
|
160
|
+
/** Which engram-coach skill produced this candidate. */
|
|
161
|
+
skill?: EngramCoachSkill;
|
|
162
|
+
|
|
163
|
+
/** What kind of coaching entity. */
|
|
164
|
+
entityType: EngramCoachEntityType;
|
|
165
|
+
|
|
166
|
+
/** The specific coaching decision being made. */
|
|
167
|
+
decisionKind: EngramCoachDecisionKind;
|
|
168
|
+
|
|
169
|
+
/** Physiological signals mentioned or involved. */
|
|
170
|
+
trainingSignals?: EngramCoachTrainingSignal[];
|
|
171
|
+
|
|
172
|
+
/** Training phase context, if identifiable. */
|
|
173
|
+
trainingPhase?: EngramCoachTrainingPhase;
|
|
174
|
+
|
|
175
|
+
/** Active persona at the time of extraction. */
|
|
176
|
+
persona?: EngramCoachPersona;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Structured delta — what changed vs. what was planned.
|
|
180
|
+
* Each skill records a different shape.
|
|
181
|
+
*/
|
|
182
|
+
delta?: Record<string, unknown>;
|
|
183
|
+
|
|
184
|
+
/** Turn index within the session for provenance. */
|
|
185
|
+
turnIndex: number;
|
|
186
|
+
|
|
187
|
+
/** Confidence: "high" when LLM was used, "low" for deterministic fallback. */
|
|
188
|
+
extractionConfidence: "high" | "low";
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Coaching topic hints — expanded set for deterministic fallback extraction.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export const COACHING_TOPIC_HINTS = [
|
|
196
|
+
// Core training metrics
|
|
197
|
+
"workout", "training", "session", "interval", "recovery",
|
|
198
|
+
"hrv", "ctl", "atl", "tsb", "ft", "ftp", "fthr",
|
|
199
|
+
"vo2", "vo2max", "lactate", "lt1", "lt2", "obla",
|
|
200
|
+
"zone", "z1", "z2", "z3", "z4", "z5",
|
|
201
|
+
"rpe", "tss", "np", "if", "power", "watt",
|
|
202
|
+
"heart rate", "hr", "rhr", "resting hr",
|
|
203
|
+
|
|
204
|
+
// Health signals
|
|
205
|
+
"illness", "sick", "injury", "pain", "discomfort",
|
|
206
|
+
"sleep", "nutrition", "fuel", "hydrat", "stress",
|
|
207
|
+
"covid", "cold", "fever", "allergy",
|
|
208
|
+
|
|
209
|
+
// Coaching domain
|
|
210
|
+
"block", "phase", "season", "arc", "goal",
|
|
211
|
+
"target event", "race", "a-race", "b-race",
|
|
212
|
+
"prescription", "adapt", "modify", "change",
|
|
213
|
+
"consult", "advice", "concern",
|
|
214
|
+
"decoupling", "fade", "drift", "strength",
|
|
215
|
+
"calibration", "pattern", "lesson",
|
|
216
|
+
"monitoring", "concern", "tracking",
|
|
217
|
+
"persona", "conservative", "aggressive", "polarized", "volume",
|
|
218
|
+
"intake", "setup", "configure",
|
|
219
|
+
] as const;
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Skill-to-topic mapping — which skill a mention of a topic likely refers to.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
export const SKILL_TOPIC_MAP: Record<EngramCoachSkill, string[]> = {
|
|
226
|
+
"adapt-plan": ["adapt", "workout", "session", "prescription", "modify", "delta", "next workout"],
|
|
227
|
+
consult: ["consult", "advice", "concern", "question", "sick", "illness", "life stress"],
|
|
228
|
+
"block-review": ["block summary", "summary", "block end", "week progression"],
|
|
229
|
+
"race-analysis": ["race", "race report", "event", "target event"],
|
|
230
|
+
"season-retrospective": ["season", "retrospective", "end of season", "year review"],
|
|
231
|
+
"lessons-rollup": ["calibration", "pattern", "lesson", "profile", "rollup"],
|
|
232
|
+
"monitoring-rollup": ["monitoring", "concern", "tracking", "symptom"],
|
|
233
|
+
"lactate-analyze": ["lactate", "lt1", "lt2", "ftp", "threshold", "fthr"],
|
|
234
|
+
intake: ["intake", "setup", "configure", "onboard", "persona"],
|
|
235
|
+
"set-goal": ["goal", "arc", "target", "block plan", "methodology"],
|
|
236
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engram-coach LLM-powered knowledge extractor.
|
|
3
|
+
*
|
|
4
|
+
* Implements the KnowledgeExtractor interface using the Engram harness LLM helper
|
|
5
|
+
* when available. For each turn, the LLM analyzes the conversation and
|
|
6
|
+
* produces structured coaching-aware candidates classified into engram-coach's
|
|
7
|
+
* domain ontology (entity types, decision kinds, training signals, phases,
|
|
8
|
+
* personas). Falls back to deterministic keyword matching when the LLM
|
|
9
|
+
* helper is absent.
|
|
10
|
+
*
|
|
11
|
+
* @module engram-coach-extractor
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TurnContext, PackHelpers } from "@isparling/engram-harness/knowledge-types";
|
|
15
|
+
import {
|
|
16
|
+
ENGRAM_COACH_ENTITY_TYPES,
|
|
17
|
+
ENGRAM_COACH_DECISION_KINDS,
|
|
18
|
+
ENGRAM_COACH_TRAINING_SIGNALS,
|
|
19
|
+
ENGRAM_COACH_TRAINING_PHASES,
|
|
20
|
+
ENGRAM_COACH_PERSONAS,
|
|
21
|
+
COACHING_TOPIC_HINTS,
|
|
22
|
+
type EngramCoachDetails,
|
|
23
|
+
type EngramCoachEntityType,
|
|
24
|
+
type EngramCoachDecisionKind,
|
|
25
|
+
} from "./engram-coach-domain.ts";
|
|
26
|
+
|
|
27
|
+
export const engramCoachPackId = "engram-coach";
|
|
28
|
+
export const engramCoachPackVersion = "0.1.0";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* System prompt for the LLM-powered extractor.
|
|
32
|
+
*
|
|
33
|
+
* Instructs the LLM to analyze a conversation turn and produce structured
|
|
34
|
+
* coaching observations classified into the engram-coach domain ontology.
|
|
35
|
+
*/
|
|
36
|
+
const EXTRACTION_SYSTEM_PROMPT = `You are a coaching domain classifier embedded in the engram-coach coaching system. Your task is to analyze conversation turns and extract structured knowledge candidates about coaching decisions.
|
|
37
|
+
|
|
38
|
+
## Domain ontology
|
|
39
|
+
|
|
40
|
+
### Entity types — what kind of coaching artifact or concept this turn is about:
|
|
41
|
+
${ENGRAM_COACH_ENTITY_TYPES.map((t) => `- ${t}`).join("\n")}
|
|
42
|
+
|
|
43
|
+
### Decision kinds — what type of coaching judgment is being rendered:
|
|
44
|
+
${ENGRAM_COACH_DECISION_KINDS.map((k) => `- ${k}`).join("\n")}
|
|
45
|
+
|
|
46
|
+
### Training signals — physiological metrics mentioned:
|
|
47
|
+
${ENGRAM_COACH_TRAINING_SIGNALS.map((s) => `- ${s}`).join("\n")}
|
|
48
|
+
|
|
49
|
+
### Training phases:
|
|
50
|
+
${ENGRAM_COACH_TRAINING_PHASES.map((p) => `- ${p}`).join("\n")}
|
|
51
|
+
|
|
52
|
+
### Personas:
|
|
53
|
+
${ENGRAM_COACH_PERSONAS.map((p) => `- ${p}`).join("\n")}
|
|
54
|
+
|
|
55
|
+
## Classification rules
|
|
56
|
+
|
|
57
|
+
1. Return an empty JSON array \`[]\` if this turn contains no coaching-relevant content. Coaching-relevant means: training data, workout execution, adaptation decisions, race/event planning, consultation/advice, health signals (illness/injury/sleep/nutrition), monitoring concerns, goal/arc planning, coaching setup (intake/persona), lactate analysis, or block/season reviews.
|
|
58
|
+
|
|
59
|
+
2. Each candidate must be a JSON object with these fields:
|
|
60
|
+
- \`entityType\`: one of the entity types above
|
|
61
|
+
- \`decisionKind\`: one of the decision kinds above
|
|
62
|
+
- \`statement\`: a concise, factual one-sentence statement of what was observed or decided (max 300 chars)
|
|
63
|
+
- \`trainingSignals\`: array of signal names mentioned (or empty array)
|
|
64
|
+
- \`trainingPhase\`: training phase if identifiable, else null
|
|
65
|
+
- \`persona\`: persona if mentioned or obvious from context, else null
|
|
66
|
+
- \`topics\`: 1-4 topic labels useful for retrieval (e.g., "interval fade", "sweet spot prescription", "LT2 update", "HRV suppression")
|
|
67
|
+
|
|
68
|
+
3. For \`statement\`, prefer concrete observations over generic ones:
|
|
69
|
+
- GOOD: "Athlete reported RPE 8.5 on final interval with power fade >10%"
|
|
70
|
+
- BAD: "Athlete had a hard workout"
|
|
71
|
+
- GOOD: "Athlete switched persona from conservative to aggressive for build phase"
|
|
72
|
+
- BAD: "Persona change"
|
|
73
|
+
|
|
74
|
+
4. Return at most 3 candidates per turn. Prioritize candidates that represent decisions or new observations over routine status updates.
|
|
75
|
+
|
|
76
|
+
5. Return ONLY the JSON array. No preamble, no explanation, no markdown.`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deterministic fallback: check if the turn narrative mentions coaching
|
|
80
|
+
* topics. Returns the most likely entity type based on matched terms.
|
|
81
|
+
*/
|
|
82
|
+
function coachingRelevantEntityType(text: string): EngramCoachEntityType | null {
|
|
83
|
+
const lower = text.toLowerCase();
|
|
84
|
+
|
|
85
|
+
// Simple greedy: return null if no topic matched
|
|
86
|
+
if (!COACHING_TOPIC_HINTS.some((hint) => lower.includes(hint))) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Map topics to entity types
|
|
91
|
+
// Arc-planning: goal+arc/target is most specific — check first
|
|
92
|
+
if (
|
|
93
|
+
(lower.includes("goal") || lower.includes("arc")) &&
|
|
94
|
+
(lower.includes("target") || lower.includes("methodology"))
|
|
95
|
+
) {
|
|
96
|
+
return "arc-plan";
|
|
97
|
+
}
|
|
98
|
+
if (lower.includes("race") && (lower.includes("report") || lower.includes("analysis"))) {
|
|
99
|
+
return "race-report";
|
|
100
|
+
}
|
|
101
|
+
if (lower.includes("block") && (lower.includes("review") || lower.includes("summary"))) {
|
|
102
|
+
return "block-review";
|
|
103
|
+
}
|
|
104
|
+
if (lower.includes("season") && (lower.includes("review") || lower.includes("retrospective"))) {
|
|
105
|
+
return "season-review";
|
|
106
|
+
}
|
|
107
|
+
if (lower.includes("consult") || lower.includes("advice") || lower.includes("question about")) {
|
|
108
|
+
return "consultation";
|
|
109
|
+
}
|
|
110
|
+
if (lower.includes("intake") || lower.includes("setup") || lower.includes("onboard")) {
|
|
111
|
+
return "intake-record";
|
|
112
|
+
}
|
|
113
|
+
if (lower.includes("adapt") || lower.includes("modify") || lower.includes("delta") || lower.includes("prescription")) {
|
|
114
|
+
return "workout-adaptation";
|
|
115
|
+
}
|
|
116
|
+
if (lower.includes("lactate") || lower.includes("threshold") || lower.includes("lt1") || lower.includes("lt2")) {
|
|
117
|
+
return "lactate-test";
|
|
118
|
+
}
|
|
119
|
+
if (lower.includes("monitor") || lower.includes("concern") || lower.includes("symptom") || lower.includes("tracking")) {
|
|
120
|
+
return "monitoring-capture";
|
|
121
|
+
}
|
|
122
|
+
if (lower.includes("persona") || lower.includes("philosophy")) {
|
|
123
|
+
return "persona-fit";
|
|
124
|
+
}
|
|
125
|
+
if (lower.includes("lesson") || lower.includes("calibration") || lower.includes("profile") || lower.includes("rollup")) {
|
|
126
|
+
return "calibration-point";
|
|
127
|
+
}
|
|
128
|
+
if (lower.includes("race") || lower.includes("event")) {
|
|
129
|
+
return "race-report";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Generic coaching observation
|
|
133
|
+
return "session-execution";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function deterministicDecisionKind(entityType: EngramCoachEntityType): EngramCoachDecisionKind {
|
|
137
|
+
const map: Record<EngramCoachEntityType, EngramCoachDecisionKind> = {
|
|
138
|
+
"workout-adaptation": "workout-adaptation",
|
|
139
|
+
consultation: "consultation-advice",
|
|
140
|
+
"block-review": "block-restructure",
|
|
141
|
+
"race-report": "workout-adaptation",
|
|
142
|
+
"season-review": "arc-planning",
|
|
143
|
+
"arc-plan": "arc-planning",
|
|
144
|
+
"lactate-test": "threshold-update",
|
|
145
|
+
"monitoring-capture": "monitoring-capture",
|
|
146
|
+
"intake-record": "setup-decision",
|
|
147
|
+
prescription: "arc-planning",
|
|
148
|
+
"persona-fit": "persona-change",
|
|
149
|
+
"calibration-point": "profile-claim",
|
|
150
|
+
methodology: "arc-planning",
|
|
151
|
+
"session-execution": "workout-adaptation",
|
|
152
|
+
};
|
|
153
|
+
return map[entityType];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse the LLM's JSON response into candidate objects.
|
|
158
|
+
* Returns empty array on any parse failure.
|
|
159
|
+
*/
|
|
160
|
+
function parseLlmResponse(raw: string): Record<string, unknown>[] {
|
|
161
|
+
const trimmed = raw.trim();
|
|
162
|
+
|
|
163
|
+
// Strip markdown code fences if present
|
|
164
|
+
const jsonStr = trimmed.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(jsonStr);
|
|
168
|
+
if (!Array.isArray(parsed)) return [];
|
|
169
|
+
return parsed;
|
|
170
|
+
} catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The engram-coach extractor facet.
|
|
177
|
+
*
|
|
178
|
+
* Uses LLM when available (via PackHelpers.llm.complete) to produce
|
|
179
|
+
* coaching-aware candidates classified into the domain ontology.
|
|
180
|
+
* Falls back to deterministic keyword matching without LLM.
|
|
181
|
+
*/
|
|
182
|
+
export const engramCoachExtractor = {
|
|
183
|
+
id: engramCoachPackId,
|
|
184
|
+
version: engramCoachPackVersion,
|
|
185
|
+
|
|
186
|
+
async extractCandidates(
|
|
187
|
+
turn: TurnContext,
|
|
188
|
+
helpers: PackHelpers,
|
|
189
|
+
): Promise<Record<string, unknown>[]> {
|
|
190
|
+
const narrative = turn.narrative?.trim() ?? "";
|
|
191
|
+
if (!narrative) return [];
|
|
192
|
+
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
// LLM path
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
if (helpers.llm?.complete) {
|
|
197
|
+
try {
|
|
198
|
+
const raw = await helpers.llm.complete(
|
|
199
|
+
`Analyze this conversation turn and extract coaching knowledge candidates as JSON:\n\n${narrative}`,
|
|
200
|
+
{ system: EXTRACTION_SYSTEM_PROMPT },
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const candidates = parseLlmResponse(raw);
|
|
204
|
+
if (candidates.length > 0) {
|
|
205
|
+
// Enrich each candidate with pack metadata
|
|
206
|
+
return candidates.map((c, i) => {
|
|
207
|
+
const entityType = (c.entityType as EngramCoachEntityType) ?? "session-execution";
|
|
208
|
+
const decisionKind = (c.decisionKind as EngramCoachDecisionKind) ?? "workout-adaptation";
|
|
209
|
+
const statement = (c.statement as string) ?? narrative.slice(0, 300);
|
|
210
|
+
const topics = (c.topics as string[]) ?? ["coaching:observation"];
|
|
211
|
+
|
|
212
|
+
const details: EngramCoachDetails = {
|
|
213
|
+
skill: undefined,
|
|
214
|
+
entityType,
|
|
215
|
+
decisionKind,
|
|
216
|
+
trainingSignals: c.trainingSignals as EngramCoachDetails["trainingSignals"],
|
|
217
|
+
trainingPhase: c.trainingPhase as EngramCoachDetails["trainingPhase"],
|
|
218
|
+
persona: c.persona as EngramCoachDetails["persona"],
|
|
219
|
+
turnIndex: turn.turnIndex,
|
|
220
|
+
extractionConfidence: "high",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
id: `engram-coach-turn-${turn.turnIndex}-${i}-${Date.now()}`,
|
|
225
|
+
kind: "decision",
|
|
226
|
+
status: "candidate",
|
|
227
|
+
disposition: "new",
|
|
228
|
+
scope: {
|
|
229
|
+
space: engramCoachPackId,
|
|
230
|
+
subjects: [],
|
|
231
|
+
topics,
|
|
232
|
+
contexts: [],
|
|
233
|
+
dimensions: {},
|
|
234
|
+
},
|
|
235
|
+
pack: { id: engramCoachPackId, version: engramCoachPackVersion },
|
|
236
|
+
sources: [
|
|
237
|
+
{ type: "engram-coach-extractor", ref: `session:${turn.session.id}` },
|
|
238
|
+
{ type: "llm-inference", ref: `turn:${turn.turnIndex}` },
|
|
239
|
+
],
|
|
240
|
+
session: turn.session,
|
|
241
|
+
submittedAt: turn.timestamp,
|
|
242
|
+
details: details as unknown as Record<string, unknown>,
|
|
243
|
+
statement: statement.slice(0, 512),
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// LLM call failed — fall through to deterministic path
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// Deterministic fallback path
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
if (narrative.length === 0) return [];
|
|
256
|
+
|
|
257
|
+
const entityType = coachingRelevantEntityType(narrative);
|
|
258
|
+
if (!entityType) return [];
|
|
259
|
+
|
|
260
|
+
const decisionKind = deterministicDecisionKind(entityType);
|
|
261
|
+
|
|
262
|
+
const details: EngramCoachDetails = {
|
|
263
|
+
skill: undefined,
|
|
264
|
+
entityType,
|
|
265
|
+
decisionKind,
|
|
266
|
+
turnIndex: turn.turnIndex,
|
|
267
|
+
extractionConfidence: "low",
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const id = `engram-coach-turn-${turn.turnIndex}-${Date.now()}`;
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
id,
|
|
274
|
+
kind: "decision",
|
|
275
|
+
status: "candidate",
|
|
276
|
+
disposition: "new",
|
|
277
|
+
scope: {
|
|
278
|
+
space: engramCoachPackId,
|
|
279
|
+
subjects: [],
|
|
280
|
+
topics: ["coaching:observation"],
|
|
281
|
+
contexts: [],
|
|
282
|
+
dimensions: {},
|
|
283
|
+
},
|
|
284
|
+
pack: { id: engramCoachPackId, version: engramCoachPackVersion },
|
|
285
|
+
sources: [
|
|
286
|
+
{ type: "engram-coach-extractor", ref: `session:${turn.session.id}` },
|
|
287
|
+
],
|
|
288
|
+
session: turn.session,
|
|
289
|
+
submittedAt: turn.timestamp,
|
|
290
|
+
details: details as unknown as Record<string, unknown>,
|
|
291
|
+
statement: narrative.slice(0, 300),
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
},
|
|
295
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engram-coach federated pack — a self-contained implementation of the
|
|
3
|
+
* Engram core's external pack interfaces. engram-coach owns its domain
|
|
4
|
+
* taxonomy, extraction prompts, validation, and reconciliation logic. The
|
|
5
|
+
* Engram core never needs to know this pack's name at build time: the CLI
|
|
6
|
+
* resolves it at runtime as an ordinary Node ESM module, imported from the
|
|
7
|
+
* `from` specifier a space's binding declares for it in `installed_packs`.
|
|
8
|
+
*
|
|
9
|
+
* The pack implements:
|
|
10
|
+
* - KnowledgeExtractor.extractCandidates — LLM-powered turn-end extraction
|
|
11
|
+
* with deterministic fallback when LLM helper is unavailable
|
|
12
|
+
* - KnowledgePack.validateEnvelope / reconcile — domain-aware validation
|
|
13
|
+
* and semantic reconciliation using the engram-coach coaching ontology
|
|
14
|
+
* - PresentationPack — deterministic athlete-profile projection and
|
|
15
|
+
* audience authorization, defined in `engram-coach-presentation.ts`
|
|
16
|
+
*
|
|
17
|
+
* See `@isparling/engram-harness`'s `harness/docs/pack-interface.md` for the
|
|
18
|
+
* external pack contract. See `engram-coach-domain.ts` for the coaching
|
|
19
|
+
* ontology types and constants.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
KnowledgePack,
|
|
24
|
+
KnowledgeExtractor,
|
|
25
|
+
KnowledgeRecord,
|
|
26
|
+
PresentationPack,
|
|
27
|
+
} from "@isparling/engram-harness/knowledge-types";
|
|
28
|
+
import { engramCoachExtractor } from "./engram-coach-extractor.ts";
|
|
29
|
+
import { engramCoachPresentation } from "./engram-coach-presentation.ts";
|
|
30
|
+
import {
|
|
31
|
+
validateEnvelope,
|
|
32
|
+
reconcile,
|
|
33
|
+
relatedQuery,
|
|
34
|
+
} from "./engram-coach-reconciliation.ts";
|
|
35
|
+
|
|
36
|
+
export const engramCoachPackId = "engram-coach";
|
|
37
|
+
export const engramCoachPackVersion = "0.1.0";
|
|
38
|
+
|
|
39
|
+
/** The engram-coach pack: KnowledgePack + KnowledgeExtractor + PresentationPack facets. */
|
|
40
|
+
export const engramCoachPack: KnowledgePack & KnowledgeExtractor & PresentationPack = {
|
|
41
|
+
id: engramCoachPackId,
|
|
42
|
+
version: engramCoachPackVersion,
|
|
43
|
+
|
|
44
|
+
// KnowledgePack facets
|
|
45
|
+
validateEnvelope,
|
|
46
|
+
relatedQuery,
|
|
47
|
+
reconcile,
|
|
48
|
+
|
|
49
|
+
// KnowledgeExtractor facets
|
|
50
|
+
extractCandidates: engramCoachExtractor.extractCandidates,
|
|
51
|
+
|
|
52
|
+
// PresentationPack facets
|
|
53
|
+
retrievalPolicy: engramCoachPresentation.retrievalPolicy,
|
|
54
|
+
views: engramCoachPresentation.views,
|
|
55
|
+
audiences: engramCoachPresentation.audiences,
|
|
56
|
+
deliveries: engramCoachPresentation.deliveries,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default engramCoachPack;
|
|
60
|
+
|
|
61
|
+
export type EngramCoachRecord = KnowledgeRecord;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engram-coach profile presentation — deterministic, record-derived athlete
|
|
3
|
+
* profile projection and audience authorization policy.
|
|
4
|
+
*
|
|
5
|
+
* Implements the PresentationPack interface: a single retrieval policy that
|
|
6
|
+
* scopes guarded retrieval to active engram-coach records, one `athlete-profile`
|
|
7
|
+
* view whose projection is built only from the statements and details already
|
|
8
|
+
* present on the retrieved records (no inferred facts, diagnoses, or
|
|
9
|
+
* prescriptions), and four audiences that gate which records each role may
|
|
10
|
+
* see before that projection is adapted into a delivery draft.
|
|
11
|
+
*
|
|
12
|
+
* `athlete`, `coach`, and `self-coach` authorize every record the retrieval
|
|
13
|
+
* policy accepts. `clinician` is narrower: monitoring captures, lactate
|
|
14
|
+
* tests, and any record (including workout adaptations) that carries a
|
|
15
|
+
* health-relevant training signal.
|
|
16
|
+
*
|
|
17
|
+
* @module engram-coach-presentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
KnowledgeRecord,
|
|
22
|
+
PresentationPack,
|
|
23
|
+
SemanticProjection,
|
|
24
|
+
AudienceAdaptationInput,
|
|
25
|
+
PresentationDraft,
|
|
26
|
+
} from "@isparling/engram-harness/knowledge-types";
|
|
27
|
+
|
|
28
|
+
// Duplicated locally (mirrors the same constants in `engram-coach-pack.ts`
|
|
29
|
+
// and `engram-coach-extractor.ts`) so this module never needs to import the
|
|
30
|
+
// pack composition module — `engram-coach-pack.ts` imports this module to
|
|
31
|
+
// assemble the full pack, and a reverse import would create a cycle.
|
|
32
|
+
export const engramCoachPackId = "engram-coach";
|
|
33
|
+
export const engramCoachPackVersion = "0.1.0";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Clinician authorization — monitoring captures, lactate tests, and any
|
|
37
|
+
// record (workout adaptations included) carrying a health-relevant signal.
|
|
38
|
+
// `record.details` is an untyped `JsonObject`; every read below narrows via
|
|
39
|
+
// `typeof`/`Array.isArray`/direct literal comparison instead of asserting
|
|
40
|
+
// the payload into the domain's `EngramCoachDetails` shape.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const CLINICIAN_TRAINING_SIGNALS: Readonly<Record<string, true>> = {
|
|
44
|
+
hrv: true,
|
|
45
|
+
rhr: true,
|
|
46
|
+
"lactate-threshold": true,
|
|
47
|
+
sleep: true,
|
|
48
|
+
stress: true,
|
|
49
|
+
illness: true,
|
|
50
|
+
injury: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function hasClinicalSignal(record: KnowledgeRecord): boolean {
|
|
54
|
+
const value = record.details.trainingSignals;
|
|
55
|
+
if (!Array.isArray(value)) return false;
|
|
56
|
+
return value.some((signal) => typeof signal === "string" && CLINICIAN_TRAINING_SIGNALS[signal] === true);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Retrieval policy — scope every query and profile enumeration to active
|
|
61
|
+
// engram-coach records only.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const retrievalPolicy: PresentationPack["retrievalPolicy"] = {
|
|
65
|
+
allowedSourceClasses: ["engram-coach"],
|
|
66
|
+
queryStrategy: ({ query }) => query,
|
|
67
|
+
classifySource: () => "engram-coach",
|
|
68
|
+
relevanceThreshold: null,
|
|
69
|
+
isEligible: (record) =>
|
|
70
|
+
record.status === "active" && record.pack.id === engramCoachPackId,
|
|
71
|
+
includePresentations: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// `athlete-profile` view — deterministic, record-derived projection.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function uniqueSorted(values: readonly string[]): string[] {
|
|
79
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function projectAthleteProfile(records: readonly KnowledgeRecord[]): SemanticProjection {
|
|
83
|
+
const facts = uniqueSorted(records.map((record) => record.statement));
|
|
84
|
+
const uncertainty = uniqueSorted(
|
|
85
|
+
records
|
|
86
|
+
.filter((record) => record.details.extractionConfidence === "low")
|
|
87
|
+
.map((record) => record.statement),
|
|
88
|
+
);
|
|
89
|
+
const activeRecommendations = records.filter(
|
|
90
|
+
(record) => record.status === "active" && record.kind === "recommendation",
|
|
91
|
+
);
|
|
92
|
+
const actions = uniqueSorted(activeRecommendations.map((record) => record.statement));
|
|
93
|
+
const recommendationIds = uniqueSorted(activeRecommendations.map((record) => record.id));
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
title: "Engram coach athlete profile",
|
|
97
|
+
summary:
|
|
98
|
+
"Deterministic projection of active, retrieval-eligible engram-coach records for the requesting audience.",
|
|
99
|
+
facts,
|
|
100
|
+
requiredFacts: [...facts],
|
|
101
|
+
uncertainty,
|
|
102
|
+
actions,
|
|
103
|
+
recommendationIds,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const athleteProfileView: PresentationPack["views"][number] = {
|
|
108
|
+
id: "athlete-profile",
|
|
109
|
+
version: 1,
|
|
110
|
+
scope: "space",
|
|
111
|
+
retrievalQuery: (requestedQuery) => requestedQuery ?? "",
|
|
112
|
+
project: projectAthleteProfile,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Audiences — authorization plus a draft adaptation that carries the
|
|
117
|
+
// projection's facts, uncertainty, actions, and recommendation IDs forward
|
|
118
|
+
// unchanged. No audience adds content the projection did not already derive
|
|
119
|
+
// from the authorized records.
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function adaptFromProjection(title: string) {
|
|
123
|
+
return ({ projection }: AudienceAdaptationInput): PresentationDraft => ({
|
|
124
|
+
title,
|
|
125
|
+
summary: projection.summary,
|
|
126
|
+
facts: [...projection.facts],
|
|
127
|
+
uncertainty: [...projection.uncertainty],
|
|
128
|
+
actions: [...projection.actions],
|
|
129
|
+
recommendationIds: [...projection.recommendationIds],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const athleteAudience: PresentationPack["audiences"][number] = {
|
|
134
|
+
id: "athlete",
|
|
135
|
+
version: 1,
|
|
136
|
+
authorize: retrievalPolicy.isEligible,
|
|
137
|
+
adapt: adaptFromProjection("Athlete profile"),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const coachAudience: PresentationPack["audiences"][number] = {
|
|
141
|
+
id: "coach",
|
|
142
|
+
version: 1,
|
|
143
|
+
authorize: retrievalPolicy.isEligible,
|
|
144
|
+
adapt: adaptFromProjection("Coach profile"),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const selfCoachAudience: PresentationPack["audiences"][number] = {
|
|
148
|
+
id: "self-coach",
|
|
149
|
+
version: 1,
|
|
150
|
+
authorize: retrievalPolicy.isEligible,
|
|
151
|
+
adapt: adaptFromProjection("Self-coach profile"),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const clinicianAudience: PresentationPack["audiences"][number] = {
|
|
155
|
+
id: "clinician",
|
|
156
|
+
version: 1,
|
|
157
|
+
authorize: (record) => {
|
|
158
|
+
if (!retrievalPolicy.isEligible(record)) return false;
|
|
159
|
+
const entityType = record.details.entityType;
|
|
160
|
+
if (entityType === "monitoring-capture" || entityType === "lactate-test") return true;
|
|
161
|
+
return hasClinicalSignal(record);
|
|
162
|
+
},
|
|
163
|
+
adapt: adaptFromProjection("Clinician profile"),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Deliveries
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const deliveries: PresentationPack["deliveries"] = [
|
|
171
|
+
{ id: "profile-markdown", version: 1, format: "markdown", maxWords: 5000, retain: true },
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// The engram-coach PresentationPack facet.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export const engramCoachPresentation: PresentationPack = {
|
|
179
|
+
id: engramCoachPackId,
|
|
180
|
+
version: engramCoachPackVersion,
|
|
181
|
+
retrievalPolicy,
|
|
182
|
+
views: [athleteProfileView],
|
|
183
|
+
audiences: [athleteAudience, coachAudience, selfCoachAudience, clinicianAudience],
|
|
184
|
+
deliveries,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export default engramCoachPresentation;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engram-coach domain-aware validation and reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Implements the KnowledgePack facets with full awareness of the coaching
|
|
5
|
+
* domain ontology defined in engram-coach-domain.ts.
|
|
6
|
+
*
|
|
7
|
+
* - validateEnvelope: validates candidates against known entity types,
|
|
8
|
+
* decision kinds, statement requirements, and detail structure.
|
|
9
|
+
* - reconcile: understands semantic relationships between candidates and
|
|
10
|
+
* existing knowledge — threshold updates supersede prior thresholds,
|
|
11
|
+
* same-block observations support each other, duplicate statements are
|
|
12
|
+
* deduped, and identical claims from different sources get merged.
|
|
13
|
+
*
|
|
14
|
+
* @module engram-coach-reconciliation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
KnowledgeEnvelope,
|
|
19
|
+
KnowledgeResult,
|
|
20
|
+
PackReconciliation,
|
|
21
|
+
PackReconcileInput,
|
|
22
|
+
KnowledgeRecord,
|
|
23
|
+
} from "@isparling/engram-harness/knowledge-types";
|
|
24
|
+
import {
|
|
25
|
+
ENGRAM_COACH_ENTITY_TYPES,
|
|
26
|
+
ENGRAM_COACH_DECISION_KINDS,
|
|
27
|
+
ENGRAM_COACH_TRAINING_PHASES,
|
|
28
|
+
ENGRAM_COACH_PERSONAS,
|
|
29
|
+
ENTITY_TYPE_TO_SKILL,
|
|
30
|
+
type EngramCoachDetails,
|
|
31
|
+
type EngramCoachEntityType,
|
|
32
|
+
} from "./engram-coach-domain.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Known destination topics used when validating scope topics.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const KNOWN_TOPIC_PREFIXES = [
|
|
39
|
+
"coaching:",
|
|
40
|
+
"training:",
|
|
41
|
+
"physiology:",
|
|
42
|
+
"health:",
|
|
43
|
+
"planning:",
|
|
44
|
+
"monitoring:",
|
|
45
|
+
"content:",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate an engram-coach knowledge envelope against the domain ontology.
|
|
50
|
+
*
|
|
51
|
+
* Checks:
|
|
52
|
+
* - Statement is non-empty and within length limits
|
|
53
|
+
* - entityType is a known engram-coach entity type
|
|
54
|
+
* - decisionKind is a known engram-coach decision kind
|
|
55
|
+
* - trainingPhase (if present) is a known phase
|
|
56
|
+
* - persona (if present) is a known persona
|
|
57
|
+
* - topics use known prefixes
|
|
58
|
+
*/
|
|
59
|
+
export function validateEnvelope(
|
|
60
|
+
envelope: KnowledgeEnvelope,
|
|
61
|
+
): KnowledgeResult<void> {
|
|
62
|
+
const errors: Array<{ kind: "validation"; code: string; field?: string; message: string }> = [];
|
|
63
|
+
|
|
64
|
+
// --- Statement checks ---
|
|
65
|
+
const statement = envelope.statement?.trim() ?? "";
|
|
66
|
+
if (!statement) {
|
|
67
|
+
errors.push({
|
|
68
|
+
kind: "validation",
|
|
69
|
+
code: "empty_statement",
|
|
70
|
+
field: "statement",
|
|
71
|
+
message: "statement must not be empty",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (statement.length > 5000) {
|
|
75
|
+
errors.push({
|
|
76
|
+
kind: "validation",
|
|
77
|
+
code: "statement_too_long",
|
|
78
|
+
field: "statement",
|
|
79
|
+
message: "statement must be at most 5000 characters",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Details checks ---
|
|
84
|
+
const details = envelope.details as Partial<EngramCoachDetails> | undefined;
|
|
85
|
+
|
|
86
|
+
if (details) {
|
|
87
|
+
const entityType = details.entityType;
|
|
88
|
+
if (entityType && !((ENGRAM_COACH_ENTITY_TYPES as readonly string[]).includes(entityType))) {
|
|
89
|
+
errors.push({
|
|
90
|
+
kind: "validation",
|
|
91
|
+
code: "unknown_entity_type",
|
|
92
|
+
field: "details.entityType",
|
|
93
|
+
message: `"${entityType}" is not a known engram-coach entity type`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const decisionKind = details.decisionKind;
|
|
98
|
+
if (decisionKind && !((ENGRAM_COACH_DECISION_KINDS as readonly string[]).includes(decisionKind))) {
|
|
99
|
+
errors.push({
|
|
100
|
+
kind: "validation",
|
|
101
|
+
code: "unknown_decision_kind",
|
|
102
|
+
field: "details.decisionKind",
|
|
103
|
+
message: `"${decisionKind}" is not a known engram-coach decision kind`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const trainingPhase = details.trainingPhase;
|
|
108
|
+
if (trainingPhase && !((ENGRAM_COACH_TRAINING_PHASES as readonly string[]).includes(trainingPhase))) {
|
|
109
|
+
errors.push({
|
|
110
|
+
kind: "validation",
|
|
111
|
+
code: "unknown_training_phase",
|
|
112
|
+
field: "details.trainingPhase",
|
|
113
|
+
message: `"${trainingPhase}" is not a known training phase`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const persona = details.persona;
|
|
118
|
+
if (persona && !((ENGRAM_COACH_PERSONAS as readonly string[]).includes(persona))) {
|
|
119
|
+
errors.push({
|
|
120
|
+
kind: "validation",
|
|
121
|
+
code: "unknown_persona",
|
|
122
|
+
field: "details.persona",
|
|
123
|
+
message: `"${persona}" is not a known persona`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Topic checks ---
|
|
129
|
+
if (envelope.scope?.topics) {
|
|
130
|
+
for (const topic of envelope.scope.topics) {
|
|
131
|
+
const hasKnownPrefix = KNOWN_TOPIC_PREFIXES.some((p) => topic.startsWith(p));
|
|
132
|
+
if (!hasKnownPrefix) {
|
|
133
|
+
errors.push({
|
|
134
|
+
kind: "validation",
|
|
135
|
+
code: "unknown_topic_prefix",
|
|
136
|
+
field: "scope.topics",
|
|
137
|
+
message: `topic "${topic}" does not use a known prefix`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (errors.length > 0) {
|
|
144
|
+
return { ok: false, errors };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { ok: true, value: undefined };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Infer the entity type from a knowledge record's details.
|
|
152
|
+
*/
|
|
153
|
+
function entityTypeFromRecord(record: KnowledgeRecord): EngramCoachEntityType | null {
|
|
154
|
+
const details = record.details as Partial<EngramCoachDetails> | undefined;
|
|
155
|
+
return details?.entityType ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Domain-aware reconciliation.
|
|
160
|
+
*
|
|
161
|
+
* Understands semantic relationships:
|
|
162
|
+
* - Same entity type + same scope + similar statement → support (refine)
|
|
163
|
+
* - Threshold update for same athlete → supersede prior threshold
|
|
164
|
+
* - Identical statement across different sources → support (merge)
|
|
165
|
+
* - Different entity types → new (independent observation)
|
|
166
|
+
* - No related records → accept as new
|
|
167
|
+
*/
|
|
168
|
+
export function reconcile(
|
|
169
|
+
input: PackReconcileInput,
|
|
170
|
+
): KnowledgeResult<PackReconciliation> {
|
|
171
|
+
const candidate = input.candidate;
|
|
172
|
+
const related = input.related;
|
|
173
|
+
const candidateDetails = candidate.details as Partial<EngramCoachDetails> | undefined;
|
|
174
|
+
const candidateEntityType = candidateDetails?.entityType;
|
|
175
|
+
|
|
176
|
+
// No related records → accept as new
|
|
177
|
+
if (related.length === 0) {
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
value: {
|
|
181
|
+
disposition: "new",
|
|
182
|
+
summary: candidateEntityType
|
|
183
|
+
? `engram-coach accepted new ${candidateEntityType} observation`
|
|
184
|
+
: "engram-coach accepted new coaching observation",
|
|
185
|
+
mutations: [],
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Semantic matching against related records ---
|
|
191
|
+
const latest = related[0];
|
|
192
|
+
const latestDetails = latest.details as Partial<EngramCoachDetails> | undefined;
|
|
193
|
+
const latestEntityType = latestDetails?.entityType;
|
|
194
|
+
|
|
195
|
+
// 1. Identical statement → no-change (dedupe)
|
|
196
|
+
if (latest.statement === candidate.statement) {
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
value: {
|
|
200
|
+
disposition: "no-change",
|
|
201
|
+
summary: "duplicate of an existing coaching observation",
|
|
202
|
+
mutations: [],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Same entity type — refine or support
|
|
208
|
+
if (candidateEntityType && candidateEntityType === latestEntityType) {
|
|
209
|
+
// Threshold updates supersede prior thresholds for the same athlete
|
|
210
|
+
if (candidateEntityType === "lactate-test") {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
value: {
|
|
214
|
+
disposition: "supersede",
|
|
215
|
+
summary: `new lactate test result supersedes prior threshold values`,
|
|
216
|
+
mutations: [
|
|
217
|
+
{
|
|
218
|
+
action: "update",
|
|
219
|
+
record: {
|
|
220
|
+
...latest,
|
|
221
|
+
status: "retired",
|
|
222
|
+
details: {
|
|
223
|
+
...(latest.details as Record<string, unknown>),
|
|
224
|
+
supersededBy: candidate.id,
|
|
225
|
+
supersededAt: candidate.submittedAt,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Persona changes supersede prior persona
|
|
235
|
+
if (candidateEntityType === "persona-fit") {
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
value: {
|
|
239
|
+
disposition: "supersede",
|
|
240
|
+
summary: `persona decision supersedes prior persona-fit record`,
|
|
241
|
+
mutations: [
|
|
242
|
+
{
|
|
243
|
+
action: "update",
|
|
244
|
+
record: {
|
|
245
|
+
...latest,
|
|
246
|
+
status: "retired",
|
|
247
|
+
details: {
|
|
248
|
+
...(latest.details as Record<string, unknown>),
|
|
249
|
+
supersededBy: candidate.id,
|
|
250
|
+
supersededAt: candidate.submittedAt,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Same entity type, different statement → refine (new info on same topic)
|
|
260
|
+
return {
|
|
261
|
+
ok: true,
|
|
262
|
+
value: {
|
|
263
|
+
disposition: "refine",
|
|
264
|
+
summary: `additional ${candidateEntityType} observation refines prior record`,
|
|
265
|
+
mutations: [],
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 3. Different entity types, same outcome scope
|
|
271
|
+
if (candidateEntityType && latestEntityType && candidateEntityType !== latestEntityType) {
|
|
272
|
+
// Different entity type but complementary scope → support
|
|
273
|
+
const skillCandidate = ENTITY_TYPE_TO_SKILL[candidateEntityType];
|
|
274
|
+
const skillLatest = ENTITY_TYPE_TO_SKILL[latestEntityType];
|
|
275
|
+
|
|
276
|
+
if (skillCandidate === skillLatest) {
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
value: {
|
|
280
|
+
disposition: "support",
|
|
281
|
+
summary: `${candidateEntityType} observation supports prior ${latestEntityType} record from same skill`,
|
|
282
|
+
mutations: [],
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 4. Unrelated records → accept as new
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
value: {
|
|
292
|
+
disposition: "new",
|
|
293
|
+
summary: "engram-coach accepted a coaching observation (no direct relationship to prior records)",
|
|
294
|
+
mutations: [],
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Build a query string from an envelope for finding related records.
|
|
301
|
+
*/
|
|
302
|
+
export function relatedQuery(envelope: KnowledgeEnvelope): string {
|
|
303
|
+
const details = envelope.details as Partial<EngramCoachDetails> | undefined;
|
|
304
|
+
const entityType = details?.entityType;
|
|
305
|
+
const persona = details?.persona;
|
|
306
|
+
const trainingPhase = details?.trainingPhase;
|
|
307
|
+
const trainingSignals = details?.trainingSignals;
|
|
308
|
+
|
|
309
|
+
const parts: string[] = [];
|
|
310
|
+
|
|
311
|
+
if (entityType) parts.push(entityType);
|
|
312
|
+
if (persona) parts.push(`persona:${persona}`);
|
|
313
|
+
if (trainingPhase) parts.push(`phase:${trainingPhase}`);
|
|
314
|
+
if (trainingSignals && trainingSignals.length > 0) {
|
|
315
|
+
parts.push(trainingSignals.slice(0, 3).join(" "));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Fall back to statement content
|
|
319
|
+
const statement = envelope.statement?.trim() ?? "";
|
|
320
|
+
if (parts.length === 0 && statement) {
|
|
321
|
+
parts.push(statement.slice(0, 100));
|
|
322
|
+
} else if (parts.length === 0) {
|
|
323
|
+
parts.push("coaching observation");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return parts.join(" ");
|
|
327
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@isparling/engram-coach",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Engram external pack for document-driven endurance coaching.",
|
|
7
|
+
"exports": { ".": { "import": "./engram-coach-pack.ts" } },
|
|
8
|
+
"files": [
|
|
9
|
+
"engram-coach-domain.ts",
|
|
10
|
+
"engram-coach-extractor.ts",
|
|
11
|
+
"engram-coach-reconciliation.ts",
|
|
12
|
+
"engram-coach-presentation.ts",
|
|
13
|
+
"engram-coach-pack.ts",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"peerDependencies": { "@isparling/engram-harness": "^0.1.0" },
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@isparling/engram-harness": "file:../engram/harness",
|
|
19
|
+
"typescript": "^7.0.2"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": { "access": "public" },
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "npm test --prefix tools",
|
|
24
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
25
|
+
"pack:local": "npm pack --json"
|
|
26
|
+
}
|
|
27
|
+
}
|