@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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nightly self-review job's three LLM passes. Each is a fresh,
|
|
3
|
+
* stateless `generateText` call — never a persisted conversation history
|
|
4
|
+
* like `agent-turn.ts`'s `runTurn` — so nothing carries over between
|
|
5
|
+
* passes or between nights except what actually landed on disk. Running
|
|
6
|
+
* all four checks as one giant multi-step call would let tool-call
|
|
7
|
+
* context accumulate across unrelated jobs at once; splitting into three
|
|
8
|
+
* independent calls keeps each one's context bounded to its own job.
|
|
9
|
+
*
|
|
10
|
+
* `generateText` comes from `ai-sdk-ollama`, matching `agent-turn.ts` —
|
|
11
|
+
* plain `ai`'s version is documented there to return empty text after a
|
|
12
|
+
* tool call on this codebase's Ollama setup, which applies here too
|
|
13
|
+
* since every pass uses tools.
|
|
14
|
+
*/
|
|
15
|
+
import { isStepCount, type LanguageModel, type Tool } from "ai";
|
|
16
|
+
import { generateText } from "ai-sdk-ollama";
|
|
17
|
+
import type { Message } from "../session/history.ts";
|
|
18
|
+
import { createSelfReviewTools } from "./self-review-tools.ts";
|
|
19
|
+
|
|
20
|
+
/** Empirical, tuned later — generous headroom for reading several docs and
|
|
21
|
+
* writing/deleting within one pass, well above a conversational turn's. */
|
|
22
|
+
export const SELF_REVIEW_STEP_COUNT = 15;
|
|
23
|
+
|
|
24
|
+
type GenerateTextFn = (params: {
|
|
25
|
+
model: LanguageModel;
|
|
26
|
+
messages: Message[];
|
|
27
|
+
tools: Record<string, Tool>;
|
|
28
|
+
instructions: string;
|
|
29
|
+
}) => Promise<{ text: string }>;
|
|
30
|
+
|
|
31
|
+
const defaultGenerateTextFn: GenerateTextFn = (params) =>
|
|
32
|
+
generateText({ ...params, stopWhen: isStepCount(SELF_REVIEW_STEP_COUNT) });
|
|
33
|
+
|
|
34
|
+
async function runPass(params: {
|
|
35
|
+
vaultPath: string;
|
|
36
|
+
model: LanguageModel;
|
|
37
|
+
instructions: string;
|
|
38
|
+
userMessage: string;
|
|
39
|
+
generateTextFn?: GenerateTextFn;
|
|
40
|
+
}): Promise<void> {
|
|
41
|
+
const tools = createSelfReviewTools({ vaultPath: params.vaultPath });
|
|
42
|
+
const generate = params.generateTextFn ?? defaultGenerateTextFn;
|
|
43
|
+
await generate({
|
|
44
|
+
model: params.model,
|
|
45
|
+
messages: [{ role: "user", content: params.userMessage }],
|
|
46
|
+
tools,
|
|
47
|
+
instructions: params.instructions,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SHARED_BOUNDARIES =
|
|
52
|
+
"You can read/list/grep curated/ and raw/, write curated docs, add/update or remove a curated " +
|
|
53
|
+
"doc's index.md entry (update_index_entry/remove_index_entry — never hand-write index.md's " +
|
|
54
|
+
"content yourself, those tools own its exact format), and delete resolved raw/ entries. You have " +
|
|
55
|
+
"no access to inferred/ — it is written exclusively by a separate deterministic process, never by " +
|
|
56
|
+
"judgment calls like this one.";
|
|
57
|
+
|
|
58
|
+
const RAW_TRIAGE_SYSTEM =
|
|
59
|
+
"You are performing Mercury's periodic wiki self-review — the raw/ triage pass. For each entry " +
|
|
60
|
+
"in raw/, read it and decide: merge its content into an existing curated doc, promote it into a " +
|
|
61
|
+
"new curated doc, or discard it (already superseded, duplicate, or simply not wiki-worthy — one " +
|
|
62
|
+
"bucket, not three). Then delete it from raw/ once resolved. If you create or meaningfully change " +
|
|
63
|
+
"a curated doc, call update_index_entry for it too. " +
|
|
64
|
+
SHARED_BOUNDARIES;
|
|
65
|
+
|
|
66
|
+
const INDEX_AND_ORPHAN_SYSTEM =
|
|
67
|
+
"You are performing Mercury's periodic wiki self-review — the index.md and orphan-page pass. For " +
|
|
68
|
+
"each orphaned curated doc listed below, decide whether it needs an index.md entry " +
|
|
69
|
+
"(update_index_entry), a cross-link from a related doc, or both. " +
|
|
70
|
+
SHARED_BOUNDARIES;
|
|
71
|
+
|
|
72
|
+
const CONTRADICTION_CHECK_SYSTEM =
|
|
73
|
+
"You are performing Mercury's periodic wiki self-review — the contradiction and cross-link check. " +
|
|
74
|
+
"Read through curated/ and look for direct contradictions between documents, or clearly-related " +
|
|
75
|
+
"documents missing a cross-link between them. Fix what you're confident about; leave the rest — " +
|
|
76
|
+
"this is best-effort, not exhaustive. If you delete a doc as a resolved duplicate, call " +
|
|
77
|
+
"remove_index_entry for it in the same pass. " +
|
|
78
|
+
SHARED_BOUNDARIES;
|
|
79
|
+
|
|
80
|
+
export type RawTriagePassDeps = {
|
|
81
|
+
vaultPath: string;
|
|
82
|
+
model: LanguageModel;
|
|
83
|
+
rawEntries: string[];
|
|
84
|
+
generateTextFn?: GenerateTextFn;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export async function runRawTriagePass(deps: RawTriagePassDeps): Promise<void> {
|
|
88
|
+
const userMessage = ["raw/ entries to triage:", ...deps.rawEntries.map((e) => `- ${e}`)].join("\n");
|
|
89
|
+
await runPass({
|
|
90
|
+
vaultPath: deps.vaultPath,
|
|
91
|
+
model: deps.model,
|
|
92
|
+
instructions: RAW_TRIAGE_SYSTEM,
|
|
93
|
+
userMessage,
|
|
94
|
+
generateTextFn: deps.generateTextFn,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type IndexAndOrphanPassDeps = {
|
|
99
|
+
vaultPath: string;
|
|
100
|
+
model: LanguageModel;
|
|
101
|
+
orphans: string[];
|
|
102
|
+
generateTextFn?: GenerateTextFn;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export async function runIndexAndOrphanPass(deps: IndexAndOrphanPassDeps): Promise<void> {
|
|
106
|
+
const userMessage = [
|
|
107
|
+
"Orphaned curated docs (not referenced by index.md nor any [[wikilink]]):",
|
|
108
|
+
...deps.orphans.map((o) => `- ${o}`),
|
|
109
|
+
].join("\n");
|
|
110
|
+
await runPass({
|
|
111
|
+
vaultPath: deps.vaultPath,
|
|
112
|
+
model: deps.model,
|
|
113
|
+
instructions: INDEX_AND_ORPHAN_SYSTEM,
|
|
114
|
+
userMessage,
|
|
115
|
+
generateTextFn: deps.generateTextFn,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type ContradictionCheckPassDeps = {
|
|
120
|
+
vaultPath: string;
|
|
121
|
+
model: LanguageModel;
|
|
122
|
+
generateTextFn?: GenerateTextFn;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export async function runContradictionCheckPass(deps: ContradictionCheckPassDeps): Promise<void> {
|
|
126
|
+
await runPass({
|
|
127
|
+
vaultPath: deps.vaultPath,
|
|
128
|
+
model: deps.model,
|
|
129
|
+
instructions: CONTRADICTION_CHECK_SYSTEM,
|
|
130
|
+
userMessage: "Review curated/ for contradictions and missing cross-links.",
|
|
131
|
+
generateTextFn: deps.generateTextFn,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nightly self-review job's own tool set — distinct from
|
|
3
|
+
* `wiki-tools.ts` (the model-invocable tools a normal conversation gets),
|
|
4
|
+
* same reasoning already applied to `writeInferredNote` never being
|
|
5
|
+
* wired into those: this is a separate trust context (a stateless admin
|
|
6
|
+
* batch job, not a live conversation), scoped via `selfReviewRoots`
|
|
7
|
+
* (curated/ + raw/, never inferred/ — reserved for deterministic,
|
|
8
|
+
* mechanically-written notes, not an LLM's own judgment call) and with
|
|
9
|
+
* capabilities no conversational tool
|
|
10
|
+
* has (deleting an entry, updating/removing an index.md entry — the
|
|
11
|
+
* model supplies a doc path and a description, never the file's raw text,
|
|
12
|
+
* so an update can't get the `[[wikilink]]` format wrong).
|
|
13
|
+
*
|
|
14
|
+
* All three nightly sub-passes (`self-review-runner.ts`) share this
|
|
15
|
+
* exact tool set — they differ only in system prompt and pre-computed
|
|
16
|
+
* input data, not in which tools they can call.
|
|
17
|
+
*/
|
|
18
|
+
import type { ExecutableTool } from "@mercury-fw/plugin-types";
|
|
19
|
+
import { tool } from "ai";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { listWikiFilesInRoots, readWikiFileInRoots, grepWikiInRoots, selfReviewRoots, readIndexFile } from "./wiki-read.ts";
|
|
22
|
+
import { writeCuratedNote, writeIndexFile, deleteRawEntry, deleteCuratedEntry } from "./wiki-note.ts";
|
|
23
|
+
import { normalizeIndexKey, upsertIndexEntry, removeIndexEntry } from "./index-entry.ts";
|
|
24
|
+
|
|
25
|
+
export type SelfReviewToolsDeps = { vaultPath: string };
|
|
26
|
+
|
|
27
|
+
export function createSelfReviewTools(
|
|
28
|
+
deps: SelfReviewToolsDeps,
|
|
29
|
+
): Record<
|
|
30
|
+
| "list_files"
|
|
31
|
+
| "read_file"
|
|
32
|
+
| "grep"
|
|
33
|
+
| "write_curated"
|
|
34
|
+
| "update_index_entry"
|
|
35
|
+
| "remove_index_entry"
|
|
36
|
+
| "delete_raw"
|
|
37
|
+
| "delete_curated",
|
|
38
|
+
ExecutableTool
|
|
39
|
+
> {
|
|
40
|
+
const { vaultPath } = deps;
|
|
41
|
+
const roots = selfReviewRoots(vaultPath);
|
|
42
|
+
|
|
43
|
+
const list_files = tool({
|
|
44
|
+
description: "List every file under curated/ and raw/ (never inferred/). Returns paths relative to the vault root.",
|
|
45
|
+
inputSchema: z.object({}),
|
|
46
|
+
execute: async () => {
|
|
47
|
+
const files = await listWikiFilesInRoots(vaultPath, roots);
|
|
48
|
+
return { ok: true as const, files };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const read_file = tool({
|
|
53
|
+
description: 'Read a file by path, e.g. "curated/standards/x.md" or "raw/notes/y.md". Only curated/ and raw/ are readable.',
|
|
54
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
55
|
+
execute: async ({ path }) => {
|
|
56
|
+
try {
|
|
57
|
+
const content = await readWikiFileInRoots(vaultPath, roots, path);
|
|
58
|
+
return { ok: true as const, content };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return { ok: false as const, error: String(err) };
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const grep = tool({
|
|
66
|
+
description: "Search curated/ and raw/ files for a regular expression pattern. Returns matching lines with their file path and line number.",
|
|
67
|
+
inputSchema: z.object({ pattern: z.string().min(1) }),
|
|
68
|
+
execute: async ({ pattern }) => {
|
|
69
|
+
try {
|
|
70
|
+
const matches = await grepWikiInRoots(vaultPath, roots, pattern);
|
|
71
|
+
return { ok: true as const, matches };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { ok: false as const, error: String(err) };
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const write_curated = tool({
|
|
79
|
+
description: 'Create or overwrite a curated doc. "path" is relative to curated/, e.g. "standards/jira-fields.md".',
|
|
80
|
+
inputSchema: z.object({ path: z.string().min(1), content: z.string() }),
|
|
81
|
+
execute: async ({ path, content }) => {
|
|
82
|
+
try {
|
|
83
|
+
await writeCuratedNote(vaultPath, path, {}, content);
|
|
84
|
+
return { ok: true as const };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { ok: false as const, error: String(err) };
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const update_index_entry = tool({
|
|
92
|
+
description:
|
|
93
|
+
'Add or update index.md\'s line for one curated doc — pass its path in any form ("curated/projects/x.md", "projects/x.md", or "projects/x", all normalize the same way) and a short description. Writes it as a [[wikilink]] so the orphan check recognizes it; never hand-write index.md yourself, it has to match this exact format to count.',
|
|
94
|
+
inputSchema: z.object({ path: z.string().min(1), description: z.string().min(1) }),
|
|
95
|
+
execute: async ({ path, description }) => {
|
|
96
|
+
const key = normalizeIndexKey(path);
|
|
97
|
+
const curatedPath = `curated/${key}.md`;
|
|
98
|
+
try {
|
|
99
|
+
await readWikiFileInRoots(vaultPath, roots, curatedPath);
|
|
100
|
+
} catch {
|
|
101
|
+
return { ok: false as const, error: `${curatedPath} does not exist — create it first with write_curated` };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const current = await readIndexFile(vaultPath);
|
|
105
|
+
await writeIndexFile(vaultPath, upsertIndexEntry(current, key, description));
|
|
106
|
+
return { ok: true as const };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { ok: false as const, error: String(err) };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const remove_index_entry = tool({
|
|
114
|
+
description:
|
|
115
|
+
'Remove index.md\'s line for one curated doc (e.g. after delete_curated) — same path forms as update_index_entry. A no-op if it has no line.',
|
|
116
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
117
|
+
execute: async ({ path }) => {
|
|
118
|
+
const key = normalizeIndexKey(path);
|
|
119
|
+
try {
|
|
120
|
+
const current = await readIndexFile(vaultPath);
|
|
121
|
+
await writeIndexFile(vaultPath, removeIndexEntry(current, key));
|
|
122
|
+
return { ok: true as const };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { ok: false as const, error: String(err) };
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const delete_raw = tool({
|
|
130
|
+
description: 'Delete a raw/ entry once it has been triaged (merged, promoted, or discarded). "path" must start with "raw/".',
|
|
131
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
132
|
+
execute: async ({ path }) => {
|
|
133
|
+
if (!path.startsWith("raw/")) {
|
|
134
|
+
return { ok: false as const, error: `path must start with "raw/" (got "${path}")` };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await deleteRawEntry(vaultPath, path.slice("raw/".length));
|
|
138
|
+
return { ok: true as const };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { ok: false as const, error: String(err) };
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const delete_curated = tool({
|
|
146
|
+
description: 'Delete a curated doc that is redundant or superseded. "path" must start with "curated/". Remove its index.md line too, if it has one.',
|
|
147
|
+
inputSchema: z.object({ path: z.string().min(1) }),
|
|
148
|
+
execute: async ({ path }) => {
|
|
149
|
+
if (!path.startsWith("curated/")) {
|
|
150
|
+
return { ok: false as const, error: `path must start with "curated/" (got "${path}")` };
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await deleteCuratedEntry(vaultPath, path.slice("curated/".length));
|
|
154
|
+
return { ok: true as const };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return { ok: false as const, error: String(err) };
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { list_files, read_file, grep, write_curated, update_index_entry, remove_index_entry, delete_raw, delete_curated };
|
|
162
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Maintenance CLI for the wiki vault — runs INSIDE the Mercury container
|
|
4
|
+
* (the vault is a Docker named volume, not a host path, see `scripts/vault.sh`
|
|
5
|
+
* and CLAUDE.md § "Manutenzione della vault wiki"). Thin argv wrapper around
|
|
6
|
+
* functions that already exist and are already tested (`wiki-note.ts`,
|
|
7
|
+
* `vault-init.ts`) — no new write/read logic here, only routing.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately does NOT expose `writeInferredNote` — that writer is
|
|
10
|
+
* reserved exclusively for a deterministic, mechanical consolidation
|
|
11
|
+
* process (see its own docstring in `wiki-note.ts`); a manual CLI writing
|
|
12
|
+
* "agent-sourced" notes by hand would defeat that guarantee.
|
|
13
|
+
*
|
|
14
|
+
* `write-raw` has no `--author`/frontmatter options, unlike `write-curated`
|
|
15
|
+
* — raw/ content is verbatim, un-triaged material (a pasted README, notes),
|
|
16
|
+
* and its provenance is recovered from git history rather than a
|
|
17
|
+
* schema field. The nightly self-review job (`self-review-tools.ts`) is
|
|
18
|
+
* the only thing that reads raw/ back to triage it into curated/.
|
|
19
|
+
*
|
|
20
|
+
* `list`/`read`/`grep` intentionally bypass `wiki-read.ts`'s per-user
|
|
21
|
+
* scoping (`allowedRoots`) — that scoping exists to isolate what the
|
|
22
|
+
* MODEL can see per caller; a maintainer running this CLI is already a
|
|
23
|
+
* trusted admin context with no such boundary, so these just walk the
|
|
24
|
+
* whole vault directly.
|
|
25
|
+
*/
|
|
26
|
+
import { initVault } from "./vault-init.ts";
|
|
27
|
+
import { writeCuratedNote, writeRawEntry } from "./wiki-note.ts";
|
|
28
|
+
|
|
29
|
+
function usage(): never {
|
|
30
|
+
console.error(
|
|
31
|
+
[
|
|
32
|
+
"Usage: vault-cli <command> [args]",
|
|
33
|
+
"",
|
|
34
|
+
"Commands:",
|
|
35
|
+
" write-curated <curated/...path.md> [--author NAME] body read from stdin",
|
|
36
|
+
" write-raw <raw/...path.md> body read from stdin",
|
|
37
|
+
" read <path>",
|
|
38
|
+
" list",
|
|
39
|
+
" grep <pattern>",
|
|
40
|
+
"",
|
|
41
|
+
"Every path is relative to the vault root (as printed by `list`), including",
|
|
42
|
+
"the leading `curated/`/`raw/` — write-curated/write-raw require it too, so",
|
|
43
|
+
"paths are consistent across every command instead of meaning different things.",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readStdin(): Promise<string> {
|
|
50
|
+
return await Bun.stdin.text();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main(): Promise<void> {
|
|
54
|
+
const vaultPath = process.env.WIKI_VAULT_PATH;
|
|
55
|
+
if (!vaultPath) {
|
|
56
|
+
console.error("WIKI_VAULT_PATH is not set");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
await initVault(vaultPath);
|
|
60
|
+
|
|
61
|
+
const [command, ...args] = process.argv.slice(2);
|
|
62
|
+
|
|
63
|
+
switch (command) {
|
|
64
|
+
case "write-curated": {
|
|
65
|
+
const vaultRelativePath = args[0];
|
|
66
|
+
if (!vaultRelativePath) usage();
|
|
67
|
+
if (!vaultRelativePath.startsWith("curated/")) {
|
|
68
|
+
console.error(`path must start with "curated/" (got "${vaultRelativePath}") — see \`list\` for real examples`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
const curatedRelativePath = vaultRelativePath.slice("curated/".length);
|
|
72
|
+
const authorFlagIndex = args.indexOf("--author");
|
|
73
|
+
const author = authorFlagIndex !== -1 ? args[authorFlagIndex + 1] : undefined;
|
|
74
|
+
const body = await readStdin();
|
|
75
|
+
if (!body.trim()) {
|
|
76
|
+
console.error("empty body on stdin — nothing to write");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
await writeCuratedNote(
|
|
80
|
+
vaultPath,
|
|
81
|
+
curatedRelativePath,
|
|
82
|
+
{ author, last_updated: new Date().toISOString().slice(0, 10) },
|
|
83
|
+
body.trimEnd(),
|
|
84
|
+
);
|
|
85
|
+
console.log(`wrote ${vaultRelativePath}`);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case "write-raw": {
|
|
90
|
+
const vaultRelativePath = args[0];
|
|
91
|
+
if (!vaultRelativePath) usage();
|
|
92
|
+
if (!vaultRelativePath.startsWith("raw/")) {
|
|
93
|
+
console.error(`path must start with "raw/" (got "${vaultRelativePath}")`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
const rawRelativePath = vaultRelativePath.slice("raw/".length);
|
|
97
|
+
const body = await readStdin();
|
|
98
|
+
if (!body.trim()) {
|
|
99
|
+
console.error("empty body on stdin — nothing to write");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
await writeRawEntry(vaultPath, rawRelativePath, body.trimEnd());
|
|
103
|
+
console.log(`wrote ${vaultRelativePath}`);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "read": {
|
|
108
|
+
const relativePath = args[0];
|
|
109
|
+
if (!relativePath) usage();
|
|
110
|
+
console.log(await Bun.file(`${vaultPath}/${relativePath}`).text());
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "list": {
|
|
115
|
+
const glob = new Bun.Glob("**/*.md");
|
|
116
|
+
for await (const file of glob.scan({ cwd: vaultPath })) {
|
|
117
|
+
console.log(file);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "grep": {
|
|
123
|
+
const pattern = args[0];
|
|
124
|
+
if (!pattern) usage();
|
|
125
|
+
const regex = new RegExp(pattern);
|
|
126
|
+
const glob = new Bun.Glob("**/*.md");
|
|
127
|
+
for await (const file of glob.scan({ cwd: vaultPath })) {
|
|
128
|
+
const content = await Bun.file(`${vaultPath}/${file}`).text();
|
|
129
|
+
content.split("\n").forEach((line, i) => {
|
|
130
|
+
if (regex.test(line)) {
|
|
131
|
+
console.log(`${file}:${i + 1}:${line}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
default:
|
|
139
|
+
usage();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await main();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent scaffolding for the wiki vault (Layer 2) — the vault itself
|
|
3
|
+
* is a separate git repository, mounted as an external volume
|
|
4
|
+
* (`wiki-vault:/app/wiki-vault` in docker-compose.yml), not part of
|
|
5
|
+
* Mercury's own repo. This runs at Mercury startup against
|
|
6
|
+
* `WIKI_VAULT_PATH` to make sure the expected curated/inferred structure
|
|
7
|
+
* and the vault's own git repo exist, without disturbing whatever
|
|
8
|
+
* content is already there.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const SUBDIRS = ["curated/design", "curated/standards", "curated/decisions", "inferred/users", "raw"];
|
|
14
|
+
|
|
15
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
16
|
+
try {
|
|
17
|
+
await stat(path);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates the vault's curated/inferred subdirectories and git-inits the
|
|
26
|
+
* vault if it isn't already a git repo. Safe to call on every startup:
|
|
27
|
+
* pre-existing directories/content are left untouched, and re-running
|
|
28
|
+
* `git init` on an already-initialized repo is a no-op.
|
|
29
|
+
*/
|
|
30
|
+
export async function initVault(vaultPath: string): Promise<void> {
|
|
31
|
+
for (const sub of SUBDIRS) {
|
|
32
|
+
await mkdir(join(vaultPath, sub), { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!(await pathExists(join(vaultPath, ".git")))) {
|
|
36
|
+
const proc = Bun.spawn(["git", "init"], { cwd: vaultPath, stdout: "pipe", stderr: "pipe" });
|
|
37
|
+
const exitCode = await proc.exited;
|
|
38
|
+
if (exitCode !== 0) {
|
|
39
|
+
const stderr = await new Response(proc.stderr).text();
|
|
40
|
+
throw new Error(`git init failed in ${vaultPath}: ${stderr}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|