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.
- package/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- 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
|
+
}
|