@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.
- package/CHANGELOG.md +19 -0
- package/README.md +38 -0
- package/dist/index.d.ts +23 -0
- package/dist/src/admin/cli-routes.d.ts +22 -0
- package/dist/src/admin/env-file.d.ts +1 -0
- package/dist/src/admin/model-routes.d.ts +26 -0
- package/dist/src/admin/qdrant-scroll.d.ts +34 -0
- package/dist/src/admin/server.d.ts +40 -0
- package/dist/src/admin/wiki-routes.d.ts +31 -0
- package/dist/src/compose.d.ts +42 -0
- package/dist/src/config/define-config.d.ts +31 -0
- package/dist/src/cron/idle-session-cron.d.ts +80 -0
- package/dist/src/cron/idle-session-scanner.d.ts +16 -0
- package/dist/src/cron/self-review-cron.d.ts +55 -0
- package/dist/src/cron/semantic-consolidation.d.ts +71 -0
- package/dist/src/memory/embedder.d.ts +9 -0
- package/dist/src/memory/episodic-store.d.ts +121 -0
- package/dist/src/memory/memory-provider.d.ts +51 -0
- package/dist/src/memory/semantic-facts-store.d.ts +37 -0
- package/dist/src/memory/tool-corrections-store.d.ts +26 -0
- package/dist/src/memory/verbatim-archive-store.d.ts +86 -0
- package/dist/src/model/client.d.ts +24 -0
- package/dist/src/model/context-size.d.ts +30 -0
- package/dist/src/plugins/manifest.d.ts +29 -0
- package/dist/src/plugins/plugin-loader.d.ts +85 -0
- package/dist/src/router/channel-loader.d.ts +30 -0
- package/dist/src/router/provider.d.ts +7 -0
- package/dist/src/router/terminal-provider.d.ts +37 -0
- package/dist/src/router/terminal.d.ts +41 -0
- package/dist/src/router/tool-log.d.ts +65 -0
- package/dist/src/router/turn-runner.d.ts +86 -0
- package/dist/src/session/agent-turn.d.ts +266 -0
- package/dist/src/session/context-primer.d.ts +16 -0
- package/dist/src/session/episodic-summarizer.d.ts +25 -0
- package/dist/src/session/history.d.ts +95 -0
- package/dist/src/session/pending-confirmation.d.ts +8 -0
- package/dist/src/session/read-skill-tool.d.ts +4 -0
- package/dist/src/session/semantic-fact-extractor.d.ts +45 -0
- package/dist/src/session/step-info.d.ts +24 -0
- package/dist/src/session/summarizer.d.ts +23 -0
- package/dist/src/session/system-prompt.d.ts +38 -0
- package/dist/src/session/tool-correction-extractor.d.ts +43 -0
- package/dist/src/session/tool-log-buffer.d.ts +24 -0
- package/dist/src/session/tool-log-recall-tool.d.ts +18 -0
- package/dist/src/session/tool-start-hook.d.ts +57 -0
- package/dist/src/tools/display-store.d.ts +36 -0
- package/dist/src/tools/present-tool.d.ts +23 -0
- package/dist/src/wiki/frontmatter-schema.d.ts +53 -0
- package/dist/src/wiki/index-entry.d.ts +15 -0
- package/dist/src/wiki/orphan-detector.d.ts +1 -0
- package/dist/src/wiki/self-review-runner.d.ts +48 -0
- package/dist/src/wiki/self-review-tools.d.ts +22 -0
- package/dist/src/wiki/vault-cli.d.ts +2 -0
- package/dist/src/wiki/vault-init.d.ts +7 -0
- package/dist/src/wiki/wiki-note.d.ts +62 -0
- package/dist/src/wiki/wiki-read.d.ts +27 -0
- package/dist/src/wiki/wiki-tools.d.ts +7 -0
- package/index.ts +23 -0
- package/package.json +49 -0
- package/src/admin/cli-routes.ts +48 -0
- package/src/admin/env-file.ts +29 -0
- package/src/admin/model-routes.ts +71 -0
- package/src/admin/public/index.html +416 -0
- package/src/admin/qdrant-scroll.ts +45 -0
- package/src/admin/server.ts +188 -0
- package/src/admin/wiki-routes.ts +93 -0
- package/src/compose.ts +599 -0
- package/src/config/define-config.ts +35 -0
- package/src/cron/.gitkeep +0 -0
- package/src/cron/idle-session-cron.ts +144 -0
- package/src/cron/idle-session-scanner.ts +37 -0
- package/src/cron/self-review-cron.ts +103 -0
- package/src/cron/semantic-consolidation.ts +228 -0
- package/src/memory/.gitkeep +0 -0
- package/src/memory/embedder.ts +15 -0
- package/src/memory/episodic-store.ts +183 -0
- package/src/memory/memory-provider.ts +98 -0
- package/src/memory/semantic-facts-store.ts +89 -0
- package/src/memory/tool-corrections-store.ts +72 -0
- package/src/memory/verbatim-archive-store.ts +202 -0
- package/src/model/client.ts +33 -0
- package/src/model/context-size.ts +42 -0
- package/src/plugins/manifest.ts +47 -0
- package/src/plugins/plugin-loader.ts +205 -0
- package/src/router/channel-loader.ts +56 -0
- package/src/router/provider.ts +7 -0
- package/src/router/terminal-provider.ts +155 -0
- package/src/router/terminal.ts +151 -0
- package/src/router/tool-log.ts +116 -0
- package/src/router/turn-runner.ts +205 -0
- package/src/session/agent-turn.ts +391 -0
- package/src/session/context-primer.ts +134 -0
- package/src/session/episodic-summarizer.ts +38 -0
- package/src/session/history.ts +168 -0
- package/src/session/pending-confirmation.ts +8 -0
- package/src/session/read-skill-tool.ts +38 -0
- package/src/session/semantic-fact-extractor.ts +69 -0
- package/src/session/step-info.ts +27 -0
- package/src/session/summarizer.ts +36 -0
- package/src/session/system-prompt.ts +142 -0
- package/src/session/tool-correction-extractor.ts +133 -0
- package/src/session/tool-log-buffer.ts +73 -0
- package/src/session/tool-log-recall-tool.ts +38 -0
- package/src/session/tool-start-hook.ts +164 -0
- package/src/tools/display-store.ts +89 -0
- package/src/tools/present-tool.ts +41 -0
- package/src/wiki/.gitkeep +0 -0
- package/src/wiki/frontmatter-schema.ts +49 -0
- package/src/wiki/index-entry.ts +59 -0
- package/src/wiki/orphan-detector.ts +61 -0
- package/src/wiki/self-review-runner.ts +133 -0
- package/src/wiki/self-review-tools.ts +162 -0
- package/src/wiki/vault-cli.ts +143 -0
- package/src/wiki/vault-init.ts +43 -0
- package/src/wiki/wiki-note.ts +326 -0
- package/src/wiki/wiki-read.ts +122 -0
- 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
|
+
}
|