@mercury-fw/core 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +38 -0
  3. package/dist/index.d.ts +23 -0
  4. package/dist/src/admin/cli-routes.d.ts +22 -0
  5. package/dist/src/admin/env-file.d.ts +1 -0
  6. package/dist/src/admin/model-routes.d.ts +26 -0
  7. package/dist/src/admin/qdrant-scroll.d.ts +34 -0
  8. package/dist/src/admin/server.d.ts +40 -0
  9. package/dist/src/admin/wiki-routes.d.ts +31 -0
  10. package/dist/src/compose.d.ts +42 -0
  11. package/dist/src/config/define-config.d.ts +31 -0
  12. package/dist/src/cron/idle-session-cron.d.ts +80 -0
  13. package/dist/src/cron/idle-session-scanner.d.ts +16 -0
  14. package/dist/src/cron/self-review-cron.d.ts +55 -0
  15. package/dist/src/cron/semantic-consolidation.d.ts +71 -0
  16. package/dist/src/memory/embedder.d.ts +9 -0
  17. package/dist/src/memory/episodic-store.d.ts +121 -0
  18. package/dist/src/memory/memory-provider.d.ts +51 -0
  19. package/dist/src/memory/semantic-facts-store.d.ts +37 -0
  20. package/dist/src/memory/tool-corrections-store.d.ts +26 -0
  21. package/dist/src/memory/verbatim-archive-store.d.ts +86 -0
  22. package/dist/src/model/client.d.ts +24 -0
  23. package/dist/src/model/context-size.d.ts +30 -0
  24. package/dist/src/plugins/manifest.d.ts +29 -0
  25. package/dist/src/plugins/plugin-loader.d.ts +85 -0
  26. package/dist/src/router/channel-loader.d.ts +30 -0
  27. package/dist/src/router/provider.d.ts +7 -0
  28. package/dist/src/router/terminal-provider.d.ts +37 -0
  29. package/dist/src/router/terminal.d.ts +41 -0
  30. package/dist/src/router/tool-log.d.ts +65 -0
  31. package/dist/src/router/turn-runner.d.ts +86 -0
  32. package/dist/src/session/agent-turn.d.ts +266 -0
  33. package/dist/src/session/context-primer.d.ts +16 -0
  34. package/dist/src/session/episodic-summarizer.d.ts +25 -0
  35. package/dist/src/session/history.d.ts +95 -0
  36. package/dist/src/session/pending-confirmation.d.ts +8 -0
  37. package/dist/src/session/read-skill-tool.d.ts +4 -0
  38. package/dist/src/session/semantic-fact-extractor.d.ts +45 -0
  39. package/dist/src/session/step-info.d.ts +24 -0
  40. package/dist/src/session/summarizer.d.ts +23 -0
  41. package/dist/src/session/system-prompt.d.ts +38 -0
  42. package/dist/src/session/tool-correction-extractor.d.ts +43 -0
  43. package/dist/src/session/tool-log-buffer.d.ts +24 -0
  44. package/dist/src/session/tool-log-recall-tool.d.ts +18 -0
  45. package/dist/src/session/tool-start-hook.d.ts +57 -0
  46. package/dist/src/tools/display-store.d.ts +36 -0
  47. package/dist/src/tools/present-tool.d.ts +23 -0
  48. package/dist/src/wiki/frontmatter-schema.d.ts +53 -0
  49. package/dist/src/wiki/index-entry.d.ts +15 -0
  50. package/dist/src/wiki/orphan-detector.d.ts +1 -0
  51. package/dist/src/wiki/self-review-runner.d.ts +48 -0
  52. package/dist/src/wiki/self-review-tools.d.ts +22 -0
  53. package/dist/src/wiki/vault-cli.d.ts +2 -0
  54. package/dist/src/wiki/vault-init.d.ts +7 -0
  55. package/dist/src/wiki/wiki-note.d.ts +62 -0
  56. package/dist/src/wiki/wiki-read.d.ts +27 -0
  57. package/dist/src/wiki/wiki-tools.d.ts +7 -0
  58. package/index.ts +23 -0
  59. package/package.json +49 -0
  60. package/src/admin/cli-routes.ts +48 -0
  61. package/src/admin/env-file.ts +29 -0
  62. package/src/admin/model-routes.ts +71 -0
  63. package/src/admin/public/index.html +416 -0
  64. package/src/admin/qdrant-scroll.ts +45 -0
  65. package/src/admin/server.ts +188 -0
  66. package/src/admin/wiki-routes.ts +93 -0
  67. package/src/compose.ts +599 -0
  68. package/src/config/define-config.ts +35 -0
  69. package/src/cron/.gitkeep +0 -0
  70. package/src/cron/idle-session-cron.ts +144 -0
  71. package/src/cron/idle-session-scanner.ts +37 -0
  72. package/src/cron/self-review-cron.ts +103 -0
  73. package/src/cron/semantic-consolidation.ts +228 -0
  74. package/src/memory/.gitkeep +0 -0
  75. package/src/memory/embedder.ts +15 -0
  76. package/src/memory/episodic-store.ts +183 -0
  77. package/src/memory/memory-provider.ts +98 -0
  78. package/src/memory/semantic-facts-store.ts +89 -0
  79. package/src/memory/tool-corrections-store.ts +72 -0
  80. package/src/memory/verbatim-archive-store.ts +202 -0
  81. package/src/model/client.ts +33 -0
  82. package/src/model/context-size.ts +42 -0
  83. package/src/plugins/manifest.ts +47 -0
  84. package/src/plugins/plugin-loader.ts +205 -0
  85. package/src/router/channel-loader.ts +56 -0
  86. package/src/router/provider.ts +7 -0
  87. package/src/router/terminal-provider.ts +155 -0
  88. package/src/router/terminal.ts +151 -0
  89. package/src/router/tool-log.ts +116 -0
  90. package/src/router/turn-runner.ts +205 -0
  91. package/src/session/agent-turn.ts +391 -0
  92. package/src/session/context-primer.ts +134 -0
  93. package/src/session/episodic-summarizer.ts +38 -0
  94. package/src/session/history.ts +168 -0
  95. package/src/session/pending-confirmation.ts +8 -0
  96. package/src/session/read-skill-tool.ts +38 -0
  97. package/src/session/semantic-fact-extractor.ts +69 -0
  98. package/src/session/step-info.ts +27 -0
  99. package/src/session/summarizer.ts +36 -0
  100. package/src/session/system-prompt.ts +142 -0
  101. package/src/session/tool-correction-extractor.ts +133 -0
  102. package/src/session/tool-log-buffer.ts +73 -0
  103. package/src/session/tool-log-recall-tool.ts +38 -0
  104. package/src/session/tool-start-hook.ts +164 -0
  105. package/src/tools/display-store.ts +89 -0
  106. package/src/tools/present-tool.ts +41 -0
  107. package/src/wiki/.gitkeep +0 -0
  108. package/src/wiki/frontmatter-schema.ts +49 -0
  109. package/src/wiki/index-entry.ts +59 -0
  110. package/src/wiki/orphan-detector.ts +61 -0
  111. package/src/wiki/self-review-runner.ts +133 -0
  112. package/src/wiki/self-review-tools.ts +162 -0
  113. package/src/wiki/vault-cli.ts +143 -0
  114. package/src/wiki/vault-init.ts +43 -0
  115. package/src/wiki/wiki-note.ts +326 -0
  116. package/src/wiki/wiki-read.ts +122 -0
  117. package/src/wiki/wiki-tools.ts +112 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Typed writers for wiki notes — the "template" that takes
3
+ * structured fields instead of a free-form file write: each function
4
+ * validates its fields against the frontmatter schema
5
+ * (frontmatter-schema.ts), serializes YAML frontmatter + markdown body,
6
+ * and writes the result under the vault path (vault-init.ts creates the
7
+ * surrounding curated/inferred directories).
8
+ *
9
+ * These are plain functions, not model-invocable tools — mirrors the
10
+ * split already used for CLIs (cli-executor.ts/command-parser.ts do the
11
+ * work, cli-tool.ts wraps a subset in `tool()` for the model). Whether
12
+ * either of these gets a `tool()` wrapper is a separate, later decision:
13
+ * `writeInferredNote` in particular must stay internal-only, called
14
+ * exclusively by the deterministic consolidation engine — never
15
+ * exposed to the model, since that would reopen the question of letting
16
+ * the LLM decide when to write semantic memory, deliberately kept
17
+ * mechanical/deterministic instead.
18
+ *
19
+ * Path segments coming from outside Mercury's own code (userId, topic)
20
+ * are resolved and checked against the vault root before any write — a
21
+ * topic string is LLM-produced free text, nothing upstream guarantees
22
+ * it can't contain `..` or `/`.
23
+ */
24
+ import { mkdir, writeFile, stat } from "node:fs/promises";
25
+ import { resolve, sep, dirname, relative } from "node:path";
26
+ import { stringify as stringifyYaml } from "yaml";
27
+ import {
28
+ CuratedFrontmatterSchema,
29
+ InferredFrontmatterSchema,
30
+ ConfirmationFrontmatterSchema,
31
+ type CuratedFrontmatter,
32
+ type InferredFrontmatter,
33
+ type ConfirmationFrontmatter,
34
+ } from "./frontmatter-schema.ts";
35
+
36
+ /**
37
+ * Resolves `segments` against `root` and checks the result stays inside
38
+ * `root` — not just inside the vault as a whole. `root` must already be
39
+ * the *specific* subtree a given write is scoped to (`curated/`, or one
40
+ * user's `inferred/users/<userId>/`): checking only against the vault
41
+ * root would let a relativePath like `"../inferred/users/x/y.md"` escape
42
+ * `curated/` while still landing somewhere else inside the vault.
43
+ */
44
+ function resolveWithinRoot(root: string, ...segments: string[]): string {
45
+ const resolvedRoot = resolve(root);
46
+ const target = resolve(resolvedRoot, ...segments);
47
+ if (target !== resolvedRoot && !target.startsWith(resolvedRoot + sep)) {
48
+ throw new Error(`refusing to write outside ${root}: ${segments.join("/")}`);
49
+ }
50
+ return target;
51
+ }
52
+
53
+ function assertNoPathSeparator(label: string, value: string): void {
54
+ if (value === "" || value.includes("/") || value.includes("\\") || value === "." || value === "..") {
55
+ throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
56
+ }
57
+ }
58
+
59
+ async function pathExists(path: string): Promise<boolean> {
60
+ try {
61
+ await stat(path);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Mercury's own git identity, passed inline on every commit (`-c
69
+ // user.email=...`) rather than relying on global/system git config —
70
+ // self-contained, works the same in a fresh dev checkout, in tests, and in
71
+ // any deployment, with nothing to set up out-of-band. Distinct from any
72
+ // human's own git identity, so `git log --author`/`git blame` cleanly
73
+ // separate Mercury's automated writes from a maintainer's — the actual
74
+ // provenance mechanism the vault's audit trail already relies on, not
75
+ // a schema-level flag.
76
+ const MERCURY_GIT_AUTHOR = { email: "mercury@comperio.local", name: "Mercury" };
77
+
78
+ async function runGit(cwd: string, args: string[]): Promise<void> {
79
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
80
+ const [stdout, stderr, exitCode] = await Promise.all([
81
+ new Response(proc.stdout).text(),
82
+ new Response(proc.stderr).text(),
83
+ proc.exited,
84
+ ]);
85
+ if (exitCode !== 0) {
86
+ // git prints some failure reasons (e.g. "nothing to commit") to
87
+ // stdout, not stderr — found by hand via the maintenance CLI, where a
88
+ // stderr-only message came back empty and gave no clue what failed.
89
+ throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${stderr || stdout}`);
90
+ }
91
+ }
92
+
93
+ /** True if `git add` staged at least one real change — i.e. there's
94
+ * something for `git commit` to actually record. */
95
+ async function hasStagedChanges(cwd: string): Promise<boolean> {
96
+ const proc = Bun.spawn(["git", "diff", "--cached", "--quiet"], { cwd });
97
+ const exitCode = await proc.exited;
98
+ return exitCode !== 0; // --quiet: 0 = no differences, 1 = differences
99
+ }
100
+
101
+ // git add/commit against the same repo aren't safe to run concurrently
102
+ // (index lock races) — every writer below shares one vault/repo, so this
103
+ // chain is shared across all of them, not per-function. `.then(fn, fn)`
104
+ // runs the next write regardless of whether the previous one succeeded or
105
+ // failed, so one bad commit doesn't wedge every write after it; the
106
+ // rejection itself still propagates to that specific caller via `result`.
107
+ // If the file write already landed on disk before a later git step throws
108
+ // (disk full, corrupt repo), `writeNoteFile` logs a dedicated
109
+ // `[wiki-vault] ... written to disk but not committed` line before
110
+ // rethrowing — distinguishable from a generic failure by whatever reads
111
+ // stderr (`docker compose logs` today; the admin-notification path this
112
+ // could eventually route through isn't wired up for this specific
113
+ // signal yet).
114
+ let commitChain: Promise<void> = Promise.resolve();
115
+
116
+ function serializeCommit<T>(fn: () => Promise<T>): Promise<T> {
117
+ const result = commitChain.then(fn, fn);
118
+ commitChain = result.then(
119
+ () => undefined,
120
+ () => undefined,
121
+ );
122
+ return result;
123
+ }
124
+
125
+ /**
126
+ * Every vault write is a commit — audit trail + `git revert` as a
127
+ * safety net. The file write itself goes through the same queue as the
128
+ * commit (not just git add/commit) — two writers targeting the same path
129
+ * must never race directly on disk content; queuing only the git half
130
+ * left that race open (found and fixed later). This makes "two
131
+ * writers, one path" deterministic (whichever is processed second wins,
132
+ * cleanly) rather than a data-loss race with confusing spurious errors —
133
+ * it does not attempt any merge of old vs new content, by design: nothing
134
+ * here promises the vault is edited "live" merge-safely, only that each
135
+ * write, once it runs, is a clean, whole-file, versioned commit.
136
+ */
137
+ async function writeVerbatimFile(
138
+ vaultPath: string,
139
+ fullPath: string,
140
+ content: string,
141
+ commitMessage: string,
142
+ ): Promise<void> {
143
+ await serializeCommit(async () => {
144
+ await mkdir(dirname(fullPath), { recursive: true });
145
+ await writeFile(fullPath, content, "utf-8");
146
+ const relPath = relative(vaultPath, fullPath);
147
+ try {
148
+ await runGit(vaultPath, ["add", relPath]);
149
+ // Byte-identical content to what's already committed stages no diff —
150
+ // asking the vault to contain X when it already contains exactly X is
151
+ // a no-op, not a failure, so skip the commit instead of letting `git
152
+ // commit` fail with "nothing to commit".
153
+ if (!(await hasStagedChanges(vaultPath))) {
154
+ return;
155
+ }
156
+ await runGit(vaultPath, [
157
+ "-c",
158
+ `user.email=${MERCURY_GIT_AUTHOR.email}`,
159
+ "-c",
160
+ `user.name=${MERCURY_GIT_AUTHOR.name}`,
161
+ "commit",
162
+ "-m",
163
+ commitMessage,
164
+ ]);
165
+ } catch (err) {
166
+ console.error(`[wiki-vault] ${relPath} written to disk but not committed: ${String(err)}`);
167
+ throw err;
168
+ }
169
+ });
170
+ }
171
+
172
+ async function writeNoteFile(
173
+ vaultPath: string,
174
+ fullPath: string,
175
+ frontmatter: CuratedFrontmatter | InferredFrontmatter | ConfirmationFrontmatter,
176
+ body: string,
177
+ commitMessage: string,
178
+ ): Promise<void> {
179
+ const content = `---\n${stringifyYaml(frontmatter)}---\n\n${body}\n`;
180
+ await writeVerbatimFile(vaultPath, fullPath, content, commitMessage);
181
+ }
182
+
183
+ /** `git rm` + commit through the same queue as every writer above, so
184
+ * the same `git revert` safety net covers deletions too. A target already gone
185
+ * is a no-op success, not an error — same philosophy as the byte-identical
186
+ * write no-op above. */
187
+ async function deleteVaultFile(vaultPath: string, fullPath: string, commitMessage: string): Promise<void> {
188
+ await serializeCommit(async () => {
189
+ if (!(await pathExists(fullPath))) return;
190
+ const relPath = relative(vaultPath, fullPath);
191
+ await runGit(vaultPath, ["rm", "--quiet", relPath]);
192
+ await runGit(vaultPath, [
193
+ "-c",
194
+ `user.email=${MERCURY_GIT_AUTHOR.email}`,
195
+ "-c",
196
+ `user.name=${MERCURY_GIT_AUTHOR.name}`,
197
+ "commit",
198
+ "-m",
199
+ commitMessage,
200
+ ]);
201
+ });
202
+ }
203
+
204
+ /** Writes a curated doc at `curated/<relativePath>` (e.g. "standards/jira-fields.md"). */
205
+ export async function writeCuratedNote(
206
+ vaultPath: string,
207
+ relativePath: string,
208
+ fields: { author?: string; last_updated?: string },
209
+ body: string,
210
+ ): Promise<void> {
211
+ const frontmatter = CuratedFrontmatterSchema.parse({ type: "curated", ...fields });
212
+ const curatedRoot = resolve(vaultPath, "curated");
213
+ const fullPath = resolveWithinRoot(curatedRoot, relativePath);
214
+ await writeNoteFile(vaultPath, fullPath, frontmatter, body, `curated: ${relativePath}`);
215
+ }
216
+
217
+ /** Writes a semantic note at `inferred/users/<userId>/<topic>.md`. */
218
+ export async function writeInferredNote(
219
+ vaultPath: string,
220
+ userId: string,
221
+ topic: string,
222
+ fields: { confidence: "low" | "medium" | "high"; derived_from: string[]; last_reviewed: string | null },
223
+ body: string,
224
+ ): Promise<void> {
225
+ assertNoPathSeparator("userId", userId);
226
+ assertNoPathSeparator("topic", topic);
227
+ const frontmatter = InferredFrontmatterSchema.parse({ type: "inferred", source: "agent", ...fields });
228
+ const inferredUserRoot = resolve(vaultPath, "inferred", "users", userId);
229
+ const fullPath = resolveWithinRoot(inferredUserRoot, `${topic}.md`);
230
+ await writeNoteFile(vaultPath, fullPath, frontmatter, body, `inferred: ${userId}/${topic}`);
231
+ }
232
+
233
+ /**
234
+ * Writes a deterministically-promoted procedural correction at
235
+ * `curated/standards/<tool>-<topic>.md` — one file per correction, not
236
+ * merged into a single per-tool doc (that would need safe section-level
237
+ * merging into whatever a human already wrote by hand there, e.g.
238
+ * `curated/standards/jira-cli.md`, deliberately out of scope here).
239
+ * Frontmatter is still `type: inferred, source: agent` (same provenance
240
+ * shape as `writeInferredNote` — probabilistic, consolidation-derived, not
241
+ * human-authored) even though the file lives under `curated/`: the path
242
+ * controls read visibility (every user's wiki tools expose `curated/`,
243
+ * only their own `inferred/users/<userId>/`), not authorship.
244
+ */
245
+ export async function writeToolCorrectionNote(
246
+ vaultPath: string,
247
+ tool: string,
248
+ topic: string,
249
+ fields: { confidence: "low" | "medium" | "high"; derived_from: string[]; last_reviewed: string | null },
250
+ body: string,
251
+ ): Promise<void> {
252
+ assertNoPathSeparator("tool", tool);
253
+ assertNoPathSeparator("topic", topic);
254
+ const frontmatter = InferredFrontmatterSchema.parse({ type: "inferred", source: "agent", ...fields });
255
+ const standardsRoot = resolve(vaultPath, "curated", "standards");
256
+ const fullPath = resolveWithinRoot(standardsRoot, `${tool}-${topic}.md`);
257
+ await writeNoteFile(vaultPath, fullPath, frontmatter, body, `inferred: standards/${tool}-${topic}`);
258
+ }
259
+
260
+ /**
261
+ * Writes (or overwrites) the deterministic lifecycle record for one
262
+ * confirm-required action at `inferred/confirmations/<encoded userId>/<token>.md`
263
+ * — see `ConfirmationFrontmatterSchema`'s own doc comment for why this
264
+ * subtree, not `inferred/users/<userId>/`. Called twice per action: once
265
+ * at staging (`status: "pending"`, `resolvedAt: null`), once at resolution
266
+ * (`"confirmed"`/`"failed"`, `resolvedAt` set) — the whole-file replace
267
+ * every writer here already does, not a partial update.
268
+ */
269
+ export async function writeConfirmationNote(
270
+ vaultPath: string,
271
+ userId: string,
272
+ token: string,
273
+ fields: { status: "pending" | "confirmed" | "failed"; requestedAt: string; resolvedAt: string | null; command: string },
274
+ ): Promise<void> {
275
+ assertNoPathSeparator("token", token);
276
+ const frontmatter = ConfirmationFrontmatterSchema.parse({
277
+ type: "confirmation",
278
+ status: fields.status,
279
+ requested_at: fields.requestedAt,
280
+ resolved_at: fields.resolvedAt,
281
+ command: fields.command,
282
+ });
283
+ const userRoot = resolve(vaultPath, "inferred", "confirmations", encodeURIComponent(userId));
284
+ const fullPath = resolveWithinRoot(userRoot, `${token}.md`);
285
+ await writeNoteFile(vaultPath, fullPath, frontmatter, "", `confirmation: ${userId}/${token} (${fields.status})`);
286
+ }
287
+
288
+ /** Writes a raw/ inbox entry verbatim at `raw/<relativePath>` — no
289
+ * frontmatter, content is whatever a human pasted as-is (see
290
+ * `vault-cli.ts`'s `write-raw`). Never called during a normal
291
+ * conversation; only the self-review job (`self-review-tools.ts`) reads
292
+ * this back to triage it into `curated/`. */
293
+ export async function writeRawEntry(vaultPath: string, relativePath: string, body: string): Promise<void> {
294
+ const rawRoot = resolve(vaultPath, "raw");
295
+ const fullPath = resolveWithinRoot(rawRoot, relativePath);
296
+ const content = body.endsWith("\n") ? body : `${body}\n`;
297
+ await writeVerbatimFile(vaultPath, fullPath, content, `raw: ${relativePath}`);
298
+ }
299
+
300
+ /** Overwrites `index.md` at the vault root with `content` verbatim — no
301
+ * frontmatter, it's a generated Karpathy-pattern index, not a note.
302
+ * Whole-file replace: the caller (self-review) computes the full new
303
+ * text and passes the complete replacement, same as every writer here. */
304
+ export async function writeIndexFile(vaultPath: string, content: string): Promise<void> {
305
+ const fullPath = resolve(vaultPath, "index.md");
306
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
307
+ await writeVerbatimFile(vaultPath, fullPath, normalized, "index: update");
308
+ }
309
+
310
+ /** Deletes a raw/ entry once self-review has resolved it (merged,
311
+ * promoted, or discarded). */
312
+ export async function deleteRawEntry(vaultPath: string, relativePath: string): Promise<void> {
313
+ const rawRoot = resolve(vaultPath, "raw");
314
+ const fullPath = resolveWithinRoot(rawRoot, relativePath);
315
+ await deleteVaultFile(vaultPath, fullPath, `raw: delete ${relativePath}`);
316
+ }
317
+
318
+ /** Deletes a curated/ doc — used only by the self-review job to retire a
319
+ * redundant/superseded doc (never during a normal conversation). Callers
320
+ * should also remove the doc's `index.md` line in the same pass, so a
321
+ * deletion doesn't leave a dangling index reference. */
322
+ export async function deleteCuratedEntry(vaultPath: string, relativePath: string): Promise<void> {
323
+ const curatedRoot = resolve(vaultPath, "curated");
324
+ const fullPath = resolveWithinRoot(curatedRoot, relativePath);
325
+ await deleteVaultFile(vaultPath, fullPath, `curated: delete ${relativePath}`);
326
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Read-side access to the wiki vault: listing, reading, and searching.
3
+ * Every function is scoped to `curated/` (visible to everyone) plus
4
+ * `inferred/users/<userId>/` for the *calling* userId only — never
5
+ * another user's semantic notes (the same per-user isolation already
6
+ * used for Layer 3/Qdrant, applied to Layer 2 too). Plain
7
+ * functions, not model-invocable tools — wiki-tools.ts wraps a subset of
8
+ * these in `tool()` for the model, same split as
9
+ * cli-executor.ts/cli-tool.ts.
10
+ */
11
+ import { readFile, stat } from "node:fs/promises";
12
+ import { join, relative, resolve, sep } from "node:path";
13
+
14
+ async function pathExists(path: string): Promise<boolean> {
15
+ try {
16
+ await stat(path);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function allowedRoots(vaultPath: string, userId: string): string[] {
24
+ const vaultRoot = resolve(vaultPath);
25
+ return [resolve(vaultRoot, "curated"), resolve(vaultRoot, "inferred", "users", userId)];
26
+ }
27
+
28
+ /** curated/ + raw/ only — never inferred/, for the nightly self-review job.
29
+ * A distinct trust boundary from a per-user conversation's `allowedRoots`
30
+ * (which trades curated/ for one user's own inferred/ instead of raw/):
31
+ * inferred/ is meant to hold only deterministic, mechanically-written
32
+ * notes (never an LLM's own judgment call about what to remember) —
33
+ * off-limits here for the same reason it's off-limits to regular
34
+ * conversations, not a special exception for self-review. */
35
+ export function selfReviewRoots(vaultPath: string): string[] {
36
+ const vaultRoot = resolve(vaultPath);
37
+ return [resolve(vaultRoot, "curated"), resolve(vaultRoot, "raw")];
38
+ }
39
+
40
+ /**
41
+ * Resolves `relativePath` against the vault and checks it falls under
42
+ * one of `roots` — rejects both vault escape (`..`) and access outside
43
+ * the caller's declared scope (cross-user inferred/ access, or anything
44
+ * outside curated/+raw/ for the self-review job).
45
+ */
46
+ function resolveAllowedWikiPath(vaultPath: string, roots: string[], relativePath: string): string {
47
+ const vaultRoot = resolve(vaultPath);
48
+ const target = resolve(vaultRoot, relativePath);
49
+ const allowed = roots.some((root) => target === root || target.startsWith(root + sep));
50
+ if (!allowed) {
51
+ throw new Error(`path not accessible: ${relativePath}`);
52
+ }
53
+ return target;
54
+ }
55
+
56
+ async function listFilesUnder(root: string, vaultRoot: string): Promise<string[]> {
57
+ if (!(await pathExists(root))) return [];
58
+ const glob = new Bun.Glob("**/*.md");
59
+ const results: string[] = [];
60
+ for await (const rel of glob.scan({ cwd: root })) {
61
+ results.push(relative(vaultRoot, join(root, rel)));
62
+ }
63
+ return results;
64
+ }
65
+
66
+ /** Lists every `.md` file under `roots`. */
67
+ export async function listWikiFilesInRoots(vaultPath: string, roots: string[]): Promise<string[]> {
68
+ const vaultRoot = resolve(vaultPath);
69
+ const lists = await Promise.all(roots.map((root) => listFilesUnder(root, vaultRoot)));
70
+ return lists.flat().sort();
71
+ }
72
+
73
+ /** Lists every `.md` file visible to `userId`: all of curated/, plus only their own inferred/users/<userId>/. */
74
+ export async function listWikiFiles(vaultPath: string, userId: string): Promise<string[]> {
75
+ return listWikiFilesInRoots(vaultPath, allowedRoots(vaultPath, userId));
76
+ }
77
+
78
+ /** Reads a single wiki file. Throws if `relativePath` falls outside `roots`. */
79
+ export async function readWikiFileInRoots(vaultPath: string, roots: string[], relativePath: string): Promise<string> {
80
+ const fullPath = resolveAllowedWikiPath(vaultPath, roots, relativePath);
81
+ return readFile(fullPath, "utf-8");
82
+ }
83
+
84
+ /** Reads a single wiki file. Throws if `relativePath` falls outside the caller's allowed scope. */
85
+ export async function readWikiFile(vaultPath: string, userId: string, relativePath: string): Promise<string> {
86
+ return readWikiFileInRoots(vaultPath, allowedRoots(vaultPath, userId), relativePath);
87
+ }
88
+
89
+ export type WikiGrepMatch = { path: string; line: number; text: string };
90
+
91
+ /** Searches every file under `roots` for `pattern` (a regular expression), line by line. */
92
+ export async function grepWikiInRoots(vaultPath: string, roots: string[], pattern: string): Promise<WikiGrepMatch[]> {
93
+ const regex = new RegExp(pattern);
94
+ const files = await listWikiFilesInRoots(vaultPath, roots);
95
+ const matches: WikiGrepMatch[] = [];
96
+
97
+ for (const file of files) {
98
+ const content = await readWikiFileInRoots(vaultPath, roots, file);
99
+ const lines = content.split("\n");
100
+ lines.forEach((text, index) => {
101
+ if (regex.test(text)) {
102
+ matches.push({ path: file, line: index + 1, text });
103
+ }
104
+ });
105
+ }
106
+
107
+ return matches;
108
+ }
109
+
110
+ /** Searches every file visible to `userId` for `pattern` (a regular expression), line by line. */
111
+ export async function grepWiki(vaultPath: string, userId: string, pattern: string): Promise<WikiGrepMatch[]> {
112
+ return grepWikiInRoots(vaultPath, allowedRoots(vaultPath, userId), pattern);
113
+ }
114
+
115
+ /** Reads index.md at the vault root; empty string if it doesn't exist yet (a brand-new vault, or one where self-review hasn't created it). */
116
+ export async function readIndexFile(vaultPath: string): Promise<string> {
117
+ try {
118
+ return await readFile(resolve(vaultPath, "index.md"), "utf-8");
119
+ } catch {
120
+ return "";
121
+ }
122
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Model-invocable wiki tools (`list_files`/`read_file`/`write_file`/
3
+ * `grep`, per SPEC.md's Layer 2), wrapping the plain functions in
4
+ * wiki-read.ts and wiki-note.ts in `tool()` — same split as
5
+ * cli-executor.ts/cli-tool.ts for the external CLIs. `write_file` only
6
+ * ever reaches `writeCuratedNote`: `writeInferredNote` is deliberately
7
+ * never wired into a tool here, since inferred/ is written exclusively
8
+ * by the deterministic consolidation engine, never by model
9
+ * choice. Every `execute` returns `{ ok, ... }` instead of throwing, so
10
+ * a rejected/invalid call is a self-correctable model turn, not a
11
+ * crashed tool call.
12
+ */
13
+ import { resolve } from "node:path";
14
+ import type { ExecutableTool } from "@mercury-fw/plugin-types";
15
+ import { tool } from "ai";
16
+ import { z } from "zod";
17
+ import { listWikiFiles, readWikiFile, grepWiki, readWikiFileInRoots } from "./wiki-read.ts";
18
+ import { writeCuratedNote } from "./wiki-note.ts";
19
+
20
+ export type WikiToolsDeps = { vaultPath: string; userId: string };
21
+
22
+ /** Builds the four wiki tools scoped to `deps.userId` (curated/ fully, only their own inferred/users/<userId>/). */
23
+ export function createWikiTools(
24
+ deps: WikiToolsDeps,
25
+ ): Record<"list_files" | "read_file" | "write_file" | "grep" | "resolve_reference", ExecutableTool> {
26
+ const { vaultPath, userId } = deps;
27
+
28
+ const list_files = tool({
29
+ description:
30
+ "List every wiki document visible to you: all of curated/ (team knowledge) plus your own inferred/ " +
31
+ "semantic notes for the current user. Other users' inferred notes are never listed. Returns paths " +
32
+ "relative to the vault root.",
33
+ inputSchema: z.object({}),
34
+ execute: async () => {
35
+ const files = await listWikiFiles(vaultPath, userId);
36
+ return { ok: true as const, files };
37
+ },
38
+ });
39
+
40
+ const read_file = tool({
41
+ description:
42
+ 'Read a wiki document by path (relative to the vault root, e.g. "curated/standards/jira-fields.md" or ' +
43
+ '"inferred/users/<your userId>/some-topic.md"). Only curated/ and your own inferred/ notes are readable.',
44
+ inputSchema: z.object({ path: z.string().min(1) }),
45
+ execute: async ({ path }) => {
46
+ try {
47
+ const content = await readWikiFile(vaultPath, userId, path);
48
+ return { ok: true as const, content };
49
+ } catch (err) {
50
+ return { ok: false as const, error: String(err) };
51
+ }
52
+ },
53
+ });
54
+
55
+ const write_file = tool({
56
+ description:
57
+ 'Write or update a curated wiki document (team knowledge — conventions, standards, decisions). "path" ' +
58
+ 'is relative to curated/, e.g. "standards/jira-fields.md". This can only write under curated/ — your ' +
59
+ "own semantic notes are managed automatically by the memory consolidation process, not through this tool.",
60
+ inputSchema: z.object({ path: z.string().min(1), content: z.string() }),
61
+ execute: async ({ path, content }) => {
62
+ try {
63
+ await writeCuratedNote(vaultPath, path, { last_updated: new Date().toISOString().slice(0, 10) }, content);
64
+ return { ok: true as const };
65
+ } catch (err) {
66
+ return { ok: false as const, error: String(err) };
67
+ }
68
+ },
69
+ });
70
+
71
+ const grep = tool({
72
+ description:
73
+ "Search wiki documents (curated/ plus your own inferred/ notes) for a regular expression pattern. " +
74
+ "Returns matching lines with their file path and line number.",
75
+ inputSchema: z.object({ pattern: z.string().min(1) }),
76
+ execute: async ({ pattern }) => {
77
+ try {
78
+ const matches = await grepWiki(vaultPath, userId, pattern);
79
+ return { ok: true as const, matches };
80
+ } catch (err) {
81
+ return { ok: false as const, error: String(err) };
82
+ }
83
+ },
84
+ });
85
+
86
+ // Deliberately not built on listWikiFiles/readWikiFile/grepWiki's
87
+ // allowedRoots (curated/ + inferred/users/<userId>/) — inferred/confirmations/
88
+ // is a different subtree on purpose, invisible to list_files/read_file/grep
89
+ // (see ConfirmationFrontmatterSchema's doc comment). Scoped to the calling
90
+ // userId's own root only, via readWikiFileInRoots directly, so a token
91
+ // string that happens to collide with another user's is still unreachable.
92
+ const resolve_reference = tool({
93
+ description:
94
+ "Resolve an opaque [REQ:<token>] reference (e.g. one you see in your own context) into the confirmation " +
95
+ "request it points to — a past action that required explicit confirmation, and whether it was confirmed, " +
96
+ "failed, or is still pending. If it's still pending, ask the user whether they still want it done — never " +
97
+ "re-run the command yourself without them explicitly saying so.",
98
+ inputSchema: z.object({ token: z.string().min(1) }),
99
+ execute: async ({ token }) => {
100
+ try {
101
+ const userRoot = resolve(vaultPath, "inferred", "confirmations", encodeURIComponent(userId));
102
+ const relativePath = `inferred/confirmations/${encodeURIComponent(userId)}/${token}.md`;
103
+ const content = await readWikiFileInRoots(vaultPath, [userRoot], relativePath);
104
+ return { ok: true as const, content };
105
+ } catch (err) {
106
+ return { ok: false as const, error: String(err) };
107
+ }
108
+ },
109
+ });
110
+
111
+ return { list_files, read_file, write_file, grep, resolve_reference };
112
+ }