@isparling/engram-coach 0.1.0 → 0.2.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.
Files changed (51) hide show
  1. package/README.md +85 -18
  2. package/SETUP.md +559 -0
  3. package/SKILL_PACK.md +75 -0
  4. package/analyses/catalog.md +257 -0
  5. package/analysis-tools/hrv-trend.ts +592 -0
  6. package/analysis-tools/migrate-structured-capture.ts +234 -0
  7. package/analysis-tools/race-context.ts +96 -0
  8. package/analysis-tools/stream-analyze.ts +1008 -0
  9. package/analysis-tools/tsb-predict.ts +117 -0
  10. package/capture-handler.ts +301 -0
  11. package/config.json.example +21 -0
  12. package/engram-coach-ambient-capture.ts +336 -0
  13. package/engram-coach-capture-types.ts +185 -0
  14. package/engram-coach-config.ts +268 -0
  15. package/engram-coach-domain.ts +7 -2
  16. package/engram-coach-keys.ts +189 -0
  17. package/engram-coach-materialization.ts +638 -0
  18. package/engram-coach-migration.ts +1078 -0
  19. package/engram-coach-pack.ts +17 -12
  20. package/engram-coach-presentation.ts +10 -1
  21. package/engram-coach-reconciliation.ts +305 -2
  22. package/engram-coach-structured-capture.ts +622 -0
  23. package/package.json +39 -6
  24. package/personas/aggressive-monitoring.md +121 -0
  25. package/personas/aggressive.json +85 -0
  26. package/personas/conservative-monitoring.md +133 -0
  27. package/personas/conservative.json +93 -0
  28. package/personas/polarized-monitoring.md +112 -0
  29. package/personas/polarized.json +72 -0
  30. package/personas/volume-monitoring.md +85 -0
  31. package/personas/volume.json +108 -0
  32. package/shared/retrieval.md +71 -0
  33. package/shared/setup.md +207 -0
  34. package/skills/.gitkeep +0 -0
  35. package/skills/adapt-plan/SKILL.md +263 -0
  36. package/skills/block-review/SKILL.md +275 -0
  37. package/skills/consult/SKILL.md +176 -0
  38. package/skills/intake/SKILL.md +315 -0
  39. package/skills/lactate-analyze/SKILL.md +230 -0
  40. package/skills/lessons-rollup/SKILL.md +196 -0
  41. package/skills/monitoring-rollup/SKILL.md +208 -0
  42. package/skills/race-analysis/SKILL.md +219 -0
  43. package/skills/season-retrospective/SKILL.md +200 -0
  44. package/skills/set-goal/SKILL.md +297 -0
  45. package/templates/base.md +55 -0
  46. package/templates/build-1.md +57 -0
  47. package/templates/build-2.md +62 -0
  48. package/templates/race-report.md +51 -0
  49. package/templates/race-specificity.md +62 -0
  50. package/templates/season-review.md +40 -0
  51. package/engram-coach-extractor.ts +0 -295
@@ -0,0 +1,117 @@
1
+ import { fileURLToPath } from 'url';
2
+ import { resolve } from 'path';
3
+
4
+ interface DayProjection {
5
+ day: number;
6
+ tss: number;
7
+ ctl: number;
8
+ atl: number;
9
+ tsb: number;
10
+ }
11
+
12
+ interface Projection {
13
+ initial: { ctl: number; atl: number; tsb: number };
14
+ projections: DayProjection[];
15
+ }
16
+
17
+ interface ProjectionOptions {
18
+ ctlTau?: number;
19
+ atlTau?: number;
20
+ }
21
+
22
+ function round1(n: number): number {
23
+ return Math.round(n * 10) / 10;
24
+ }
25
+
26
+ export function projectTSB(
27
+ ctl: number,
28
+ atl: number,
29
+ tssSequence: number[],
30
+ options?: ProjectionOptions,
31
+ ): Projection {
32
+ const ctlTau = options?.ctlTau ?? 42;
33
+ const atlTau = options?.atlTau ?? 7;
34
+
35
+ const projections: DayProjection[] = [];
36
+ let currentCtl = ctl;
37
+ let currentAtl = atl;
38
+
39
+ for (let i = 0; i < tssSequence.length; i++) {
40
+ const tss = tssSequence[i];
41
+ currentCtl = currentCtl * (1 - 1 / ctlTau) + tss / ctlTau;
42
+ currentAtl = currentAtl * (1 - 1 / atlTau) + tss / atlTau;
43
+ const roundedCtl = round1(currentCtl);
44
+ const roundedAtl = round1(currentAtl);
45
+ projections.push({
46
+ day: i + 1,
47
+ tss,
48
+ ctl: roundedCtl,
49
+ atl: roundedAtl,
50
+ tsb: round1(roundedCtl - roundedAtl),
51
+ });
52
+ }
53
+
54
+ return {
55
+ initial: { ctl, atl, tsb: round1(ctl - atl) },
56
+ projections,
57
+ };
58
+ }
59
+
60
+ // --- CLI entry point ---
61
+ function parseArgs(args: string[]): {
62
+ ctl: number;
63
+ atl: number;
64
+ tss: number[];
65
+ ctlTau?: number;
66
+ atlTau?: number;
67
+ } {
68
+ const flagValue = (name: string): string | undefined => {
69
+ const i = args.indexOf(name);
70
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
71
+ };
72
+
73
+ const ctl = flagValue('--ctl');
74
+ const atl = flagValue('--atl');
75
+ const tss = flagValue('--tss');
76
+
77
+ if (ctl === undefined || atl === undefined || tss === undefined) {
78
+ process.stderr.write(
79
+ 'Usage: tsb-predict --ctl <number> --atl <number> --tss <n1,n2,...> [--ctl-tau <n>] [--atl-tau <n>]\n',
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ const ctlNum = Number(ctl);
85
+ const atlNum = Number(atl);
86
+ if (isNaN(ctlNum) || isNaN(atlNum)) {
87
+ process.stderr.write('Error: --ctl and --atl must be numbers\n');
88
+ process.exit(1);
89
+ }
90
+
91
+ const tssValues = tss.split(',').map(Number);
92
+ if (tssValues.some(isNaN)) {
93
+ process.stderr.write('Error: --tss values must be numbers (e.g., --tss 100,0,50)\n');
94
+ process.exit(1);
95
+ }
96
+
97
+ return {
98
+ ctl: ctlNum,
99
+ atl: atlNum,
100
+ tss: tssValues,
101
+ ctlTau: flagValue('--ctl-tau') ? Number(flagValue('--ctl-tau')) : undefined,
102
+ atlTau: flagValue('--atl-tau') ? Number(flagValue('--atl-tau')) : undefined,
103
+ };
104
+ }
105
+
106
+ // Only run CLI when executed directly (not imported)
107
+ const isDirectExecution =
108
+ process.argv[1] !== undefined &&
109
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
110
+ if (isDirectExecution) {
111
+ const parsed = parseArgs(process.argv.slice(2));
112
+ const result = projectTSB(parsed.ctl, parsed.atl, parsed.tss, {
113
+ ctlTau: parsed.ctlTau,
114
+ atlTau: parsed.atlTau,
115
+ });
116
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
117
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * engram-coach ambient capture handler — candidate-only persistence.
3
+ *
4
+ * The extension calls this at awaited OMP `session_stop` with the latest user
5
+ * turn. Extraction itself lives in `engram-coach-ambient-capture.ts`; this
6
+ * module owns what happens to the result:
7
+ *
8
+ * - canonical key derivation that never authorizes replacement;
9
+ * - suppression of ambient candidates the explicit channel already applied
10
+ * in this same turn;
11
+ * - deterministic IDs plus create-only writes, so re-settling a turn cannot
12
+ * duplicate a draft;
13
+ * - a single scoped index refresh.
14
+ *
15
+ * Every record written here is `status: "candidate"` with empty relationship
16
+ * arrays. Ambient extraction proposes; it never retires or supersedes an
17
+ * active record before review. Every failure becomes a warning rather than an
18
+ * exception, so a slow or broken extraction model can never block the
19
+ * coaching response or the explicit Phase 5 path.
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+ import { join } from "node:path";
24
+ import type {
25
+ JsonObject,
26
+ KnowledgeEnvelope,
27
+ KnowledgeRelationships,
28
+ TurnContext,
29
+ TurnToolCall,
30
+ } from "@isparling/engram-harness/knowledge-types";
31
+ import type { CompletionRequest } from "@isparling/engram-harness/capture-types";
32
+ import type { CaptureSummary } from "./engram-coach-capture-types.ts";
33
+ import {
34
+ extractAmbientCandidates,
35
+ type AmbientCandidate,
36
+ } from "./engram-coach-ambient-capture.ts";
37
+ import { loadEngramCoachConfig, type EngramCoachRuntimeConfig } from "./engram-coach-config.ts";
38
+ import { deriveCanonicalEntityKey } from "./engram-coach-keys.ts";
39
+ import { validateEnvelope } from "./engram-coach-reconciliation.ts";
40
+ import { canonicalJson } from "./engram-coach-structured-capture.ts";
41
+
42
+ /**
43
+ * Host mechanics supplied by the extension. It owns no coaching ontology: it
44
+ * only spawns the isolated completion, confines writes to the records root,
45
+ * and refreshes the scoped index.
46
+ */
47
+ export type CaptureTools = {
48
+ recordsRoot: string;
49
+ spaceId: string;
50
+ projectRoot: string;
51
+ writeFile(path: string, content: string): Promise<void>;
52
+ refreshIndex(): Promise<void>;
53
+ complete(request: CompletionRequest): Promise<string>;
54
+ };
55
+
56
+ export type { CaptureSummary } from "./engram-coach-capture-types.ts";
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Duplicate suppression from explicit tool provenance
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function isObject(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ function stringArray(value: unknown): string[] {
67
+ return Array.isArray(value)
68
+ ? value.filter((item): item is string => typeof item === "string")
69
+ : [];
70
+ }
71
+
72
+ function safeJson(text: string): unknown {
73
+ try {
74
+ return JSON.parse(text);
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Entity keys a successful `engram_capture_apply` already committed in this
82
+ * same turn. Membership is decided at runtime over an unbounded key space, so
83
+ * a Set is the right structure here.
84
+ */
85
+ export function appliedEntityKeys(toolCalls: readonly TurnToolCall[]): Set<string> {
86
+ const keys = new Set<string>();
87
+ for (const call of toolCalls) {
88
+ if (call.tool !== "engram_capture_apply") continue;
89
+ const result = call.result;
90
+ const parsed = typeof result === "string" ? safeJson(result) : result;
91
+ if (!isObject(parsed)) continue;
92
+ const status = parsed.status;
93
+ if (status !== "committed" && status !== "no-change" && status !== "records-committed") continue;
94
+ for (const key of stringArray(parsed.entity_keys)) keys.add(key);
95
+ }
96
+ return keys;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Record construction
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Deterministic ambient record ID. Session, the stable turn index, the
105
+ * candidate index, and canonical candidate content all participate, so
106
+ * re-settling the same turn with the same extraction reproduces the same ID —
107
+ * which is what makes create-only writes idempotent instead of duplicating.
108
+ */
109
+ export function ambientRecordId(
110
+ sessionId: string,
111
+ turnIndex: number,
112
+ candidateIndex: number,
113
+ candidate: AmbientCandidate,
114
+ ): string {
115
+ const digest = createHash("sha256")
116
+ .update(canonicalJson({
117
+ sessionId,
118
+ turnIndex,
119
+ candidateIndex,
120
+ kind: candidate.kind,
121
+ statement: candidate.statement,
122
+ entityType: candidate.entityType,
123
+ keyedEntityType: candidate.keyedEntityType,
124
+ keyComponents: candidate.keyComponents,
125
+ effectiveAt: candidate.effectiveAt,
126
+ }))
127
+ .digest("hex")
128
+ .slice(0, 24);
129
+ return `coach-ambient-${digest}`;
130
+ }
131
+
132
+ const EMPTY_RELATIONSHIPS: KnowledgeRelationships = {
133
+ supports: [],
134
+ contradicts: [],
135
+ refines: [],
136
+ supersedes: [],
137
+ };
138
+
139
+ function serializeDraftRecord(envelope: KnowledgeEnvelope): string {
140
+ return [
141
+ "---",
142
+ "schema_version: 0",
143
+ `id: ${canonicalJson(envelope.id)}`,
144
+ `kind: ${canonicalJson(envelope.kind)}`,
145
+ `status: ${canonicalJson(envelope.status)}`,
146
+ `statement: ${canonicalJson(envelope.statement)}`,
147
+ `details: ${canonicalJson(envelope.details)}`,
148
+ `scope: ${canonicalJson(envelope.scope)}`,
149
+ `pack: ${canonicalJson(envelope.pack)}`,
150
+ `sources: ${canonicalJson(envelope.sources)}`,
151
+ `session: ${canonicalJson(envelope.session)}`,
152
+ `submitted_at: ${canonicalJson(envelope.submittedAt)}`,
153
+ `disposition: ${canonicalJson(envelope.disposition)}`,
154
+ `relationships: ${canonicalJson(EMPTY_RELATIONSHIPS)}`,
155
+ "history: []",
156
+ "---",
157
+ "## Statement",
158
+ "",
159
+ envelope.statement,
160
+ "",
161
+ ].join("\n");
162
+ }
163
+
164
+ function submittedDate(turnTimestamp: string): string {
165
+ return /^\d{4}-\d{2}-\d{2}/.exec(turnTimestamp)?.[0] ?? turnTimestamp;
166
+ }
167
+
168
+ function isAlreadyPresent(error: unknown): boolean {
169
+ return isObject(error) && error.code === "EEXIST";
170
+ }
171
+
172
+ /**
173
+ * Build the candidate envelope. A derivable key is recorded so review can find
174
+ * the entity it concerns; an underivable one records the diagnostic instead.
175
+ * Either way the record stays a candidate with empty relationships.
176
+ */
177
+ function buildEnvelope(
178
+ candidate: AmbientCandidate,
179
+ id: string,
180
+ entityKey: string | null,
181
+ bindingError: string | null,
182
+ turn: TurnContext,
183
+ tools: CaptureTools,
184
+ config: EngramCoachRuntimeConfig,
185
+ ): KnowledgeEnvelope {
186
+ const details: JsonObject = {
187
+ captureChannel: "ambient",
188
+ entityKey,
189
+ activeProfile: config.activeProfile,
190
+ };
191
+ if (candidate.entityType !== null) details.entityType = candidate.entityType;
192
+ if (candidate.effectiveAt !== null) details.effectiveAt = candidate.effectiveAt;
193
+ if (Object.keys(candidate.keyComponents).length > 0) {
194
+ details.keyComponents = candidate.keyComponents;
195
+ }
196
+ if (bindingError !== null) details.bindingError = bindingError;
197
+
198
+ return {
199
+ id,
200
+ kind: candidate.kind,
201
+ status: "candidate",
202
+ statement: candidate.statement,
203
+ details,
204
+ scope: {
205
+ space: tools.spaceId,
206
+ subjects: candidate.subjects,
207
+ topics: candidate.topics,
208
+ contexts: [],
209
+ dimensions: {},
210
+ },
211
+ pack: { id: "engram-coach", version: "0.1.0" },
212
+ sources: [
213
+ { type: "session", ref: `session:${turn.session.id}/turn:${turn.turnIndex}` },
214
+ { type: "inference", ref: `llm-inference:${config.capture.model}` },
215
+ ],
216
+ session: turn.session,
217
+ submittedAt: submittedDate(turn.timestamp),
218
+ disposition: "new",
219
+ };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Entry point
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** Ambient capture for one settled turn. Always resolves. */
227
+ export async function captureFromTurn(
228
+ turn: TurnContext,
229
+ tools: CaptureTools,
230
+ ): Promise<CaptureSummary> {
231
+ const summary: CaptureSummary = { created: [], existing: [], invalid: [], warnings: [] };
232
+
233
+ const userText = turn.narrative.trim();
234
+ if (userText.length === 0) return summary;
235
+
236
+ let config: EngramCoachRuntimeConfig;
237
+ try {
238
+ config = await loadEngramCoachConfig({ projectRoot: tools.projectRoot });
239
+ } catch (error) {
240
+ summary.warnings.push(`ambient capture is not configured: ${String(error)}`);
241
+ return summary;
242
+ }
243
+
244
+ const extraction = await extractAmbientCandidates(userText, config, tools);
245
+ summary.warnings.push(...extraction.warnings);
246
+ if (extraction.candidates.length === 0) return summary;
247
+
248
+ const alreadyApplied = appliedEntityKeys(turn.toolCalls);
249
+
250
+ for (const [index, candidate] of extraction.candidates.entries()) {
251
+ let entityKey: string | null = null;
252
+ let bindingError: string | null = null;
253
+ if (candidate.keyedEntityType !== null) {
254
+ const derived = deriveCanonicalEntityKey({
255
+ entity_type: candidate.keyedEntityType,
256
+ key_components: candidate.keyComponents,
257
+ });
258
+ if (derived.kind === "bound") entityKey = derived.key;
259
+ else bindingError = derived.reason;
260
+ }
261
+
262
+ // The explicit channel already captured this key in this same turn, under
263
+ // athlete approval. Unrelated candidates from the turn are untouched.
264
+ if (entityKey !== null && alreadyApplied.has(entityKey)) continue;
265
+
266
+ const id = ambientRecordId(turn.session.id, turn.turnIndex, index, candidate);
267
+ const envelope = buildEnvelope(candidate, id, entityKey, bindingError, turn, tools, config);
268
+
269
+ const validation = validateEnvelope(envelope);
270
+ if (!validation.ok) {
271
+ summary.invalid.push({
272
+ id: envelope.id,
273
+ errors: validation.errors.map((error) => `${error.field ?? "envelope"}: ${error.message}`),
274
+ });
275
+ continue;
276
+ }
277
+
278
+ try {
279
+ await tools.writeFile(
280
+ join(tools.recordsRoot, `${envelope.id}.md`),
281
+ serializeDraftRecord(envelope),
282
+ );
283
+ summary.created.push(envelope.id);
284
+ } catch (error) {
285
+ if (isAlreadyPresent(error)) {
286
+ summary.existing.push(envelope.id);
287
+ continue;
288
+ }
289
+ summary.warnings.push(`ambient draft write failed for ${envelope.id}: ${String(error)}`);
290
+ }
291
+ }
292
+
293
+ if (summary.created.length > 0 || summary.existing.length > 0) {
294
+ try {
295
+ await tools.refreshIndex();
296
+ } catch (error) {
297
+ summary.warnings.push(`ambient index refresh failed: ${String(error)}`);
298
+ }
299
+ }
300
+ return summary;
301
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "_comment": "Copy this file to config.json and edit values. config.json is gitignored. season is optional: if absent, adapt-plan will ask for it at runtime.",
3
+ "active_profile": "default",
4
+ "profiles": {
5
+ "default": {
6
+ "active_persona": "conservative",
7
+ "coaching_docs_dir": "~/REPLACE_WITH_YOUR_COACHING_DOCS_PATH",
8
+ "prescriptions_dir": "~/REPLACE_WITH_YOUR_PRESCRIPTIONS_PATH",
9
+ "season": "REPLACE_WITH_SEASON_LABEL"
10
+ }
11
+ },
12
+ "capture": {
13
+ "model": "REPLACE_WITH_PROVIDER/MODEL",
14
+ "timeout_seconds": 60,
15
+ "max_candidates_per_turn": 3
16
+ },
17
+ "intervals_icu": {
18
+ "api_key": "REPLACE_WITH_YOUR_API_KEY",
19
+ "athlete_id": "REPLACE_WITH_YOUR_ATHLETE_ID"
20
+ }
21
+ }