@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
package/src/compose.ts ADDED
@@ -0,0 +1,599 @@
1
+ /**
2
+ * Builds a Mercury instance from its composition config: the model, the tools
3
+ * this instance has enabled, the memory layers, and the turn pipeline. This is
4
+ * the one place that decides which tools and channels exist on this instance —
5
+ * every other module takes them as inputs.
6
+ *
7
+ * It *builds*, it does not *start*: `composeMercury()` returns `handleTurn`, the
8
+ * channel runtime context, and deferred `startCrons`/`startAdmin` closures, so
9
+ * each entrypoint decides what to run. The long-running service (`index.ts`)
10
+ * starts the network channels + crons + admin; the dev REPL (`repl.ts`) starts
11
+ * only the terminal against the same `handleTurn`.
12
+ *
13
+ * Note: this factory (and `repl.ts`) are written to be lifted into the future
14
+ * Mercury CLI (a separate monorepo app that scaffolds an instance) — that CLI
15
+ * would reuse `composeMercury` to boot the app it built. Not built yet.
16
+ */
17
+ import { QdrantClient } from "@qdrant/js-client-rest";
18
+ import { getOllamaProvider } from "./model/client.ts";
19
+ import { runCli } from "@mercury-fw/cli-engine";
20
+ import { createConfirmationStore, createStageConfirmation, tryConfirm, resolveConfirmation, type ConfirmationStore } from "@mercury-fw/confirm-engine";
21
+ import { createDisplayStore } from "./tools/display-store.ts";
22
+ import { createPresentTool } from "./tools/present-tool.ts";
23
+ import { loadPlugins } from "./plugins/plugin-loader.ts";
24
+ import type { MercuryConfig } from "./config/define-config.ts";
25
+ import { createSessionHistory, type SessionHistory, type Message } from "./session/history.ts";
26
+ import { createSummarizer } from "./session/summarizer.ts";
27
+ import { createEpisodicSummarizer } from "./session/episodic-summarizer.ts";
28
+ import { createSemanticFactExtractor } from "./session/semantic-fact-extractor.ts";
29
+ import { buildContextPrimer } from "./session/context-primer.ts";
30
+ import { buildSystemPrompts } from "./session/system-prompt.ts";
31
+ import { createTurnRunner } from "./router/turn-runner.ts";
32
+ import type { TurnSink } from "./router/provider.ts";
33
+ import type { HandleTurn, ChannelRuntimeContext, ChannelPlugin } from "@mercury-fw/channel-types";
34
+ import {
35
+ truncateForDisplay,
36
+ describeToolOutcome,
37
+ } from "./router/tool-log.ts";
38
+ import type { StepInfo } from "./session/step-info.ts";
39
+ import { withToolStartHook } from "./session/tool-start-hook.ts";
40
+ import {
41
+ writeInferredNote,
42
+ writeToolCorrectionNote,
43
+ writeConfirmationNote,
44
+ } from "./wiki/wiki-note.ts";
45
+ import { createWikiTools } from "./wiki/wiki-tools.ts";
46
+ import { createToolLogRecallTool } from "./session/tool-log-recall-tool.ts";
47
+ import { createReadSkillTool } from "./session/read-skill-tool.ts";
48
+ import { createIdleSessionScanner } from "./cron/idle-session-scanner.ts";
49
+ import { startIdleSessionCron, captureSessionToMemory, type CaptureDeps } from "./cron/idle-session-cron.ts";
50
+ import {
51
+ ensureEpisodicCollection,
52
+ storeEpisodicSummary,
53
+ getLastSessionEpisodicSummaries,
54
+ } from "./memory/episodic-store.ts";
55
+ import { ensureVerbatimCollection, listVerbatimBySession, listVerbatimSessions } from "./memory/verbatim-archive-store.ts";
56
+ import { createVerbatimArchiveProvider } from "./memory/memory-provider.ts";
57
+ import { ensureSemanticFactsCollection, storeSemanticFact, searchSemanticFactsByTopic } from "./memory/semantic-facts-store.ts";
58
+ import { ensureToolCorrectionsCollection, storeToolCorrection, searchToolCorrectionsByTopic } from "./memory/tool-corrections-store.ts";
59
+ import { consolidateSemanticFact, consolidateToolCorrection, type ToolCorrectionConsolidationDeps } from "./cron/semantic-consolidation.ts";
60
+ import { createToolCorrectionExtractor } from "./session/tool-correction-extractor.ts";
61
+ import { createEmbedder } from "./memory/embedder.ts";
62
+ import { initVault } from "./wiki/vault-init.ts";
63
+ import { findOrphanCuratedDocs } from "./wiki/orphan-detector.ts";
64
+ import { listWikiFilesInRoots, readWikiFile, readWikiFileInRoots, readIndexFile } from "./wiki/wiki-read.ts";
65
+ import { runRawTriagePass, runIndexAndOrphanPass, runContradictionCheckPass } from "./wiki/self-review-runner.ts";
66
+ import { startSelfReviewCron } from "./cron/self-review-cron.ts";
67
+ import { resolve as resolvePath } from "node:path";
68
+ import type { Tool } from "ai";
69
+ import { startAdminServer } from "./admin/server.ts";
70
+ // The HTTP surface's read routes (4b) reuse the admin panel's per-domain
71
+ // functions — the admin is a POC to be retired later; these reads outlive it.
72
+ import { listWikiVault, readWikiVaultFile, grepWikiVault } from "./admin/wiki-routes.ts";
73
+ import { scrollCollection } from "./admin/qdrant-scroll.ts";
74
+ import { getSelfHealth } from "./admin/model-routes.ts";
75
+ import { getToolLog } from "./session/tool-log-buffer.ts";
76
+ import { buildPluginManifest } from "./plugins/manifest.ts";
77
+
78
+ /** A stoppable subsystem (cron, server). */
79
+ type Stoppable = { stop: () => void };
80
+
81
+ /** The confirm capability's binding to this instance's store/vault/note-writer, shared by the terminal and the channel runtime. */
82
+ export type ConfirmDeps = {
83
+ store: ConfirmationStore;
84
+ vaultPath: string;
85
+ writeConfirmationNoteFn: typeof writeConfirmationNote;
86
+ };
87
+
88
+ /**
89
+ * The built Mercury instance. `build`, not `start`: the channels, crons and
90
+ * admin are not running until an entrypoint starts them.
91
+ */
92
+ export type ComposedApp = {
93
+ /** The turn driver every channel funnels through (see `turn-runner.ts`). */
94
+ handleTurn: HandleTurn;
95
+ /** The declared channel plugins (from `mercury.config.ts`). */
96
+ channels: ChannelPlugin[];
97
+ /** The runtime context each channel's `build()` gets — confirm + in-process reads injected by the core. */
98
+ channelRuntime: ChannelRuntimeContext;
99
+ /** The confirm binding, for a caller (the terminal) that intercepts tokens directly. */
100
+ confirmDeps: ConfirmDeps;
101
+ ollamaHost: string;
102
+ ollamaModel: string;
103
+ /** Starts the Layer-3 idle-capture and self-review crons; returns a single stopper for shutdown. */
104
+ startCrons: () => Stoppable;
105
+ /** Starts the POC admin panel if `ADMIN_PANEL_ENABLED`; returns it (to stop on shutdown), or undefined. */
106
+ startAdmin: () => Stoppable | undefined;
107
+ };
108
+
109
+ /** Reads a required env var, failing fast instead of silently defaulting. */
110
+ function requireEnv(name: string): string {
111
+ const value = process.env[name];
112
+ if (!value) {
113
+ throw new Error(`${name} is not set`);
114
+ }
115
+ return value;
116
+ }
117
+
118
+ /**
119
+ * Builds a Mercury instance from the composition `config` it is given + env. The
120
+ * config is a parameter, never imported here: that's what makes the core
121
+ * app-agnostic — an entrypoint (the service, the dev REPL, a future scaffolded
122
+ * app) reads its own `mercury.config.ts` and passes it in. See {@link ComposedApp}.
123
+ */
124
+ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp> {
125
+ const enabledClis = (process.env.MERCURY_CLIS ?? "")
126
+ .split(",")
127
+ .map((s) => s.trim())
128
+ .filter(Boolean);
129
+
130
+ // The model is constructed up front, before the plugins load and the system
131
+ // prompt is built: a plugin's post-turn guard can be model-backed (Jira's
132
+ // issue-list corrector is), and the system prompt is assembled from the
133
+ // fragments the plugin loader returns — both need the model in hand first.
134
+ const provider = getOllamaProvider();
135
+ const ollamaHost = requireEnv("OLLAMA_HOST"); // already validated by getOllamaProvider(); read again here for the terminal provider's getLoadedContextLength call
136
+ const ollamaModel = requireEnv("OLLAMA_MODEL");
137
+ // think: true enables Ollama's native extended-thinking tokens — see
138
+ // agent-turn.ts's reasoning-delta handling. OLLAMA_THINK lets the deployer
139
+ // turn it off for a model that doesn't support it (Ollama rejects the request
140
+ // outright otherwise, 400 "does not support thinking"), same reasoning as
141
+ // never guessing OLLAMA_HOST. Model-construction-time setting only, set once
142
+ // here for the one shared model instance used by every channel.
143
+ const ollamaThink = process.env.OLLAMA_THINK !== "false";
144
+ const model = provider(ollamaModel, { think: ollamaThink });
145
+ const summarize = createSummarizer(model);
146
+
147
+ // Plugins are declared in the instance's config (its `mercury.config.ts`) and
148
+ // handed in — this file no longer names them. The loader processes an opaque
149
+ // list (see plugins/plugin-loader.ts): each supplies its allowlist as data, a
150
+ // system-prompt fragment, and a `build()` that turns the runtime context into
151
+ // post-processors and post-turn guards. A plugin contributes only when it's
152
+ // both listed in MERCURY_CLIS and its allowlist validates; one that fails
153
+ // degrades only itself.
154
+ const plugins = config.plugins;
155
+
156
+ const loadedPlugins = await loadPlugins(plugins, {
157
+ enabledClis,
158
+ model,
159
+ env: process.env,
160
+ log: (msg) => console.error(msg),
161
+ });
162
+
163
+ // Status labels for the tool-start hook, keyed by tool name: each plugin's
164
+ // describer for its own tool (jiraCommand, …).
165
+ const toolStatusDescribers: Record<string, (input: unknown) => string> = { ...loadedPlugins.toolStatusDescribers };
166
+
167
+ // Two system prompts (1:1 and shared-space), both built from the fragments of
168
+ // whatever plugins actually loaded and from the instance's persona.
169
+ const { system, chatSystem } = buildSystemPrompts({
170
+ pluginFragments: loadedPlugins.promptFragments,
171
+ skills: loadedPlugins.skills,
172
+ persona: config.persona,
173
+ });
174
+
175
+ const histories = new Map<string, SessionHistory>();
176
+ /**
177
+ * `trackForCapture` wires `onBeforeCompress` so a Layer 1 compression also
178
+ * mirrors the compressed batch to Qdrant (see `captureIncrement`) — only
179
+ * meaningful for sessions tracked in `sessionUsers`/`sessionCaptureMarkers`
180
+ * (a real per-user identity, i.e. Google Chat); an identity-less channel
181
+ * omits it.
182
+ */
183
+ function getOrCreateHistory(key: string, trackForCapture = false, primer?: string): SessionHistory {
184
+ let history = histories.get(key);
185
+ if (!history) {
186
+ history = createSessionHistory(
187
+ summarize,
188
+ trackForCapture
189
+ ? (messages) => {
190
+ void captureIncrement(key, messages).finally(() => {
191
+ // The new getMessages() view after compression starts fresh
192
+ // (just the new synthetic summary message) — the old marker's
193
+ // index has no meaning against it regardless of whether the
194
+ // capture above succeeded.
195
+ sessionCaptureMarkers.set(key, 0);
196
+ });
197
+ }
198
+ : undefined,
199
+ primer,
200
+ );
201
+ histories.set(key, history);
202
+ }
203
+ return history;
204
+ }
205
+
206
+ // Session persistence, Layer 3: a tracked session idle past
207
+ // SESSION_IDLE_TIMEOUT_MS is summarized and written to Qdrant as a dated
208
+ // episodic record, then discarded. Identity-less sessions (the terminal) are
209
+ // never tracked here — per-user isolation needs a real sender identity.
210
+ const sessionUsers = new Map<string, string>(); // session key -> sender (userId)
211
+ // How many of a session's current getMessages() entries have already been
212
+ // mirrored to Qdrant by captureIncrement — advanced only after a successful
213
+ // capture, so a failure retries the same (or a larger) slice next time.
214
+ // Reset to 0 whenever Layer 1 compresses the session. Discarded on
215
+ // idle-timeout close, same as sessionUsers.
216
+ const sessionCaptureMarkers = new Map<string, number>();
217
+ // The current turn's tool-status callbacks, refreshed each turn — looked up
218
+ // lazily by captureIncrement/onBeforeCompress rather than captured once.
219
+ const sessionOnCaptureCallbacks = new Map<
220
+ string,
221
+ { onToolStart: TurnSink["onToolStart"]; onToolFinish: TurnSink["onToolFinish"] }
222
+ >();
223
+ const idleScanner = createIdleSessionScanner();
224
+ const episodicSummarize = createEpisodicSummarizer(model);
225
+ const embeddingModel = provider.textEmbeddingModel(process.env.OLLAMA_EMBEDDING_MODEL ?? "nomic-embed-text");
226
+ const embed = createEmbedder(embeddingModel);
227
+ const qdrant = new QdrantClient({ url: process.env.QDRANT_URL ?? "http://qdrant:6333" });
228
+ const episodicCollection = process.env.QDRANT_EPISODIC_COLLECTION ?? "episodic_memory";
229
+ const episodicVectorSize = Number(process.env.QDRANT_EPISODIC_VECTOR_SIZE ?? "768");
230
+ await ensureEpisodicCollection(qdrant, episodicCollection, episodicVectorSize);
231
+
232
+ // Verbatim conversation archive (#4): a distinct collection holding the raw
233
+ // user<->model exchange, lossless and durable — separate from the lossy
234
+ // Layer-1 window and the derived episodic summaries above.
235
+ const verbatimCollection = process.env.QDRANT_VERBATIM_COLLECTION ?? "verbatim_archive";
236
+ const verbatimVectorSize = Number(process.env.QDRANT_VERBATIM_VECTOR_SIZE ?? "768");
237
+ await ensureVerbatimCollection(qdrant, verbatimCollection, verbatimVectorSize);
238
+ const verbatimProvider = createVerbatimArchiveProvider({ client: qdrant, collectionName: verbatimCollection, embed });
239
+
240
+ // Semantic consolidation (D-22/D-34): a separate Qdrant collection from
241
+ // episodic memory above — one point per extracted {topic, value} candidate,
242
+ // vector embedded on the topic alone (see semantic-facts-store.ts for why).
243
+ const semanticFactsCollection = process.env.QDRANT_SEMANTIC_FACTS_COLLECTION ?? "semantic_facts";
244
+ const semanticFactsVectorSize = Number(process.env.QDRANT_SEMANTIC_FACTS_VECTOR_SIZE ?? "768");
245
+ await ensureSemanticFactsCollection(qdrant, semanticFactsCollection, semanticFactsVectorSize);
246
+ const extractFacts = createSemanticFactExtractor(model);
247
+
248
+ // Idempotent self-heal: the vault lives on a named Docker volume, empty on
249
+ // first boot and not pre-populatable at build time — re-running this every
250
+ // startup is cheap and means a wiped/fresh volume never needs a separate
251
+ // manual provisioning step.
252
+ const wikiVaultPath = requireEnv("WIKI_VAULT_PATH");
253
+ await initVault(wikiVaultPath);
254
+
255
+ // Shared by the idle sweep (final capture + close) and by captureIncrement
256
+ // (the two mid-conversation triggers, neither of which closes the session) —
257
+ // one definition of "how to capture", reused everywhere.
258
+ const captureDeps: CaptureDeps = {
259
+ summarize: episodicSummarize,
260
+ store: (entry) => storeEpisodicSummary(qdrant, episodicCollection, embed, entry),
261
+ extractFacts,
262
+ storeFact: (entry) => storeSemanticFact(qdrant, semanticFactsCollection, embed, entry),
263
+ consolidateFact: (userId, topic) =>
264
+ consolidateSemanticFact(userId, topic, {
265
+ vaultPath: wikiVaultPath,
266
+ clusterFn: (u, t, limit) => searchSemanticFactsByTopic(qdrant, semanticFactsCollection, embed, { userId: u, topic: t, limit }),
267
+ readWikiFileFn: readWikiFile,
268
+ writeInferredNoteFn: writeInferredNote,
269
+ }),
270
+ log: (msg) => console.error(`[cron] ${msg}`),
271
+ };
272
+
273
+ // How many new messages (since the last capture) a live tracked session needs
274
+ // before captureIncrement mirrors them to Qdrant, instead of only ever
275
+ // capturing on idle-timeout.
276
+ const MESSAGE_COUNT_CAPTURE_THRESHOLD = Number(process.env.SESSION_CAPTURE_MESSAGE_THRESHOLD ?? "6");
277
+
278
+ // How much of the pending messages' actual text shows up in a capture status
279
+ // card's detail — enough to recognize which exchange is being saved.
280
+ const MESSAGE_PREVIEW_CHARS = 200;
281
+
282
+ /** Joins `messages`' content and head-truncates to `maxChars`, "…"-suffixed when cut. */
283
+ function previewMessages(messages: Message[], maxChars: number): string {
284
+ const joined = messages.map((m) => m.content).join(" ");
285
+ return joined.length <= maxChars ? joined : `${joined.slice(0, maxChars)}…`;
286
+ }
287
+
288
+ /**
289
+ * Captures whatever's new in `messages` since the last capture for
290
+ * `sessionKey` — a no-op if nothing new. Shared by both mid-conversation
291
+ * triggers (message-count threshold, Layer 1 compression); the idle-timeout
292
+ * trigger uses `captureSessionToMemory` directly since it also closes the
293
+ * session. Drives the turn's tool-status callbacks like a real tool call so a
294
+ * live conversation shows this happening.
295
+ */
296
+ async function captureIncrement(sessionKey: string, messages: Message[]): Promise<void> {
297
+ const userId = sessionUsers.get(sessionKey);
298
+ if (!userId) return;
299
+
300
+ const alreadyCaptured = sessionCaptureMarkers.get(sessionKey) ?? 0;
301
+ const pending = messages.slice(alreadyCaptured);
302
+ if (pending.length === 0) return;
303
+
304
+ const callbacks = sessionOnCaptureCallbacks.get(sessionKey);
305
+ const captureId = crypto.randomUUID();
306
+ callbacks?.onToolStart(
307
+ "Mi sto segnando un'informazione importante…",
308
+ `Conversazione recente (${pending.length} messaggi: "${previewMessages(pending, MESSAGE_PREVIEW_CHARS)}"), collection "${episodicCollection}"`,
309
+ captureId,
310
+ );
311
+ try {
312
+ await captureSessionToMemory(userId, sessionKey, pending, Date.now(), captureDeps);
313
+ sessionCaptureMarkers.set(sessionKey, messages.length);
314
+ callbacks?.onToolFinish?.(captureId, "success");
315
+ } catch (err) {
316
+ console.error(`[capture] failed for ${sessionKey}, will retry next trigger: ${String(err)}`);
317
+ callbacks?.onToolFinish?.(captureId, "failed");
318
+ }
319
+ }
320
+
321
+ // Procedural corrections (punto 2/Fase D): per-turn, not per-idle-session —
322
+ // the tool-call trace only exists in memory for the duration of its turn.
323
+ const toolCorrectionsCollection = process.env.QDRANT_TOOL_CORRECTIONS_COLLECTION ?? "tool_corrections";
324
+ const toolCorrectionsVectorSize = Number(process.env.QDRANT_TOOL_CORRECTIONS_VECTOR_SIZE ?? "768");
325
+ await ensureToolCorrectionsCollection(qdrant, toolCorrectionsCollection, toolCorrectionsVectorSize);
326
+ const extractToolCorrections = createToolCorrectionExtractor(model, undefined, {
327
+ log: (msg) => console.error(`[cron] ${msg}`),
328
+ });
329
+ const toolCorrectionConsolidationDeps: ToolCorrectionConsolidationDeps = {
330
+ vaultPath: wikiVaultPath,
331
+ clusterFn: (tool, topic, limit) =>
332
+ searchToolCorrectionsByTopic(qdrant, toolCorrectionsCollection, embed, { tool, topic, limit }),
333
+ readNoteFn: (vp, relativePath) => readWikiFileInRoots(vp, [resolvePath(vp, "curated")], relativePath),
334
+ writeNoteFn: writeToolCorrectionNote,
335
+ // A single confirmed correction is already a strong signal — unlike
336
+ // identity/preference facts (DEFAULT_CONSOLIDATION_K = 3). k: 1 fires
337
+ // defaultConfidenceForCount's dominantCount >= k branch on the first
338
+ // candidate ("high" confidence immediately).
339
+ k: 1,
340
+ };
341
+
342
+ /**
343
+ * Extracts and consolidates any procedural corrections found in one turn's
344
+ * `steps` — a no-op if none are found. Drives `onToolStart`/`onToolFinish`
345
+ * once per correction like a real tool call.
346
+ */
347
+ async function processToolCorrections(
348
+ steps: StepInfo[],
349
+ onToolStart?: TurnSink["onToolStart"],
350
+ onToolFinish?: TurnSink["onToolFinish"],
351
+ ): Promise<void> {
352
+ const corrections = await extractToolCorrections(steps);
353
+ for (const correction of corrections) {
354
+ const correctionId = crypto.randomUUID();
355
+ onToolStart?.(
356
+ "Mi sto segnando un'informazione importante…",
357
+ `Correzione per lo strumento "${correction.tool}" (argomento: "${correction.topic}", collection: "${toolCorrectionsCollection}")`,
358
+ correctionId,
359
+ );
360
+ try {
361
+ const timestamp = new Date().toISOString();
362
+ await storeToolCorrection(qdrant, toolCorrectionsCollection, embed, { ...correction, timestamp });
363
+ await consolidateToolCorrection(correction.tool, correction.topic, toolCorrectionConsolidationDeps);
364
+ onToolFinish?.(correctionId, "success");
365
+ } catch (err) {
366
+ console.error(`[capture] procedural correction failed for ${correction.tool}/${correction.topic}: ${String(err)}`);
367
+ onToolFinish?.(correctionId, "failed");
368
+ }
369
+ }
370
+ }
371
+
372
+ // `wikiUserId` is separate from `sessionKey`: inferred/users/<userId> notes
373
+ // are scoped per-person, not per-(space,person) pair, so it must not include
374
+ // the space. An identity-less channel (the terminal) uses a fixed id.
375
+ function buildTools(
376
+ sessionKey: string,
377
+ wikiUserId: string,
378
+ onToolStart?: TurnSink["onToolStart"],
379
+ onToolFinish?: TurnSink["onToolFinish"],
380
+ ): Record<string, Tool> {
381
+ const sessionTools: Record<string, Tool> = {};
382
+
383
+ // The session-scoped capabilities every CLI tool needs, bound to this turn:
384
+ // staging a confirm-required action (token + pending note) and stashing a
385
+ // display artifact the model can `present`.
386
+ const sessionToolContext = {
387
+ sessionKey,
388
+ stageConfirmation: createStageConfirmation({
389
+ store: confirmationStore,
390
+ sessionKey,
391
+ userId: wikiUserId,
392
+ vaultPath: wikiVaultPath,
393
+ writeConfirmationNoteFn: writeConfirmationNote,
394
+ }),
395
+ stashDisplay: (artifact: string) => displayStore.stash(sessionKey, artifact),
396
+ };
397
+
398
+ // Each CLI-based plugin owns its tool (jiraCommand, …), built from its own
399
+ // allowlist and post-processor. The core just invokes what they contributed.
400
+ for (const bundle of loadedPlugins.sessionToolBundles) {
401
+ Object.assign(sessionTools, bundle.build(sessionToolContext, bundle.postProcess));
402
+ }
403
+
404
+ // `present` only makes sense alongside CLI tools: they are what produce the
405
+ // display artifacts it surfaces. An instance with no CLI tool never sees it.
406
+ const hasCliTool = loadedPlugins.sessionToolBundles.length > 0;
407
+ if (hasCliTool) {
408
+ Object.assign(sessionTools, createPresentTool({ sessionKey, store: displayStore }));
409
+ }
410
+
411
+ Object.assign(sessionTools, createWikiTools({ vaultPath: wikiVaultPath, userId: wikiUserId }));
412
+ Object.assign(sessionTools, createToolLogRecallTool({ sessionKey }));
413
+ // Verbatim archive recall, scoped to this person — lets the model resurface
414
+ // what was actually said in earlier conversations, beyond the live window.
415
+ Object.assign(sessionTools, verbatimProvider.sessionTools!({ sessionKey, userId: wikiUserId }));
416
+ // read_skill only exists when a plugin contributed at least one skill.
417
+ if (loadedPlugins.skills.length > 0) {
418
+ Object.assign(sessionTools, createReadSkillTool(loadedPlugins.skills));
419
+ }
420
+ return onToolStart ? withToolStartHook(sessionTools, onToolStart, toolStatusDescribers, onToolFinish) : sessionTools;
421
+ }
422
+
423
+ const confirmationStore = createConfirmationStore();
424
+ // Shared across turns (like confirmationStore): a display artifact stashed
425
+ // while answering can be surfaced by `present` in a later turn, so it's
426
+ // session-scoped, not rebuilt per turn.
427
+ const displayStore = createDisplayStore();
428
+
429
+ // Raw tool output can be tens of KB — too long to print in full and stay
430
+ // readable. MAX_INLINE_CHARS bounds what's shown per call/result.
431
+ const MAX_INLINE_CHARS = 600;
432
+
433
+ /**
434
+ * Server-side-only tool-call/result visibility for debugging a turn —
435
+ * written to this process's own stderr, never sent back to whoever asked.
436
+ * `prefix` distinguishes which conversation a line belongs to.
437
+ */
438
+ function logStep(prefix: string, step: StepInfo): void {
439
+ for (const call of step.toolCalls) {
440
+ console.error(
441
+ `${prefix}[tool] ${call.toolName}(${truncateForDisplay(call.input, MAX_INLINE_CHARS)})`,
442
+ );
443
+ console.error(`${prefix}${describeToolOutcome(step, call.toolCallId, MAX_INLINE_CHARS)}`);
444
+ }
445
+ }
446
+
447
+ // Shared turn-taking pipeline (see src/router/turn-runner.ts). Parameterized
448
+ // entirely by Provider/InboundTurn/TurnSink — it doesn't know which provider
449
+ // a given turn came from. getOrCreateHistory seeds a context primer only for
450
+ // a genuinely new, tracked (real per-user identity) session; an identity-less
451
+ // turn (userId undefined) never triggers it.
452
+ const handleTurn = createTurnRunner({
453
+ model,
454
+ systemPrompts: { singleUser: system, multiUser: chatSystem },
455
+ buildTools,
456
+ // Post-turn guards contributed by whatever plugins loaded — the core runs
457
+ // them without knowing what any does (see PostTurnGuard).
458
+ postTurnGuards: loadedPlugins.postTurnGuards,
459
+ getOrCreateHistory: async (key, trackForCapture, userId) => {
460
+ if (trackForCapture && userId && !histories.has(key)) {
461
+ const primer = await buildContextPrimer(userId, {
462
+ vaultPath: wikiVaultPath,
463
+ getLastSessionEntries: (uid) => getLastSessionEpisodicSummaries(qdrant, episodicCollection, { userId: uid }),
464
+ listWikiFilesInRootsFn: listWikiFilesInRoots,
465
+ readWikiFileInRootsFn: readWikiFileInRoots,
466
+ readIndexFileFn: readIndexFile,
467
+ });
468
+ return getOrCreateHistory(key, trackForCapture, primer);
469
+ }
470
+ return getOrCreateHistory(key, trackForCapture);
471
+ },
472
+ trackSession: (key, userId, at) => {
473
+ sessionUsers.set(key, userId);
474
+ idleScanner.touch(key, at);
475
+ },
476
+ registerCaptureCallback: (key, onToolStart, onToolFinish) => sessionOnCaptureCallbacks.set(key, { onToolStart, onToolFinish }),
477
+ maybeCapture: async (key, history) => {
478
+ const messages = history.getMessages();
479
+ const alreadyCaptured = sessionCaptureMarkers.get(key) ?? 0;
480
+ if (messages.length - alreadyCaptured >= MESSAGE_COUNT_CAPTURE_THRESHOLD) {
481
+ await captureIncrement(key, messages);
482
+ }
483
+ },
484
+ captureVerbatim: verbatimProvider.captureExchange,
485
+ processToolCorrections,
486
+ logStep,
487
+ // Appends what the model surfaced via `present` this turn — nothing if it
488
+ // presented nothing.
489
+ takeSurfacedDisplays: (sessionKey) => displayStore.takeSurfaced(sessionKey),
490
+ });
491
+
492
+ // The confirm capability, bound to this instance's store/vault/note-writer.
493
+ const confirmDeps: ConfirmDeps = { store: confirmationStore, vaultPath: wikiVaultPath, writeConfirmationNoteFn: writeConfirmationNote };
494
+
495
+ // The runtime context each channel's build() gets. The floor (confirm) plus
496
+ // HTTP's optional in-process capabilities (resolveConfirmation + reads), which
497
+ // can't come from env; a channel that doesn't need them ignores them.
498
+ const channelRuntime: ChannelRuntimeContext = {
499
+ env: process.env,
500
+ log: (msg) => console.error(msg),
501
+ confirm: (token, sessionKey, userId) => tryConfirm(token, sessionKey, { ...confirmDeps, userId }),
502
+ resolveConfirmation: (token, sessionKey, userId) => resolveConfirmation(token, sessionKey, { ...confirmDeps, userId }),
503
+ reads: {
504
+ manifest: () => buildPluginManifest(plugins, loadedPlugins.activated, [], loadedPlugins.skills),
505
+ pendingConfirmations: () => confirmationStore.pending(),
506
+ // Durable per-conversation transcript from the verbatim archive (#4):
507
+ // the client's conversationId is the sessionKey.
508
+ conversation: (sessionKey, limit, offset) =>
509
+ listVerbatimBySession(qdrant, verbatimCollection, { sessionKey, limit, offset }),
510
+ conversations: (limit) => listVerbatimSessions(qdrant, verbatimCollection, { limit }),
511
+ wikiList: () => listWikiVault(wikiVaultPath),
512
+ wikiRead: (path) => readWikiVaultFile(wikiVaultPath, path),
513
+ wikiGrep: (pattern) => grepWikiVault(wikiVaultPath, pattern),
514
+ memoryScroll: (collection, limit, offset) => scrollCollection(qdrant, collection, { limit, offset }),
515
+ toolLog: () => getToolLog(),
516
+ health: () => getSelfHealth({ qdrant, ollamaHost }),
517
+ },
518
+ };
519
+
520
+ /** Starts the Layer-3 idle-capture and self-review crons; returns one stopper. */
521
+ function startCrons(): Stoppable {
522
+ const idleCron = startIdleSessionCron(
523
+ idleScanner,
524
+ {
525
+ getSession: (key) => {
526
+ const history = histories.get(key);
527
+ const userId = sessionUsers.get(key);
528
+ if (!history || !userId) {
529
+ return undefined;
530
+ }
531
+ return { key, userId, messages: history.getMessages() };
532
+ },
533
+ closeSession: (key) => {
534
+ histories.delete(key);
535
+ sessionUsers.delete(key);
536
+ sessionCaptureMarkers.delete(key);
537
+ sessionOnCaptureCallbacks.delete(key);
538
+ },
539
+ ...captureDeps,
540
+ },
541
+ {
542
+ idleTimeoutMs: Number(process.env.SESSION_IDLE_TIMEOUT_MS ?? String(30 * 60_000)),
543
+ checkIntervalMs: Number(process.env.SESSION_IDLE_CHECK_INTERVAL_MS ?? String(60_000)),
544
+ },
545
+ );
546
+ const selfReviewCron = startSelfReviewCron({
547
+ listRawEntries: () => listWikiFilesInRoots(wikiVaultPath, [resolvePath(wikiVaultPath, "raw")]),
548
+ findOrphans: () => findOrphanCuratedDocs(wikiVaultPath),
549
+ runRawTriage: (rawEntries) => runRawTriagePass({ vaultPath: wikiVaultPath, model, rawEntries }),
550
+ runIndexAndOrphan: (orphans) => runIndexAndOrphanPass({ vaultPath: wikiVaultPath, model, orphans }),
551
+ runContradictionCheck: () => runContradictionCheckPass({ vaultPath: wikiVaultPath, model }),
552
+ log: (msg) => console.error(`[cron] ${msg}`),
553
+ });
554
+ return {
555
+ stop: () => {
556
+ idleCron.stop();
557
+ selfReviewCron.stop();
558
+ },
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Starts the POC admin panel if `ADMIN_PANEL_ENABLED` — opt-in, dev-only,
564
+ * unauthenticated. Reuses the already-constructed qdrant client, model and
565
+ * vault path. Returns it (to stop on shutdown), or undefined when disabled.
566
+ */
567
+ function startAdmin(): Stoppable | undefined {
568
+ if (process.env.ADMIN_PANEL_ENABLED !== "true") return undefined;
569
+ const adminPort = Number(process.env.ADMIN_PANEL_PORT ?? "4000");
570
+ const adminServer = startAdminServer({
571
+ port: adminPort,
572
+ vaultPath: wikiVaultPath,
573
+ model,
574
+ qdrant,
575
+ qdrantCollections: { episodic: episodicCollection, semanticFacts: semanticFactsCollection },
576
+ // Every CLI is a plugin now (no file-based bucket), so there are no
577
+ // centrally-configured CLIs for the POC admin's CLI status to cover.
578
+ activeCliConfigs: {},
579
+ runCliFn: runCli,
580
+ ollamaHost,
581
+ ollamaModel,
582
+ systemPrompts: { terminal: system, googleChat: chatSystem },
583
+ envFilePath: ".env",
584
+ });
585
+ console.error(`[admin] panel listening on http://localhost:${adminPort}`);
586
+ return adminServer;
587
+ }
588
+
589
+ return {
590
+ handleTurn,
591
+ channels: config.channels ?? [],
592
+ channelRuntime,
593
+ confirmDeps,
594
+ ollamaHost,
595
+ ollamaModel,
596
+ startCrons,
597
+ startAdmin,
598
+ };
599
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The instance composition config contract and its `defineMercuryConfig`
3
+ * helper. A Mercury instance is composed by declaring, in one place, which
4
+ * plugins it runs — the way a Nuxt/Vite project declares its config through a
5
+ * `defineConfig` call in a `*.config.ts` file. `defineMercuryConfig` adds no
6
+ * runtime behavior; it exists purely so the app-root `mercury.config.ts` gets
7
+ * editor/compiler support (the argument is checked against `MercuryConfig`) and
8
+ * so there is a single, stable seam the composition root reads from.
9
+ *
10
+ * This is the minimal foundational slice of a broader composition rethink: for
11
+ * now the config carries only the plugin list. Per-plugin configuration and the
12
+ * eventual retirement of the file-based cli-configs are deliberately not here.
13
+ */
14
+ import type { Plugin } from "@mercury-fw/plugin-types";
15
+ import type { ChannelPlugin } from "@mercury-fw/channel-types";
16
+ import type { Persona } from "../session/system-prompt.ts";
17
+
18
+ /** The shape of a Mercury instance's composition config: the tool plugins
19
+ * (gated by MERCURY_CLIS) and the channel plugins (enabled by being declared
20
+ * here — declared = active), each loaded by its own loader, plus the
21
+ * assistant's persona (its identity and tone; the defaults when left out). */
22
+ export type MercuryConfig = {
23
+ plugins: Plugin[];
24
+ channels?: ChannelPlugin[];
25
+ persona?: Persona;
26
+ };
27
+
28
+ /**
29
+ * Identity + typing helper for `mercury.config.ts`. Returns its argument
30
+ * unchanged; its only job is to type the config literal against `MercuryConfig`
31
+ * at the call site, exactly like `defineConfig` in the Vite/Nuxt ecosystem.
32
+ */
33
+ export function defineMercuryConfig(config: MercuryConfig): MercuryConfig {
34
+ return config;
35
+ }
File without changes