@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,121 @@
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<{
14
+ collections: Array<{
15
+ name: string;
16
+ }>;
17
+ }>;
18
+ createCollection(name: string, params: {
19
+ vectors: {
20
+ size: number;
21
+ distance: "Cosine" | "Euclid" | "Dot" | "Manhattan";
22
+ };
23
+ }): Promise<unknown>;
24
+ upsert(name: string, params: {
25
+ points: Array<{
26
+ id: string;
27
+ vector: number[];
28
+ payload: Record<string, unknown>;
29
+ }>;
30
+ }): Promise<unknown>;
31
+ /** @qdrant/js-client-rest 1.19 removed `search` in favour of the universal
32
+ * `query` endpoint: the vector moves to `query`, and results come back under
33
+ * `.points` instead of as a bare array. */
34
+ query(name: string, params: {
35
+ query: number[];
36
+ filter: Record<string, unknown>;
37
+ limit: number;
38
+ with_payload: boolean;
39
+ }): Promise<{
40
+ points: Array<{
41
+ id: string | number;
42
+ score: number;
43
+ payload?: Record<string, unknown> | null;
44
+ }>;
45
+ }>;
46
+ /**
47
+ * Optional — not part of the similarity-search surface every caller
48
+ * needs, only used by `getLastSessionEpisodicSummaries` below. Optional
49
+ * so `semantic-facts-store.ts`/`tool-corrections-store.ts` (which share
50
+ * this type but never call `scroll`) don't need a stub in every test
51
+ * fixture.
52
+ */
53
+ scroll?(name: string, params: {
54
+ filter: Record<string, unknown>;
55
+ order_by: {
56
+ key: string;
57
+ direction: "asc" | "desc";
58
+ };
59
+ limit: number;
60
+ /** Opaque pagination cursor (from a prior page's `next_page_offset`). */
61
+ offset?: string | number | Record<string, unknown> | null;
62
+ /** Whether to return point payloads (`listVerbatimBySession` needs them). */
63
+ with_payload?: boolean;
64
+ }): Promise<{
65
+ points: Array<{
66
+ id: string | number;
67
+ payload?: Record<string, unknown> | null;
68
+ }>;
69
+ next_page_offset?: string | number | Record<string, unknown> | null;
70
+ }>;
71
+ /** Optional — same reasoning as `scroll` above: only `ensureEpisodicCollection` needs it. */
72
+ createPayloadIndex?(name: string, params: {
73
+ field_name: string;
74
+ field_schema: "datetime" | "keyword";
75
+ }): Promise<unknown>;
76
+ };
77
+ /**
78
+ * Creates `collectionName` (cosine distance, `vectorSize`-dim) if it
79
+ * doesn't already exist, then ensures the `timestamp`/`userId` payload
80
+ * indexes exist regardless — `getLastSessionEpisodicSummaries`'s
81
+ * `order_by`/filter scroll queries fail with an HTTP 400 without them.
82
+ * Idempotent either way (Qdrant no-ops re-creating an existing index), so
83
+ * safe to call on every startup, including against a collection that
84
+ * predates this fix.
85
+ */
86
+ export declare function ensureEpisodicCollection(client: QdrantClientLike, collectionName: string, vectorSize: number): Promise<void>;
87
+ export type EpisodicSummary = {
88
+ userId: string;
89
+ sessionKey: string;
90
+ summary: string;
91
+ timestamp: string;
92
+ };
93
+ /** Embeds `entry.summary` and upserts it as a new point in `collectionName`, payload carrying the full entry. */
94
+ export declare function storeEpisodicSummary(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, entry: EpisodicSummary): Promise<void>;
95
+ /**
96
+ * Past episodic events for a specific user, most relevant to `queryText`
97
+ * (e.g. "notifications about KAN-123") — lets Mercury see how many times
98
+ * it already notified this user about a given item before composing a
99
+ * message. Not a general-purpose semantic consolidation/pattern-extraction
100
+ * engine (that doesn't exist here) — this only ever reads, never writes
101
+ * or promotes anything.
102
+ */
103
+ export declare function searchEpisodicMemory(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, query: {
104
+ userId: string;
105
+ queryText: string;
106
+ limit?: number;
107
+ }): Promise<EpisodicSummary[]>;
108
+ /**
109
+ * The last closed session's episodic entries for `userId`, most recent
110
+ * first — up to `limit` entries, all sharing the same `sessionKey` as the
111
+ * single most recent entry. Used to seed a brand-new session's context
112
+ * primer with "what happened last time"; not similarity-scoped like
113
+ * `searchEpisodicMemory` (there's no query yet to compare against at
114
+ * session start). Returns an empty array (never throws) if the client
115
+ * doesn't support `scroll`, or if the user has no prior episodic entries —
116
+ * this is enrichment, the caller must work fine without it.
117
+ */
118
+ export declare function getLastSessionEpisodicSummaries(client: QdrantClientLike, collectionName: string, query: {
119
+ userId: string;
120
+ limit?: number;
121
+ }): Promise<EpisodicSummary[]>;
@@ -0,0 +1,51 @@
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 { type Tool } from "ai";
14
+ import type { QdrantClientLike } from "./episodic-store.ts";
15
+ /** One side of an exchange to archive — the provider stamps its own durable timestamp. */
16
+ export type VerbatimExchange = {
17
+ userId: string;
18
+ sessionKey: string;
19
+ role: "user" | "assistant";
20
+ content: string;
21
+ };
22
+ /** Per-turn identity a provider's recall tools are scoped to. `userId` is what makes recall cross-session. */
23
+ export type MemoryProviderContext = {
24
+ sessionKey: string;
25
+ userId: string;
26
+ };
27
+ /**
28
+ * A memory mechanism the composition root can wire in. `captureExchange`
29
+ * runs after a turn resolves (post-message); `sessionTools` contributes
30
+ * model-invocable recall for the calling session. Both optional — a
31
+ * provider may only capture, or only recall.
32
+ */
33
+ export type MemoryProvider = {
34
+ captureExchange?(exchange: VerbatimExchange): Promise<void>;
35
+ sessionTools?(ctx: MemoryProviderContext): Record<string, Tool>;
36
+ };
37
+ export type VerbatimArchiveProviderDeps = {
38
+ client: QdrantClientLike;
39
+ collectionName: string;
40
+ embed: (text: string) => Promise<number[]>;
41
+ /** Test seam; defaults to `() => new Date()`. The provider owns the durable ordering timestamp. */
42
+ now?: () => Date;
43
+ };
44
+ /**
45
+ * Builds the verbatim-archive provider: captures every user/assistant
46
+ * message verbatim to its Qdrant collection, and exposes a
47
+ * `recall_verbatim` tool that similarity-searches that archive scoped to
48
+ * the calling user — letting a later session resurface what was actually
49
+ * said before, beyond the live history window.
50
+ */
51
+ export declare function createVerbatimArchiveProvider(deps: VerbatimArchiveProviderDeps): MemoryProvider;
@@ -0,0 +1,37 @@
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
+ /** Creates `collectionName` (cosine distance, `vectorSize`-dim) if it doesn't already exist — safe to call on every startup. */
12
+ export declare function ensureSemanticFactsCollection(client: QdrantClientLike, collectionName: string, vectorSize: number): Promise<void>;
13
+ export type SemanticFactEntry = {
14
+ userId: string;
15
+ topic: string;
16
+ value: string;
17
+ timestamp: string;
18
+ };
19
+ /**
20
+ * Embeds `entry.topic` alone (never `topic + value`) and upserts it as a
21
+ * new point in `collectionName`, payload carrying the full entry. One
22
+ * point per extracted fact, never an update-in-place — consolidation
23
+ * reads the whole history back via `searchSemanticFactsByTopic` and
24
+ * decides what to promote.
25
+ */
26
+ export declare function storeSemanticFact(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, entry: SemanticFactEntry): Promise<void>;
27
+ /**
28
+ * Past facts for a specific user, clustered by topic similarity — the
29
+ * read side consolidation uses to gather "every occurrence of roughly
30
+ * this topic" before counting a dominant value. Filters by userId so
31
+ * one user's facts never leak into another's cluster.
32
+ */
33
+ export declare function searchSemanticFactsByTopic(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, query: {
34
+ userId: string;
35
+ topic: string;
36
+ limit?: number;
37
+ }): Promise<SemanticFactEntry[]>;
@@ -0,0 +1,26 @@
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
+ /** Creates `collectionName` (cosine distance, `vectorSize`-dim) if it doesn't already exist — safe to call on every startup. */
12
+ export declare function ensureToolCorrectionsCollection(client: QdrantClientLike, collectionName: string, vectorSize: number): Promise<void>;
13
+ export type ToolCorrectionEntry = {
14
+ tool: string;
15
+ topic: string;
16
+ value: string;
17
+ timestamp: string;
18
+ };
19
+ /** Embeds `entry.topic` alone (never `topic + value`), same reasoning as `storeSemanticFact` — upserts a new point, never an update-in-place. */
20
+ export declare function storeToolCorrection(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, entry: ToolCorrectionEntry): Promise<void>;
21
+ /** Past corrections for a specific tool, clustered by topic similarity — filters by `tool` so one tool's corrections never leak into another's cluster. */
22
+ export declare function searchToolCorrectionsByTopic(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, query: {
23
+ tool: string;
24
+ topic: string;
25
+ limit?: number;
26
+ }): Promise<ToolCorrectionEntry[]>;
@@ -0,0 +1,86 @@
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
+ * Creates `collectionName` (cosine distance, `vectorSize`-dim) if it
22
+ * doesn't already exist, then ensures the `userId` keyword payload index
23
+ * regardless — `searchVerbatim` filters by `userId` server-side, and the
24
+ * index keeps that filter usable as the archive grows. Idempotent
25
+ * (Qdrant no-ops re-creating an existing index), so safe to call on every
26
+ * startup, including against a collection created before this existed.
27
+ */
28
+ export declare function ensureVerbatimCollection(client: QdrantClientLike, collectionName: string, vectorSize: number): Promise<void>;
29
+ /** A single verbatim message as it was emitted in chat. `timestamp` (ISO 8601, ms precision) is the durable ordering key. */
30
+ export type VerbatimMessage = {
31
+ userId: string;
32
+ sessionKey: string;
33
+ role: "user" | "assistant";
34
+ content: string;
35
+ timestamp: string;
36
+ };
37
+ /** Embeds `entry.content` and upserts it as a new point in `collectionName`, payload carrying the full verbatim message. */
38
+ export declare function appendVerbatimMessage(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, entry: VerbatimMessage): Promise<void>;
39
+ /**
40
+ * The verbatim messages for a specific user most relevant to `queryText`
41
+ * — e.g. "what did we say about KAN-1 last week". Filtered by `userId` so
42
+ * one user's transcript never leaks into another's. Only reads; returns
43
+ * the raw messages, ordering left to the caller (each carries its own
44
+ * `timestamp`).
45
+ */
46
+ export declare function searchVerbatim(client: QdrantClientLike, collectionName: string, embed: (text: string) => Promise<number[]>, query: {
47
+ userId: string;
48
+ queryText: string;
49
+ limit?: number;
50
+ }): Promise<VerbatimMessage[]>;
51
+ /** One conversation in the list view: its key, the timestamp of its most recent message, and a short preview of it. */
52
+ export type VerbatimSession = {
53
+ sessionKey: string;
54
+ lastTimestamp: string;
55
+ preview: string;
56
+ };
57
+ /**
58
+ * The known conversations, most-recently-active first — the sidebar a UI shows
59
+ * to switch between conversations. Scrolls the newest `SESSION_SCAN_LIMIT`
60
+ * messages (timestamp desc) and dedups by `sessionKey`, keeping each
61
+ * conversation's most recent message for its timestamp and preview, then caps
62
+ * the result at `limit`. Returns empty if the client can't scroll.
63
+ */
64
+ export declare function listVerbatimSessions(client: QdrantClientLike, collectionName: string, query: {
65
+ limit: number;
66
+ }): Promise<{
67
+ conversations: VerbatimSession[];
68
+ }>;
69
+ /** A page of a conversation's verbatim messages, plus the opaque cursor for the next page (null when exhausted). */
70
+ export type VerbatimPage = {
71
+ messages: VerbatimMessage[];
72
+ nextOffset: string | number | Record<string, unknown> | null;
73
+ };
74
+ /**
75
+ * The verbatim messages of one conversation (`sessionKey`) in chronological
76
+ * order — the durable transcript a UI renders when it (re)loads a conversation.
77
+ * Unlike `searchVerbatim` this is a plain scroll (no similarity), filtered by
78
+ * `sessionKey` (the true per-conversation key; in the HTTP surface it equals
79
+ * the client's `conversationId`) and ordered by `timestamp` ascending, paged
80
+ * via the opaque `offset` cursor. Returns empty if the client can't scroll.
81
+ */
82
+ export declare function listVerbatimBySession(client: QdrantClientLike, collectionName: string, query: {
83
+ sessionKey: string;
84
+ limit: number;
85
+ offset?: string | number | Record<string, unknown> | null;
86
+ }): Promise<VerbatimPage>;
@@ -0,0 +1,24 @@
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 { type OllamaProvider } from "ai-sdk-ollama";
17
+ /**
18
+ * Returns an Ollama provider bound to `OLLAMA_HOST`.
19
+ *
20
+ * Throws synchronously if `OLLAMA_HOST` is unset or empty — there is no
21
+ * fallback to `localhost`, since which Ollama endpoint to use is a
22
+ * deployment decision, not something Mercury's code should guess.
23
+ */
24
+ export declare function getOllamaProvider(): OllamaProvider;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Queries Ollama directly (its native HTTP API, not the AI SDK — which
3
+ * has no concept of this) for the real context window size the running
4
+ * model is currently loaded with.
5
+ *
6
+ * Why this exists, instead of just reading the model's architectural max
7
+ * context length (available via `/api/show`'s `model_info`): Ollama can
8
+ * load a model with a smaller effective context window than what the
9
+ * model architecture supports (e.g. capped by `OLLAMA_CONTEXT_LENGTH` on
10
+ * the server, or a per-request `num_ctx`), so the architectural max can
11
+ * overstate what's actually usable. `/api/ps` reports what's actually
12
+ * loaded right now — verified live against a real Ollama server (GB10,
13
+ * qwen3.5:35b): it loaded with the full architectural context (262144),
14
+ * but that's specific to this deployment's configuration, not something
15
+ * to assume in general.
16
+ *
17
+ * Used by: `src/index.ts`, to show a real (not estimated) context-usage
18
+ * indicator next to the terminal prompt — see `src/router/tool-log.ts`'s
19
+ * `formatContextUsage`.
20
+ */
21
+ /**
22
+ * Returns the context length Ollama has the given model loaded with
23
+ * right now, or `null` if that model isn't currently loaded (e.g. before
24
+ * its first use this process) — `/api/ps` only lists loaded models, so
25
+ * there's nothing to report yet. Querying again after the model has
26
+ * been used at least once will find it.
27
+ *
28
+ * @param fetchFn - Test seam; defaults to the real global `fetch`.
29
+ */
30
+ export declare function getLoadedContextLength(host: string, model: string, fetchFn?: typeof fetch): Promise<number | null>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Builds the read-only installation manifest the HTTP surface exposes (4b):
3
+ * what this instance actually loaded — the core's plugin apiVersion, each
4
+ * plugin (name, declared apiVersion, whether it activated, the skills it
5
+ * contributes, whether it has a `build()`), the active CLI binaries, and the
6
+ * skill descriptors. Pure and side-effect-free so it's unit-tested directly.
7
+ *
8
+ * A plugin no longer exposes a central config, so activation is reported from
9
+ * the loader's `activated` set rather than inferred from a config map. The
10
+ * active CLI binaries are the activated plugins plus the file-based CLIs (the
11
+ * residual with no owning plugin).
12
+ */
13
+ import { type Plugin, type Skill } from "@mercury-fw/plugin-types";
14
+ export type PluginManifest = {
15
+ coreApiVersion: number;
16
+ plugins: Array<{
17
+ name: string;
18
+ apiVersion: number;
19
+ active: boolean;
20
+ skills: string[];
21
+ hasBuild: boolean;
22
+ }>;
23
+ activeClis: string[];
24
+ skills: Array<{
25
+ name: string;
26
+ description: string;
27
+ }>;
28
+ };
29
+ export declare function buildPluginManifest(plugins: Plugin[], activatedPluginNames: string[], fileCliBinaries: string[], skills: Skill[]): PluginManifest;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The generic, fail-soft plugin loader. Turns a hand-listed set of plugin
3
+ * modules into what the composition root wires into a running Mercury: the
4
+ * per-plugin tool bundles the model-facing tools are built from, their status
5
+ * describers, the system-prompt fragments, and the post-turn guards. It knows
6
+ * nothing about any specific plugin — or about CLIs — the composition root
7
+ * names them, this loop processes them identically.
8
+ *
9
+ * Two properties it guarantees, both required by the plan's fail-soft step:
10
+ * - a plugin contributes only when it is enabled on this instance (its name is
11
+ * in MERCURY_CLIS);
12
+ * - a plugin that fails — its `build()` throws — degrades as a single unit:
13
+ * none of its contributions land, the failure is
14
+ * logged with detail, and every other plugin and the process itself carry
15
+ * on. This is the CLAUDE.md lesson made structural: one plugin's bad tick
16
+ * never takes down the rest.
17
+ *
18
+ * The contract types (`Plugin`, `SessionToolContext`, `CliPostProcessor`,
19
+ * `PostTurnGuard`, and the `build()` context) live in `@mercury-fw/plugin-types`,
20
+ * the shared package both the core and the plugins import, so neither mirrors
21
+ * the other. This module keeps only the core-runtime pieces: how a hand-listed
22
+ * set of plugins is loaded and what the load produces.
23
+ */
24
+ import type { LanguageModel } from "ai";
25
+ import type { Plugin, CliPostProcessor, PostTurnGuard, Skill, SessionToolContext } from "@mercury-fw/plugin-types";
26
+ import type { Tool } from "ai";
27
+ /** One plugin's tool contribution, kept paired so the composition root can build
28
+ * its tools from its own post-processor: the plugin authored both, but a
29
+ * formatter decorator wraps the post-processor after `build()` returns, so the
30
+ * factory receives the final one at invocation rather than closing over it. */
31
+ export interface SessionToolBundle {
32
+ build: (ctx: SessionToolContext, postProcess?: CliPostProcessor) => Record<string, Tool>;
33
+ postProcess?: CliPostProcessor;
34
+ }
35
+ /** What the loader hands back to the composition root, aggregated across every
36
+ * plugin that loaded. The core knows nothing about CLIs: a plugin's tool is an
37
+ * opaque `SessionToolBundle` it contributed, not a config the core assembles. */
38
+ export interface LoadedPlugins {
39
+ promptFragments: string[];
40
+ skills: Skill[];
41
+ /** Per-plugin tool factories + their post-processors; the composition root
42
+ * invokes each per turn with the session context (see `SessionToolBundle`). */
43
+ sessionToolBundles: SessionToolBundle[];
44
+ /** Merged across plugins, keyed by the tool name each contributes, turning a
45
+ * tool call's input into its status label. */
46
+ toolStatusDescribers: Record<string, (input: unknown) => string>;
47
+ postTurnGuards: PostTurnGuard[];
48
+ /** Names of the plugins that fully activated — for read-only introspection
49
+ * (the manifest), since a plugin's tool is opaque and there's no central
50
+ * config map to infer activation from anymore. */
51
+ activated: string[];
52
+ }
53
+ /** Everything the loader needs from the composition root: which plugins are
54
+ * enabled on this instance, and the runtime context every `build()` gets. */
55
+ export interface PluginLoadContext {
56
+ enabledClis: string[];
57
+ model: LanguageModel;
58
+ env: Record<string, string | undefined>;
59
+ log: (msg: string) => void;
60
+ }
61
+ /**
62
+ * Orders `plugins` so every plugin comes after each of its declared `dependsOn`
63
+ * (a stable topological sort: among plugins with no ordering constraint between
64
+ * them, listing order is preserved). Only edges to a plugin actually present in
65
+ * the set are honored — an unknown dependency name creates no edge and is
66
+ * caught later, at activation, as an absent dependency. Any plugin left over
67
+ * after the sort is part of, or downstream of, a dependency **cycle**: it comes
68
+ * back in `cyclic`, never in `ordered`, so a cycle degrades fail-soft (the
69
+ * caller skips those plugins) instead of dropping the whole load.
70
+ */
71
+ export declare function orderByDependencies(plugins: Plugin[]): {
72
+ ordered: Plugin[];
73
+ cyclic: Plugin[];
74
+ };
75
+ /**
76
+ * Loads every plugin in `plugins`, returning their combined contributions.
77
+ * Plugins load in dependency-first order (see `orderByDependencies`), not
78
+ * listing order — though listing order is preserved among plugins with no
79
+ * dependency between them. Never throws — a plugin that fails is logged and
80
+ * skipped, its contributions staged and merged only once the whole plugin
81
+ * succeeds so a later failure can't leave it half-wired. A plugin whose
82
+ * declared `dependsOn` isn't fully activated (a dependency disabled, failed,
83
+ * unknown, or itself skipped) is skipped fail-soft too, transitively.
84
+ */
85
+ export declare function loadPlugins(plugins: Plugin[], ctx: PluginLoadContext): Promise<LoadedPlugins>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Loads the hand-listed channel plugins into the providers the composition root
3
+ * starts — the channel-side mirror of `plugins/plugin-loader.ts`. It knows
4
+ * nothing about any specific channel: the composition root names them, this
5
+ * loop builds them identically.
6
+ *
7
+ * A channel is active when it's declared in `mercury.config.ts`'s `channels`
8
+ * (there is no separate env gate): declared = active. Each channel self-gates on
9
+ * its real config via `build()` returning `undefined` (Google Chat with no
10
+ * subscription; HTTP always builds).
11
+ *
12
+ * Fail-soft, same guarantees as the tool-plugin loader:
13
+ * - an `apiVersion` mismatch is refused before `build()` runs;
14
+ * - a `build()` that throws degrades as a single unit (logged, skipped) while
15
+ * every other channel and the process carry on;
16
+ * - a `build()` that returns `undefined` means "present but inert" (the
17
+ * instance isn't configured for it) — not started, not an error.
18
+ */
19
+ import { type ChannelPlugin, type ChannelRuntimeContext, type Provider } from "@mercury-fw/channel-types";
20
+ /** Everything the loader needs from the composition root: the runtime context every `build()` gets. */
21
+ export type LoadChannelsContext = {
22
+ runtime: ChannelRuntimeContext;
23
+ };
24
+ /** One built channel, kept with its plugin name so the composition root can start it and look it up (e.g. the cron `Notifier`). */
25
+ export type LoadedChannel = {
26
+ name: string;
27
+ provider: Provider;
28
+ };
29
+ /** Builds every declared channel in `channels`, returning the providers that actually constructed (skipping incompatible, inert, or failed ones). */
30
+ export declare function loadChannels(channels: ChannelPlugin[], ctx: LoadChannelsContext): LoadedChannel[];
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Channel contract, re-exported from `@mercury-fw/channel-types`. A thin shim so
3
+ * the core modules importing from `router/provider.ts` don't change; the
4
+ * definitions moved to the shared package so channel plugins can import them
5
+ * without depending on the app.
6
+ */
7
+ export type { Provider, InboundTurn, TurnSink, HandleTurn, Notifier } from "@mercury-fw/channel-types";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The terminal channel's `Provider` implementation — wraps
3
+ * `startTerminalRepl` (`src/router/terminal.ts`, unmodified) and owns
4
+ * everything specific to running Mercury from a terminal: the `/dump`
5
+ * command, bare-token confirmation interception, the dim/italic
6
+ * tool-status rendering, and the live context-usage prompt suffix.
7
+ *
8
+ * A single operator, one conversation at a time — there is no real
9
+ * per-user identity here, so `notify` just writes to stderr rather than
10
+ * actually reaching anyone; implemented (not left optional) so a caller
11
+ * that expects a `Notifier` never silently loses a message.
12
+ */
13
+ import { startTerminalRepl } from "./terminal.ts";
14
+ import { tryConfirm, type ConfirmationStore } from "@mercury-fw/confirm-engine";
15
+ import { getLoadedContextLength } from "../model/context-size.ts";
16
+ import type { Provider } from "./provider.ts";
17
+ import type { writeConfirmationNote } from "../wiki/wiki-note.ts";
18
+ export type TerminalProviderDeps = {
19
+ confirmDeps: {
20
+ store: ConfirmationStore;
21
+ vaultPath: string;
22
+ writeConfirmationNoteFn: typeof writeConfirmationNote;
23
+ now?: () => Date;
24
+ };
25
+ ollamaHost: string;
26
+ ollamaModel: string;
27
+ /** Test seam; defaults to the real `getLoadedContextLength`. */
28
+ getLoadedContextLengthFn?: typeof getLoadedContextLength;
29
+ /** Test seam; defaults to the real `startTerminalRepl`. */
30
+ startTerminalReplFn?: typeof startTerminalRepl;
31
+ /** Test seam; defaults to the real `tryConfirm`. */
32
+ tryConfirmFn?: typeof tryConfirm;
33
+ /** Test seam; defaults to `console.error`. */
34
+ stderrWrite?: (s: string) => void;
35
+ };
36
+ /** Builds the terminal's `Provider`. */
37
+ export declare function createTerminalProvider(deps: TerminalProviderDeps): Provider;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Written before the first input and again after every result, so a
3
+ * human at the terminal can tell an answer has finished and the next
4
+ * question can be typed — without this, a multi-line answer and the
5
+ * start of a new question were visually indistinguishable.
6
+ */
7
+ export declare const PROMPT = "> ";
8
+ /**
9
+ * Runs the REPL loop: read a line, pass it to `handleInput`, write the
10
+ * result, repeat until the input source is exhausted (EOF).
11
+ *
12
+ * If `handleInput` rejects, the error is written as a line (so a long-
13
+ * running debug session can see what went wrong) and the loop continues
14
+ * with the next line — one bad turn shouldn't kill the whole session.
15
+ *
16
+ * @param handleInput - Turns one line of input into one line of output.
17
+ * Knows nothing about this being a terminal. Receives `onChunk`, which
18
+ * it may call zero or more times with pieces of the answer as they
19
+ * become available (e.g. while streaming a model response) — each
20
+ * call is written immediately, without waiting for `handleInput` to
21
+ * resolve. If `onChunk` is never called, the function's returned
22
+ * string is written instead once `handleInput` resolves, exactly as
23
+ * before streaming existed — this is what keeps the `/dump` command
24
+ * and any other non-streaming reply working unchanged.
25
+ * @param io - Test seam. Defaults to real stdin/stdout when omitted.
26
+ * @param opts.promptSuffix - Optional, called fresh right before every
27
+ * prompt (including the very first one) and written ahead of it — e.g.
28
+ * a live context-usage indicator (see `src/router/tool-log.ts`'s
29
+ * `formatContextUsage`), recomputed each time since the value changes
30
+ * turn to turn.
31
+ */
32
+ export declare function startTerminalRepl(handleInput: (input: string, onChunk: (chunk: string) => void) => Promise<string>, io?: {
33
+ input?: AsyncIterable<string>;
34
+ output?: {
35
+ write(s: string, opts?: {
36
+ newline?: boolean;
37
+ }): void;
38
+ };
39
+ }, opts?: {
40
+ promptSuffix?: () => string;
41
+ }): Promise<void>;