@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.
- package/README.md +85 -18
- package/SETUP.md +559 -0
- package/SKILL_PACK.md +75 -0
- package/analyses/catalog.md +257 -0
- package/analysis-tools/hrv-trend.ts +592 -0
- package/analysis-tools/migrate-structured-capture.ts +234 -0
- package/analysis-tools/race-context.ts +96 -0
- package/analysis-tools/stream-analyze.ts +1008 -0
- package/analysis-tools/tsb-predict.ts +117 -0
- package/capture-handler.ts +301 -0
- package/config.json.example +21 -0
- package/engram-coach-ambient-capture.ts +336 -0
- package/engram-coach-capture-types.ts +185 -0
- package/engram-coach-config.ts +268 -0
- package/engram-coach-domain.ts +7 -2
- package/engram-coach-keys.ts +189 -0
- package/engram-coach-materialization.ts +638 -0
- package/engram-coach-migration.ts +1078 -0
- package/engram-coach-pack.ts +17 -12
- package/engram-coach-presentation.ts +10 -1
- package/engram-coach-reconciliation.ts +305 -2
- package/engram-coach-structured-capture.ts +622 -0
- package/package.json +39 -6
- package/personas/aggressive-monitoring.md +121 -0
- package/personas/aggressive.json +85 -0
- package/personas/conservative-monitoring.md +133 -0
- package/personas/conservative.json +93 -0
- package/personas/polarized-monitoring.md +112 -0
- package/personas/polarized.json +72 -0
- package/personas/volume-monitoring.md +85 -0
- package/personas/volume.json +108 -0
- package/shared/retrieval.md +71 -0
- package/shared/setup.md +207 -0
- package/skills/.gitkeep +0 -0
- package/skills/adapt-plan/SKILL.md +263 -0
- package/skills/block-review/SKILL.md +275 -0
- package/skills/consult/SKILL.md +176 -0
- package/skills/intake/SKILL.md +315 -0
- package/skills/lactate-analyze/SKILL.md +230 -0
- package/skills/lessons-rollup/SKILL.md +196 -0
- package/skills/monitoring-rollup/SKILL.md +208 -0
- package/skills/race-analysis/SKILL.md +219 -0
- package/skills/season-retrospective/SKILL.md +200 -0
- package/skills/set-goal/SKILL.md +297 -0
- package/templates/base.md +55 -0
- package/templates/build-1.md +57 -0
- package/templates/build-2.md +62 -0
- package/templates/race-report.md +51 -0
- package/templates/race-specificity.md +62 -0
- package/templates/season-review.md +40 -0
- package/engram-coach-extractor.ts +0 -295
package/engram-coach-pack.ts
CHANGED
|
@@ -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
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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 +
|
|
40
|
-
export const engramCoachPack: KnowledgePack &
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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;
|