@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,183 @@
1
+ /**
2
+ * Layer 3 (Qdrant) episodic storage: one point per closed,
3
+ * summarized session — a raw, dated "what happened", not an
4
+ * interpretation. Consolidation into semantic memory (per-topic
5
+ * promotion into the wiki) is a separate, later concern that
6
+ * reads from this collection; this module only ever writes to it.
7
+ *
8
+ * `QdrantClientLike` describes only the subset of `@qdrant/js-client-rest`'s
9
+ * `QdrantClient` this file actually calls — real client instances satisfy
10
+ * it structurally, tests use a plain object instead of a real connection.
11
+ */
12
+ export type QdrantClientLike = {
13
+ getCollections(): Promise<{ collections: Array<{ name: string }> }>;
14
+ createCollection(
15
+ name: string,
16
+ params: { vectors: { size: number; distance: "Cosine" | "Euclid" | "Dot" | "Manhattan" } },
17
+ ): Promise<unknown>;
18
+ upsert(
19
+ name: string,
20
+ params: { points: Array<{ id: string; vector: number[]; payload: Record<string, unknown> }> },
21
+ ): Promise<unknown>;
22
+ /** @qdrant/js-client-rest 1.19 removed `search` in favour of the universal
23
+ * `query` endpoint: the vector moves to `query`, and results come back under
24
+ * `.points` instead of as a bare array. */
25
+ query(
26
+ name: string,
27
+ params: { query: number[]; filter: Record<string, unknown>; limit: number; with_payload: boolean },
28
+ ): Promise<{ points: Array<{ id: string | number; score: number; payload?: Record<string, unknown> | null }> }>;
29
+ /**
30
+ * Optional — not part of the similarity-search surface every caller
31
+ * needs, only used by `getLastSessionEpisodicSummaries` below. Optional
32
+ * so `semantic-facts-store.ts`/`tool-corrections-store.ts` (which share
33
+ * this type but never call `scroll`) don't need a stub in every test
34
+ * fixture.
35
+ */
36
+ scroll?(
37
+ name: string,
38
+ params: {
39
+ filter: Record<string, unknown>;
40
+ order_by: { key: string; direction: "asc" | "desc" };
41
+ limit: number;
42
+ /** Opaque pagination cursor (from a prior page's `next_page_offset`). */
43
+ offset?: string | number | Record<string, unknown> | null;
44
+ /** Whether to return point payloads (`listVerbatimBySession` needs them). */
45
+ with_payload?: boolean;
46
+ },
47
+ ): Promise<{
48
+ points: Array<{ id: string | number; payload?: Record<string, unknown> | null }>;
49
+ next_page_offset?: string | number | Record<string, unknown> | null;
50
+ }>;
51
+ /** Optional — same reasoning as `scroll` above: only `ensureEpisodicCollection` needs it. */
52
+ createPayloadIndex?(name: string, params: { field_name: string; field_schema: "datetime" | "keyword" }): Promise<unknown>;
53
+ };
54
+
55
+ /**
56
+ * Creates `collectionName` (cosine distance, `vectorSize`-dim) if it
57
+ * doesn't already exist, then ensures the `timestamp`/`userId` payload
58
+ * indexes exist regardless — `getLastSessionEpisodicSummaries`'s
59
+ * `order_by`/filter scroll queries fail with an HTTP 400 without them.
60
+ * Idempotent either way (Qdrant no-ops re-creating an existing index), so
61
+ * safe to call on every startup, including against a collection that
62
+ * predates this fix.
63
+ */
64
+ export async function ensureEpisodicCollection(
65
+ client: QdrantClientLike,
66
+ collectionName: string,
67
+ vectorSize: number,
68
+ ): Promise<void> {
69
+ const { collections } = await client.getCollections();
70
+ if (!collections.some((c) => c.name === collectionName)) {
71
+ await client.createCollection(collectionName, { vectors: { size: vectorSize, distance: "Cosine" } });
72
+ }
73
+ if (client.createPayloadIndex) {
74
+ await client.createPayloadIndex(collectionName, { field_name: "timestamp", field_schema: "datetime" });
75
+ await client.createPayloadIndex(collectionName, { field_name: "userId", field_schema: "keyword" });
76
+ }
77
+ }
78
+
79
+ export type EpisodicSummary = {
80
+ userId: string;
81
+ sessionKey: string;
82
+ summary: string;
83
+ timestamp: string;
84
+ };
85
+
86
+ /** Embeds `entry.summary` and upserts it as a new point in `collectionName`, payload carrying the full entry. */
87
+ export async function storeEpisodicSummary(
88
+ client: QdrantClientLike,
89
+ collectionName: string,
90
+ embed: (text: string) => Promise<number[]>,
91
+ entry: EpisodicSummary,
92
+ ): Promise<void> {
93
+ const vector = await embed(entry.summary);
94
+ await client.upsert(collectionName, {
95
+ points: [
96
+ {
97
+ id: crypto.randomUUID(),
98
+ vector,
99
+ payload: { ...entry },
100
+ },
101
+ ],
102
+ });
103
+ }
104
+
105
+ const DEFAULT_SEARCH_LIMIT = 5;
106
+
107
+ function isEpisodicSummary(payload: Record<string, unknown> | null): payload is EpisodicSummary {
108
+ return (
109
+ payload !== null &&
110
+ typeof payload.userId === "string" &&
111
+ typeof payload.sessionKey === "string" &&
112
+ typeof payload.summary === "string" &&
113
+ typeof payload.timestamp === "string"
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Past episodic events for a specific user, most relevant to `queryText`
119
+ * (e.g. "notifications about KAN-123") — lets Mercury see how many times
120
+ * it already notified this user about a given item before composing a
121
+ * message. Not a general-purpose semantic consolidation/pattern-extraction
122
+ * engine (that doesn't exist here) — this only ever reads, never writes
123
+ * or promotes anything.
124
+ */
125
+ export async function searchEpisodicMemory(
126
+ client: QdrantClientLike,
127
+ collectionName: string,
128
+ embed: (text: string) => Promise<number[]>,
129
+ query: { userId: string; queryText: string; limit?: number },
130
+ ): Promise<EpisodicSummary[]> {
131
+ const vector = await embed(query.queryText);
132
+ const results = await client.query(collectionName, {
133
+ query: vector,
134
+ filter: { must: [{ key: "userId", match: { value: query.userId } }] },
135
+ limit: query.limit ?? DEFAULT_SEARCH_LIMIT,
136
+ with_payload: true,
137
+ });
138
+ return results.points.map((r) => r.payload ?? null).filter(isEpisodicSummary);
139
+ }
140
+
141
+ const DEFAULT_SESSION_LIMIT = 3;
142
+
143
+ /**
144
+ * The last closed session's episodic entries for `userId`, most recent
145
+ * first — up to `limit` entries, all sharing the same `sessionKey` as the
146
+ * single most recent entry. Used to seed a brand-new session's context
147
+ * primer with "what happened last time"; not similarity-scoped like
148
+ * `searchEpisodicMemory` (there's no query yet to compare against at
149
+ * session start). Returns an empty array (never throws) if the client
150
+ * doesn't support `scroll`, or if the user has no prior episodic entries —
151
+ * this is enrichment, the caller must work fine without it.
152
+ */
153
+ export async function getLastSessionEpisodicSummaries(
154
+ client: QdrantClientLike,
155
+ collectionName: string,
156
+ query: { userId: string; limit?: number },
157
+ ): Promise<EpisodicSummary[]> {
158
+ if (!client.scroll) {
159
+ return [];
160
+ }
161
+
162
+ const latest = await client.scroll(collectionName, {
163
+ filter: { must: [{ key: "userId", match: { value: query.userId } }] },
164
+ order_by: { key: "timestamp", direction: "desc" },
165
+ limit: 1,
166
+ });
167
+ const mostRecent = latest.points[0]?.payload ?? null;
168
+ if (!isEpisodicSummary(mostRecent)) {
169
+ return [];
170
+ }
171
+
172
+ const session = await client.scroll(collectionName, {
173
+ filter: {
174
+ must: [
175
+ { key: "userId", match: { value: query.userId } },
176
+ { key: "sessionKey", match: { value: mostRecent.sessionKey } },
177
+ ],
178
+ },
179
+ order_by: { key: "timestamp", direction: "desc" },
180
+ limit: query.limit ?? DEFAULT_SESSION_LIMIT,
181
+ });
182
+ return session.points.map((p) => p.payload ?? null).filter(isEpisodicSummary);
183
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The memory-provider seam (issue #4, built ahead of the broader #30
3
+ * design): a memory mechanism that hooks the turn — capturing the
4
+ * exchange after it happens, and contributing its own recall tools — kept
5
+ * behind a small interface so the composition root wires it like any
6
+ * other provider rather than hardwiring it into the pipeline. Only the two
7
+ * hooks #4 exercises live here; the wider generalization (priming,
8
+ * summarizer, wiki strategy as swappable providers) is deferred to #30.
9
+ *
10
+ * The one implementation today is the verbatim archive
11
+ * (`./verbatim-archive-store.ts`).
12
+ */
13
+ import { tool, type Tool } from "ai";
14
+ import { z } from "zod";
15
+ import { appendVerbatimMessage, searchVerbatim } from "./verbatim-archive-store.ts";
16
+ import type { QdrantClientLike } from "./episodic-store.ts";
17
+
18
+ /** One side of an exchange to archive — the provider stamps its own durable timestamp. */
19
+ export type VerbatimExchange = {
20
+ userId: string;
21
+ sessionKey: string;
22
+ role: "user" | "assistant";
23
+ content: string;
24
+ };
25
+
26
+ /** Per-turn identity a provider's recall tools are scoped to. `userId` is what makes recall cross-session. */
27
+ export type MemoryProviderContext = {
28
+ sessionKey: string;
29
+ userId: string;
30
+ };
31
+
32
+ /**
33
+ * A memory mechanism the composition root can wire in. `captureExchange`
34
+ * runs after a turn resolves (post-message); `sessionTools` contributes
35
+ * model-invocable recall for the calling session. Both optional — a
36
+ * provider may only capture, or only recall.
37
+ */
38
+ export type MemoryProvider = {
39
+ captureExchange?(exchange: VerbatimExchange): Promise<void>;
40
+ sessionTools?(ctx: MemoryProviderContext): Record<string, Tool>;
41
+ };
42
+
43
+ export type VerbatimArchiveProviderDeps = {
44
+ client: QdrantClientLike;
45
+ collectionName: string;
46
+ embed: (text: string) => Promise<number[]>;
47
+ /** Test seam; defaults to `() => new Date()`. The provider owns the durable ordering timestamp. */
48
+ now?: () => Date;
49
+ };
50
+
51
+ const RECALL_DEFAULT_LIMIT = 5;
52
+ const RECALL_MAX_LIMIT = 20;
53
+
54
+ /**
55
+ * Builds the verbatim-archive provider: captures every user/assistant
56
+ * message verbatim to its Qdrant collection, and exposes a
57
+ * `recall_verbatim` tool that similarity-searches that archive scoped to
58
+ * the calling user — letting a later session resurface what was actually
59
+ * said before, beyond the live history window.
60
+ */
61
+ export function createVerbatimArchiveProvider(deps: VerbatimArchiveProviderDeps): MemoryProvider {
62
+ const now = deps.now ?? (() => new Date());
63
+ return {
64
+ captureExchange: async (exchange) => {
65
+ // An empty/whitespace-only message (e.g. a present()-only turn with no
66
+ // prose) has nothing to archive — skip it rather than store a
67
+ // meaningless point and embed an empty string.
68
+ if (exchange.content.trim() === "") {
69
+ return;
70
+ }
71
+ await appendVerbatimMessage(deps.client, deps.collectionName, deps.embed, {
72
+ ...exchange,
73
+ timestamp: now().toISOString(),
74
+ });
75
+ },
76
+ sessionTools: (ctx) => {
77
+ const recall_verbatim = tool({
78
+ description:
79
+ "Search the durable verbatim archive of what you and this user actually said in earlier " +
80
+ "conversations, beyond the current history window — e.g. 'what did we say about KAN-1 last week'. " +
81
+ "Returns real past messages with their timestamps; quote them, don't reconstruct from memory.",
82
+ inputSchema: z.object({
83
+ query: z.string().min(1),
84
+ limit: z.number().int().positive().max(RECALL_MAX_LIMIT).optional(),
85
+ }),
86
+ execute: async ({ query, limit }) => {
87
+ const messages = await searchVerbatim(deps.client, deps.collectionName, deps.embed, {
88
+ userId: ctx.userId,
89
+ queryText: query,
90
+ limit: limit ?? RECALL_DEFAULT_LIMIT,
91
+ });
92
+ return { ok: true as const, messages };
93
+ },
94
+ });
95
+ return { recall_verbatim };
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Layer 3 (Qdrant) semantic facts storage — one collection, separate from
3
+ * `episodic-store.ts`. Episodic points are a raw dated account of a whole
4
+ * session; semantic facts are `{topic, value}` pairs extracted from a
5
+ * session, clustered per user+topic, and deterministically promoted to a
6
+ * standing wiki note. This module only owns the collection lifecycle;
7
+ * extraction, clustering, and promotion are separate concerns built on
8
+ * top of it.
9
+ */
10
+ import type { QdrantClientLike } from "./episodic-store.ts";
11
+
12
+ /** Creates `collectionName` (cosine distance, `vectorSize`-dim) if it doesn't already exist — safe to call on every startup. */
13
+ export async function ensureSemanticFactsCollection(
14
+ client: QdrantClientLike,
15
+ collectionName: string,
16
+ vectorSize: number,
17
+ ): Promise<void> {
18
+ const { collections } = await client.getCollections();
19
+ if (collections.some((c) => c.name === collectionName)) {
20
+ return;
21
+ }
22
+ await client.createCollection(collectionName, { vectors: { size: vectorSize, distance: "Cosine" } });
23
+ }
24
+
25
+ export type SemanticFactEntry = {
26
+ userId: string;
27
+ topic: string;
28
+ value: string;
29
+ timestamp: string;
30
+ };
31
+
32
+ /**
33
+ * Embeds `entry.topic` alone (never `topic + value`) and upserts it as a
34
+ * new point in `collectionName`, payload carrying the full entry. One
35
+ * point per extracted fact, never an update-in-place — consolidation
36
+ * reads the whole history back via `searchSemanticFactsByTopic` and
37
+ * decides what to promote.
38
+ */
39
+ export async function storeSemanticFact(
40
+ client: QdrantClientLike,
41
+ collectionName: string,
42
+ embed: (text: string) => Promise<number[]>,
43
+ entry: SemanticFactEntry,
44
+ ): Promise<void> {
45
+ const vector = await embed(entry.topic);
46
+ await client.upsert(collectionName, {
47
+ points: [
48
+ {
49
+ id: crypto.randomUUID(),
50
+ vector,
51
+ payload: { ...entry },
52
+ },
53
+ ],
54
+ });
55
+ }
56
+
57
+ const DEFAULT_SEARCH_LIMIT = 5;
58
+
59
+ function isSemanticFactEntry(payload: Record<string, unknown> | null): payload is SemanticFactEntry {
60
+ return (
61
+ payload !== null &&
62
+ typeof payload.userId === "string" &&
63
+ typeof payload.topic === "string" &&
64
+ typeof payload.value === "string" &&
65
+ typeof payload.timestamp === "string"
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Past facts for a specific user, clustered by topic similarity — the
71
+ * read side consolidation uses to gather "every occurrence of roughly
72
+ * this topic" before counting a dominant value. Filters by userId so
73
+ * one user's facts never leak into another's cluster.
74
+ */
75
+ export async function searchSemanticFactsByTopic(
76
+ client: QdrantClientLike,
77
+ collectionName: string,
78
+ embed: (text: string) => Promise<number[]>,
79
+ query: { userId: string; topic: string; limit?: number },
80
+ ): Promise<SemanticFactEntry[]> {
81
+ const vector = await embed(query.topic);
82
+ const results = await client.query(collectionName, {
83
+ query: vector,
84
+ filter: { must: [{ key: "userId", match: { value: query.userId } }] },
85
+ limit: query.limit ?? DEFAULT_SEARCH_LIMIT,
86
+ with_payload: true,
87
+ });
88
+ return results.points.map((r) => r.payload ?? null).filter(isSemanticFactEntry);
89
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Layer 3 (Qdrant) procedural-correction staging — separate collection
3
+ * from `semantic-facts-store.ts`: that one is keyed by userId (a fact
4
+ * about a specific person), this one is keyed by tool (a fact about a
5
+ * CLI, true for whoever uses it next). Kept as its own collection rather
6
+ * than overloading `semantic-facts-store.ts`'s `userId` field with a tool
7
+ * name — a tool name in a field called `userId` would be a standing
8
+ * source of confusion for anyone reading this collection later.
9
+ */
10
+ import type { QdrantClientLike } from "./episodic-store.ts";
11
+
12
+ /** Creates `collectionName` (cosine distance, `vectorSize`-dim) if it doesn't already exist — safe to call on every startup. */
13
+ export async function ensureToolCorrectionsCollection(
14
+ client: QdrantClientLike,
15
+ collectionName: string,
16
+ vectorSize: number,
17
+ ): Promise<void> {
18
+ const { collections } = await client.getCollections();
19
+ if (collections.some((c) => c.name === collectionName)) {
20
+ return;
21
+ }
22
+ await client.createCollection(collectionName, { vectors: { size: vectorSize, distance: "Cosine" } });
23
+ }
24
+
25
+ export type ToolCorrectionEntry = {
26
+ tool: string;
27
+ topic: string;
28
+ value: string;
29
+ timestamp: string;
30
+ };
31
+
32
+ /** Embeds `entry.topic` alone (never `topic + value`), same reasoning as `storeSemanticFact` — upserts a new point, never an update-in-place. */
33
+ export async function storeToolCorrection(
34
+ client: QdrantClientLike,
35
+ collectionName: string,
36
+ embed: (text: string) => Promise<number[]>,
37
+ entry: ToolCorrectionEntry,
38
+ ): Promise<void> {
39
+ const vector = await embed(entry.topic);
40
+ await client.upsert(collectionName, {
41
+ points: [{ id: crypto.randomUUID(), vector, payload: { ...entry } }],
42
+ });
43
+ }
44
+
45
+ const DEFAULT_SEARCH_LIMIT = 5;
46
+
47
+ function isToolCorrectionEntry(payload: Record<string, unknown> | null): payload is ToolCorrectionEntry {
48
+ return (
49
+ payload !== null &&
50
+ typeof payload.tool === "string" &&
51
+ typeof payload.topic === "string" &&
52
+ typeof payload.value === "string" &&
53
+ typeof payload.timestamp === "string"
54
+ );
55
+ }
56
+
57
+ /** Past corrections for a specific tool, clustered by topic similarity — filters by `tool` so one tool's corrections never leak into another's cluster. */
58
+ export async function searchToolCorrectionsByTopic(
59
+ client: QdrantClientLike,
60
+ collectionName: string,
61
+ embed: (text: string) => Promise<number[]>,
62
+ query: { tool: string; topic: string; limit?: number },
63
+ ): Promise<ToolCorrectionEntry[]> {
64
+ const vector = await embed(query.topic);
65
+ const results = await client.query(collectionName, {
66
+ query: vector,
67
+ filter: { must: [{ key: "tool", match: { value: query.tool } }] },
68
+ limit: query.limit ?? DEFAULT_SEARCH_LIMIT,
69
+ with_payload: true,
70
+ });
71
+ return results.points.map((r) => r.payload ?? null).filter(isToolCorrectionEntry);
72
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The verbatim conversation archive (issue #4): a durable, lossless
3
+ * Qdrant collection holding the complete user↔model exchange, one point
4
+ * per message, distinct from every other memory layer.
5
+ *
6
+ * How it differs from the neighbours in this directory:
7
+ * - Layer-1 history (`../session/history.ts`) is a sliding window that
8
+ * summarizes itself away — lossy by design.
9
+ * - The episodic store (`./episodic-store.ts`) captures a *summary* of a
10
+ * closed session — derived, not raw.
11
+ * This archive instead keeps what was actually said, verbatim, so a later
12
+ * session can resurface it and re-read it in light of new facts. It only
13
+ * ever appends and reads; nothing here evicts (retention bounding is a
14
+ * separate, later concern).
15
+ *
16
+ * Reuses `episodic-store.ts`'s structural `QdrantClientLike` — the same
17
+ * subset of the real client every store in this directory shares.
18
+ */
19
+ import type { QdrantClientLike } from "./episodic-store.ts";
20
+
21
+ /**
22
+ * Creates `collectionName` (cosine distance, `vectorSize`-dim) if it
23
+ * doesn't already exist, then ensures the `userId` keyword payload index
24
+ * regardless — `searchVerbatim` filters by `userId` server-side, and the
25
+ * index keeps that filter usable as the archive grows. Idempotent
26
+ * (Qdrant no-ops re-creating an existing index), so safe to call on every
27
+ * startup, including against a collection created before this existed.
28
+ */
29
+ export async function ensureVerbatimCollection(
30
+ client: QdrantClientLike,
31
+ collectionName: string,
32
+ vectorSize: number,
33
+ ): Promise<void> {
34
+ const { collections } = await client.getCollections();
35
+ if (!collections.some((c) => c.name === collectionName)) {
36
+ await client.createCollection(collectionName, { vectors: { size: vectorSize, distance: "Cosine" } });
37
+ }
38
+ if (client.createPayloadIndex) {
39
+ // `userId` for searchVerbatim's per-person filter; `sessionKey` for
40
+ // listVerbatimBySession's per-conversation filter; `timestamp` (datetime)
41
+ // for its chronological `order_by`. Qdrant rejects a filter/order_by on an
42
+ // unindexed field with a 400, so all three must exist.
43
+ await client.createPayloadIndex(collectionName, { field_name: "userId", field_schema: "keyword" });
44
+ await client.createPayloadIndex(collectionName, { field_name: "sessionKey", field_schema: "keyword" });
45
+ await client.createPayloadIndex(collectionName, { field_name: "timestamp", field_schema: "datetime" });
46
+ }
47
+ }
48
+
49
+ /** A single verbatim message as it was emitted in chat. `timestamp` (ISO 8601, ms precision) is the durable ordering key. */
50
+ export type VerbatimMessage = {
51
+ userId: string;
52
+ sessionKey: string;
53
+ role: "user" | "assistant";
54
+ content: string;
55
+ timestamp: string;
56
+ };
57
+
58
+ /** Embeds `entry.content` and upserts it as a new point in `collectionName`, payload carrying the full verbatim message. */
59
+ export async function appendVerbatimMessage(
60
+ client: QdrantClientLike,
61
+ collectionName: string,
62
+ embed: (text: string) => Promise<number[]>,
63
+ entry: VerbatimMessage,
64
+ ): Promise<void> {
65
+ const vector = await embed(entry.content);
66
+ await client.upsert(collectionName, {
67
+ points: [
68
+ {
69
+ id: crypto.randomUUID(),
70
+ vector,
71
+ payload: { ...entry },
72
+ },
73
+ ],
74
+ });
75
+ }
76
+
77
+ const DEFAULT_SEARCH_LIMIT = 5;
78
+
79
+ /** Narrows an arbitrary payload to a `VerbatimMessage`, rejecting null/malformed points (e.g. from an older schema). */
80
+ function isVerbatimMessage(payload: Record<string, unknown> | null): payload is VerbatimMessage {
81
+ return (
82
+ payload !== null &&
83
+ typeof payload.userId === "string" &&
84
+ typeof payload.sessionKey === "string" &&
85
+ (payload.role === "user" || payload.role === "assistant") &&
86
+ typeof payload.content === "string" &&
87
+ typeof payload.timestamp === "string"
88
+ );
89
+ }
90
+
91
+ /**
92
+ * The verbatim messages for a specific user most relevant to `queryText`
93
+ * — e.g. "what did we say about KAN-1 last week". Filtered by `userId` so
94
+ * one user's transcript never leaks into another's. Only reads; returns
95
+ * the raw messages, ordering left to the caller (each carries its own
96
+ * `timestamp`).
97
+ */
98
+ export async function searchVerbatim(
99
+ client: QdrantClientLike,
100
+ collectionName: string,
101
+ embed: (text: string) => Promise<number[]>,
102
+ query: { userId: string; queryText: string; limit?: number },
103
+ ): Promise<VerbatimMessage[]> {
104
+ const vector = await embed(query.queryText);
105
+ const results = await client.query(collectionName, {
106
+ query: vector,
107
+ filter: { must: [{ key: "userId", match: { value: query.userId } }] },
108
+ limit: query.limit ?? DEFAULT_SEARCH_LIMIT,
109
+ with_payload: true,
110
+ });
111
+ return results.points.map((r) => r.payload ?? null).filter(isVerbatimMessage);
112
+ }
113
+
114
+ /**
115
+ * How many recent messages `listVerbatimSessions` scans to build the
116
+ * conversation list. Qdrant has no native DISTINCT, so we dedup client-side
117
+ * over a bounded window of the newest points — a conversation whose newest
118
+ * message falls outside this window won't appear. Ample for a live-testing UI
119
+ * sidebar; retention/aggregation is a separate later concern.
120
+ */
121
+ const SESSION_SCAN_LIMIT = 500;
122
+
123
+ /** One conversation in the list view: its key, the timestamp of its most recent message, and a short preview of it. */
124
+ export type VerbatimSession = {
125
+ sessionKey: string;
126
+ lastTimestamp: string;
127
+ preview: string;
128
+ };
129
+
130
+ /** Max characters of the most-recent message shown as a conversation's preview. */
131
+ const PREVIEW_CHARS = 120;
132
+
133
+ /**
134
+ * The known conversations, most-recently-active first — the sidebar a UI shows
135
+ * to switch between conversations. Scrolls the newest `SESSION_SCAN_LIMIT`
136
+ * messages (timestamp desc) and dedups by `sessionKey`, keeping each
137
+ * conversation's most recent message for its timestamp and preview, then caps
138
+ * the result at `limit`. Returns empty if the client can't scroll.
139
+ */
140
+ export async function listVerbatimSessions(
141
+ client: QdrantClientLike,
142
+ collectionName: string,
143
+ query: { limit: number },
144
+ ): Promise<{ conversations: VerbatimSession[] }> {
145
+ if (!client.scroll) {
146
+ return { conversations: [] };
147
+ }
148
+ const result = await client.scroll(collectionName, {
149
+ filter: { must: [] },
150
+ order_by: { key: "timestamp", direction: "desc" },
151
+ limit: SESSION_SCAN_LIMIT,
152
+ with_payload: true,
153
+ });
154
+ const messages = result.points.map((p) => p.payload ?? null).filter(isVerbatimMessage);
155
+ const seen = new Map<string, VerbatimSession>();
156
+ for (const m of messages) {
157
+ if (seen.has(m.sessionKey)) {
158
+ continue; // desc order → first occurrence is the most recent
159
+ }
160
+ seen.set(m.sessionKey, {
161
+ sessionKey: m.sessionKey,
162
+ lastTimestamp: m.timestamp,
163
+ preview: m.content.length <= PREVIEW_CHARS ? m.content : `${m.content.slice(0, PREVIEW_CHARS)}…`,
164
+ });
165
+ }
166
+ return { conversations: [...seen.values()].slice(0, query.limit) };
167
+ }
168
+
169
+ /** A page of a conversation's verbatim messages, plus the opaque cursor for the next page (null when exhausted). */
170
+ export type VerbatimPage = {
171
+ messages: VerbatimMessage[];
172
+ nextOffset: string | number | Record<string, unknown> | null;
173
+ };
174
+
175
+ /**
176
+ * The verbatim messages of one conversation (`sessionKey`) in chronological
177
+ * order — the durable transcript a UI renders when it (re)loads a conversation.
178
+ * Unlike `searchVerbatim` this is a plain scroll (no similarity), filtered by
179
+ * `sessionKey` (the true per-conversation key; in the HTTP surface it equals
180
+ * the client's `conversationId`) and ordered by `timestamp` ascending, paged
181
+ * via the opaque `offset` cursor. Returns empty if the client can't scroll.
182
+ */
183
+ export async function listVerbatimBySession(
184
+ client: QdrantClientLike,
185
+ collectionName: string,
186
+ query: { sessionKey: string; limit: number; offset?: string | number | Record<string, unknown> | null },
187
+ ): Promise<VerbatimPage> {
188
+ if (!client.scroll) {
189
+ return { messages: [], nextOffset: null };
190
+ }
191
+ const result = await client.scroll(collectionName, {
192
+ filter: { must: [{ key: "sessionKey", match: { value: query.sessionKey } }] },
193
+ order_by: { key: "timestamp", direction: "asc" },
194
+ limit: query.limit,
195
+ offset: query.offset,
196
+ with_payload: true,
197
+ });
198
+ return {
199
+ messages: result.points.map((p) => p.payload ?? null).filter(isVerbatimMessage),
200
+ nextOffset: result.next_page_offset ?? null,
201
+ };
202
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Constructs the Ollama provider Mercury uses for every LLM call (main
3
+ * agent turns and Layer 1 summarization alike).
4
+ *
5
+ * Why it exists: the Ollama endpoint Mercury talks to varies by
6
+ * deployment (a local GPU box in development, a different host in
7
+ * production) and must always come from configuration, never be
8
+ * hardcoded or silently defaulted to `localhost` — this is the single
9
+ * place that reads that configuration, so the fail-fast behavior only
10
+ * has to be correct once.
11
+ *
12
+ * Used by: `src/index.ts` (wiring), which passes the resulting
13
+ * provider's model instance into `createSummarizer` (src/session/summarizer.ts)
14
+ * and `runTurn` (src/session/agent-turn.ts).
15
+ */
16
+ import { createOllama, type OllamaProvider } from "ai-sdk-ollama";
17
+
18
+ /**
19
+ * Returns an Ollama provider bound to `OLLAMA_HOST`.
20
+ *
21
+ * Throws synchronously if `OLLAMA_HOST` is unset or empty — there is no
22
+ * fallback to `localhost`, since which Ollama endpoint to use is a
23
+ * deployment decision, not something Mercury's code should guess.
24
+ */
25
+ export function getOllamaProvider(): OllamaProvider {
26
+ const baseURL = process.env.OLLAMA_HOST;
27
+ if (!baseURL) {
28
+ throw new Error(
29
+ "OLLAMA_HOST is not set — never default to localhost",
30
+ );
31
+ }
32
+ return createOllama({ baseURL });
33
+ }