@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
@@ -7,13 +7,19 @@
7
7
  * `from` specifier a space's binding declares for it in `installed_packs`.
8
8
  *
9
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
10
+ * - KnowledgePack.validateEnvelope / selectRelatedRecords / reconcile —
11
+ * domain-aware validation, exact related-record selection, and
12
+ * reconciliation using the engram-coach coaching ontology
14
13
  * - PresentationPack — deterministic athlete-profile projection and
15
14
  * audience authorization, defined in `engram-coach-presentation.ts`
16
15
  *
16
+ * It also exports the three binding-selected capture functions the OMP
17
+ * adapter resolves by name: `captureFromTurn` (ambient, candidate-only),
18
+ * `previewStructuredCapture` (explicit, hash-bound), and `materialize`
19
+ * (deterministic compatibility views). Ambient capture is LLM-only; there is
20
+ * no deterministic transcript extractor, so this pack deliberately does not
21
+ * implement the generic `KnowledgeExtractor` facet.
22
+ *
17
23
  * See `@isparling/engram-harness`'s `harness/docs/pack-interface.md` for the
18
24
  * external pack contract. See `engram-coach-domain.ts` for the coaching
19
25
  * ontology types and constants.
@@ -21,33 +27,32 @@
21
27
 
22
28
  import type {
23
29
  KnowledgePack,
24
- KnowledgeExtractor,
25
30
  KnowledgeRecord,
26
31
  PresentationPack,
27
32
  } from "@isparling/engram-harness/knowledge-types";
28
- import { engramCoachExtractor } from "./engram-coach-extractor.ts";
29
33
  import { engramCoachPresentation } from "./engram-coach-presentation.ts";
30
34
  import {
31
35
  validateEnvelope,
32
36
  reconcile,
33
- relatedQuery,
37
+ selectRelatedRecords,
34
38
  } from "./engram-coach-reconciliation.ts";
39
+ export { captureFromTurn } from "./capture-handler.ts";
40
+ export { previewStructuredCapture } from "./engram-coach-structured-capture.ts";
41
+ export { materialize } from "./engram-coach-materialization.ts";
35
42
 
36
43
  export const engramCoachPackId = "engram-coach";
37
44
  export const engramCoachPackVersion = "0.1.0";
38
45
 
39
- /** The engram-coach pack: KnowledgePack + KnowledgeExtractor + PresentationPack facets. */
40
- export const engramCoachPack: KnowledgePack & KnowledgeExtractor & PresentationPack = {
46
+ /** The engram-coach pack: KnowledgePack + PresentationPack facets. */
47
+ export const engramCoachPack: KnowledgePack & PresentationPack = {
41
48
  id: engramCoachPackId,
42
49
  version: engramCoachPackVersion,
43
50
 
44
51
  // KnowledgePack facets
45
52
  validateEnvelope,
46
- relatedQuery,
53
+ selectRelatedRecords,
47
54
  reconcile,
48
55
 
49
- // KnowledgeExtractor facets
50
- extractCandidates: engramCoachExtractor.extractCandidates,
51
56
 
52
57
  // PresentationPack facets
53
58
  retrievalPolicy: engramCoachPresentation.retrievalPolicy,
@@ -55,6 +55,13 @@ function hasClinicalSignal(record: KnowledgeRecord): boolean {
55
55
  if (!Array.isArray(value)) return false;
56
56
  return value.some((signal) => typeof signal === "string" && CLINICIAN_TRAINING_SIGNALS[signal] === true);
57
57
  }
58
+ function isTemporallyEffective(record: KnowledgeRecord): boolean {
59
+ const value = record.details.effectiveAt;
60
+ if (typeof value !== "string") return true;
61
+ const time = Date.parse(value);
62
+ return Number.isNaN(time) ? true : time <= Date.now();
63
+ }
64
+
58
65
 
59
66
  // ---------------------------------------------------------------------------
60
67
  // Retrieval policy — scope every query and profile enumeration to active
@@ -67,7 +74,9 @@ const retrievalPolicy: PresentationPack["retrievalPolicy"] = {
67
74
  classifySource: () => "engram-coach",
68
75
  relevanceThreshold: null,
69
76
  isEligible: (record) =>
70
- record.status === "active" && record.pack.id === engramCoachPackId,
77
+ record.status === "active"
78
+ && record.pack.id === engramCoachPackId
79
+ && isTemporallyEffective(record),
71
80
  includePresentations: false,
72
81
  };
73
82
 
@@ -15,11 +15,18 @@
15
15
  */
16
16
 
17
17
  import type {
18
+ JsonObject,
19
+ JsonValue,
20
+ KnowledgeDisposition,
18
21
  KnowledgeEnvelope,
22
+ KnowledgeError,
23
+ KnowledgeRelationships,
19
24
  KnowledgeResult,
25
+ PackMutation,
20
26
  PackReconciliation,
21
27
  PackReconcileInput,
22
28
  KnowledgeRecord,
29
+ RelatedRecordSelection,
23
30
  } from "@isparling/engram-harness/knowledge-types";
24
31
  import {
25
32
  ENGRAM_COACH_ENTITY_TYPES,
@@ -30,6 +37,7 @@ import {
30
37
  type EngramCoachDetails,
31
38
  type EngramCoachEntityType,
32
39
  } from "./engram-coach-domain.ts";
40
+ import { canonicalJson } from "./engram-coach-structured-capture.ts";
33
41
 
34
42
  // ---------------------------------------------------------------------------
35
43
  // Known destination topics used when validating scope topics.
@@ -168,6 +176,8 @@ function entityTypeFromRecord(record: KnowledgeRecord): EngramCoachEntityType |
168
176
  export function reconcile(
169
177
  input: PackReconcileInput,
170
178
  ): KnowledgeResult<PackReconciliation> {
179
+ if (input.candidate.details["captureChannel"] === "explicit") return reconcileExplicit(input);
180
+
171
181
  const candidate = input.candidate;
172
182
  const related = input.related;
173
183
  const candidateDetails = candidate.details as Partial<EngramCoachDetails> | undefined;
@@ -296,10 +306,303 @@ export function reconcile(
296
306
  };
297
307
  }
298
308
 
309
+ // ---------------------------------------------------------------------------
310
+ // Explicit capture channel — exact selection and per-item reconciliation
311
+ // ---------------------------------------------------------------------------
312
+
313
+ const engramCoachPackId = "engram-coach";
314
+
315
+ type ExplicitItem = {
316
+ sourceId: string;
317
+ recordId: string;
318
+ role: string;
319
+ entityType: string;
320
+ entityKey: string | null;
321
+ effectiveAt: string;
322
+ statement: string;
323
+ value: JsonObject;
324
+ artifact: JsonObject;
325
+ actionTargets?: JsonValue;
326
+ sourceDocument?: JsonValue;
327
+ };
328
+
329
+ function isJsonObject(value: JsonValue): value is JsonObject {
330
+ return typeof value === "object" && value !== null && !Array.isArray(value);
331
+ }
332
+
333
+ function asString(value: JsonValue | undefined): string | null {
334
+ return typeof value === "string" ? value : null;
335
+ }
336
+
337
+ /**
338
+ * Narrow the aggregate candidate's `details.items` array back into typed
339
+ * items. The array was built by `buildAggregateCandidate`, so every entry
340
+ * already satisfies the shape; anything else fails closed.
341
+ */
342
+ function explicitItems(envelope: KnowledgeEnvelope): ExplicitItem[] | null {
343
+ const rawItems = envelope.details["items"];
344
+ if (!Array.isArray(rawItems)) return null;
345
+ const items: ExplicitItem[] = [];
346
+ for (const raw of rawItems) {
347
+ if (!isJsonObject(raw)) return null;
348
+ const artifact = raw["artifact"];
349
+ const value = raw["value"];
350
+ const sourceId = asString(raw["sourceId"]);
351
+ const recordId = asString(raw["recordId"]);
352
+ const role = asString(raw["role"]);
353
+ const entityType = asString(raw["entityType"]);
354
+ const statement = asString(raw["statement"]);
355
+ const effectiveAt = asString(raw["effectiveAt"]);
356
+ if (sourceId === null || recordId === null || role === null || entityType === null || statement === null || effectiveAt === null || !isJsonObject(artifact) || !isJsonObject(value)) {
357
+ return null;
358
+ }
359
+ items.push({
360
+ sourceId,
361
+ recordId,
362
+ role,
363
+ entityType,
364
+ entityKey: asString(raw["entityKey"]),
365
+ effectiveAt,
366
+ statement,
367
+ value,
368
+ artifact,
369
+ actionTargets: raw["actionTargets"],
370
+ sourceDocument: raw["sourceDocument"],
371
+ });
372
+ }
373
+ return items;
374
+ }
375
+
376
+ function validationError(code: string, message: string): KnowledgeError {
377
+ return { kind: "validation", code, message };
378
+ }
379
+
380
+ function activeByKey(records: readonly KnowledgeRecord[]): Map<string, KnowledgeRecord[]> {
381
+ const byKey = new Map<string, KnowledgeRecord[]>();
382
+ for (const record of records) {
383
+ if (record.status !== "active") continue;
384
+ const key = asString(record.details["entityKey"]);
385
+ if (key === null) continue;
386
+ const existing = byKey.get(key);
387
+ if (existing === undefined) byKey.set(key, [record]);
388
+ else existing.push(record);
389
+ }
390
+ return byKey;
391
+ }
392
+
393
+ function effectiveTime(value: string): number {
394
+ const parsed = Date.parse(value);
395
+ return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
396
+ }
397
+
398
+ /** True when the candidate value keeps every current entry identical AND adds at least one new entry. */
399
+ function isStrictSuperset(currentValue: JsonObject, candidateValue: JsonObject): boolean {
400
+ for (const [key, item] of Object.entries(currentValue)) {
401
+ if (!(key in candidateValue) || canonicalJson(candidateValue[key]) !== canonicalJson(item)) return false;
402
+ }
403
+ return Object.keys(candidateValue).length > Object.keys(currentValue).length;
404
+ }
405
+ /** Fill every relationship edge; a partial input leaves unused edges empty. */
406
+ function completeRelationships(partial: Partial<KnowledgeRelationships>): KnowledgeRelationships {
407
+ return { supports: [], contradicts: [], refines: [], supersedes: [], ...partial };
408
+ }
409
+
410
+ function createdExplicitRecord(
411
+ candidate: KnowledgeEnvelope,
412
+ item: ExplicitItem,
413
+ disposition: KnowledgeDisposition,
414
+ relatedEdges: Partial<KnowledgeRelationships>,
415
+ relatedId: string,
416
+ ): KnowledgeRecord {
417
+ return {
418
+ schemaVersion: 0,
419
+ id: item.recordId,
420
+ kind: item.role === "event" ? "evidence" : item.role === "report-claim" ? "claim" : "decision",
421
+ status: "active",
422
+ statement: item.statement,
423
+ details: {
424
+ recordRole: item.role,
425
+ entityType: item.entityType,
426
+ entityKey: item.entityKey,
427
+ effectiveAt: item.effectiveAt,
428
+ sourceId: item.sourceId,
429
+ value: item.value,
430
+ artifact: item.artifact,
431
+ captureChannel: "explicit",
432
+ },
433
+ scope: candidate.scope,
434
+ pack: candidate.pack,
435
+ sources: [{ type: "engram-coach-capture", ref: item.sourceId }],
436
+ session: candidate.session,
437
+ submittedAt: candidate.submittedAt,
438
+ disposition,
439
+ relationships: completeRelationships(relatedEdges),
440
+ history: [{ event: "created", relatedId, submittedAt: candidate.submittedAt }],
441
+ };
442
+ }
443
+
444
+ function retiredCopy(current: KnowledgeRecord, retiredBy: string, submittedAt: string): KnowledgeRecord {
445
+ // Preserves sources, session, scope, relationships, and history verbatim;
446
+ // adds ONLY the status transition and one retirement history entry.
447
+ return {
448
+ ...current,
449
+ status: "retired",
450
+ history: [...current.history, { event: "retired", relatedId: retiredBy, submittedAt }],
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Reconcile an explicit aggregate candidate against exact-key related
456
+ * records using the design's rules 1-7:
457
+ * 1. events append, never updating another record;
458
+ * 2. no active exact key → create active state/report claim;
459
+ * 3. equal canonical value → no mutation (state) or support edge (claim);
460
+ * 4. conflict-free strict superset → refine + retire current;
461
+ * 5. later effective time → supersede + retire current;
462
+ * 6. same/earlier time with conflicting values → validation error;
463
+ * 7. more than one active exact-key record → ambiguous_state error.
464
+ */
465
+ function reconcileExplicit(input: PackReconcileInput): KnowledgeResult<PackReconciliation> {
466
+ const items = explicitItems(input.candidate);
467
+ if (items === null) {
468
+ return { ok: false, errors: [validationError("explicit_items_invalid", "explicit candidate details.items is missing or malformed")] };
469
+ }
470
+ const errors: KnowledgeError[] = [];
471
+ const mutations: PackMutation[] = [];
472
+ const actives = activeByKey(input.related);
473
+ for (const item of items) {
474
+ if (item.role !== "state" && item.role !== "event" && item.role !== "report-claim") {
475
+ errors.push(validationError("explicit_role_invalid", `item ${item.sourceId} has unknown recordRole ${item.role}`));
476
+ continue;
477
+ }
478
+ if (item.role === "event") {
479
+ // Deterministic record ids make an identical re-import of the same
480
+ // source entry the SAME event, not a new one: equal id plus equal
481
+ // canonical value and effective time dedupes to no mutation. Anything
482
+ // else falls through to a create that still fails closed at the host
483
+ // when the id already exists.
484
+ const twin = input.related.find((record) => record.id === item.recordId);
485
+ if (
486
+ twin !== undefined
487
+ && isJsonObject(twin.details["value"])
488
+ && item.effectiveAt === asString(twin.details["effectiveAt"])
489
+ && canonicalJson(item.value) === canonicalJson(twin.details["value"])
490
+ ) {
491
+ continue;
492
+ }
493
+ mutations.push({
494
+ action: "create",
495
+ record: createdExplicitRecord(input.candidate, item, "new", {}, item.recordId),
496
+ });
497
+ continue;
498
+ }
499
+ const key = item.entityKey;
500
+ if (key === null) {
501
+ errors.push(validationError("explicit_key_unbound", `item ${item.sourceId} has no bound canonical entity key`));
502
+ continue;
503
+ }
504
+ const current = actives.get(key) ?? [];
505
+ if (current.length > 1) {
506
+ errors.push(validationError("ambiguous_state", `${current.length} active records share entity key ${key}; approval blocked pending correction`));
507
+ continue;
508
+ }
509
+ const prior = current[0];
510
+ if (prior === undefined) {
511
+ mutations.push({
512
+ action: "create",
513
+ record: createdExplicitRecord(input.candidate, item, "new", {}, item.recordId),
514
+ });
515
+ continue;
516
+ }
517
+ const priorValue = prior.details["value"];
518
+ if (!isJsonObject(priorValue)) {
519
+ errors.push(validationError("explicit_value_invalid", `active record ${prior.id} has a non-object details.value`));
520
+ continue;
521
+ }
522
+ if (item.role === "report-claim") {
523
+ mutations.push({
524
+ action: "create",
525
+ record: createdExplicitRecord(input.candidate, item, "support", { supports: [prior.id] }, prior.id),
526
+ });
527
+ continue;
528
+ }
529
+ if (canonicalJson(item.value) === canonicalJson(priorValue) && item.effectiveAt === asString(prior.details["effectiveAt"])) {
530
+ continue;
531
+ }
532
+ if (isStrictSuperset(priorValue, item.value)) {
533
+ mutations.push({
534
+ action: "create",
535
+ record: createdExplicitRecord(input.candidate, item, "refine", { refines: [prior.id] }, prior.id),
536
+ });
537
+ mutations.push({ action: "update", record: retiredCopy(prior, item.recordId, input.candidate.submittedAt) });
538
+ continue;
539
+ }
540
+ if (effectiveTime(item.effectiveAt) > effectiveTime(asString(prior.details["effectiveAt"]) ?? "")) {
541
+ mutations.push({
542
+ action: "create",
543
+ record: createdExplicitRecord(input.candidate, item, "supersede", { supersedes: [prior.id] }, prior.id),
544
+ });
545
+ mutations.push({ action: "update", record: retiredCopy(prior, item.recordId, input.candidate.submittedAt) });
546
+ continue;
547
+ }
548
+ errors.push(validationError(
549
+ "state_conflict",
550
+ `item ${item.sourceId} conflicts with active record ${prior.id} at the same or earlier effective time; approval blocked pending correction`,
551
+ ));
552
+ }
553
+ if (errors.length > 0) return { ok: false, errors };
554
+ return {
555
+ ok: true,
556
+ value: {
557
+ disposition: "new",
558
+ summary: `explicit capture planned ${mutations.filter((mutation) => mutation.action === "create").length} record(s)`,
559
+ mutations,
560
+ },
561
+ };
562
+ }
563
+
564
+ /**
565
+ * Related-record selection for the coaching pack.
566
+ *
567
+ * Explicit aggregate candidates (`details.captureChannel === "explicit"`)
568
+ * use EXACT mode: records from this pack whose `details.entityKey` is one
569
+ * of the candidate's bound keys — semantic search never selects identity.
570
+ * Legacy generic envelopes keep the coaching search query until ambient
571
+ * review promotion uses structured records.
572
+ */
573
+ export function selectRelatedRecords(envelope: KnowledgeEnvelope): RelatedRecordSelection {
574
+ if (envelope.details["captureChannel"] === "explicit") {
575
+ const items = explicitItems(envelope) ?? [];
576
+ const keys = [...new Set(items.map((item) => item.entityKey).filter((key): key is string => key !== null))];
577
+ const keySet: Record<string, true> = {};
578
+ for (const key of keys) keySet[key] = true;
579
+ // Append-only events are typically unbound, so their deterministic
580
+ // record ids are the only exact identity for recognizing an identical
581
+ // re-import of the same source entry.
582
+ const idSet: Record<string, true> = {};
583
+ for (const item of items) idSet[item.recordId] = true;
584
+ const descriptions = [
585
+ keys.length > 0 ? `exact entity keys: ${keys.join(", ")}` : "explicit capture without bound entity keys",
586
+ Object.keys(idSet).length > 0 ? "candidate record ids" : null,
587
+ ];
588
+ return {
589
+ mode: "exact",
590
+ description: descriptions.filter((part): part is string => part !== null).join("; "),
591
+ matches: (record) =>
592
+ record.pack.id === engramCoachPackId
593
+ && ((typeof record.details["entityKey"] === "string"
594
+ && keySet[record.details["entityKey"] as string] === true)
595
+ || idSet[record.id] === true),
596
+ };
597
+ }
598
+ return { mode: "search", query: coachingQuery(envelope) };
599
+ }
600
+
299
601
  /**
300
- * Build a query string from an envelope for finding related records.
602
+ * Build a query string from an envelope for finding related records
603
+ * (legacy generic envelopes only).
301
604
  */
302
- export function relatedQuery(envelope: KnowledgeEnvelope): string {
605
+ function coachingQuery(envelope: KnowledgeEnvelope): string {
303
606
  const details = envelope.details as Partial<EngramCoachDetails> | undefined;
304
607
  const entityType = details?.entityType;
305
608
  const persona = details?.persona;