@remnic/core 9.18.0 → 9.20.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/dist/access-admin-ops-surface.d.ts +8 -8
- package/dist/access-admin-ops-surface.js +8 -8
- package/dist/access-boundary.d.ts +8 -8
- package/dist/access-boundary.js +8 -8
- package/dist/access-cli.js +32 -32
- package/dist/access-http.d.ts +8 -8
- package/dist/access-http.js +12 -12
- package/dist/access-identity-continuity-surface.d.ts +6 -6
- package/dist/access-identity-continuity-surface.js +8 -8
- package/dist/access-lcm-surface.d.ts +8 -8
- package/dist/access-lcm-surface.js +8 -8
- package/dist/access-mcp.d.ts +8 -8
- package/dist/access-mcp.js +11 -11
- package/dist/access-memory-search-fanout.d.ts +2 -2
- package/dist/{access-namespace-preflight-IbQ0O8Kf.d.ts → access-namespace-preflight-tmwQgCT6.d.ts} +1 -1
- package/dist/access-namespace-preflight.d.ts +3 -3
- package/dist/access-namespace-preflight.js +8 -8
- package/dist/access-observe-write-surface.d.ts +8 -8
- package/dist/access-observe-write-surface.js +8 -8
- package/dist/access-operations-batch.js +9 -9
- package/dist/access-operations.d.ts +8 -8
- package/dist/access-operations.js +10 -10
- package/dist/access-recall-concurrency.d.ts +8 -8
- package/dist/access-recall-concurrency.js +8 -8
- package/dist/access-recall-response.d.ts +8 -8
- package/dist/access-recall-response.js +8 -8
- package/dist/access-recall-surface.d.ts +8 -8
- package/dist/access-recall-surface.js +8 -8
- package/dist/{access-service-BqjsJRn_.d.ts → access-service-BqUii7LY.d.ts} +6 -6
- package/dist/access-service.d.ts +8 -8
- package/dist/access-service.js +8 -8
- package/dist/access-surface-catalog.d.ts +8 -8
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/{auto-sync-IH3G25MT.js → auto-sync-UPTUVE4X.js} +3 -3
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +6 -6
- package/dist/briefing.d.ts +2 -2
- package/dist/briefing.js +4 -4
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +2 -2
- package/dist/bulk-import/index.d.ts +3 -3
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{catalog-D_ToaAZM.d.ts → catalog-iWvWz61I.d.ts} +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +5 -5
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/{chunk-52H7OB7Y.js → chunk-275ENJDK.js} +2 -2
- package/dist/{chunk-OR3OQHHS.js → chunk-3ZUY2VQF.js} +2 -2
- package/dist/{chunk-NEFMSUC2.js → chunk-4F6AANKS.js} +3 -3
- package/dist/{chunk-NGO4QKMY.js → chunk-4PYLBXX6.js} +2 -2
- package/dist/{chunk-UQXIW7OZ.js → chunk-53WDAG7X.js} +2 -2
- package/dist/{chunk-CNNQVI2A.js → chunk-5IXPWEQB.js} +5 -5
- package/dist/{chunk-XGYCI23S.js → chunk-5JQLAQUZ.js} +2 -2
- package/dist/{chunk-ALPBE5EC.js → chunk-62QM5MKY.js} +7 -7
- package/dist/{chunk-KPEIWAZY.js → chunk-6NZ7U4R4.js} +1 -1
- package/dist/chunk-6NZ7U4R4.js.map +1 -0
- package/dist/{chunk-ZHWEUG7Y.js → chunk-7M6APKJL.js} +333 -8
- package/dist/chunk-7M6APKJL.js.map +1 -0
- package/dist/{chunk-AKWX5E6R.js → chunk-AT3TDN6O.js} +1017 -296
- package/dist/chunk-AT3TDN6O.js.map +1 -0
- package/dist/{chunk-LU22QKFA.js → chunk-B7C3TF5H.js} +2 -2
- package/dist/{chunk-JHIEQCMG.js → chunk-EILX7SQR.js} +3 -3
- package/dist/{chunk-WIP3MQZY.js → chunk-EXU5IQ3S.js} +2 -2
- package/dist/{chunk-UL6WTQN4.js → chunk-EZKKHVFY.js} +2 -2
- package/dist/{chunk-LTGSOACX.js → chunk-GEUDRENF.js} +2 -2
- package/dist/{chunk-B3R5CC35.js → chunk-HJEEAO4O.js} +2 -2
- package/dist/{chunk-7NHIHUBW.js → chunk-HPOY2PHQ.js} +3 -3
- package/dist/{chunk-VMALXCR5.js → chunk-JFDEYXF7.js} +2 -2
- package/dist/{chunk-WMMT6W7C.js → chunk-JN3IUA2Q.js} +2 -2
- package/dist/{chunk-A2KGXUOU.js → chunk-JWMYVQAM.js} +2 -2
- package/dist/{chunk-6P6ZR6JR.js → chunk-KHRKEELC.js} +17 -17
- package/dist/{chunk-UVCY7VDX.js → chunk-LI4ZOFHW.js} +2 -2
- package/dist/{chunk-6YJLLDWO.js → chunk-LMHDFOVV.js} +3 -3
- package/dist/{chunk-UHOUNNY3.js → chunk-N7EVMKP6.js} +6 -6
- package/dist/{chunk-7JXXI3GW.js → chunk-Q3FWRDCP.js} +2 -2
- package/dist/{chunk-JEMLK7ED.js → chunk-QO2KODU6.js} +2 -2
- package/dist/{chunk-7KXE7KN5.js → chunk-TCZWBJBO.js} +2 -2
- package/dist/{chunk-B3ABVQ36.js → chunk-UHVXE24Y.js} +2 -1
- package/dist/{chunk-UX3ZXN34.js → chunk-UV72DEZD.js} +2 -2
- package/dist/{chunk-FO3WM76R.js → chunk-V5TSUGRB.js} +5 -5
- package/dist/{chunk-SGCWBLHN.js → chunk-X5QS5C2O.js} +2 -2
- package/dist/{cli-D2g5mRgf.d.ts → cli-DPEhq0Cp.d.ts} +5 -5
- package/dist/cli.d.ts +10 -10
- package/dist/cli.js +25 -25
- package/dist/compounding/engine.d.ts +2 -2
- package/dist/compounding/engine.js +4 -4
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +4 -4
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +4 -4
- package/dist/consolidation-provenance-check.d.ts +2 -2
- package/dist/consolidation-undo.d.ts +2 -2
- package/dist/contradiction/index.d.ts +2 -2
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +2 -2
- package/dist/entity-retrieval.js +4 -4
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +6 -6
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/importers/index.d.ts +1 -1
- package/dist/in-flight-reads.d.ts +1 -1
- package/dist/index.d.ts +470 -90
- package/dist/index.js +66 -36
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-model-endpoint.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +2 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection.d.ts +2 -2
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/mcp-memory-inspector-app.d.ts +8 -8
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +2 -2
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +3 -3
- package/dist/namespaces/migrate.js +5 -5
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/storage.d.ts +3 -3
- package/dist/namespaces/storage.js +4 -4
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/offline-sync-impression-drain.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +2 -2
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
- package/dist/orchestration/maintenance.d.ts +3 -3
- package/dist/orchestration/maintenance.js +9 -9
- package/dist/{orchestrator-DrILRi-n.d.ts → orchestrator-DB93S48I.d.ts} +5 -5
- package/dist/orchestrator.d.ts +6 -6
- package/dist/orchestrator.js +32 -32
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/{pipeline-BYo4b_y9.d.ts → pipeline-DmGc-H66.d.ts} +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/proactive-contention.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/{qmd-BIrL8e69.d.ts → qmd-cJT0crkF.d.ts} +1 -1
- package/dist/qmd-preflight.d.ts +2 -2
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +2 -2
- package/dist/recall-concurrency-config.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-memory-map.d.ts +1 -1
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-timings.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/replay/normalizers/chatgpt.d.ts +1 -1
- package/dist/replay/normalizers/claude.d.ts +1 -1
- package/dist/replay/normalizers/openclaw.d.ts +1 -1
- package/dist/replay/normalizers/shared.d.ts +1 -1
- package/dist/replay/runner.d.ts +1 -1
- package/dist/replay/types.d.ts +1 -1
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/retrieval-agents.d.ts +2 -2
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/salvage-envelope.d.ts +1 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/{scope-profiles-BiQ9yXP4.d.ts → scope-profiles-KG2nODX7.d.ts} +1 -1
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/index.d.ts +1 -1
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-C-cSATcR.d.ts → semantic-consolidation-BaZlg0D8.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +5 -5
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +4 -4
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/{storage-ByTv05qM.d.ts → storage-BKhHHnRo.d.ts} +2 -221
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +3 -3
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +2 -2
- package/dist/temporal-timeline-recall.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +2 -2
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/types.d.ts +12 -12
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-e0_MI3Iq.d.ts → types-GBQZuT7l.d.ts} +397 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +4 -4
- package/dist/write-envelope.d.ts +1 -1
- package/package.json +2 -2
- package/src/config.test.ts +11 -0
- package/src/config.ts +5 -6
- package/src/meetings/build.test.ts +646 -0
- package/src/meetings/build.ts +365 -0
- package/src/meetings/cli.test.ts +276 -0
- package/src/meetings/cli.ts +242 -0
- package/src/meetings/config.test.ts +72 -0
- package/src/meetings/config.ts +183 -0
- package/src/meetings/errors.ts +13 -0
- package/src/meetings/fuse.test.ts +208 -0
- package/src/meetings/fuse.ts +297 -0
- package/src/meetings/index.ts +11 -1
- package/src/meetings/memory-generator.ts +116 -0
- package/src/meetings/recall-exclusion.test.ts +88 -0
- package/src/meetings/store.test.ts +232 -0
- package/src/meetings/store.ts +465 -0
- package/src/meetings/types.ts +111 -0
- package/src/orchestration/orchestrator-helpers.ts +23 -3
- package/src/orchestration/recall-search-pipeline.test.ts +182 -0
- package/src/orchestration/recall-search-pipeline.ts +36 -15
- package/src/types.ts +8 -0
- package/dist/chunk-AKWX5E6R.js.map +0 -1
- package/dist/chunk-KPEIWAZY.js.map +0 -1
- package/dist/chunk-ZHWEUG7Y.js.map +0 -1
- /package/dist/{auto-sync-IH3G25MT.js.map → auto-sync-UPTUVE4X.js.map} +0 -0
- /package/dist/{chunk-52H7OB7Y.js.map → chunk-275ENJDK.js.map} +0 -0
- /package/dist/{chunk-OR3OQHHS.js.map → chunk-3ZUY2VQF.js.map} +0 -0
- /package/dist/{chunk-NEFMSUC2.js.map → chunk-4F6AANKS.js.map} +0 -0
- /package/dist/{chunk-NGO4QKMY.js.map → chunk-4PYLBXX6.js.map} +0 -0
- /package/dist/{chunk-UQXIW7OZ.js.map → chunk-53WDAG7X.js.map} +0 -0
- /package/dist/{chunk-CNNQVI2A.js.map → chunk-5IXPWEQB.js.map} +0 -0
- /package/dist/{chunk-XGYCI23S.js.map → chunk-5JQLAQUZ.js.map} +0 -0
- /package/dist/{chunk-ALPBE5EC.js.map → chunk-62QM5MKY.js.map} +0 -0
- /package/dist/{chunk-LU22QKFA.js.map → chunk-B7C3TF5H.js.map} +0 -0
- /package/dist/{chunk-JHIEQCMG.js.map → chunk-EILX7SQR.js.map} +0 -0
- /package/dist/{chunk-WIP3MQZY.js.map → chunk-EXU5IQ3S.js.map} +0 -0
- /package/dist/{chunk-UL6WTQN4.js.map → chunk-EZKKHVFY.js.map} +0 -0
- /package/dist/{chunk-LTGSOACX.js.map → chunk-GEUDRENF.js.map} +0 -0
- /package/dist/{chunk-B3R5CC35.js.map → chunk-HJEEAO4O.js.map} +0 -0
- /package/dist/{chunk-7NHIHUBW.js.map → chunk-HPOY2PHQ.js.map} +0 -0
- /package/dist/{chunk-VMALXCR5.js.map → chunk-JFDEYXF7.js.map} +0 -0
- /package/dist/{chunk-WMMT6W7C.js.map → chunk-JN3IUA2Q.js.map} +0 -0
- /package/dist/{chunk-A2KGXUOU.js.map → chunk-JWMYVQAM.js.map} +0 -0
- /package/dist/{chunk-6P6ZR6JR.js.map → chunk-KHRKEELC.js.map} +0 -0
- /package/dist/{chunk-UVCY7VDX.js.map → chunk-LI4ZOFHW.js.map} +0 -0
- /package/dist/{chunk-6YJLLDWO.js.map → chunk-LMHDFOVV.js.map} +0 -0
- /package/dist/{chunk-UHOUNNY3.js.map → chunk-N7EVMKP6.js.map} +0 -0
- /package/dist/{chunk-7JXXI3GW.js.map → chunk-Q3FWRDCP.js.map} +0 -0
- /package/dist/{chunk-JEMLK7ED.js.map → chunk-QO2KODU6.js.map} +0 -0
- /package/dist/{chunk-7KXE7KN5.js.map → chunk-TCZWBJBO.js.map} +0 -0
- /package/dist/{chunk-B3ABVQ36.js.map → chunk-UHVXE24Y.js.map} +0 -0
- /package/dist/{chunk-UX3ZXN34.js.map → chunk-UV72DEZD.js.map} +0 -0
- /package/dist/{chunk-FO3WM76R.js.map → chunk-V5TSUGRB.js.map} +0 -0
- /package/dist/{chunk-SGCWBLHN.js.map → chunk-X5QS5C2O.js.map} +0 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { MeetingsBuilder, type MeetingDayData, type MeetingsDayBuildSummary, type MeetingsDaySource } from "./build.js";
|
|
5
|
+
import { DEFAULT_MEETINGS_CONFIG } from "./config.js";
|
|
6
|
+
import { MeetingRecordStore, type MeetingRecordFileIo } from "./store.js";
|
|
7
|
+
import type {
|
|
8
|
+
MeetingActivitySnapshot,
|
|
9
|
+
MeetingsConfig,
|
|
10
|
+
MeetingAppSpan,
|
|
11
|
+
MeetingAudioWindow,
|
|
12
|
+
MeetingRecord,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import type { FusionConversationInput, FusionSegmentInput } from "../wearables/fusion/types.js";
|
|
15
|
+
import type { MeetingMemoryGenerator, MeetingMemoryOutcome } from "./memory-generator.js";
|
|
16
|
+
|
|
17
|
+
const MEMORY_DIR = "/mem";
|
|
18
|
+
const DATE = "2026-03-10";
|
|
19
|
+
const START = "2026-03-10T14:00:00.000Z";
|
|
20
|
+
const END = "2026-03-10T15:00:00.000Z";
|
|
21
|
+
|
|
22
|
+
class InMemoryIo implements MeetingRecordFileIo {
|
|
23
|
+
files = new Map<string, string>();
|
|
24
|
+
async writeFile(p: string, c: string): Promise<void> {
|
|
25
|
+
this.files.set(p, c);
|
|
26
|
+
}
|
|
27
|
+
async readFile(p: string): Promise<string> {
|
|
28
|
+
const v = this.files.get(p);
|
|
29
|
+
if (v === undefined) throw enoent();
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
async readDir(dirPath: string): Promise<string[]> {
|
|
33
|
+
const prefix = dirPath.endsWith("/") ? dirPath : `${dirPath}/`;
|
|
34
|
+
const names = new Set<string>();
|
|
35
|
+
let found = false;
|
|
36
|
+
for (const key of this.files.keys()) {
|
|
37
|
+
if (!key.startsWith(prefix)) continue;
|
|
38
|
+
found = true;
|
|
39
|
+
names.add(key.slice(prefix.length).split("/")[0]!);
|
|
40
|
+
}
|
|
41
|
+
if (!found) throw enoent();
|
|
42
|
+
return [...names];
|
|
43
|
+
}
|
|
44
|
+
async deleteFile(p: string): Promise<void> {
|
|
45
|
+
if (!this.files.delete(p)) throw enoent();
|
|
46
|
+
}
|
|
47
|
+
async realpath(p: string): Promise<string> {
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
async lstat(): Promise<{ isSymbolicLink: boolean }> {
|
|
51
|
+
return { isSymbolicLink: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function enoent(): NodeJS.ErrnoException {
|
|
56
|
+
const err = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
57
|
+
err.code = "ENOENT";
|
|
58
|
+
return err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Inline fake of the engine-layer `MeetingMemoryGenerator` SEAM (issue #1900).
|
|
63
|
+
* The deterministic engine depends ONLY on this interface — never on the
|
|
64
|
+
* memory-gen module — so the engine's build tests assert SEAM behavior with a
|
|
65
|
+
* recording fake: it captures the `{ built, removedIds, unchangedIds, updatedIds }`
|
|
66
|
+
* handed to it and returns a canned `MeetingMemoryOutcome`. Real memory-generation
|
|
67
|
+
* behavior (episode/fact idempotency, trust-gating, retract-on-delete,
|
|
68
|
+
* off/review/smart) is exercised in the surface slice against the concrete generator.
|
|
69
|
+
*/
|
|
70
|
+
interface RecordedSeamCall {
|
|
71
|
+
built: readonly MeetingRecord[];
|
|
72
|
+
removedIds: readonly string[];
|
|
73
|
+
unchangedIds: readonly string[];
|
|
74
|
+
updatedIds: readonly string[];
|
|
75
|
+
}
|
|
76
|
+
class FakeMemoryGenerator implements MeetingMemoryGenerator {
|
|
77
|
+
calls: RecordedSeamCall[] = [];
|
|
78
|
+
constructor(private readonly outcome: MeetingMemoryOutcome) {}
|
|
79
|
+
async onRecordsBuilt(input: RecordedSeamCall): Promise<MeetingMemoryOutcome> {
|
|
80
|
+
this.calls.push(input);
|
|
81
|
+
return this.outcome;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Seam fake whose `onRecordsBuilt` REJECTS — to prove the builder isolates a
|
|
86
|
+
* generator failure the way it isolates a reindex-hook failure. */
|
|
87
|
+
class ThrowingMemoryGenerator implements MeetingMemoryGenerator {
|
|
88
|
+
async onRecordsBuilt(): Promise<MeetingMemoryOutcome> {
|
|
89
|
+
throw new Error("secret episode extractor stack trace");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function config(overrides: Partial<MeetingsConfig> = {}): MeetingsConfig {
|
|
94
|
+
return {
|
|
95
|
+
...DEFAULT_MEETINGS_CONFIG,
|
|
96
|
+
appPatterns: [...DEFAULT_MEETINGS_CONFIG.appPatterns],
|
|
97
|
+
enabled: true,
|
|
98
|
+
...overrides,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fixedSource(data: MeetingDayData): MeetingsDaySource {
|
|
103
|
+
return { loadDayData: () => data };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function appSpan(app: string, start: string, end: string): MeetingAppSpan {
|
|
107
|
+
return { app, startUtc: start, endUtc: end };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function audioWin(
|
|
111
|
+
source: string,
|
|
112
|
+
start: string,
|
|
113
|
+
end: string,
|
|
114
|
+
overrides: Partial<MeetingAudioWindow> = {},
|
|
115
|
+
): MeetingAudioWindow {
|
|
116
|
+
return { source, startUtc: start, endUtc: end, distinctNonWearerSpeakers: 2, ...overrides };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function seg(text: string, startIso: string): FusionSegmentInput {
|
|
120
|
+
return { speaker: "Jane", isSelf: false, text, startIso };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function conv(source: string, id: string, segments: FusionSegmentInput[]): FusionConversationInput {
|
|
124
|
+
return { source, conversationId: id, startIso: START, endIso: END, segments };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function builder(data: MeetingDayData, cfg: MeetingsConfig, fusionOptions?: { sourceTrust?: Record<string, number> }) {
|
|
128
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
129
|
+
return {
|
|
130
|
+
store,
|
|
131
|
+
builder: new MeetingsBuilder({ source: fixedSource(data), store, config: cfg, ...(fusionOptions ? { fusionOptions } : {}) }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("degradation — meetings.enabled false builds nothing on every surface", async () => {
|
|
136
|
+
const { builder: b, store } = builder(
|
|
137
|
+
{ detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] }, conversations: [] },
|
|
138
|
+
config({ enabled: false }),
|
|
139
|
+
);
|
|
140
|
+
const summary = await b.buildDay(DATE);
|
|
141
|
+
assert.equal(summary.enabled, false);
|
|
142
|
+
assert.deepEqual(summary.meetings, []);
|
|
143
|
+
assert.deepEqual(await store.listMeetingDates(), []);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("degradation — activity only (no audio) detects zero meetings", async () => {
|
|
147
|
+
const { builder: b, store } = builder(
|
|
148
|
+
{ detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [] }, conversations: [] },
|
|
149
|
+
config(),
|
|
150
|
+
);
|
|
151
|
+
const summary = await b.buildDay(DATE);
|
|
152
|
+
assert.equal(summary.enabled, true);
|
|
153
|
+
assert.equal(summary.meetings.length, 0);
|
|
154
|
+
assert.deepEqual(await store.listMeetingDates(), []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("degradation — audio only yields a record with no screen-context section", async () => {
|
|
158
|
+
const { builder: b, store } = builder(
|
|
159
|
+
{
|
|
160
|
+
detection: { date: DATE, appSpans: [], audioWindows: [audioWin("limitless", START, END)] },
|
|
161
|
+
conversations: [conv("limitless", "l1", [seg("quarterly numbers", "2026-03-10T14:05:00.000Z")])],
|
|
162
|
+
// No activity.
|
|
163
|
+
},
|
|
164
|
+
config(),
|
|
165
|
+
);
|
|
166
|
+
const summary = await b.buildDay(DATE);
|
|
167
|
+
assert.equal(summary.meetings.length, 1);
|
|
168
|
+
assert.equal(summary.meetings[0]?.detectionSource, "audio");
|
|
169
|
+
assert.equal(summary.built, 1);
|
|
170
|
+
const raw = await store.readMeetingRecord(DATE, summary.meetings[0]!.id);
|
|
171
|
+
assert.notEqual(raw, null);
|
|
172
|
+
assert.doesNotMatch(raw!, /## Screen context/);
|
|
173
|
+
assert.match(raw!, /## Transcript/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("full — app+audio with activity fuses transcript and screen context", async () => {
|
|
177
|
+
const activity: MeetingActivitySnapshot[] = [
|
|
178
|
+
{ tsUtc: "2026-03-10T14:05:00.000Z", app: "Preview", title: "Q3-roadmap.pdf" },
|
|
179
|
+
{ tsUtc: "2026-03-10T14:30:00.000Z", app: "Zoom", text: "meeting chat" },
|
|
180
|
+
];
|
|
181
|
+
const { builder: b, store } = builder(
|
|
182
|
+
{
|
|
183
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
184
|
+
conversations: [conv("desktop", "d1", [seg("agenda", "2026-03-10T14:06:00.000Z")])],
|
|
185
|
+
activity,
|
|
186
|
+
},
|
|
187
|
+
config(),
|
|
188
|
+
);
|
|
189
|
+
const summary = await b.buildDay(DATE);
|
|
190
|
+
assert.equal(summary.meetings.length, 1);
|
|
191
|
+
const outcome = summary.meetings[0]!;
|
|
192
|
+
assert.equal(outcome.detectionSource, "app+audio");
|
|
193
|
+
assert.equal(outcome.written, true);
|
|
194
|
+
assert.ok(outcome.snapshotCount >= 1);
|
|
195
|
+
const raw = await store.readMeetingRecord(DATE, outcome.id);
|
|
196
|
+
assert.match(raw!, /## Screen context/);
|
|
197
|
+
assert.match(raw!, /Preview: Q3-roadmap\.pdf/);
|
|
198
|
+
|
|
199
|
+
// Re-build with the same data is idempotent (nothing rewritten).
|
|
200
|
+
const again = await b.buildDay(DATE);
|
|
201
|
+
assert.equal(again.built, 0);
|
|
202
|
+
assert.equal(again.skipped, 1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("multiple audio sources for one meeting fuse with recorded corroboration", async () => {
|
|
206
|
+
const { builder: b, store } = builder(
|
|
207
|
+
{
|
|
208
|
+
detection: {
|
|
209
|
+
date: DATE,
|
|
210
|
+
appSpans: [appSpan("Zoom", START, END)],
|
|
211
|
+
audioWindows: [audioWin("desktop", START, END), audioWin("pendant", START, END)],
|
|
212
|
+
},
|
|
213
|
+
conversations: [
|
|
214
|
+
conv("desktop", "d1", [seg("hello everyone", "2026-03-10T14:05:00.000Z")]),
|
|
215
|
+
conv("pendant", "p1", [seg("hello everyone", "2026-03-10T14:05:00.000Z")]),
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
config(),
|
|
219
|
+
{ sourceTrust: { desktop: 0.9, pendant: 0.6 } },
|
|
220
|
+
);
|
|
221
|
+
const summary = await b.buildDay(DATE);
|
|
222
|
+
assert.equal(summary.meetings.length, 1, "the two sources collapse into one meeting");
|
|
223
|
+
assert.deepEqual(summary.meetings[0]?.sources, ["desktop", "pendant"]);
|
|
224
|
+
const record = await store.listMeetingSummaries(DATE);
|
|
225
|
+
assert.deepEqual(record[0]?.corroboratedBy, ["pendant"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("degradation — provider transcript detects without an app span", async () => {
|
|
229
|
+
const { builder: b } = builder(
|
|
230
|
+
{
|
|
231
|
+
detection: {
|
|
232
|
+
date: DATE,
|
|
233
|
+
appSpans: [],
|
|
234
|
+
audioWindows: [audioWin("granola", START, END, { providerMeeting: true, title: "Weekly sync", distinctNonWearerSpeakers: 1 })],
|
|
235
|
+
},
|
|
236
|
+
conversations: [conv("granola", "g1", [seg("standup", "2026-03-10T14:05:00.000Z")])],
|
|
237
|
+
},
|
|
238
|
+
config(),
|
|
239
|
+
);
|
|
240
|
+
const summary = await b.buildDay(DATE);
|
|
241
|
+
assert.equal(summary.meetings.length, 1);
|
|
242
|
+
assert.equal(summary.meetings[0]?.detectionSource, "provider");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("rebuild reconciles stale same-day records whose meeting no longer detects", async () => {
|
|
246
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
247
|
+
const cfg = config();
|
|
248
|
+
const START2 = "2026-03-10T16:00:00.000Z";
|
|
249
|
+
const END2 = "2026-03-10T17:00:00.000Z";
|
|
250
|
+
|
|
251
|
+
// First build: a single meeting in the 14:00 window.
|
|
252
|
+
const firstData: MeetingDayData = {
|
|
253
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
254
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
255
|
+
};
|
|
256
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(firstData), store, config: cfg }).buildDay(DATE);
|
|
257
|
+
assert.equal(firstSummary.meetings.length, 1);
|
|
258
|
+
const staleId = firstSummary.meetings[0]!.id;
|
|
259
|
+
assert.deepEqual(await store.listMeetingIds(DATE), [staleId]);
|
|
260
|
+
|
|
261
|
+
// Second build of the SAME day: the meeting moved to a different window, so a
|
|
262
|
+
// different stable id is produced. The old record must be reconciled away.
|
|
263
|
+
const secondData: MeetingDayData = {
|
|
264
|
+
detection: {
|
|
265
|
+
date: DATE,
|
|
266
|
+
appSpans: [appSpan("Zoom", START2, END2)],
|
|
267
|
+
audioWindows: [audioWin("desktop", START2, END2)],
|
|
268
|
+
},
|
|
269
|
+
conversations: [conv("desktop", "d2", [seg("later meeting", "2026-03-10T16:05:00.000Z")])],
|
|
270
|
+
};
|
|
271
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(secondData), store, config: cfg }).buildDay(DATE);
|
|
272
|
+
assert.equal(secondSummary.meetings.length, 1);
|
|
273
|
+
const freshId = secondSummary.meetings[0]!.id;
|
|
274
|
+
assert.notEqual(freshId, staleId);
|
|
275
|
+
assert.deepEqual(secondSummary.removed, [staleId], "the stale record must be deleted");
|
|
276
|
+
assert.deepEqual(await store.listMeetingIds(DATE), [freshId]);
|
|
277
|
+
assert.equal(await store.readMeetingRecord(DATE, staleId), null);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("rebuild preserves the id of an overlapping shifted-start meeting", async () => {
|
|
281
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
282
|
+
const cfg = config();
|
|
283
|
+
const first: MeetingDayData = {
|
|
284
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
285
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
286
|
+
};
|
|
287
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(first), store, config: cfg }).buildDay(DATE);
|
|
288
|
+
const originalId = firstSummary.meetings[0]!.id;
|
|
289
|
+
|
|
290
|
+
// A resync shifts the start by 5 min (richer audio boundary) → the detector
|
|
291
|
+
// mints a NEW start-anchored id, but the window still overlaps the stored
|
|
292
|
+
// record, so the builder must ADOPT the original id, not delete + recreate.
|
|
293
|
+
const shiftedStart = "2026-03-10T14:05:00.000Z";
|
|
294
|
+
const second: MeetingDayData = {
|
|
295
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", shiftedStart, END)], audioWindows: [audioWin("desktop", shiftedStart, END)] },
|
|
296
|
+
conversations: [conv("desktop", "d2", [seg("hello again", "2026-03-10T14:10:00.000Z")])],
|
|
297
|
+
};
|
|
298
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(second), store, config: cfg }).buildDay(DATE);
|
|
299
|
+
assert.equal(secondSummary.meetings.length, 1);
|
|
300
|
+
assert.equal(secondSummary.meetings[0]?.id, originalId, "overlapping shifted-start resync must keep the original id");
|
|
301
|
+
assert.deepEqual(secondSummary.removed, [], "the meeting was adopted, not deleted");
|
|
302
|
+
assert.deepEqual(await store.listMeetingIds(DATE), [originalId]);
|
|
303
|
+
const raw = await store.readMeetingRecord(DATE, originalId);
|
|
304
|
+
assert.match(raw!, /startUtc: "2026-03-10T14:05:00.000Z"/, "the adopted record's window is updated to the shifted start");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("reindex hook fires only when records change", async () => {
|
|
308
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
309
|
+
const data: MeetingDayData = {
|
|
310
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
311
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
312
|
+
};
|
|
313
|
+
const calls: MeetingsDayBuildSummary[] = [];
|
|
314
|
+
const builder = new MeetingsBuilder({
|
|
315
|
+
source: fixedSource(data),
|
|
316
|
+
store,
|
|
317
|
+
config: config(),
|
|
318
|
+
hooks: { reindex: (summary) => { calls.push(summary); } },
|
|
319
|
+
});
|
|
320
|
+
const first = await builder.buildDay(DATE);
|
|
321
|
+
assert.equal(first.built, 1);
|
|
322
|
+
assert.equal(calls.length, 1, "reindex fires after new records are written");
|
|
323
|
+
assert.equal(calls[0]?.built, 1);
|
|
324
|
+
// Idempotent rebuild: nothing changed → hook must NOT fire again.
|
|
325
|
+
const second = await builder.buildDay(DATE);
|
|
326
|
+
assert.equal(second.built, 0);
|
|
327
|
+
assert.equal(second.removed.length, 0);
|
|
328
|
+
assert.equal(calls.length, 1, "reindex does not fire when nothing changed");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("finding 3 — a rejecting reindex hook is isolated with a SANITIZED warning (no raw error text)", async () => {
|
|
332
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
333
|
+
const data: MeetingDayData = {
|
|
334
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
335
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
336
|
+
};
|
|
337
|
+
const builder = new MeetingsBuilder({
|
|
338
|
+
source: fixedSource(data),
|
|
339
|
+
store,
|
|
340
|
+
config: config(),
|
|
341
|
+
hooks: { reindex: () => { throw new Error("qmd backend unreachable (ECONNREFUSED 127.0.0.1:9999)"); } },
|
|
342
|
+
});
|
|
343
|
+
const summary = await builder.buildDay(DATE);
|
|
344
|
+
assert.equal(summary.built, 1, "records persist even though reindex threw");
|
|
345
|
+
// The warning is surfaced (the CLI prints it) but must NOT leak the raw error
|
|
346
|
+
// message — that internal detail goes to the logs, not stdout.
|
|
347
|
+
assert.match(summary.reindexWarning ?? "", /reindex hook failed after records were persisted/);
|
|
348
|
+
assert.doesNotMatch(summary.reindexWarning ?? "", /qmd|ECONNREFUSED|9999/i, "raw internal error text must not reach the surfaced warning");
|
|
349
|
+
// The record was still written to the store.
|
|
350
|
+
assert.equal((await store.listMeetingIds(DATE)).length, 1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("buildDay rejects a day source whose detection.date disagrees with the requested day", async () => {
|
|
354
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
355
|
+
const wrongDay: MeetingDayData = {
|
|
356
|
+
detection: { date: "2026-03-11", appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
357
|
+
conversations: [],
|
|
358
|
+
};
|
|
359
|
+
const builder = new MeetingsBuilder({ source: fixedSource(wrongDay), store, config: config() });
|
|
360
|
+
await assert.rejects(() => builder.buildDay(DATE), /detection\.date "2026-03-11" for requested day "2026-03-10"/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("finding 6 — a marginally-overlapping different meeting does not adopt a stale id", async () => {
|
|
364
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
365
|
+
const cfg = config();
|
|
366
|
+
const first: MeetingDayData = {
|
|
367
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
368
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
369
|
+
};
|
|
370
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(first), store, config: cfg }).buildDay(DATE);
|
|
371
|
+
const staleId = firstSummary.meetings[0]!.id;
|
|
372
|
+
|
|
373
|
+
// A genuinely DIFFERENT meeting [14:55,16:00): 5 min overlap of a >= 60 min
|
|
374
|
+
// shorter window (not substantial), a different app AND source (no identity
|
|
375
|
+
// signal). It must NOT adopt the stored id and overwrite the other meeting's
|
|
376
|
+
// record — old is removed, new gets a fresh id.
|
|
377
|
+
const OVERLAP_START = "2026-03-10T14:55:00.000Z";
|
|
378
|
+
const LATER_END = "2026-03-10T16:00:00.000Z";
|
|
379
|
+
const second: MeetingDayData = {
|
|
380
|
+
detection: {
|
|
381
|
+
date: DATE,
|
|
382
|
+
appSpans: [appSpan("Microsoft Teams", OVERLAP_START, LATER_END)],
|
|
383
|
+
audioWindows: [audioWin("pendant", OVERLAP_START, LATER_END)],
|
|
384
|
+
},
|
|
385
|
+
conversations: [
|
|
386
|
+
{
|
|
387
|
+
source: "pendant",
|
|
388
|
+
conversationId: "p1",
|
|
389
|
+
startIso: OVERLAP_START,
|
|
390
|
+
endIso: LATER_END,
|
|
391
|
+
segments: [{ speaker: "Bob", isSelf: false, text: "different meeting", startIso: "2026-03-10T15:30:00.000Z" }],
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(second), store, config: cfg }).buildDay(DATE);
|
|
396
|
+
assert.equal(secondSummary.meetings.length, 1);
|
|
397
|
+
assert.notEqual(secondSummary.meetings[0]?.id, staleId, "marginal overlap must NOT reuse the stale id");
|
|
398
|
+
assert.deepEqual(secondSummary.removed, [staleId], "the stale meeting is removed, not overwritten");
|
|
399
|
+
assert.equal(await store.readMeetingRecord(DATE, staleId), null);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("seam — the memory generator receives built records + removed ids and its counts fold into the summary", async () => {
|
|
403
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
404
|
+
const data: MeetingDayData = {
|
|
405
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
406
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
407
|
+
};
|
|
408
|
+
const gen = new FakeMemoryGenerator({
|
|
409
|
+
episodes: { written: 1, skipped: 0 },
|
|
410
|
+
facts: { llmInvoked: true, active: 2, review: 0, dropped: 0, skipped: 0, summariesWritten: 1 },
|
|
411
|
+
reindexNeeded: false,
|
|
412
|
+
warnings: [],
|
|
413
|
+
});
|
|
414
|
+
const cfg = config();
|
|
415
|
+
const summary = await new MeetingsBuilder({ source: fixedSource(data), store, config: cfg, memoryGenerator: gen }).buildDay(DATE);
|
|
416
|
+
assert.equal(gen.calls.length, 1, "the generator is invoked once per build");
|
|
417
|
+
assert.equal(gen.calls[0]?.built.length, 1, "the built records are handed to the generator");
|
|
418
|
+
assert.equal(gen.calls[0]?.built[0]?.id, summary.meetings[0]?.id, "the same record ids flow through the seam");
|
|
419
|
+
assert.deepEqual(gen.calls[0]?.removedIds, [], "no records were removed on a first build");
|
|
420
|
+
// The generator's returned counts fold verbatim into the day summary.
|
|
421
|
+
assert.deepEqual(summary.episodes, { written: 1, skipped: 0 });
|
|
422
|
+
assert.deepEqual(summary.facts, { llmInvoked: true, active: 2, review: 0, dropped: 0, skipped: 0, summariesWritten: 1 });
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("finding 2 (round-4) — non-fatal generator warnings surface in the day-build summary", async () => {
|
|
426
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
427
|
+
const data: MeetingDayData = {
|
|
428
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
429
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
430
|
+
};
|
|
431
|
+
const gen = new FakeMemoryGenerator({
|
|
432
|
+
episodes: { written: 1, skipped: 0 },
|
|
433
|
+
reindexNeeded: false,
|
|
434
|
+
warnings: ["summary dep degraded: skipped fact synthesis", "1 transcript segment dropped"],
|
|
435
|
+
});
|
|
436
|
+
const summary = await new MeetingsBuilder({
|
|
437
|
+
source: fixedSource(data),
|
|
438
|
+
store,
|
|
439
|
+
config: config(),
|
|
440
|
+
memoryGenerator: gen,
|
|
441
|
+
}).buildDay(DATE);
|
|
442
|
+
assert.equal(summary.built, 1, "the build still succeeds when the generator returns warnings");
|
|
443
|
+
assert.match(
|
|
444
|
+
summary.memoryWarning ?? "",
|
|
445
|
+
/summary dep degraded/,
|
|
446
|
+
"a successful build must not drop the generator's non-fatal warnings",
|
|
447
|
+
);
|
|
448
|
+
assert.match(
|
|
449
|
+
summary.memoryWarning ?? "",
|
|
450
|
+
/transcript segment dropped/,
|
|
451
|
+
"every generator warning surfaces in the summary",
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("seam — removed record ids are forwarded to the generator on a reconciling rebuild", async () => {
|
|
456
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
457
|
+
const cfg = config();
|
|
458
|
+
const first: MeetingDayData = {
|
|
459
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
460
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
461
|
+
};
|
|
462
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(first), store, config: cfg }).buildDay(DATE);
|
|
463
|
+
const staleId = firstSummary.meetings[0]!.id;
|
|
464
|
+
|
|
465
|
+
// A non-overlapping later meeting replaces the first → the stale record is
|
|
466
|
+
// reconciled away, and its id must reach the generator so it can retire the
|
|
467
|
+
// removed meeting's memories.
|
|
468
|
+
const LATER_START = "2026-03-10T16:00:00.000Z";
|
|
469
|
+
const LATER_END = "2026-03-10T17:00:00.000Z";
|
|
470
|
+
const second: MeetingDayData = {
|
|
471
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", LATER_START, LATER_END)], audioWindows: [audioWin("desktop", LATER_START, LATER_END)] },
|
|
472
|
+
conversations: [conv("desktop", "d2", [seg("later", "2026-03-10T16:05:00.000Z")])],
|
|
473
|
+
};
|
|
474
|
+
const gen = new FakeMemoryGenerator({ reindexNeeded: false, warnings: [] });
|
|
475
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(second), store, config: cfg, memoryGenerator: gen }).buildDay(DATE);
|
|
476
|
+
assert.deepEqual(secondSummary.removed, [staleId]);
|
|
477
|
+
assert.equal(gen.calls.length, 1);
|
|
478
|
+
assert.deepEqual(gen.calls[0]?.removedIds, [staleId], "the removed meeting id is handed to the generator to retire");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("seam — outcome.reindexNeeded fires the reindex hook even when no record changed", async () => {
|
|
482
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
483
|
+
const data: MeetingDayData = {
|
|
484
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
485
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
486
|
+
};
|
|
487
|
+
const cfg = config();
|
|
488
|
+
// Seed the record so the second build leaves it unchanged (built == 0).
|
|
489
|
+
await new MeetingsBuilder({ source: fixedSource(data), store, config: cfg }).buildDay(DATE);
|
|
490
|
+
|
|
491
|
+
const calls: MeetingsDayBuildSummary[] = [];
|
|
492
|
+
const gen = new FakeMemoryGenerator({ episodes: { written: 1, skipped: 0 }, reindexNeeded: true, warnings: [] });
|
|
493
|
+
const summary = await new MeetingsBuilder({
|
|
494
|
+
source: fixedSource(data),
|
|
495
|
+
store,
|
|
496
|
+
config: cfg,
|
|
497
|
+
memoryGenerator: gen,
|
|
498
|
+
hooks: { reindex: (s) => { calls.push(s); } },
|
|
499
|
+
}).buildDay(DATE);
|
|
500
|
+
assert.equal(summary.built, 0, "the record was unchanged");
|
|
501
|
+
assert.equal(summary.removed.length, 0);
|
|
502
|
+
assert.deepEqual(summary.episodes, { written: 1, skipped: 0 }, "episode counts still fold in");
|
|
503
|
+
assert.equal(calls.length, 1, "reindexNeeded from the generator drives the reindex even with no record change");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("seam — no record change and a false reindexNeeded does not fire the reindex hook", async () => {
|
|
507
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
508
|
+
const data: MeetingDayData = {
|
|
509
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
510
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
511
|
+
};
|
|
512
|
+
const cfg = config();
|
|
513
|
+
await new MeetingsBuilder({ source: fixedSource(data), store, config: cfg }).buildDay(DATE);
|
|
514
|
+
|
|
515
|
+
const calls: MeetingsDayBuildSummary[] = [];
|
|
516
|
+
const gen = new FakeMemoryGenerator({ episodes: { written: 0, skipped: 1 }, reindexNeeded: false, warnings: [] });
|
|
517
|
+
await new MeetingsBuilder({
|
|
518
|
+
source: fixedSource(data),
|
|
519
|
+
store,
|
|
520
|
+
config: cfg,
|
|
521
|
+
memoryGenerator: gen,
|
|
522
|
+
hooks: { reindex: (s) => { calls.push(s); } },
|
|
523
|
+
}).buildDay(DATE);
|
|
524
|
+
assert.equal(calls.length, 0, "no record change + no reindexNeeded → no reindex");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("finding 2 — a rejecting memory generator is isolated: records persist + a sanitized warning", async () => {
|
|
528
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
529
|
+
const data: MeetingDayData = {
|
|
530
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
531
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
532
|
+
};
|
|
533
|
+
// The record-store writes complete BEFORE the generator runs, so a generator
|
|
534
|
+
// rejection must not lose them or fail the build — mirror the reindex-hook
|
|
535
|
+
// isolation. ThrowingMemoryGenerator throws a message with internal detail.
|
|
536
|
+
const summary = await new MeetingsBuilder({
|
|
537
|
+
source: fixedSource(data),
|
|
538
|
+
store,
|
|
539
|
+
config: config(),
|
|
540
|
+
memoryGenerator: new ThrowingMemoryGenerator(),
|
|
541
|
+
}).buildDay(DATE);
|
|
542
|
+
assert.equal(summary.built, 1, "the built record survives a generator failure");
|
|
543
|
+
assert.equal(summary.meetings.length, 1);
|
|
544
|
+
assert.equal((await store.listMeetingIds(DATE)).length, 1, "the record is on disk despite the generator throwing");
|
|
545
|
+
assert.match(summary.memoryWarning ?? "", /memory generation failed after records were persisted/);
|
|
546
|
+
assert.doesNotMatch(summary.memoryWarning ?? "", /stack trace|extractor/i, "raw generator error must not reach the surfaced warning");
|
|
547
|
+
assert.equal(summary.episodes, undefined, "no counts fold in from a failed generator");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("finding 4 — marginal overlap with a matching app+source still does NOT adopt a stale id", async () => {
|
|
551
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
552
|
+
const cfg = config();
|
|
553
|
+
const first: MeetingDayData = {
|
|
554
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
555
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
556
|
+
};
|
|
557
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(first), store, config: cfg }).buildDay(DATE);
|
|
558
|
+
const staleId = firstSummary.meetings[0]!.id;
|
|
559
|
+
|
|
560
|
+
// A genuinely DIFFERENT later meeting [14:55,16:00): only ~5 min overlap of a
|
|
561
|
+
// >= 60 min shorter window (~8%, marginal), but the SAME app (Zoom) AND the
|
|
562
|
+
// SAME transcript source (desktop) as the stored one. The coarse app/source
|
|
563
|
+
// identity signals must NOT license adopting the stale id across this thin
|
|
564
|
+
// sliver — the stale meeting is removed and the new one gets a fresh id.
|
|
565
|
+
const OVERLAP_START = "2026-03-10T14:55:00.000Z";
|
|
566
|
+
const LATER_END = "2026-03-10T16:00:00.000Z";
|
|
567
|
+
const second: MeetingDayData = {
|
|
568
|
+
detection: {
|
|
569
|
+
date: DATE,
|
|
570
|
+
appSpans: [appSpan("Zoom", OVERLAP_START, LATER_END)],
|
|
571
|
+
audioWindows: [audioWin("desktop", OVERLAP_START, LATER_END)],
|
|
572
|
+
},
|
|
573
|
+
conversations: [
|
|
574
|
+
{
|
|
575
|
+
source: "desktop",
|
|
576
|
+
conversationId: "d2",
|
|
577
|
+
startIso: OVERLAP_START,
|
|
578
|
+
endIso: LATER_END,
|
|
579
|
+
segments: [{ speaker: "Bob", isSelf: false, text: "different meeting", startIso: "2026-03-10T15:30:00.000Z" }],
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
};
|
|
583
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(second), store, config: cfg }).buildDay(DATE);
|
|
584
|
+
assert.equal(secondSummary.meetings.length, 1);
|
|
585
|
+
assert.notEqual(
|
|
586
|
+
secondSummary.meetings[0]?.id,
|
|
587
|
+
staleId,
|
|
588
|
+
"marginal overlap must not reuse the stale id even when app + source match",
|
|
589
|
+
);
|
|
590
|
+
assert.deepEqual(secondSummary.removed, [staleId], "the stale meeting is removed, not overwritten");
|
|
591
|
+
assert.equal(await store.readMeetingRecord(DATE, staleId), null);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("finding 5 — the seam forwards updatedIds when a record keeps its id but its content changes", async () => {
|
|
595
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
596
|
+
const cfg = config();
|
|
597
|
+
const first: MeetingDayData = {
|
|
598
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
599
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
600
|
+
};
|
|
601
|
+
const firstGen = new FakeMemoryGenerator({ reindexNeeded: false, warnings: [] });
|
|
602
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(first), store, config: cfg, memoryGenerator: firstGen }).buildDay(DATE);
|
|
603
|
+
const id = firstSummary.meetings[0]!.id;
|
|
604
|
+
assert.deepEqual(firstGen.calls[0]?.updatedIds, [], "a brand-new record is not an update");
|
|
605
|
+
assert.deepEqual(firstGen.calls[0]?.unchangedIds, [], "a brand-new record is not unchanged");
|
|
606
|
+
|
|
607
|
+
// A resync shifts the start 5 min: the window overlaps substantially so the id
|
|
608
|
+
// is ADOPTED (kept), but the record content (window) changed, so it is rewritten
|
|
609
|
+
// under the SAME id. That id must reach the generator as an UPDATE so it can
|
|
610
|
+
// retract + regenerate the now-stale episode/summary memories.
|
|
611
|
+
const shiftedStart = "2026-03-10T14:05:00.000Z";
|
|
612
|
+
const second: MeetingDayData = {
|
|
613
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", shiftedStart, END)], audioWindows: [audioWin("desktop", shiftedStart, END)] },
|
|
614
|
+
conversations: [conv("desktop", "d2", [seg("hello again", "2026-03-10T14:10:00.000Z")])],
|
|
615
|
+
};
|
|
616
|
+
const gen = new FakeMemoryGenerator({ reindexNeeded: false, warnings: [] });
|
|
617
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(second), store, config: cfg, memoryGenerator: gen }).buildDay(DATE);
|
|
618
|
+
assert.equal(secondSummary.meetings[0]?.id, id, "the id is adopted (kept) across the shifted-start resync");
|
|
619
|
+
assert.equal(secondSummary.built, 1, "the record was rewritten (content changed)");
|
|
620
|
+
assert.deepEqual(gen.calls[0]?.updatedIds, [id], "the same-id rewrite is forwarded as an updated id");
|
|
621
|
+
assert.deepEqual(gen.calls[0]?.unchangedIds, [], "an updated record is not reported unchanged");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("finding 6 — the seam forwards unchangedIds for an idempotent rebuild", async () => {
|
|
625
|
+
const store = new MeetingRecordStore(MEMORY_DIR, new InMemoryIo());
|
|
626
|
+
const cfg = config();
|
|
627
|
+
const data: MeetingDayData = {
|
|
628
|
+
detection: { date: DATE, appSpans: [appSpan("Zoom", START, END)], audioWindows: [audioWin("desktop", START, END)] },
|
|
629
|
+
conversations: [conv("desktop", "d1", [seg("hello", "2026-03-10T14:05:00.000Z")])],
|
|
630
|
+
};
|
|
631
|
+
const firstGen = new FakeMemoryGenerator({ reindexNeeded: false, warnings: [] });
|
|
632
|
+
const firstSummary = await new MeetingsBuilder({ source: fixedSource(data), store, config: cfg, memoryGenerator: firstGen }).buildDay(DATE);
|
|
633
|
+
const id = firstSummary.meetings[0]!.id;
|
|
634
|
+
assert.deepEqual(firstGen.calls[0]?.unchangedIds, [], "the first write is not unchanged");
|
|
635
|
+
|
|
636
|
+
// Second build with identical inputs: the record's contentHash is identical, so
|
|
637
|
+
// nothing is rewritten and the id is forwarded as UNCHANGED — the generator must
|
|
638
|
+
// skip all episode/summary work (no LLM re-run, no duplicate memories).
|
|
639
|
+
const gen = new FakeMemoryGenerator({ reindexNeeded: false, warnings: [] });
|
|
640
|
+
const secondSummary = await new MeetingsBuilder({ source: fixedSource(data), store, config: cfg, memoryGenerator: gen }).buildDay(DATE);
|
|
641
|
+
assert.equal(secondSummary.built, 0, "nothing was rewritten on the idempotent rebuild");
|
|
642
|
+
assert.equal(secondSummary.skipped, 1);
|
|
643
|
+
assert.deepEqual(gen.calls[0]?.unchangedIds, [id], "the unchanged record id is forwarded so the generator skips regeneration");
|
|
644
|
+
assert.deepEqual(gen.calls[0]?.updatedIds, [], "an unchanged record is not an update");
|
|
645
|
+
assert.deepEqual(gen.calls[0]?.built.map((r) => r.id), [id], "built still carries every record for context");
|
|
646
|
+
});
|