mercury-agent 0.16.0 → 0.16.2
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/README.md +6 -0
- package/container/Dockerfile +6 -1
- package/container/Dockerfile.base +6 -1
- package/docs/README.md +50 -0
- package/docs/authoring-profiles.md +8 -0
- package/docs/behavior-layers.md +193 -0
- package/docs/extensions.md +2 -0
- package/docs/goals/football-reporter-profile/decisions.md +479 -0
- package/docs/goals/football-reporter-profile/goal.md +91 -0
- package/docs/goals/football-reporter-profile/roadmap.md +196 -0
- package/docs/goals/whatsapp-bot-hardening/README.md +43 -0
- package/docs/goals/whatsapp-bot-hardening/archive/ambient-group-context.md +211 -0
- package/docs/goals/whatsapp-bot-hardening/archive/command-routing-consistency.md +210 -0
- package/docs/goals/whatsapp-bot-hardening/archive/destructive-command-guards.md +218 -0
- package/docs/goals/whatsapp-bot-hardening/archive/docs-extension.md +182 -0
- package/docs/goals/whatsapp-bot-hardening/archive/handoff-2026-08-09.md +588 -0
- package/docs/goals/whatsapp-bot-hardening/archive/media-size-and-silent-drop.md +178 -0
- package/docs/goals/whatsapp-bot-hardening/archive/member-memory.md +199 -0
- package/docs/goals/whatsapp-bot-hardening/archive/message-author-attribution.md +211 -0
- package/docs/goals/whatsapp-bot-hardening/archive/run-from-source-switchover.md +212 -0
- package/docs/goals/whatsapp-bot-hardening/archive/setup-plan-2026-08-06.md +1117 -0
- package/docs/goals/whatsapp-bot-hardening/decisions.md +200 -0
- package/docs/goals/whatsapp-bot-hardening/goal.md +75 -0
- package/docs/goals/whatsapp-bot-hardening/open-threads.md +396 -0
- package/docs/goals/whatsapp-bot-hardening/roadmap.md +245 -0
- package/docs/memory.md +30 -0
- package/docs/profile-guide.md +483 -0
- package/docs/refactor/.gitkeep +0 -0
- package/docs/refactor/archive/.gitkeep +0 -0
- package/docs/refactor/audits/.gitkeep +0 -0
- package/docs/refactor/backlog/.gitkeep +0 -0
- package/docs/skills-guide.md +154 -0
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/feed-watch/config.ts +354 -0
- package/examples/extensions/feed-watch/digest.ts +344 -0
- package/examples/extensions/feed-watch/feeds.ts +341 -0
- package/examples/extensions/feed-watch/index.ts +302 -0
- package/examples/extensions/feed-watch/items.ts +171 -0
- package/examples/extensions/feed-watch/match.ts +107 -0
- package/examples/extensions/feed-watch/prompts/verify.md +35 -0
- package/examples/extensions/feed-watch/skill/SKILL.md +128 -0
- package/examples/extensions/feed-watch/watch.ts +548 -0
- package/examples/extensions/longview/hook.ts +179 -16
- package/examples/extensions/longview/index.ts +13 -2
- package/examples/extensions/longview/prompts/summarize.md +3 -2
- package/examples/extensions/longview/render/telegraph-nodes.ts +21 -1
- package/examples/extensions/longview/summarize.ts +57 -14
- package/examples/extensions/napkin/index.ts +334 -86
- package/examples/extensions/napkin/pi-spawn.ts +196 -0
- package/examples/extensions/pinchtab/index.ts +37 -4
- package/examples/extensions/pinchtab/lib/session-injector.ts +31 -9
- package/examples/extensions/pinchtab/skill/SKILL.md +29 -0
- package/examples/profiles/_template/AGENTS.md +138 -0
- package/examples/profiles/_template/README.md +65 -0
- package/examples/profiles/_template/config.yaml +57 -0
- package/examples/profiles/_template/tasks/daily.md +32 -0
- package/examples/profiles/football-reporter/AGENTS.md +219 -0
- package/examples/profiles/football-reporter/README.md +138 -0
- package/examples/profiles/football-reporter/config.yaml +174 -0
- package/examples/profiles/football-reporter/seed/MEMORY.md +42 -0
- package/examples/profiles/football-reporter/seed/episodes/barcelona-2026-27.md +25 -0
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +25 -0
- package/examples/profiles/football-reporter/seed/episodes/maccabi-haifa-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +26 -0
- package/examples/profiles/football-reporter/seed/episodes/real-madrid-2026-27.md +25 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +55 -0
- package/examples/profiles/football-reporter/tasks/daily-article.md +142 -0
- package/package.json +8 -5
- package/src/adapters/whatsapp.ts +75 -13
- package/src/bridges/whatsapp.ts +6 -4
- package/src/cli/mrctl.ts +9 -1
- package/src/core/handler.ts +51 -6
- package/src/core/routes/dashboard.ts +106 -5
- package/src/core/routes/tasks.ts +28 -0
- package/src/core/runtime.ts +63 -2
- package/src/core/task-scheduler.ts +103 -12
- package/src/extensions/catalog.ts +9 -0
- package/src/profile/space-profile.ts +780 -0
- package/src/storage/db.ts +215 -0
- package/src/types.ts +56 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Space profiles — one folder in the repo that owns every editorial rule for a
|
|
3
|
+
* space, plus the drift check that proves the live space still matches it.
|
|
4
|
+
*
|
|
5
|
+
* The rules for the `football-friends` space used to live in four places (two
|
|
6
|
+
* preferences, two task prompts, an empty `AGENTS.md`) and two of them
|
|
7
|
+
* disagreed about when the bot should stay silent. A profile folder gives them
|
|
8
|
+
* one home; `check` is what stops them drifting apart again.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is pure apart from the two injected ports, so the whole
|
|
11
|
+
* apply/check surface is testable against a temp DB and a temp directory
|
|
12
|
+
* without a live project. The CLI in `scripts/space-profile.ts` is a thin
|
|
13
|
+
* wrapper: no logic lives there that is not exercised by a test.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { parse as parseYaml } from "yaml";
|
|
20
|
+
|
|
21
|
+
/** A profile that could not be loaded, or an apply that must not proceed. */
|
|
22
|
+
export class ProfileError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ProfileError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProfileTaskSpec {
|
|
30
|
+
/** Live `tasks.id`. Asserted to belong to the profile's space before a write. */
|
|
31
|
+
id: number;
|
|
32
|
+
/** Label for humans and for the drift report; never written to the database. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** Absolute path the prompt was read from. */
|
|
35
|
+
promptPath: string;
|
|
36
|
+
prompt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A file the profile puts in the space *once* and never touches again.
|
|
41
|
+
*
|
|
42
|
+
* The difference from `agentsMd` and the task prompts is the whole point: those
|
|
43
|
+
* are repo-owned and a hand edit is drift to be overwritten, while a seed hands
|
|
44
|
+
* ownership over on first write. The reporter's notebook — `MEMORY.md` and the
|
|
45
|
+
* episode notes under `knowledge/` — is written by the model mid-turn and by
|
|
46
|
+
* napkin's nightly distillation, so a profile that re-asserted its version would
|
|
47
|
+
* delete a day of memory on every `apply`.
|
|
48
|
+
*/
|
|
49
|
+
export interface ProfileSeedSpec {
|
|
50
|
+
/** Path inside the space directory. Validated at load, never escapes it. */
|
|
51
|
+
relPath: string;
|
|
52
|
+
/** Absolute path the seed content was read from. */
|
|
53
|
+
sourcePath: string;
|
|
54
|
+
content: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SpaceProfile {
|
|
58
|
+
/** Absolute path of the profile folder. */
|
|
59
|
+
dir: string;
|
|
60
|
+
space: string;
|
|
61
|
+
agentsMd: string;
|
|
62
|
+
tasks: ProfileTaskSpec[];
|
|
63
|
+
removePreferences: string[];
|
|
64
|
+
extensionConfig: Record<string, string>;
|
|
65
|
+
workspaceRemove: string[];
|
|
66
|
+
workspaceSeed: ProfileSeedSpec[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The database surface a profile needs. `Db` satisfies it structurally. */
|
|
70
|
+
export interface ProfileStore {
|
|
71
|
+
/** Null when the space does not exist — `apply` must not invent one. */
|
|
72
|
+
getSpace(spaceId: string): { id: string } | null;
|
|
73
|
+
getTask(id: number): { id: number; spaceId: string; prompt: string } | null;
|
|
74
|
+
listTasks(spaceId?: string): {
|
|
75
|
+
id: number;
|
|
76
|
+
spaceId: string;
|
|
77
|
+
prompt: string;
|
|
78
|
+
name: string | null;
|
|
79
|
+
cron: string | null;
|
|
80
|
+
timezone: string | null;
|
|
81
|
+
}[];
|
|
82
|
+
updateTaskPrompt(id: number, prompt: string): boolean;
|
|
83
|
+
listSpacePreferences(spaceId: string): { key: string; value: string }[];
|
|
84
|
+
deleteSpacePreference(spaceId: string, key: string): boolean;
|
|
85
|
+
getSpaceConfig(spaceId: string, key: string): string | null;
|
|
86
|
+
listSpaceConfig(spaceId: string): { key: string; value: string }[];
|
|
87
|
+
setSpaceConfig(
|
|
88
|
+
spaceId: string,
|
|
89
|
+
key: string,
|
|
90
|
+
value: string,
|
|
91
|
+
updatedBy: string,
|
|
92
|
+
): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The filesystem surface a profile needs, injected so tests can watch it. */
|
|
96
|
+
export interface ProfileFs {
|
|
97
|
+
exists(p: string): boolean;
|
|
98
|
+
/** True only for an existing directory. */
|
|
99
|
+
isDirectory(p: string): boolean;
|
|
100
|
+
/** File contents, or null when the path does not exist. */
|
|
101
|
+
readText(p: string): string | null;
|
|
102
|
+
/** Write, creating parent directories. */
|
|
103
|
+
writeText(p: string, content: string): void;
|
|
104
|
+
/** Remove a file or a directory tree. */
|
|
105
|
+
removeRecursive(p: string): void;
|
|
106
|
+
/** Entry names directly under a directory; empty when it does not exist. */
|
|
107
|
+
listDir(p: string): string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const nodeProfileFs: ProfileFs = {
|
|
111
|
+
exists: (p) => fs.existsSync(p),
|
|
112
|
+
isDirectory: (p) => {
|
|
113
|
+
try {
|
|
114
|
+
return fs.statSync(p).isDirectory();
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
readText: (p) => (fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null),
|
|
120
|
+
writeText: (p, content) => {
|
|
121
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
122
|
+
fs.writeFileSync(p, content, "utf8");
|
|
123
|
+
},
|
|
124
|
+
removeRecursive: (p) => fs.rmSync(p, { recursive: true, force: true }),
|
|
125
|
+
listDir: (p) => (fs.existsSync(p) ? fs.readdirSync(p).sort() : []),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type Drift =
|
|
129
|
+
| { kind: "agents-md"; path: string; diff: string }
|
|
130
|
+
| { kind: "task-prompt"; taskId: number; name: string; diff: string }
|
|
131
|
+
| { kind: "task-missing"; taskId: number; name: string }
|
|
132
|
+
| {
|
|
133
|
+
kind: "task-foreign";
|
|
134
|
+
taskId: number;
|
|
135
|
+
name: string;
|
|
136
|
+
actualSpace: string;
|
|
137
|
+
}
|
|
138
|
+
| { kind: "preference"; key: string }
|
|
139
|
+
| { kind: "config"; key: string; expected: string; actual: string | null }
|
|
140
|
+
| { kind: "workspace"; relPath: string }
|
|
141
|
+
| { kind: "seed"; relPath: string; path: string };
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A task id that is absent or owned by another space aborts `apply` outright:
|
|
145
|
+
* writing a football prompt over another space's task is the one mistake here
|
|
146
|
+
* that reaches real people.
|
|
147
|
+
*/
|
|
148
|
+
export function isBlockingDrift(drift: Drift): boolean {
|
|
149
|
+
return drift.kind === "task-missing" || drift.kind === "task-foreign";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function describeDrift(drift: Drift): string {
|
|
153
|
+
switch (drift.kind) {
|
|
154
|
+
case "agents-md":
|
|
155
|
+
return `AGENTS.md differs from the profile (${drift.path})\n${drift.diff}`;
|
|
156
|
+
case "task-prompt":
|
|
157
|
+
return `task ${drift.taskId} (${drift.name}) prompt differs from the profile\n${drift.diff}`;
|
|
158
|
+
case "task-missing":
|
|
159
|
+
return `task ${drift.taskId} (${drift.name}) does not exist`;
|
|
160
|
+
case "task-foreign":
|
|
161
|
+
return `task ${drift.taskId} (${drift.name}) belongs to space "${drift.actualSpace}"`;
|
|
162
|
+
case "preference":
|
|
163
|
+
return `preference "${drift.key}" is still set and the profile removes it`;
|
|
164
|
+
case "config":
|
|
165
|
+
return `config "${drift.key}" is ${drift.actual === null ? "unset" : `"${drift.actual}"`}, profile wants "${drift.expected}"`;
|
|
166
|
+
case "workspace":
|
|
167
|
+
return `workspace item "${drift.relPath}" is still present and the profile removes it`;
|
|
168
|
+
case "seed":
|
|
169
|
+
return `seed "${drift.relPath}" is missing and the profile seeds it (${drift.path})`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a path inside the space directory, refusing anything that escapes it
|
|
175
|
+
* or names the directory itself. `workspace_remove` entries are deletions, and
|
|
176
|
+
* a `..` in one is the difference between tidying a workspace and deleting a
|
|
177
|
+
* space. `workspace_seed` targets get the same guard for the same reason from
|
|
178
|
+
* the other side: a seed is a write, and a `..` in one writes outside the space.
|
|
179
|
+
*/
|
|
180
|
+
export function assertSafeRelative(
|
|
181
|
+
relPath: string,
|
|
182
|
+
field = "workspace_remove",
|
|
183
|
+
): void {
|
|
184
|
+
if (path.isAbsolute(relPath)) {
|
|
185
|
+
throw new ProfileError(`${field} entry must be relative, got "${relPath}"`);
|
|
186
|
+
}
|
|
187
|
+
const normalized = path.normalize(relPath);
|
|
188
|
+
if (normalized === "." || normalized === "") {
|
|
189
|
+
throw new ProfileError(
|
|
190
|
+
`${field} entry "${relPath}" names the space directory itself`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
194
|
+
throw new ProfileError(
|
|
195
|
+
`${field} entry "${relPath}" escapes the space directory`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function resolveInsideSpace(
|
|
201
|
+
spaceDir: string,
|
|
202
|
+
relPath: string,
|
|
203
|
+
field = "workspace_remove",
|
|
204
|
+
): string {
|
|
205
|
+
assertSafeRelative(relPath, field);
|
|
206
|
+
return path.resolve(spaceDir, relPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Same shape the database enforces (`SPACE_ID_RE` in `src/storage/db.ts`).
|
|
211
|
+
* Duplicated rather than exported because the check here guards a different
|
|
212
|
+
* thing: `space` is a path segment in `resolveSpaceDir`, so an unvalidated
|
|
213
|
+
* value with `..` in it puts `apply` outside the project entirely — writing an
|
|
214
|
+
* `AGENTS.md` and running `removeRecursive` over whatever it finds there. A
|
|
215
|
+
* profile with no `tasks:` has nothing else standing in the way.
|
|
216
|
+
*/
|
|
217
|
+
const SPACE_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
218
|
+
|
|
219
|
+
function requireString(value: unknown, field: string): string {
|
|
220
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
221
|
+
throw new ProfileError(
|
|
222
|
+
`config.yaml: "${field}" must be a non-empty string`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function optionalStringArray(value: unknown, field: string): string[] {
|
|
229
|
+
if (value === undefined || value === null) return [];
|
|
230
|
+
if (!Array.isArray(value)) {
|
|
231
|
+
throw new ProfileError(`config.yaml: "${field}" must be a list`);
|
|
232
|
+
}
|
|
233
|
+
return value.map((entry, i) => requireString(entry, `${field}[${i}]`));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function loadProfile(
|
|
237
|
+
dir: string,
|
|
238
|
+
fsPort: ProfileFs = nodeProfileFs,
|
|
239
|
+
): SpaceProfile {
|
|
240
|
+
const profileDir = path.resolve(dir);
|
|
241
|
+
const configPath = path.join(profileDir, "config.yaml");
|
|
242
|
+
const rawConfig = fsPort.readText(configPath);
|
|
243
|
+
if (rawConfig === null) {
|
|
244
|
+
throw new ProfileError(`no config.yaml in ${profileDir}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let parsed: unknown;
|
|
248
|
+
try {
|
|
249
|
+
parsed = parseYaml(rawConfig);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
throw new ProfileError(
|
|
252
|
+
`config.yaml is not valid YAML: ${e instanceof Error ? e.message : String(e)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
256
|
+
throw new ProfileError("config.yaml must be a mapping");
|
|
257
|
+
}
|
|
258
|
+
const cfg = parsed as Record<string, unknown>;
|
|
259
|
+
|
|
260
|
+
const space = requireString(cfg.space, "space");
|
|
261
|
+
if (!SPACE_ID_RE.test(space)) {
|
|
262
|
+
throw new ProfileError(
|
|
263
|
+
`config.yaml: "space" must match ${SPACE_ID_RE.source}, got "${space}"`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const agentsMdRel = requireString(cfg.agents_md, "agents_md");
|
|
268
|
+
const agentsMdPath = path.resolve(profileDir, agentsMdRel);
|
|
269
|
+
const agentsMd = fsPort.readText(agentsMdPath);
|
|
270
|
+
if (agentsMd === null) {
|
|
271
|
+
throw new ProfileError(`agents_md file not found: ${agentsMdPath}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// A bare `tasks:` parses as null; that is an empty list, not an error.
|
|
275
|
+
const rawTasks = cfg.tasks ?? [];
|
|
276
|
+
if (!Array.isArray(rawTasks)) {
|
|
277
|
+
throw new ProfileError('config.yaml: "tasks" must be a list');
|
|
278
|
+
}
|
|
279
|
+
const seenIds = new Set<number>();
|
|
280
|
+
const tasks: ProfileTaskSpec[] = rawTasks.map((entry, i) => {
|
|
281
|
+
if (typeof entry !== "object" || entry === null) {
|
|
282
|
+
throw new ProfileError(`config.yaml: tasks[${i}] must be a mapping`);
|
|
283
|
+
}
|
|
284
|
+
const t = entry as Record<string, unknown>;
|
|
285
|
+
if (typeof t.id !== "number" || !Number.isInteger(t.id)) {
|
|
286
|
+
throw new ProfileError(`config.yaml: tasks[${i}].id must be an integer`);
|
|
287
|
+
}
|
|
288
|
+
if (seenIds.has(t.id)) {
|
|
289
|
+
throw new ProfileError(`config.yaml: task id ${t.id} listed twice`);
|
|
290
|
+
}
|
|
291
|
+
seenIds.add(t.id);
|
|
292
|
+
const promptRel = requireString(t.prompt, `tasks[${i}].prompt`);
|
|
293
|
+
const promptPath = path.resolve(profileDir, promptRel);
|
|
294
|
+
const prompt = fsPort.readText(promptPath);
|
|
295
|
+
if (prompt === null) {
|
|
296
|
+
throw new ProfileError(`task prompt file not found: ${promptPath}`);
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
id: t.id,
|
|
300
|
+
name: typeof t.name === "string" ? t.name : String(t.id),
|
|
301
|
+
promptPath,
|
|
302
|
+
prompt,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const rawExtCfg = cfg.extension_config;
|
|
307
|
+
if (
|
|
308
|
+
rawExtCfg !== undefined &&
|
|
309
|
+
rawExtCfg !== null &&
|
|
310
|
+
(typeof rawExtCfg !== "object" || Array.isArray(rawExtCfg))
|
|
311
|
+
) {
|
|
312
|
+
throw new ProfileError('config.yaml: "extension_config" must be a mapping');
|
|
313
|
+
}
|
|
314
|
+
const extensionConfig: Record<string, string> = {};
|
|
315
|
+
for (const [key, value] of Object.entries(
|
|
316
|
+
(rawExtCfg ?? {}) as Record<string, unknown>,
|
|
317
|
+
)) {
|
|
318
|
+
// Values are compared against TEXT columns, so a YAML `true` or `2000`
|
|
319
|
+
// would report drift against the string the database holds forever.
|
|
320
|
+
if (typeof value !== "string") {
|
|
321
|
+
throw new ProfileError(
|
|
322
|
+
`config.yaml: extension_config."${key}" must be a quoted string, got ${typeof value}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
extensionConfig[key] = value;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const workspaceRemove = optionalStringArray(
|
|
329
|
+
cfg.workspace_remove,
|
|
330
|
+
"workspace_remove",
|
|
331
|
+
);
|
|
332
|
+
// Validate at load time so a bad entry fails before anything is written.
|
|
333
|
+
for (const rel of workspaceRemove) assertSafeRelative(rel);
|
|
334
|
+
|
|
335
|
+
const rawSeed = cfg.workspace_seed;
|
|
336
|
+
if (
|
|
337
|
+
rawSeed !== undefined &&
|
|
338
|
+
rawSeed !== null &&
|
|
339
|
+
(typeof rawSeed !== "object" || Array.isArray(rawSeed))
|
|
340
|
+
) {
|
|
341
|
+
throw new ProfileError(
|
|
342
|
+
'config.yaml: "workspace_seed" must be a mapping of target path -> source file',
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const workspaceSeed: ProfileSeedSpec[] = [];
|
|
346
|
+
for (const [relPath, rawSource] of Object.entries(
|
|
347
|
+
(rawSeed ?? {}) as Record<string, unknown>,
|
|
348
|
+
)) {
|
|
349
|
+
const sourceRel = requireString(rawSource, `workspace_seed."${relPath}"`);
|
|
350
|
+
assertSafeRelative(relPath, "workspace_seed");
|
|
351
|
+
const sourcePath = path.resolve(profileDir, sourceRel);
|
|
352
|
+
const content = fsPort.readText(sourcePath);
|
|
353
|
+
// Same rule as a task prompt: a missing source is an error, never an empty
|
|
354
|
+
// seed. A seed is written once, so a silently empty one is permanent.
|
|
355
|
+
if (content === null) {
|
|
356
|
+
throw new ProfileError(`seed source file not found: ${sourcePath}`);
|
|
357
|
+
}
|
|
358
|
+
workspaceSeed.push({ relPath, sourcePath, content });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// A path that is both seeded and removed has no defined outcome — apply
|
|
362
|
+
// would write it and delete it in the same run, or leave the result to the
|
|
363
|
+
// order of two unrelated lists. Reject the manifest instead of picking one.
|
|
364
|
+
// `knowledge/` and `knowledge` must compare equal, or a trailing slash on one
|
|
365
|
+
// side is enough to walk past the check. Case is folded too: this only ever
|
|
366
|
+
// rejects a manifest, so erring towards "these might be the same path" costs
|
|
367
|
+
// an author one rename and catches the Windows case.
|
|
368
|
+
const normalizeRel = (p: string) =>
|
|
369
|
+
path
|
|
370
|
+
.normalize(p)
|
|
371
|
+
.replace(/[\\/]+$/, "")
|
|
372
|
+
.toLowerCase();
|
|
373
|
+
for (const seed of workspaceSeed) {
|
|
374
|
+
const seedNorm = normalizeRel(seed.relPath);
|
|
375
|
+
for (const rel of workspaceRemove) {
|
|
376
|
+
const removeNorm = normalizeRel(rel);
|
|
377
|
+
if (
|
|
378
|
+
seedNorm === removeNorm ||
|
|
379
|
+
seedNorm.startsWith(`${removeNorm}${path.sep}`) ||
|
|
380
|
+
seedNorm.startsWith(`${removeNorm}/`)
|
|
381
|
+
) {
|
|
382
|
+
throw new ProfileError(
|
|
383
|
+
`config.yaml: "${seed.relPath}" is seeded by workspace_seed and removed by workspace_remove ("${rel}")`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
dir: profileDir,
|
|
391
|
+
space,
|
|
392
|
+
agentsMd,
|
|
393
|
+
tasks,
|
|
394
|
+
removePreferences: optionalStringArray(
|
|
395
|
+
cfg.remove_preferences,
|
|
396
|
+
"remove_preferences",
|
|
397
|
+
),
|
|
398
|
+
extensionConfig,
|
|
399
|
+
workspaceRemove,
|
|
400
|
+
workspaceSeed,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `<project>/<dataDir>/spaces/<space>` — where the container mounts from. */
|
|
405
|
+
export function resolveSpaceDir(
|
|
406
|
+
projectDir: string,
|
|
407
|
+
dataDir: string,
|
|
408
|
+
space: string,
|
|
409
|
+
): string {
|
|
410
|
+
return path.resolve(projectDir, dataDir, "spaces", space);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* `<project>/<dataDir>/state.db`, refusing a path that does not already hold a
|
|
415
|
+
* database. `Db`'s constructor creates the file and its directory on open, so
|
|
416
|
+
* without this check a mistyped `--project` would quietly leave an empty,
|
|
417
|
+
* migrated `state.db` behind and report every task as missing.
|
|
418
|
+
*
|
|
419
|
+
* `path.resolve`, not `path.join`: an absolute `dataDir` must replace the
|
|
420
|
+
* project dir, which is how the bot itself resolves it.
|
|
421
|
+
*/
|
|
422
|
+
export function locateDatabase(
|
|
423
|
+
projectDir: string,
|
|
424
|
+
dataDir: string,
|
|
425
|
+
fsPort: ProfileFs = nodeProfileFs,
|
|
426
|
+
): string {
|
|
427
|
+
const dbPath = path.resolve(projectDir, dataDir, "state.db");
|
|
428
|
+
if (!fsPort.exists(dbPath)) {
|
|
429
|
+
throw new ProfileError(
|
|
430
|
+
`no database at ${dbPath} — is --project a Mercury project directory?`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return dbPath;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Copy the live database with `VACUUM INTO`, which is safe against a running
|
|
438
|
+
* bot: it takes an ordinary read transaction on the WAL file and writes a
|
|
439
|
+
* consistent, compacted copy. Refuses to overwrite, so a second backup into the
|
|
440
|
+
* same path is an error rather than a silent replacement of the one that
|
|
441
|
+
* mattered.
|
|
442
|
+
*/
|
|
443
|
+
export function backupDatabase(
|
|
444
|
+
dbPath: string,
|
|
445
|
+
outPath: string,
|
|
446
|
+
fsPort: ProfileFs = nodeProfileFs,
|
|
447
|
+
): void {
|
|
448
|
+
const resolvedOut = path.resolve(outPath);
|
|
449
|
+
if (fsPort.exists(resolvedOut)) {
|
|
450
|
+
throw new ProfileError(`backup target already exists: ${resolvedOut}`);
|
|
451
|
+
}
|
|
452
|
+
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
453
|
+
const db = new Database(dbPath, { readonly: true });
|
|
454
|
+
try {
|
|
455
|
+
// A parameter is not allowed in VACUUM INTO; escape by doubling quotes.
|
|
456
|
+
db.exec(`VACUUM INTO '${resolvedOut.replace(/'/g, "''")}'`);
|
|
457
|
+
} finally {
|
|
458
|
+
db.close();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function requireSpace(store: ProfileStore, space: string): void {
|
|
463
|
+
if (store.getSpace(space) === null) {
|
|
464
|
+
throw new ProfileError(
|
|
465
|
+
`space "${space}" does not exist in this database — nothing here will create one`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function agentsMdPathFor(spaceDir: string): string {
|
|
471
|
+
return path.join(spaceDir, "AGENTS.md");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Minimal unified diff over lines. The inputs are whole prompt files of a few
|
|
476
|
+
* hundred lines at most, so the quadratic LCS table is cheaper than a
|
|
477
|
+
* dependency.
|
|
478
|
+
*/
|
|
479
|
+
export function unifiedDiff(
|
|
480
|
+
expected: string,
|
|
481
|
+
actual: string,
|
|
482
|
+
label: string,
|
|
483
|
+
): string {
|
|
484
|
+
const a = actual.split("\n");
|
|
485
|
+
const b = expected.split("\n");
|
|
486
|
+
const lcs: number[][] = Array.from({ length: a.length + 1 }, () =>
|
|
487
|
+
new Array<number>(b.length + 1).fill(0),
|
|
488
|
+
);
|
|
489
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
490
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
491
|
+
lcs[i][j] =
|
|
492
|
+
a[i] === b[j]
|
|
493
|
+
? lcs[i + 1][j + 1] + 1
|
|
494
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const out: string[] = [`--- live/${label}`, `+++ profile/${label}`];
|
|
499
|
+
let i = 0;
|
|
500
|
+
let j = 0;
|
|
501
|
+
while (i < a.length && j < b.length) {
|
|
502
|
+
if (a[i] === b[j]) {
|
|
503
|
+
out.push(` ${a[i]}`);
|
|
504
|
+
i++;
|
|
505
|
+
j++;
|
|
506
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
507
|
+
out.push(`- ${a[i]}`);
|
|
508
|
+
i++;
|
|
509
|
+
} else {
|
|
510
|
+
out.push(`+ ${b[j]}`);
|
|
511
|
+
j++;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
while (i < a.length) out.push(`- ${a[i++]}`);
|
|
515
|
+
while (j < b.length) out.push(`+ ${b[j++]}`);
|
|
516
|
+
return out.join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Everything the live space does not match the profile on. Never writes.
|
|
521
|
+
*
|
|
522
|
+
* A missing `AGENTS.md` reads as an empty file rather than an error: that is
|
|
523
|
+
* exactly the state this feature exists to fix, and it should show as drift,
|
|
524
|
+
* not as a crash.
|
|
525
|
+
*/
|
|
526
|
+
export function diffProfile(
|
|
527
|
+
profile: SpaceProfile,
|
|
528
|
+
store: ProfileStore,
|
|
529
|
+
spaceDir: string,
|
|
530
|
+
fsPort: ProfileFs = nodeProfileFs,
|
|
531
|
+
): Drift[] {
|
|
532
|
+
// `space_config` has no foreign key to `spaces`, so without this a typo'd
|
|
533
|
+
// space id with no tasks listed would apply cleanly to a space that is not
|
|
534
|
+
// there: a new directory, a few config rows, exit 0.
|
|
535
|
+
requireSpace(store, profile.space);
|
|
536
|
+
|
|
537
|
+
const drifts: Drift[] = [];
|
|
538
|
+
|
|
539
|
+
const agentsPath = agentsMdPathFor(spaceDir);
|
|
540
|
+
const liveAgents = fsPort.readText(agentsPath) ?? "";
|
|
541
|
+
if (liveAgents !== profile.agentsMd) {
|
|
542
|
+
drifts.push({
|
|
543
|
+
kind: "agents-md",
|
|
544
|
+
path: agentsPath,
|
|
545
|
+
diff: unifiedDiff(profile.agentsMd, liveAgents, "AGENTS.md"),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const task of profile.tasks) {
|
|
550
|
+
const row = store.getTask(task.id);
|
|
551
|
+
if (!row) {
|
|
552
|
+
drifts.push({ kind: "task-missing", taskId: task.id, name: task.name });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (row.spaceId !== profile.space) {
|
|
556
|
+
drifts.push({
|
|
557
|
+
kind: "task-foreign",
|
|
558
|
+
taskId: task.id,
|
|
559
|
+
name: task.name,
|
|
560
|
+
actualSpace: row.spaceId,
|
|
561
|
+
});
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (row.prompt !== task.prompt) {
|
|
565
|
+
drifts.push({
|
|
566
|
+
kind: "task-prompt",
|
|
567
|
+
taskId: task.id,
|
|
568
|
+
name: task.name,
|
|
569
|
+
diff: unifiedDiff(task.prompt, row.prompt, `task-${task.id}`),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const livePrefs = new Set(
|
|
575
|
+
store.listSpacePreferences(profile.space).map((p) => p.key),
|
|
576
|
+
);
|
|
577
|
+
for (const key of profile.removePreferences) {
|
|
578
|
+
if (livePrefs.has(key)) drifts.push({ kind: "preference", key });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for (const [key, expected] of Object.entries(profile.extensionConfig)) {
|
|
582
|
+
const actual = store.getSpaceConfig(profile.space, key);
|
|
583
|
+
if (actual !== expected) {
|
|
584
|
+
drifts.push({ kind: "config", key, expected, actual });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const rel of profile.workspaceRemove) {
|
|
589
|
+
if (fsPort.exists(resolveInsideSpace(spaceDir, rel))) {
|
|
590
|
+
drifts.push({ kind: "workspace", relPath: rel });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Absence is the only seed drift there is. A seeded file that has since been
|
|
595
|
+
// edited — by the model mid-turn, by napkin's nightly distillation, by hand —
|
|
596
|
+
// is the file doing its job, so it is deliberately not compared. Seeds come
|
|
597
|
+
// last so that on a first apply they are written after `workspace_remove` has
|
|
598
|
+
// finished deleting, rather than into a directory about to be removed.
|
|
599
|
+
for (const seed of profile.workspaceSeed) {
|
|
600
|
+
const target = resolveInsideSpace(spaceDir, seed.relPath, "workspace_seed");
|
|
601
|
+
// A directory where the seed file should be is not "present": the seed can
|
|
602
|
+
// never be written there, and reporting nothing would leave `check` green
|
|
603
|
+
// over a notebook that does not exist. Fail loudly instead.
|
|
604
|
+
if (fsPort.isDirectory(target)) {
|
|
605
|
+
throw new ProfileError(
|
|
606
|
+
`seed target "${seed.relPath}" is a directory, not a file (${target})`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (!fsPort.exists(target)) {
|
|
610
|
+
drifts.push({ kind: "seed", relPath: seed.relPath, path: target });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return drifts;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export interface ApplyOptions {
|
|
618
|
+
dryRun?: boolean;
|
|
619
|
+
updatedBy?: string;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export interface ApplyChange {
|
|
623
|
+
kind: Drift["kind"];
|
|
624
|
+
detail: string;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Bring the live space in line with the profile. Idempotent: a second call
|
|
629
|
+
* finds no drift and changes nothing.
|
|
630
|
+
*
|
|
631
|
+
* Aborts before the first write if any configured task id is missing or owned
|
|
632
|
+
* by another space, so a stale `config.yaml` cannot half-apply.
|
|
633
|
+
*/
|
|
634
|
+
export function applyProfile(
|
|
635
|
+
profile: SpaceProfile,
|
|
636
|
+
store: ProfileStore,
|
|
637
|
+
spaceDir: string,
|
|
638
|
+
fsPort: ProfileFs = nodeProfileFs,
|
|
639
|
+
options: ApplyOptions = {},
|
|
640
|
+
): ApplyChange[] {
|
|
641
|
+
const dryRun = options.dryRun === true;
|
|
642
|
+
const updatedBy = options.updatedBy ?? "space-profile";
|
|
643
|
+
|
|
644
|
+
const drifts = diffProfile(profile, store, spaceDir, fsPort);
|
|
645
|
+
const blocking = drifts.filter(isBlockingDrift);
|
|
646
|
+
if (blocking.length > 0) {
|
|
647
|
+
throw new ProfileError(
|
|
648
|
+
`refusing to apply — ${blocking.length} task id(s) do not belong to space "${profile.space}":\n` +
|
|
649
|
+
blocking.map((d) => ` - ${describeDrift(d)}`).join("\n"),
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const changes: ApplyChange[] = [];
|
|
654
|
+
for (const drift of drifts) {
|
|
655
|
+
switch (drift.kind) {
|
|
656
|
+
case "agents-md": {
|
|
657
|
+
const target = agentsMdPathFor(spaceDir);
|
|
658
|
+
if (!dryRun) fsPort.writeText(target, profile.agentsMd);
|
|
659
|
+
changes.push({ kind: drift.kind, detail: `write ${target}` });
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
case "task-prompt": {
|
|
663
|
+
const task = profile.tasks.find((t) => t.id === drift.taskId);
|
|
664
|
+
// diffProfile only emits this for a task that is in the profile.
|
|
665
|
+
if (!task) throw new ProfileError(`no profile task ${drift.taskId}`);
|
|
666
|
+
if (!dryRun && !store.updateTaskPrompt(task.id, task.prompt)) {
|
|
667
|
+
throw new ProfileError(
|
|
668
|
+
`task ${task.id} vanished between check and write`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
changes.push({
|
|
672
|
+
kind: drift.kind,
|
|
673
|
+
detail: `set prompt of task ${task.id} (${task.name})`,
|
|
674
|
+
});
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case "preference": {
|
|
678
|
+
if (!dryRun) store.deleteSpacePreference(profile.space, drift.key);
|
|
679
|
+
changes.push({
|
|
680
|
+
kind: drift.kind,
|
|
681
|
+
detail: `delete preference ${drift.key}`,
|
|
682
|
+
});
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
case "config": {
|
|
686
|
+
if (!dryRun) {
|
|
687
|
+
store.setSpaceConfig(
|
|
688
|
+
profile.space,
|
|
689
|
+
drift.key,
|
|
690
|
+
drift.expected,
|
|
691
|
+
updatedBy,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
changes.push({
|
|
695
|
+
kind: drift.kind,
|
|
696
|
+
detail: `set ${drift.key} = ${drift.expected}`,
|
|
697
|
+
});
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
case "workspace": {
|
|
701
|
+
const target = resolveInsideSpace(spaceDir, drift.relPath);
|
|
702
|
+
if (!dryRun) fsPort.removeRecursive(target);
|
|
703
|
+
changes.push({ kind: drift.kind, detail: `remove ${target}` });
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
case "seed": {
|
|
707
|
+
const seed = profile.workspaceSeed.find(
|
|
708
|
+
(s) => s.relPath === drift.relPath,
|
|
709
|
+
);
|
|
710
|
+
// diffProfile only emits this for a seed that is in the profile.
|
|
711
|
+
if (!seed) throw new ProfileError(`no profile seed ${drift.relPath}`);
|
|
712
|
+
if (!dryRun) fsPort.writeText(drift.path, seed.content);
|
|
713
|
+
changes.push({ kind: drift.kind, detail: `seed ${drift.path}` });
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case "task-missing":
|
|
717
|
+
case "task-foreign":
|
|
718
|
+
throw new ProfileError("blocking drift reached the write loop");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return changes;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export interface LiveDump {
|
|
726
|
+
space: string;
|
|
727
|
+
agentsMdPath: string;
|
|
728
|
+
agentsMd: string | null;
|
|
729
|
+
tasks: {
|
|
730
|
+
id: number;
|
|
731
|
+
name: string | null;
|
|
732
|
+
cron: string | null;
|
|
733
|
+
timezone: string | null;
|
|
734
|
+
prompt: string;
|
|
735
|
+
}[];
|
|
736
|
+
preferences: { key: string; value: string }[];
|
|
737
|
+
config: { key: string; value: string }[];
|
|
738
|
+
workspaceEntries: string[];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Read-only snapshot of every layer a profile owns, for the ops step that runs
|
|
743
|
+
* before the first apply.
|
|
744
|
+
*
|
|
745
|
+
* `taskIds` narrows the dump to the tasks a profile names. Passing an empty
|
|
746
|
+
* list dumps *every* task in the space — that is the point of the no-profile
|
|
747
|
+
* form (`dump --project <dir> --space <id>`), which is what you run before a
|
|
748
|
+
* profile exists, and which would otherwise print nothing.
|
|
749
|
+
*/
|
|
750
|
+
export function dumpLive(
|
|
751
|
+
space: string,
|
|
752
|
+
store: ProfileStore,
|
|
753
|
+
spaceDir: string,
|
|
754
|
+
taskIds: number[],
|
|
755
|
+
fsPort: ProfileFs = nodeProfileFs,
|
|
756
|
+
): LiveDump {
|
|
757
|
+
requireSpace(store, space);
|
|
758
|
+
const agentsMdPath = agentsMdPathFor(spaceDir);
|
|
759
|
+
const wanted = new Set(taskIds);
|
|
760
|
+
const tasks = store
|
|
761
|
+
.listTasks(space)
|
|
762
|
+
.filter((t) => t.spaceId === space)
|
|
763
|
+
.filter((t) => wanted.size === 0 || wanted.has(t.id))
|
|
764
|
+
.map((t) => ({
|
|
765
|
+
id: t.id,
|
|
766
|
+
name: t.name,
|
|
767
|
+
cron: t.cron,
|
|
768
|
+
timezone: t.timezone,
|
|
769
|
+
prompt: t.prompt,
|
|
770
|
+
}));
|
|
771
|
+
return {
|
|
772
|
+
space,
|
|
773
|
+
agentsMdPath,
|
|
774
|
+
agentsMd: fsPort.readText(agentsMdPath),
|
|
775
|
+
tasks,
|
|
776
|
+
preferences: store.listSpacePreferences(space),
|
|
777
|
+
config: store.listSpaceConfig(space),
|
|
778
|
+
workspaceEntries: fsPort.listDir(spaceDir),
|
|
779
|
+
};
|
|
780
|
+
}
|