mercury-agent 0.4.5

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,821 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { delimiter, join } from "node:path";
13
+
14
+ const KNOWLEDGE_DIR = "knowledge";
15
+ const VAULT_DIRS = ["people", "projects", "references", "daily", "episodes", "weekly", "monthly", "templates"];
16
+ // Entity categories shown in the NAPKIN.md vault map (excludes scaffold/output dirs).
17
+ const MAP_DIRS = ["people", "projects", "references", "episodes", "daily"];
18
+ // Daily distillation. Distilled memory serves cross-session, weeks-old recall —
19
+ // recent facts are already in live context — so daily (vs hourly) cadence cuts
20
+ // the LLM bill ~24× at near-zero user-visible cost.
21
+ const DEFAULT_DISTILL_INTERVAL_MS = "86400000"; // 24h
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Obsidian configs
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const DAILY_NOTES_CONFIG = JSON.stringify(
28
+ { folder: "daily", format: "YYYY-MM-DD", template: "templates/Daily Note" },
29
+ null,
30
+ 2,
31
+ );
32
+
33
+ const TEMPLATES_CONFIG = JSON.stringify({ folder: "templates" }, null, 2);
34
+
35
+ const DAILY_TEMPLATE = `---
36
+ tags:
37
+ - daily
38
+ ---
39
+
40
+ ## Conversations
41
+
42
+ ## Learned
43
+
44
+ ## Tasks
45
+
46
+ - [ ]
47
+ `;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // KB Distillation prompt — versioned file, loaded at runtime
51
+ // ---------------------------------------------------------------------------
52
+
53
+ // The distillation prompt is a dedicated, version-controlled file so its
54
+ // behavior is reviewable in git diffs rather than buried in a string literal.
55
+ // It ships alongside this extension via the `git:...#examples/extensions/napkin`
56
+ // install, so `import.meta.dir` resolves to the installed extension directory.
57
+ const KB_DISTILLER_PROMPT_PATH = join(
58
+ import.meta.dir,
59
+ "prompts",
60
+ "kb-distillation.md",
61
+ );
62
+
63
+ const WEEKLY_CONSOLIDATION_PROMPT_PATH = join(
64
+ import.meta.dir,
65
+ "prompts",
66
+ "consolidation-weekly.md",
67
+ );
68
+
69
+ const MONTHLY_CONSOLIDATION_PROMPT_PATH = join(
70
+ import.meta.dir,
71
+ "prompts",
72
+ "consolidation-monthly.md",
73
+ );
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Distillation helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ interface MessageRow {
80
+ role: string;
81
+ content: string;
82
+ createdAt: number;
83
+ }
84
+
85
+ function formatDate(ts: number): string {
86
+ return new Date(ts).toISOString().slice(0, 10);
87
+ }
88
+
89
+ function todayDate(): string {
90
+ return formatDate(Date.now());
91
+ }
92
+
93
+ function md5(content: string): string {
94
+ return new Bun.CryptoHasher("md5").update(content).digest("hex");
95
+ }
96
+
97
+ function exportMessages(
98
+ db: Database,
99
+ spaceId: string,
100
+ messagesDir: string,
101
+ ): Set<string> {
102
+ mkdirSync(messagesDir, { recursive: true });
103
+
104
+ const rows = db
105
+ .query(
106
+ `SELECT role, content, created_at as createdAt
107
+ FROM messages
108
+ WHERE space_id = ?
109
+ ORDER BY id ASC`,
110
+ )
111
+ .all(spaceId) as MessageRow[];
112
+
113
+ const byDate = new Map<string, Array<{ ts: number; role: string; content: string }>>();
114
+ for (const row of rows) {
115
+ const date = formatDate(row.createdAt);
116
+ if (!byDate.has(date)) byDate.set(date, []);
117
+ byDate.get(date)!.push({ ts: row.createdAt, role: row.role, content: row.content });
118
+ }
119
+
120
+ const changed = new Set<string>();
121
+ for (const [date, messages] of byDate) {
122
+ const filePath = join(messagesDir, `${date}.jsonl`);
123
+ const newContent = `${messages.map((m) => JSON.stringify(m)).join("\n")}\n`;
124
+
125
+ const oldHash = existsSync(filePath) ? md5(readFileSync(filePath, "utf-8")) : "";
126
+ writeFileSync(filePath, newContent);
127
+ const newHash = md5(newContent);
128
+
129
+ if (oldHash !== newHash) {
130
+ changed.add(date);
131
+ }
132
+ }
133
+
134
+ return changed;
135
+ }
136
+
137
+ /**
138
+ * Build a child env whose PATH also contains the standard global-bin
139
+ * directories where the `pi` CLI is installed. The distill job runs in the
140
+ * Mercury HOST process, which — unlike the cloud Docker image, where `pi` is
141
+ * always on PATH — may have been launched with an incomplete PATH (e.g.
142
+ * `bun <abs>/mercury.ts run` from an IDE or a scheduled task resolves
143
+ * `.bun/bin` but not the npm global bin). A bare `spawn("pi")` then fails with
144
+ * ENOENT before pi ever starts. Appending the well-known global-bin dirs makes
145
+ * resolution robust on local installs and is a no-op in the cloud.
146
+ */
147
+ function envWithPiOnPath(): NodeJS.ProcessEnv {
148
+ const isWindows = process.platform === "win32";
149
+ const base = process.env.PATH ?? process.env.Path ?? "";
150
+ const home = homedir();
151
+ const candidates = [
152
+ join(home, ".bun", "bin"),
153
+ isWindows
154
+ ? join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "npm")
155
+ : "/usr/local/bin",
156
+ ];
157
+ // PATH entries are case-insensitive on Windows, case-sensitive on POSIX.
158
+ const normalize = (p: string) => (isWindows ? p.toLowerCase() : p);
159
+ const existing = new Set(base.split(delimiter).map(normalize).filter(Boolean));
160
+ const additions = candidates.filter((c) => c && !existing.has(normalize(c)));
161
+ const path = [base, ...additions].filter(Boolean).join(delimiter);
162
+ // On Windows also set `Path`: process.env keys are case-sensitive but the
163
+ // Windows env is not, so a stale `Path` could otherwise shadow our `PATH`.
164
+ return { ...process.env, PATH: path, ...(isWindows ? { Path: path } : {}) };
165
+ }
166
+
167
+ function runPromptAgent(
168
+ vaultDir: string,
169
+ promptPath: string,
170
+ instruction: string,
171
+ ): Promise<{ ok: boolean; detail?: string }> {
172
+ let promptText: string;
173
+ try {
174
+ promptText = readFileSync(promptPath, "utf-8");
175
+ } catch (err) {
176
+ // Prompt file missing/unreadable — fail safe: the day is not marked
177
+ // distilled, so it retries on a later run rather than silently no-op'ing.
178
+ return Promise.resolve({
179
+ ok: false,
180
+ detail: `prompt unreadable: ${err instanceof Error ? err.message : String(err)}`,
181
+ });
182
+ }
183
+
184
+ const promptFile = join(tmpdir(), `kb-distiller-${process.pid}.md`);
185
+ writeFileSync(promptFile, promptText);
186
+
187
+ return new Promise((resolve) => {
188
+ let stderr = "";
189
+ const child = spawn(
190
+ "pi",
191
+ [
192
+ "--print",
193
+ "--no-session",
194
+ "--tools",
195
+ "read,bash,write",
196
+ "--append-system-prompt",
197
+ promptFile,
198
+ instruction,
199
+ ],
200
+ {
201
+ cwd: vaultDir,
202
+ env: envWithPiOnPath(),
203
+ // Capture stderr (a background job has no console to inherit usefully):
204
+ // the captured tail is what makes a non-zero exit diagnosable in logs.
205
+ stdio: ["ignore", "inherit", "pipe"],
206
+ },
207
+ );
208
+
209
+ child.stderr?.on("data", (chunk) => {
210
+ stderr += chunk.toString();
211
+ });
212
+
213
+ child.on("close", (code) => {
214
+ try { unlinkSync(promptFile); } catch {}
215
+ if (code === 0) {
216
+ resolve({ ok: true });
217
+ return;
218
+ }
219
+ const tail = stderr.trim().split("\n").slice(-3).join(" | ").slice(0, 300);
220
+ resolve({ ok: false, detail: `pi exited ${code}${tail ? `: ${tail}` : ""}` });
221
+ });
222
+ child.on("error", (err) => {
223
+ try { unlinkSync(promptFile); } catch {}
224
+ resolve({ ok: false, detail: `spawn failed: ${err.message}` });
225
+ });
226
+ });
227
+ }
228
+
229
+ function runDistiller(
230
+ vaultDir: string,
231
+ dateFile: string,
232
+ ): Promise<{ ok: boolean; detail?: string }> {
233
+ return runPromptAgent(
234
+ vaultDir,
235
+ KB_DISTILLER_PROMPT_PATH,
236
+ `Distill knowledge from: ${dateFile}`,
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Deterministic 32-bit hash of a space id (FNV-1a). Used to stagger each
242
+ * space's first distillation so many tenants don't all hit the LLM API in the
243
+ * same tick.
244
+ */
245
+ function hashSpaceId(spaceId: string): number {
246
+ let h = 0x811c9dc5;
247
+ for (let i = 0; i < spaceId.length; i++) {
248
+ h ^= spaceId.charCodeAt(i);
249
+ h = Math.imul(h, 0x01000193);
250
+ }
251
+ return h >>> 0;
252
+ }
253
+
254
+ /**
255
+ * Read a note's frontmatter `summary:` for the vault map preview. Best-effort:
256
+ * returns an empty string if the file is unreadable or has no summary.
257
+ */
258
+ function readSummary(filePath: string): string {
259
+ try {
260
+ const text = readFileSync(filePath, "utf-8");
261
+ const raw = text.match(/^summary:\s*(.+)$/m)?.[1];
262
+ return raw ? raw.trim().replace(/^["']|["']$/g, "") : "";
263
+ } catch {
264
+ return "";
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Regenerate `NAPKIN.md` — the Level-0 vault map — by scanning the category
270
+ * directories. Deterministic (no LLM), so it cannot drift or hallucinate; run
271
+ * after each distillation so the map always reflects what is actually on disk.
272
+ */
273
+ function regenerateNapkinMap(knowledgeDir: string): void {
274
+ const lines: string[] = [
275
+ "# Knowledge Vault",
276
+ "",
277
+ "Auto-generated map of this space's memory (regenerated after each distillation run — do not edit).",
278
+ "The current value of any fact lives in a note's frontmatter and `## Current View`; `## History` holds superseded values.",
279
+ "",
280
+ ];
281
+
282
+ for (const dir of MAP_DIRS) {
283
+ const dirPath = join(knowledgeDir, dir);
284
+ let files: string[];
285
+ try {
286
+ files = readdirSync(dirPath)
287
+ .filter((f) => f.endsWith(".md"))
288
+ .sort();
289
+ } catch {
290
+ continue; // category dir absent
291
+ }
292
+ if (files.length === 0) continue;
293
+
294
+ const heading = dir.charAt(0).toUpperCase() + dir.slice(1);
295
+ lines.push(`## ${heading}`);
296
+ if (dir === "daily") {
297
+ // Daily notes are dated logs — list newest first, no summary.
298
+ for (const file of files.slice().reverse()) {
299
+ lines.push(`- ${file.replace(/\.md$/, "")}`);
300
+ }
301
+ } else {
302
+ for (const file of files) {
303
+ const slug = file.replace(/\.md$/, "");
304
+ const summary = readSummary(join(dirPath, file));
305
+ lines.push(summary ? `- [[${slug}]] — ${summary}` : `- [[${slug}]]`);
306
+ }
307
+ }
308
+ lines.push("");
309
+ }
310
+
311
+ lines.push(`_Last updated: ${todayDate()}_`);
312
+ writeFileSync(join(knowledgeDir, "NAPKIN.md"), `${lines.join("\n")}\n`);
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Extension setup
317
+ // ---------------------------------------------------------------------------
318
+
319
+ export default function (mercury: {
320
+ cli(opts: { name: string; install: string }): void;
321
+ permission(opts: { defaultRoles: string[] }): void;
322
+ skill(relativePath: string): void;
323
+ on(event: string, handler: (event: any, ctx: any) => Promise<any>): void;
324
+ job(name: string, def: { interval?: number; cron?: string; run: (ctx: any) => Promise<void> }): void;
325
+ config(key: string, def: { description: string; default: string; validate?: (v: string) => boolean }): void;
326
+ widget(def: { label: string; render: (ctx: any) => string }): void;
327
+ store: { get(key: string): string | null; set(key: string, value: string): void };
328
+ }) {
329
+ mercury.cli({ name: "napkin", install: "bun add -g napkin-ai" });
330
+ mercury.permission({ defaultRoles: ["admin", "member"] });
331
+ mercury.skill("./skill");
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Config
335
+ // ---------------------------------------------------------------------------
336
+
337
+ mercury.config("distill_interval_ms", {
338
+ description:
339
+ "KB distillation interval in milliseconds (0 = disabled). Default: 86400000 (daily)",
340
+ default: DEFAULT_DISTILL_INTERVAL_MS,
341
+ validate: (v) => {
342
+ const n = Number.parseInt(v, 10);
343
+ return !Number.isNaN(n) && n >= 0;
344
+ },
345
+ });
346
+
347
+ mercury.config("distill_backfill_days", {
348
+ description: "How far back (in days) to look for unprocessed dates on first enable. Default: 90",
349
+ default: "90",
350
+ validate: (v) => {
351
+ const n = Number.parseInt(v, 10);
352
+ return !Number.isNaN(n) && n >= 0;
353
+ },
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Hooks
358
+ // ---------------------------------------------------------------------------
359
+
360
+ mercury.on("workspace_init", async ({ workspace }) => {
361
+ const knowledgeDir = join(workspace, KNOWLEDGE_DIR);
362
+ const obsidianDir = join(knowledgeDir, ".obsidian");
363
+ const napkinDir = join(knowledgeDir, ".napkin");
364
+
365
+ mkdirSync(obsidianDir, { recursive: true });
366
+ mkdirSync(napkinDir, { recursive: true });
367
+ for (const dir of VAULT_DIRS) {
368
+ mkdirSync(join(knowledgeDir, dir), { recursive: true });
369
+ }
370
+
371
+ const dailyNotesConfig = join(obsidianDir, "daily-notes.json");
372
+ if (!existsSync(dailyNotesConfig)) {
373
+ writeFileSync(dailyNotesConfig, DAILY_NOTES_CONFIG, "utf8");
374
+ }
375
+
376
+ const templatesConfig = join(obsidianDir, "templates.json");
377
+ if (!existsSync(templatesConfig)) {
378
+ writeFileSync(templatesConfig, TEMPLATES_CONFIG, "utf8");
379
+ }
380
+
381
+ const dailyTemplatePath = join(knowledgeDir, "templates", "Daily Note.md");
382
+ if (!existsSync(dailyTemplatePath)) {
383
+ writeFileSync(dailyTemplatePath, DAILY_TEMPLATE, "utf8");
384
+ }
385
+
386
+ return undefined;
387
+ });
388
+
389
+ mercury.on("before_container", async ({ containerWorkspace }) => {
390
+ return {
391
+ env: { NAPKIN_VAULT: join(containerWorkspace, KNOWLEDGE_DIR) },
392
+ };
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // KB Distillation job
397
+ // ---------------------------------------------------------------------------
398
+
399
+ mercury.job("distill", {
400
+ interval: 3600_000, // check every hour
401
+ async run(ctx) {
402
+ ctx.log.info("Running KB distillation");
403
+
404
+ try {
405
+ const dbPath = join(ctx.config.dataDir, "state.db");
406
+ const spacesDir = join(ctx.config.dataDir, "spaces");
407
+
408
+ if (!existsSync(dbPath)) {
409
+ ctx.log.error("Database not found", { dbPath });
410
+ return;
411
+ }
412
+
413
+ const db = new Database(dbPath, { readonly: true });
414
+
415
+ const spaces = db
416
+ .query("SELECT DISTINCT space_id as spaceId FROM messages")
417
+ .all() as { spaceId: string }[];
418
+
419
+ const today = todayDate();
420
+ let totalEnabled = 0;
421
+ let distilledTodayCount = 0;
422
+
423
+ for (const { spaceId } of spaces) {
424
+ const spaceWorkspace = join(spacesDir, spaceId);
425
+ const knowledgeDir = join(spaceWorkspace, KNOWLEDGE_DIR);
426
+ const messagesDir = join(spaceWorkspace, ".messages");
427
+
428
+ if (!existsSync(spaceWorkspace)) continue;
429
+
430
+ // Ensure knowledge dir exists
431
+ if (!existsSync(knowledgeDir)) continue;
432
+
433
+ // --- Step 1: Read per-space distill interval ---
434
+ // getSpaceConfig returns only an explicit per-space override (or
435
+ // null) — it does NOT surface the registered mercury.config default,
436
+ // so the fallback here must mirror that default. An env var still
437
+ // wins for operators who want to override globally.
438
+ const spaceIntervalRaw = ctx.db.getSpaceConfig(spaceId, "napkin.distill_interval_ms");
439
+ const intervalMs = Number.parseInt(
440
+ spaceIntervalRaw ??
441
+ process.env.MERCURY_KB_DISTILL_INTERVAL_MS ??
442
+ DEFAULT_DISTILL_INTERVAL_MS,
443
+ 10,
444
+ );
445
+ // Treat a non-finite interval (NaN from a corrupted config value)
446
+ // as disabled too — otherwise it slips past `<= 0` and later
447
+ // `new Date(NaN).toISOString()` throws, killing the whole job.
448
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
449
+ ctx.log.debug("Distillation disabled for space", { spaceId });
450
+ continue;
451
+ }
452
+
453
+ totalEnabled++;
454
+
455
+ // --- Step 2: Interval throttle ---
456
+ const lastDistillAt = mercury.store.get(`last_distill_at:${spaceId}`);
457
+ if (lastDistillAt) {
458
+ const elapsed = Date.now() - new Date(lastDistillAt).getTime();
459
+ if (elapsed < intervalMs) {
460
+ ctx.log.debug("Skipping space — too soon", { spaceId, elapsed, intervalMs });
461
+ continue;
462
+ }
463
+ } else {
464
+ // First time we've seen this space: stagger its first run by a
465
+ // deterministic per-space offset (0..intervalMs) so many tenants
466
+ // don't all distill in the same tick. We back-date a synthetic
467
+ // last_distill_at so the space becomes eligible after the offset;
468
+ // subsequent runs are then naturally spread by their varied
469
+ // completion times.
470
+ const offset = hashSpaceId(spaceId) % intervalMs;
471
+ const seed = Date.now() - (intervalMs - offset);
472
+ mercury.store.set(`last_distill_at:${spaceId}`, new Date(seed).toISOString());
473
+ ctx.log.debug("Staggering first distill for space", {
474
+ spaceId,
475
+ offsetMs: offset,
476
+ });
477
+ continue;
478
+ }
479
+
480
+ // --- Step 3: Backfill window ---
481
+ const backfillDaysRaw = ctx.db.getSpaceConfig(spaceId, "napkin.distill_backfill_days");
482
+ const backfillDays = Number.parseInt(backfillDaysRaw ?? "90", 10);
483
+ const cutoffDate = formatDate(Date.now() - backfillDays * 86_400_000);
484
+
485
+ // --- Step 4: Load distilled set ---
486
+ let distilledSet: Set<string>;
487
+ const distilledRaw = mercury.store.get(`distilled:${spaceId}`);
488
+ if (distilledRaw) {
489
+ try {
490
+ distilledSet = new Set(JSON.parse(distilledRaw) as string[]);
491
+ } catch {
492
+ ctx.log.warn("Corrupted distilled set for space — resetting", { spaceId });
493
+ distilledSet = new Set();
494
+ }
495
+ } else {
496
+ distilledSet = new Set();
497
+ }
498
+
499
+ // --- Step 5: Export messages and find eligible dates ---
500
+ const changed = exportMessages(db, spaceId, messagesDir);
501
+
502
+ // Collect all known dates from the messages dir
503
+ let allDates: string[];
504
+ try {
505
+ allDates = readdirSync(messagesDir)
506
+ .filter((f) => f.endsWith(".jsonl"))
507
+ .map((f) => f.replace(".jsonl", ""));
508
+ } catch {
509
+ allDates = [];
510
+ }
511
+
512
+ const eligibleDates: string[] = [];
513
+ for (const date of allDates) {
514
+ if (date === today) {
515
+ // Today: re-distill if in changed set
516
+ if (changed.has(date)) {
517
+ eligibleDates.push(date);
518
+ }
519
+ } else {
520
+ // Past dates: eligible if >= cutoff AND not already distilled
521
+ if (date >= cutoffDate && !distilledSet.has(date)) {
522
+ eligibleDates.push(date);
523
+ }
524
+ }
525
+ }
526
+
527
+ // Sort ascending (oldest first)
528
+ eligibleDates.sort();
529
+
530
+ if (eligibleDates.length === 0) {
531
+ ctx.log.debug("No dates to distill", { spaceId });
532
+ // Self-heal an empty/missing vault map even when nothing changed,
533
+ // so a previously-drifted vault (empty NAPKIN.md) gets populated.
534
+ const napkinMap = join(knowledgeDir, "NAPKIN.md");
535
+ const mapEmpty =
536
+ !existsSync(napkinMap) ||
537
+ readFileSync(napkinMap, "utf-8").trim().length === 0;
538
+ if (mapEmpty) {
539
+ try {
540
+ regenerateNapkinMap(knowledgeDir);
541
+ } catch (err) {
542
+ ctx.log.warn("Failed to regenerate NAPKIN.md", {
543
+ spaceId,
544
+ error: err instanceof Error ? err.message : String(err),
545
+ });
546
+ }
547
+ }
548
+ mercury.store.set(`last_distill_at:${spaceId}`, new Date().toISOString());
549
+ continue;
550
+ }
551
+
552
+ ctx.log.info("Distilling space", { spaceId, dates: eligibleDates });
553
+
554
+ // --- Step 6: Distill each eligible date ---
555
+ for (const date of eligibleDates) {
556
+ const dateFile = join(messagesDir, `${date}.jsonl`);
557
+ const result = await runDistiller(knowledgeDir, dateFile);
558
+ if (result.ok) {
559
+ ctx.log.info("Distillation complete", { spaceId, date });
560
+ // Only persist past dates to distilled set (today will always be re-checked)
561
+ if (date !== today) {
562
+ distilledSet.add(date);
563
+ mercury.store.set(`distilled:${spaceId}`, JSON.stringify([...distilledSet]));
564
+ }
565
+ if (date === today) {
566
+ distilledTodayCount++;
567
+ }
568
+ } else {
569
+ ctx.log.error("Distillation failed", {
570
+ spaceId,
571
+ date,
572
+ detail: result.detail,
573
+ });
574
+ // Do NOT add to distilled set — retry next run
575
+ }
576
+ }
577
+
578
+ // Regenerate the Level-0 vault map from what's now on disk.
579
+ try {
580
+ regenerateNapkinMap(knowledgeDir);
581
+ } catch (err) {
582
+ ctx.log.warn("Failed to regenerate NAPKIN.md", {
583
+ spaceId,
584
+ error: err instanceof Error ? err.message : String(err),
585
+ });
586
+ }
587
+
588
+ mercury.store.set(`last_distill_at:${spaceId}`, new Date().toISOString());
589
+ }
590
+
591
+ db.close();
592
+
593
+ const now = new Date().toISOString();
594
+ mercury.store.set("last-distill", now);
595
+ mercury.store.set("last-distill-status", "success");
596
+ mercury.store.set("last-distill-total-enabled", String(totalEnabled));
597
+ mercury.store.set("last-distill-distilled-today", String(distilledTodayCount));
598
+ ctx.log.info("KB distillation complete", { totalEnabled, distilledTodayCount });
599
+ } catch (err) {
600
+ mercury.store.set("last-distill", new Date().toISOString());
601
+ mercury.store.set("last-distill-status", "failed");
602
+ ctx.log.error(
603
+ "KB distillation failed",
604
+ err instanceof Error ? err : undefined,
605
+ );
606
+ }
607
+ },
608
+ });
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // Consolidation job (weekly + monthly)
612
+ // ---------------------------------------------------------------------------
613
+
614
+ const WEEKLY_INTERVAL_MS = 604_800_000; // 7 days
615
+ const MONTHLY_INTERVAL_MS = 2_592_000_000; // ~30 days
616
+
617
+ function isoWeek(dateStr: string): string {
618
+ const d = new Date(`${dateStr}T00:00:00Z`);
619
+ const dayNum = d.getUTCDay() || 7;
620
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
621
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
622
+ const weekNo = Math.ceil(
623
+ ((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7,
624
+ );
625
+ return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
626
+ }
627
+
628
+ mercury.job("consolidate", {
629
+ interval: 3600_000,
630
+ async run(ctx) {
631
+ ctx.log.info("Running consolidation check");
632
+
633
+ try {
634
+ const spacesDir = join(ctx.config.dataDir, "spaces");
635
+
636
+ const dbPath = join(ctx.config.dataDir, "state.db");
637
+ if (!existsSync(dbPath)) return;
638
+ const db = new Database(dbPath, { readonly: true });
639
+
640
+ const spaces = db
641
+ .query("SELECT DISTINCT space_id as spaceId FROM messages")
642
+ .all() as { spaceId: string }[];
643
+
644
+ for (const { spaceId } of spaces) {
645
+ const spaceWorkspace = join(spacesDir, spaceId);
646
+ const knowledgeDir = join(spaceWorkspace, KNOWLEDGE_DIR);
647
+ const dailyDir = join(knowledgeDir, "daily");
648
+ const weeklyDir = join(knowledgeDir, "weekly");
649
+ const monthlyDir = join(knowledgeDir, "monthly");
650
+
651
+ if (!existsSync(knowledgeDir) || !existsSync(dailyDir)) continue;
652
+
653
+ // --- Weekly consolidation ---
654
+ const lastWeekly = mercury.store.get(`last_consolidation_weekly:${spaceId}`);
655
+ const weeklyElapsed = lastWeekly ? Date.now() - new Date(lastWeekly).getTime() : Infinity;
656
+
657
+ if (weeklyElapsed >= WEEKLY_INTERVAL_MS) {
658
+ let dailyFiles: string[];
659
+ try {
660
+ dailyFiles = readdirSync(dailyDir)
661
+ .filter((f) => f.endsWith(".md"))
662
+ .map((f) => f.replace(/\.md$/, ""))
663
+ .sort();
664
+ } catch {
665
+ dailyFiles = [];
666
+ }
667
+
668
+ const datesByWeek = new Map<string, string[]>();
669
+ for (const date of dailyFiles) {
670
+ const week = isoWeek(date);
671
+ if (!datesByWeek.has(week)) datesByWeek.set(week, []);
672
+ datesByWeek.get(week)!.push(date);
673
+ }
674
+
675
+ let existingWeekly: Set<string>;
676
+ try {
677
+ existingWeekly = new Set(
678
+ readdirSync(weeklyDir)
679
+ .filter((f) => f.endsWith(".md"))
680
+ .map((f) => f.replace(/\.md$/, "")),
681
+ );
682
+ } catch {
683
+ existingWeekly = new Set();
684
+ }
685
+
686
+ const missingWeeks = [...datesByWeek.keys()]
687
+ .filter((w) => !existingWeekly.has(w))
688
+ .sort()
689
+ .slice(0, 4);
690
+
691
+ for (const week of missingWeeks) {
692
+ const dates = datesByWeek.get(week)!;
693
+ const dailyPaths = dates
694
+ .map((d) => `daily/${d}.md`)
695
+ .join(", ");
696
+ const instruction = `Consolidate week ${week} from daily files: ${dailyPaths}. End date of this week for lifecycle calculations: ${dates[dates.length - 1]}.`;
697
+ ctx.log.info("Running weekly consolidation", { spaceId, week });
698
+ const result = await runPromptAgent(
699
+ knowledgeDir,
700
+ WEEKLY_CONSOLIDATION_PROMPT_PATH,
701
+ instruction,
702
+ );
703
+ if (result.ok) {
704
+ ctx.log.info("Weekly consolidation complete", { spaceId, week });
705
+ } else {
706
+ ctx.log.error("Weekly consolidation failed", {
707
+ spaceId,
708
+ week,
709
+ detail: result.detail,
710
+ });
711
+ }
712
+ }
713
+
714
+ mercury.store.set(
715
+ `last_consolidation_weekly:${spaceId}`,
716
+ new Date().toISOString(),
717
+ );
718
+ }
719
+
720
+ // --- Monthly consolidation ---
721
+ const lastMonthly = mercury.store.get(`last_consolidation_monthly:${spaceId}`);
722
+ const monthlyElapsed = lastMonthly ? Date.now() - new Date(lastMonthly).getTime() : Infinity;
723
+
724
+ if (monthlyElapsed >= MONTHLY_INTERVAL_MS) {
725
+ let weeklyFiles: string[];
726
+ try {
727
+ weeklyFiles = readdirSync(weeklyDir)
728
+ .filter((f) => f.endsWith(".md"))
729
+ .sort();
730
+ } catch {
731
+ weeklyFiles = [];
732
+ }
733
+
734
+ if (weeklyFiles.length > 0) {
735
+ const currentMonth = todayDate().slice(0, 7);
736
+ const monthlyPath = join(monthlyDir, `${currentMonth}.md`);
737
+
738
+ if (!existsSync(monthlyPath)) {
739
+ const relevantWeekly = weeklyFiles.filter((f) => {
740
+ const weekStr = f.replace(/\.md$/, "");
741
+ const m = weekStr.match(/^(\d{4})-W(\d{2})$/);
742
+ if (!m) return false;
743
+ const year = Number.parseInt(m[1], 10);
744
+ const week = Number.parseInt(m[2], 10);
745
+ const jan4 = new Date(Date.UTC(year, 0, 4));
746
+ const weekStart = new Date(
747
+ jan4.getTime() -
748
+ ((jan4.getUTCDay() || 7) - 1) * 86_400_000 +
749
+ (week - 1) * 7 * 86_400_000,
750
+ );
751
+ const ym = `${weekStart.getUTCFullYear()}-${String(weekStart.getUTCMonth() + 1).padStart(2, "0")}`;
752
+ return ym === currentMonth;
753
+ });
754
+ if (relevantWeekly.length === 0) {
755
+ mercury.store.set(
756
+ `last_consolidation_monthly:${spaceId}`,
757
+ new Date().toISOString(),
758
+ );
759
+ continue;
760
+ }
761
+ const weeklyPaths = relevantWeekly
762
+ .map((f) => `weekly/${f}`)
763
+ .join(", ");
764
+ const instruction = `Consolidate month ${currentMonth} from weekly files: ${weeklyPaths}. Today's date: ${todayDate()}.`;
765
+ ctx.log.info("Running monthly consolidation", {
766
+ spaceId,
767
+ month: currentMonth,
768
+ });
769
+ const result = await runPromptAgent(
770
+ knowledgeDir,
771
+ MONTHLY_CONSOLIDATION_PROMPT_PATH,
772
+ instruction,
773
+ );
774
+ if (result.ok) {
775
+ ctx.log.info("Monthly consolidation complete", {
776
+ spaceId,
777
+ month: currentMonth,
778
+ });
779
+ } else {
780
+ ctx.log.error("Monthly consolidation failed", {
781
+ spaceId,
782
+ month: currentMonth,
783
+ detail: result.detail,
784
+ });
785
+ }
786
+ }
787
+ }
788
+
789
+ mercury.store.set(
790
+ `last_consolidation_monthly:${spaceId}`,
791
+ new Date().toISOString(),
792
+ );
793
+ }
794
+ }
795
+
796
+ db.close();
797
+ ctx.log.info("Consolidation check complete");
798
+ } catch (err) {
799
+ ctx.log.error(
800
+ "Consolidation failed",
801
+ err instanceof Error ? err : undefined,
802
+ );
803
+ }
804
+ },
805
+ });
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // Dashboard widget
809
+ // ---------------------------------------------------------------------------
810
+
811
+ mercury.widget({
812
+ label: "Knowledge Vault",
813
+ render: () => {
814
+ const lastDistill = mercury.store.get("last-distill") ?? "never";
815
+ const lastStatus = mercury.store.get("last-distill-status") ?? "—";
816
+ const totalEnabled = mercury.store.get("last-distill-total-enabled") ?? "0";
817
+ const distilledToday = mercury.store.get("last-distill-distilled-today") ?? "0";
818
+ return `<div><strong>Last distill:</strong> ${lastDistill}<br><strong>Status:</strong> ${lastStatus}<br><strong>Spaces enabled:</strong> ${totalEnabled}<br><strong>Distilled today:</strong> ${distilledToday}</div>`;
819
+ },
820
+ });
821
+ }