@mercury-fw/core 0.25.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 (117) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +38 -0
  3. package/dist/index.d.ts +23 -0
  4. package/dist/src/admin/cli-routes.d.ts +22 -0
  5. package/dist/src/admin/env-file.d.ts +1 -0
  6. package/dist/src/admin/model-routes.d.ts +26 -0
  7. package/dist/src/admin/qdrant-scroll.d.ts +34 -0
  8. package/dist/src/admin/server.d.ts +40 -0
  9. package/dist/src/admin/wiki-routes.d.ts +31 -0
  10. package/dist/src/compose.d.ts +42 -0
  11. package/dist/src/config/define-config.d.ts +31 -0
  12. package/dist/src/cron/idle-session-cron.d.ts +80 -0
  13. package/dist/src/cron/idle-session-scanner.d.ts +16 -0
  14. package/dist/src/cron/self-review-cron.d.ts +55 -0
  15. package/dist/src/cron/semantic-consolidation.d.ts +71 -0
  16. package/dist/src/memory/embedder.d.ts +9 -0
  17. package/dist/src/memory/episodic-store.d.ts +121 -0
  18. package/dist/src/memory/memory-provider.d.ts +51 -0
  19. package/dist/src/memory/semantic-facts-store.d.ts +37 -0
  20. package/dist/src/memory/tool-corrections-store.d.ts +26 -0
  21. package/dist/src/memory/verbatim-archive-store.d.ts +86 -0
  22. package/dist/src/model/client.d.ts +24 -0
  23. package/dist/src/model/context-size.d.ts +30 -0
  24. package/dist/src/plugins/manifest.d.ts +29 -0
  25. package/dist/src/plugins/plugin-loader.d.ts +85 -0
  26. package/dist/src/router/channel-loader.d.ts +30 -0
  27. package/dist/src/router/provider.d.ts +7 -0
  28. package/dist/src/router/terminal-provider.d.ts +37 -0
  29. package/dist/src/router/terminal.d.ts +41 -0
  30. package/dist/src/router/tool-log.d.ts +65 -0
  31. package/dist/src/router/turn-runner.d.ts +86 -0
  32. package/dist/src/session/agent-turn.d.ts +266 -0
  33. package/dist/src/session/context-primer.d.ts +16 -0
  34. package/dist/src/session/episodic-summarizer.d.ts +25 -0
  35. package/dist/src/session/history.d.ts +95 -0
  36. package/dist/src/session/pending-confirmation.d.ts +8 -0
  37. package/dist/src/session/read-skill-tool.d.ts +4 -0
  38. package/dist/src/session/semantic-fact-extractor.d.ts +45 -0
  39. package/dist/src/session/step-info.d.ts +24 -0
  40. package/dist/src/session/summarizer.d.ts +23 -0
  41. package/dist/src/session/system-prompt.d.ts +38 -0
  42. package/dist/src/session/tool-correction-extractor.d.ts +43 -0
  43. package/dist/src/session/tool-log-buffer.d.ts +24 -0
  44. package/dist/src/session/tool-log-recall-tool.d.ts +18 -0
  45. package/dist/src/session/tool-start-hook.d.ts +57 -0
  46. package/dist/src/tools/display-store.d.ts +36 -0
  47. package/dist/src/tools/present-tool.d.ts +23 -0
  48. package/dist/src/wiki/frontmatter-schema.d.ts +53 -0
  49. package/dist/src/wiki/index-entry.d.ts +15 -0
  50. package/dist/src/wiki/orphan-detector.d.ts +1 -0
  51. package/dist/src/wiki/self-review-runner.d.ts +48 -0
  52. package/dist/src/wiki/self-review-tools.d.ts +22 -0
  53. package/dist/src/wiki/vault-cli.d.ts +2 -0
  54. package/dist/src/wiki/vault-init.d.ts +7 -0
  55. package/dist/src/wiki/wiki-note.d.ts +62 -0
  56. package/dist/src/wiki/wiki-read.d.ts +27 -0
  57. package/dist/src/wiki/wiki-tools.d.ts +7 -0
  58. package/index.ts +23 -0
  59. package/package.json +49 -0
  60. package/src/admin/cli-routes.ts +48 -0
  61. package/src/admin/env-file.ts +29 -0
  62. package/src/admin/model-routes.ts +71 -0
  63. package/src/admin/public/index.html +416 -0
  64. package/src/admin/qdrant-scroll.ts +45 -0
  65. package/src/admin/server.ts +188 -0
  66. package/src/admin/wiki-routes.ts +93 -0
  67. package/src/compose.ts +599 -0
  68. package/src/config/define-config.ts +35 -0
  69. package/src/cron/.gitkeep +0 -0
  70. package/src/cron/idle-session-cron.ts +144 -0
  71. package/src/cron/idle-session-scanner.ts +37 -0
  72. package/src/cron/self-review-cron.ts +103 -0
  73. package/src/cron/semantic-consolidation.ts +228 -0
  74. package/src/memory/.gitkeep +0 -0
  75. package/src/memory/embedder.ts +15 -0
  76. package/src/memory/episodic-store.ts +183 -0
  77. package/src/memory/memory-provider.ts +98 -0
  78. package/src/memory/semantic-facts-store.ts +89 -0
  79. package/src/memory/tool-corrections-store.ts +72 -0
  80. package/src/memory/verbatim-archive-store.ts +202 -0
  81. package/src/model/client.ts +33 -0
  82. package/src/model/context-size.ts +42 -0
  83. package/src/plugins/manifest.ts +47 -0
  84. package/src/plugins/plugin-loader.ts +205 -0
  85. package/src/router/channel-loader.ts +56 -0
  86. package/src/router/provider.ts +7 -0
  87. package/src/router/terminal-provider.ts +155 -0
  88. package/src/router/terminal.ts +151 -0
  89. package/src/router/tool-log.ts +116 -0
  90. package/src/router/turn-runner.ts +205 -0
  91. package/src/session/agent-turn.ts +391 -0
  92. package/src/session/context-primer.ts +134 -0
  93. package/src/session/episodic-summarizer.ts +38 -0
  94. package/src/session/history.ts +168 -0
  95. package/src/session/pending-confirmation.ts +8 -0
  96. package/src/session/read-skill-tool.ts +38 -0
  97. package/src/session/semantic-fact-extractor.ts +69 -0
  98. package/src/session/step-info.ts +27 -0
  99. package/src/session/summarizer.ts +36 -0
  100. package/src/session/system-prompt.ts +142 -0
  101. package/src/session/tool-correction-extractor.ts +133 -0
  102. package/src/session/tool-log-buffer.ts +73 -0
  103. package/src/session/tool-log-recall-tool.ts +38 -0
  104. package/src/session/tool-start-hook.ts +164 -0
  105. package/src/tools/display-store.ts +89 -0
  106. package/src/tools/present-tool.ts +41 -0
  107. package/src/wiki/.gitkeep +0 -0
  108. package/src/wiki/frontmatter-schema.ts +49 -0
  109. package/src/wiki/index-entry.ts +59 -0
  110. package/src/wiki/orphan-detector.ts +61 -0
  111. package/src/wiki/self-review-runner.ts +133 -0
  112. package/src/wiki/self-review-tools.ts +162 -0
  113. package/src/wiki/vault-cli.ts +143 -0
  114. package/src/wiki/vault-init.ts +43 -0
  115. package/src/wiki/wiki-note.ts +326 -0
  116. package/src/wiki/wiki-read.ts +122 -0
  117. package/src/wiki/wiki-tools.ts +112 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The session-persistence cron loop: periodically sweeps for sessions idle past the
3
+ * configured timeout, summarizes each one (episodic-summarizer.ts),
4
+ * writes the result to Qdrant (episodic-store.ts), and discards the raw
5
+ * transcript (`deps.closeSession`). Every dependency is injected — this
6
+ * file owns only the sweep/interval mechanics, not session storage, the
7
+ * LLM call, or Qdrant itself.
8
+ */
9
+ import type { IdleSessionScanner } from "./idle-session-scanner.ts";
10
+ import type { Message } from "../session/history.ts";
11
+ import type { EpisodicSummary } from "../memory/episodic-store.ts";
12
+ import type { SemanticFact } from "../session/semantic-fact-extractor.ts";
13
+ import type { SemanticFactEntry } from "../memory/semantic-facts-store.ts";
14
+
15
+ export type IdleSession = { key: string; userId: string; messages: Message[] };
16
+
17
+ /**
18
+ * Dependencies for `captureSessionToMemory` — a subset of
19
+ * `IdleSessionSweepDeps`, without `getSession`/`closeSession`: this
20
+ * function never decides *whether* a session should be captured or
21
+ * whether it should be closed afterwards, only *how* to capture a given
22
+ * slice of messages. Reused by the idle sweep below (final capture +
23
+ * close) and, without touching this file's own tests, by the two
24
+ * mid-conversation triggers wired in `index.ts` (message-count threshold,
25
+ * Layer 1 compression) — neither of which closes the session.
26
+ */
27
+ export type CaptureDeps = {
28
+ summarize: (messages: Message[]) => Promise<string>;
29
+ store: (entry: EpisodicSummary) => Promise<void>;
30
+ /**
31
+ * Semantic fact extraction/consolidation (D-22/D-34) — an enrichment on
32
+ * top of the episodic summary above, not a required part of it: omit
33
+ * all three and capture behaves exactly as before. When present, a
34
+ * failure here is logged and never propagates — the episodic write
35
+ * already succeeded and is the source of truth being preserved, same
36
+ * "system must work when this enrichment is absent" boundary as every
37
+ * other Layer 2/3 store in Mercury.
38
+ */
39
+ extractFacts?: (messages: Message[]) => Promise<SemanticFact[]>;
40
+ storeFact?: (entry: SemanticFactEntry) => Promise<void>;
41
+ consolidateFact?: (userId: string, topic: string) => Promise<void>;
42
+ log?: (msg: string) => void;
43
+ };
44
+
45
+ /**
46
+ * Summarizes+stores an episodic entry for `messages`, then (when the
47
+ * semantic deps are provided) extracts and consolidates semantic facts —
48
+ * the one place this logic lives, shared by every capture trigger. A
49
+ * failure summarizing/storing propagates to the caller (it decides what
50
+ * "capture failed" means for its own trigger — e.g. the idle sweep below
51
+ * leaves the session tracked for retry and skips `closeSession`); a
52
+ * failure in the semantic enrichment layer is caught and logged here,
53
+ * never propagated, since it must never undo an episodic write that
54
+ * already succeeded.
55
+ */
56
+ export async function captureSessionToMemory(
57
+ userId: string,
58
+ sessionKey: string,
59
+ messages: Message[],
60
+ now: number,
61
+ deps: CaptureDeps,
62
+ ): Promise<void> {
63
+ const log = deps.log ?? ((msg: string) => console.error(msg));
64
+
65
+ const summary = await deps.summarize(messages);
66
+ const timestamp = new Date(now).toISOString();
67
+ await deps.store({ userId, sessionKey, summary, timestamp });
68
+
69
+ if (deps.extractFacts && deps.storeFact && deps.consolidateFact) {
70
+ try {
71
+ const facts = await deps.extractFacts(messages);
72
+ for (const fact of facts) {
73
+ try {
74
+ await deps.storeFact({ userId, topic: fact.topic, value: fact.value, timestamp });
75
+ await deps.consolidateFact(userId, fact.topic);
76
+ } catch (err) {
77
+ log(`semantic fact consolidation failed for ${sessionKey}/${fact.topic}: ${String(err)}`);
78
+ }
79
+ }
80
+ } catch (err) {
81
+ log(`semantic fact extraction failed for ${sessionKey}: ${String(err)}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ export type IdleSessionSweepDeps = CaptureDeps & {
87
+ /** Looks up a session's current content by key; `undefined` if it's already gone (e.g. closed by something else in the meantime). */
88
+ getSession: (key: string) => IdleSession | undefined;
89
+ /** Discards the session's raw transcript — called only after a successful summarize+store. */
90
+ closeSession: (key: string) => void;
91
+ };
92
+
93
+ /**
94
+ * Runs one sweep at `now`: every session `scanner` reports idle (past
95
+ * `idleTimeoutMs`) gets captured (`captureSessionToMemory`) and closed,
96
+ * then cleared from `scanner`. A failure capturing is logged and leaves
97
+ * that session's tracking untouched (retried on the next sweep) — it must
98
+ * never stop the sweep from processing the others (hard-won convention:
99
+ * one bad tick can't take down the rest of Mercury).
100
+ */
101
+ export async function runIdleSessionSweep(
102
+ scanner: IdleSessionScanner,
103
+ now: number,
104
+ idleTimeoutMs: number,
105
+ deps: IdleSessionSweepDeps,
106
+ ): Promise<void> {
107
+ const log = deps.log ?? ((msg: string) => console.error(msg));
108
+
109
+ for (const key of scanner.scanIdle(now, idleTimeoutMs)) {
110
+ try {
111
+ const session = deps.getSession(key);
112
+ if (!session) {
113
+ scanner.clear(key);
114
+ continue;
115
+ }
116
+
117
+ await captureSessionToMemory(session.userId, key, session.messages, now, deps);
118
+
119
+ deps.closeSession(key);
120
+ scanner.clear(key);
121
+ } catch (err) {
122
+ log(`idle session sweep failed for ${key}: ${String(err)}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ export type IdleSessionCron = { stop: () => void };
128
+
129
+ /** Starts the periodic sweep on `opts.checkIntervalMs`, gated on `opts.idleTimeoutMs`. `stop()` halts it. */
130
+ export function startIdleSessionCron(
131
+ scanner: IdleSessionScanner,
132
+ deps: IdleSessionSweepDeps,
133
+ opts: { idleTimeoutMs: number; checkIntervalMs: number },
134
+ ): IdleSessionCron {
135
+ const interval = setInterval(() => {
136
+ runIdleSessionSweep(scanner, Date.now(), opts.idleTimeoutMs, deps).catch((err) => {
137
+ (deps.log ?? ((msg: string) => console.error(msg)))(`idle session cron tick failed: ${String(err)}`);
138
+ });
139
+ }, opts.checkIntervalMs);
140
+
141
+ return {
142
+ stop: () => clearInterval(interval),
143
+ };
144
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure idle-tracking for session persistence: remembers the last
3
+ * activity time per session key and reports which are idle at a given
4
+ * moment. Time is always passed in, never read internally (`Date.now()`
5
+ * lives in the caller, e.g. `src/index.ts`/`idle-session-cron.ts`) — this
6
+ * is what makes `scanIdle`'s threshold behavior exactly testable.
7
+ */
8
+ export type IdleSessionScanner = {
9
+ /** Records activity for `key` at `now`, resetting its idle clock. */
10
+ touch(key: string, now: number): void;
11
+ /** Returns every tracked key whose last activity is at least `idleTimeoutMs` before `now`. */
12
+ scanIdle(now: number, idleTimeoutMs: number): string[];
13
+ /** Stops tracking `key` — call after a session has been consolidated and its raw transcript discarded. */
14
+ clear(key: string): void;
15
+ };
16
+
17
+ export function createIdleSessionScanner(): IdleSessionScanner {
18
+ const lastActivity = new Map<string, number>();
19
+
20
+ return {
21
+ touch(key, now) {
22
+ lastActivity.set(key, now);
23
+ },
24
+ scanIdle(now, idleTimeoutMs) {
25
+ const idle: string[] = [];
26
+ for (const [key, lastSeen] of lastActivity) {
27
+ if (now - lastSeen >= idleTimeoutMs) {
28
+ idle.push(key);
29
+ }
30
+ }
31
+ return idle;
32
+ },
33
+ clear(key) {
34
+ lastActivity.delete(key);
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The wiki self-review cron: runs once nightly (a fixed local hour,
3
+ * hardcoded, no env override — deliberately different from every other
4
+ * interval in this codebase, per an explicit request to keep this one
5
+ * out of runtime configurability), running raw/ triage, index.md/orphan
6
+ * maintenance, and a contradiction check as three independent sub-passes
7
+ * (see `self-review-runner.ts` for why they're independent, not one
8
+ * multi-step call). Raw-triage and index/orphan each skip when their own
9
+ * cheap pre-check finds nothing to do; the contradiction check has no
10
+ * such pre-check and always runs on a triggered tick — running the whole
11
+ * thing at night, when nothing else contends for the shared local model,
12
+ * is what makes that affordable.
13
+ *
14
+ * This file owns only the scheduling/orchestration mechanics — vault
15
+ * access and the actual LLM calls are injected, same separation
16
+ * `idle-session-cron.ts` uses for session storage and the LLM summarizer.
17
+ */
18
+ export type SelfReviewTickDeps = {
19
+ listRawEntries: () => Promise<string[]>;
20
+ findOrphans: () => Promise<string[]>;
21
+ runRawTriage: (rawEntries: string[]) => Promise<void>;
22
+ runIndexAndOrphan: (orphans: string[]) => Promise<void>;
23
+ runContradictionCheck: () => Promise<void>;
24
+ log?: (msg: string) => void;
25
+ };
26
+
27
+ /**
28
+ * Runs one nightly pass: raw-triage and index/orphan each run only if
29
+ * their own cheap signal found something; the contradiction check always
30
+ * runs, since nothing cheap can tell it whether there's anything to find.
31
+ * Each sub-pass is wrapped in its own try/catch — one failing must not
32
+ * stop the other two from running in the same pass (hard-won convention:
33
+ * one bad tick can't take down the rest of Mercury).
34
+ */
35
+ export async function runSelfReviewTick(deps: SelfReviewTickDeps): Promise<void> {
36
+ const log = deps.log ?? ((msg: string) => console.error(msg));
37
+
38
+ const [rawEntries, orphans] = await Promise.all([deps.listRawEntries(), deps.findOrphans()]);
39
+
40
+ if (rawEntries.length > 0) {
41
+ try {
42
+ await deps.runRawTriage(rawEntries);
43
+ } catch (err) {
44
+ log(`self-review raw-triage pass failed: ${String(err)}`);
45
+ }
46
+ }
47
+
48
+ if (orphans.length > 0) {
49
+ try {
50
+ await deps.runIndexAndOrphan(orphans);
51
+ } catch (err) {
52
+ log(`self-review index/orphan pass failed: ${String(err)}`);
53
+ }
54
+ }
55
+
56
+ try {
57
+ await deps.runContradictionCheck();
58
+ } catch (err) {
59
+ log(`self-review contradiction-check pass failed: ${String(err)}`);
60
+ }
61
+ }
62
+
63
+ /** 3 AM local time — hardcoded on purpose, see file header. */
64
+ export const SELF_REVIEW_HOUR = 3;
65
+ /** How often to check whether it's time to run — mirrors idle-session-cron's
66
+ * check-vs-timeout split, hardcoded for the same reason as the hour above. */
67
+ export const SELF_REVIEW_CHECK_INTERVAL_MS = 15 * 60_000;
68
+
69
+ function localDateKey(d: Date): string {
70
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
71
+ }
72
+
73
+ export type SelfReviewCron = { stop: () => void };
74
+
75
+ /**
76
+ * Checks every `opts.checkIntervalMs` whether the current local hour
77
+ * matches `opts.hour` and today hasn't run yet; if so, runs one tick.
78
+ * `lastRunDate` is in-memory only — if the process restarts mid-window it
79
+ * just waits for tomorrow's, which is fine, nothing here needs to survive
80
+ * a restart. `opts.now`/`opts.hour`/`opts.checkIntervalMs` exist purely as
81
+ * test seams; production (`index.ts`) never overrides them.
82
+ */
83
+ export function startSelfReviewCron(deps: SelfReviewTickDeps, opts: { hour?: number; checkIntervalMs?: number; now?: () => Date } = {}): SelfReviewCron {
84
+ const hour = opts.hour ?? SELF_REVIEW_HOUR;
85
+ const checkIntervalMs = opts.checkIntervalMs ?? SELF_REVIEW_CHECK_INTERVAL_MS;
86
+ const now = opts.now ?? (() => new Date());
87
+ const log = deps.log ?? ((msg: string) => console.error(msg));
88
+ let lastRunDate: string | null = null;
89
+
90
+ const interval = setInterval(() => {
91
+ const current = now();
92
+ if (current.getHours() !== hour) return;
93
+ const today = localDateKey(current);
94
+ if (lastRunDate === today) return;
95
+ lastRunDate = today;
96
+
97
+ runSelfReviewTick(deps).catch((err) => {
98
+ log(`self-review cron tick failed: ${String(err)}`);
99
+ });
100
+ }, checkIntervalMs);
101
+
102
+ return { stop: () => clearInterval(interval) };
103
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Deterministic promotion of clustered semantic facts to a standing wiki
3
+ * note — the consolidation half of D-22/D-34, paired with
4
+ * `semantic-fact-extractor.ts` (the LLM half, which only ever proposes
5
+ * candidate facts). Zero model judgment here: given the last `k`
6
+ * occurrences of a topic for a user, count the most common value and
7
+ * compare it against whatever's already written at
8
+ * `inferred/users/<userId>/<topic>.md` — write only if the challenger's
9
+ * count strictly exceeds the incumbent's (never on a tie, and never when
10
+ * no single value is unambiguously dominant in the current window).
11
+ */
12
+ import { parse as parseYaml } from "yaml";
13
+ import type { readWikiFile } from "../wiki/wiki-read.ts";
14
+ import type { writeInferredNote, writeToolCorrectionNote } from "../wiki/wiki-note.ts";
15
+ import type { SemanticFactEntry } from "../memory/semantic-facts-store.ts";
16
+ import type { ToolCorrectionEntry } from "../memory/tool-corrections-store.ts";
17
+
18
+ type ClusterFn = (userId: string, topic: string, limit: number) => Promise<SemanticFactEntry[]>;
19
+ type Confidence = "low" | "medium" | "high";
20
+
21
+ export type ConsolidationDeps = {
22
+ vaultPath: string;
23
+ clusterFn: ClusterFn;
24
+ readWikiFileFn: typeof readWikiFile;
25
+ writeInferredNoteFn: typeof writeInferredNote;
26
+ k?: number;
27
+ confidenceForCount?: (dominantCount: number, k: number) => Confidence;
28
+ now?: () => string;
29
+ };
30
+
31
+ type ToolCorrectionClusterFn = (tool: string, topic: string, limit: number) => Promise<ToolCorrectionEntry[]>;
32
+
33
+ /**
34
+ * Same shape as `ConsolidationDeps`, keyed by `tool` instead of `userId` —
35
+ * `readNoteFn`/`writeNoteFn` deliberately don't take a userId at all
36
+ * (unlike `readWikiFileFn`/`writeInferredNoteFn` above): a procedural
37
+ * correction lives under `curated/standards/`, visible to every session
38
+ * regardless of who asks, never scoped to one user's own
39
+ * `inferred/users/<userId>/`.
40
+ */
41
+ export type ToolCorrectionConsolidationDeps = {
42
+ vaultPath: string;
43
+ clusterFn: ToolCorrectionClusterFn;
44
+ readNoteFn: (vaultPath: string, relativePath: string) => Promise<string>;
45
+ writeNoteFn: typeof writeToolCorrectionNote;
46
+ k?: number;
47
+ confidenceForCount?: (dominantCount: number, k: number) => Confidence;
48
+ now?: () => string;
49
+ };
50
+
51
+ /** Window size for consolidation — how many recent occurrences of a topic to consider. Uncalibrated: chosen without real usage data, to revisit once there's actual traffic to tune against. */
52
+ export const DEFAULT_CONSOLIDATION_K = 3;
53
+
54
+ /**
55
+ * Uncalibrated confidence bands, count relative to `k`: a single
56
+ * occurrence is unconfirmed (low); repeated but not unanimous within the
57
+ * tracked window is medium; the dominant value filling the whole window
58
+ * is high. Same "revisit with real usage" caveat as `DEFAULT_CONSOLIDATION_K`.
59
+ */
60
+ export function defaultConfidenceForCount(dominantCount: number, k: number): Confidence {
61
+ if (dominantCount >= k) {
62
+ return "high";
63
+ }
64
+ if (dominantCount > 1) {
65
+ return "medium";
66
+ }
67
+ return "low";
68
+ }
69
+
70
+ // Generic over anything shaped like {value, timestamp} — both
71
+ // SemanticFactEntry and ToolCorrectionEntry satisfy this structurally,
72
+ // reused by consolidateSemanticFact and consolidateToolCorrection alike.
73
+ function dominantValue(
74
+ entries: Array<{ value: string; timestamp: string }>,
75
+ ): { value: string; supportingTimestamps: string[] } | null {
76
+ const byValue = new Map<string, string[]>();
77
+ for (const e of entries) {
78
+ const timestamps = byValue.get(e.value) ?? [];
79
+ timestamps.push(e.timestamp);
80
+ byValue.set(e.value, timestamps);
81
+ }
82
+
83
+ let best: { value: string; timestamps: string[] } | null = null;
84
+ let tie = false;
85
+ for (const [value, timestamps] of byValue) {
86
+ if (!best || timestamps.length > best.timestamps.length) {
87
+ best = { value, timestamps };
88
+ tie = false;
89
+ } else if (timestamps.length === best.timestamps.length) {
90
+ tie = true;
91
+ }
92
+ }
93
+
94
+ if (!best || tie) {
95
+ return null;
96
+ }
97
+ return { value: best.value, supportingTimestamps: best.timestamps };
98
+ }
99
+
100
+ async function readIncumbentCount(deps: ConsolidationDeps, userId: string, topic: string): Promise<number> {
101
+ let text: string;
102
+ try {
103
+ text = await deps.readWikiFileFn(deps.vaultPath, userId, `inferred/users/${userId}/${topic}.md`);
104
+ } catch {
105
+ return 0;
106
+ }
107
+
108
+ const match = /^---\n([\s\S]*?)\n---\n/.exec(text);
109
+ if (!match) {
110
+ return 0;
111
+ }
112
+ const frontmatter = parseYaml(match[1] as string) as { derived_from?: unknown };
113
+ return Array.isArray(frontmatter.derived_from) ? frontmatter.derived_from.length : 0;
114
+ }
115
+
116
+ /**
117
+ * Re-clusters `topic` for `userId`, and promotes the dominant value to a
118
+ * wiki note if it beats the current incumbent's count. No-op if the
119
+ * cluster is empty or has no single dominant value.
120
+ *
121
+ * `userId` here is Qdrant's own storage form (e.g. `"users/42"`, a raw
122
+ * Google Chat resource name) — `clusterFn` above searches with it as-is,
123
+ * matching how `storeSemanticFact` wrote it. The wiki's
124
+ * `inferred/users/<userId>/` convention expects a different,
125
+ * `encodeURIComponent`-encoded form instead (the same one the model's
126
+ * own wiki tools already use) — a raw userId containing "/" would
127
+ * otherwise be rejected outright by
128
+ * `writeInferredNote`'s own path-separator guard, and even without that
129
+ * guard would land in a directory the model's wiki tools never look at.
130
+ * Two different representations of the same identity, for two different
131
+ * purposes — `wikiUserId` below is used only for the wiki-facing calls,
132
+ * never for `clusterFn`.
133
+ */
134
+ export async function consolidateSemanticFact(userId: string, topic: string, deps: ConsolidationDeps): Promise<void> {
135
+ const k = deps.k ?? DEFAULT_CONSOLIDATION_K;
136
+ const confidenceForCount = deps.confidenceForCount ?? defaultConfidenceForCount;
137
+ const cluster = (await deps.clusterFn(userId, topic, k)).filter((e) => e.topic === topic);
138
+
139
+ const dominant = dominantValue(cluster);
140
+ if (!dominant) {
141
+ return;
142
+ }
143
+
144
+ const wikiUserId = encodeURIComponent(userId);
145
+ const incumbentCount = await readIncumbentCount(deps, wikiUserId, topic);
146
+ if (dominant.supportingTimestamps.length <= incumbentCount) {
147
+ return;
148
+ }
149
+
150
+ const now = deps.now ?? (() => new Date().toISOString());
151
+ await deps.writeInferredNoteFn(
152
+ deps.vaultPath,
153
+ wikiUserId,
154
+ topic,
155
+ {
156
+ confidence: confidenceForCount(dominant.supportingTimestamps.length, k),
157
+ derived_from: dominant.supportingTimestamps,
158
+ last_reviewed: now(),
159
+ },
160
+ dominant.value,
161
+ );
162
+ }
163
+
164
+ async function readToolCorrectionIncumbentCount(
165
+ deps: ToolCorrectionConsolidationDeps,
166
+ tool: string,
167
+ topic: string,
168
+ ): Promise<number> {
169
+ let text: string;
170
+ try {
171
+ // Full vault-root-relative path, matching how readWikiFileInRoots (the
172
+ // real implementation) resolves it — always against the vault root,
173
+ // never against whichever specific root in the allowed list happens to
174
+ // match (same convention readIncumbentCount above uses for
175
+ // inferred/users/<userId>/<topic>.md).
176
+ text = await deps.readNoteFn(deps.vaultPath, `curated/standards/${tool}-${topic}.md`);
177
+ } catch {
178
+ return 0;
179
+ }
180
+
181
+ const match = /^---\n([\s\S]*?)\n---\n/.exec(text);
182
+ if (!match) {
183
+ return 0;
184
+ }
185
+ const frontmatter = parseYaml(match[1] as string) as { derived_from?: unknown };
186
+ return Array.isArray(frontmatter.derived_from) ? frontmatter.derived_from.length : 0;
187
+ }
188
+
189
+ /**
190
+ * Same promotion logic as `consolidateSemanticFact` — re-clusters `topic`
191
+ * for `tool`, promotes the dominant value if it beats the incumbent's
192
+ * count — but keyed by `tool` (a CLI, not a person) and writing to
193
+ * `curated/standards/<tool>-<topic>.md` (global) instead of
194
+ * `inferred/users/<userId>/<topic>.md` (per-user). No userId encoding
195
+ * needed here: a tool name (e.g. "jira") never contains a path separator.
196
+ */
197
+ export async function consolidateToolCorrection(
198
+ tool: string,
199
+ topic: string,
200
+ deps: ToolCorrectionConsolidationDeps,
201
+ ): Promise<void> {
202
+ const k = deps.k ?? DEFAULT_CONSOLIDATION_K;
203
+ const confidenceForCount = deps.confidenceForCount ?? defaultConfidenceForCount;
204
+ const cluster = (await deps.clusterFn(tool, topic, k)).filter((e) => e.topic === topic);
205
+
206
+ const dominant = dominantValue(cluster);
207
+ if (!dominant) {
208
+ return;
209
+ }
210
+
211
+ const incumbentCount = await readToolCorrectionIncumbentCount(deps, tool, topic);
212
+ if (dominant.supportingTimestamps.length <= incumbentCount) {
213
+ return;
214
+ }
215
+
216
+ const now = deps.now ?? (() => new Date().toISOString());
217
+ await deps.writeNoteFn(
218
+ deps.vaultPath,
219
+ tool,
220
+ topic,
221
+ {
222
+ confidence: confidenceForCount(dominant.supportingTimestamps.length, k),
223
+ derived_from: dominant.supportingTimestamps,
224
+ last_reviewed: now(),
225
+ },
226
+ dominant.value,
227
+ );
228
+ }
File without changes
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Thin glue turning an embedding model into the `(text) => Promise<number[]>`
3
+ * shape `episodic-store.ts`'s `storeEpisodicSummary` expects. Same "not
4
+ * worth mocking deeply" reasoning as `session/summarizer.ts` — one line
5
+ * of glue around the AI SDK's `embed`, no dedicated test file.
6
+ */
7
+ import { embed, type EmbeddingModel } from "ai";
8
+
9
+ /** Returns a function that embeds a string using `model`. */
10
+ export function createEmbedder(model: EmbeddingModel): (text: string) => Promise<number[]> {
11
+ return async (text) => {
12
+ const { embedding } = await embed({ model, value: text });
13
+ return embedding;
14
+ };
15
+ }