@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 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
+ }