@zosmaai/pi-llm-wiki 0.5.0 → 0.6.1
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 +40 -0
- package/README.md +89 -52
- package/docs/architecture.md +30 -26
- package/docs/commands.md +1 -1
- package/docs/configuration.md +1 -1
- package/docs/obsidian.md +5 -5
- package/extensions/llm-wiki/index.ts +38 -12
- package/extensions/llm-wiki/lib/guardrails.ts +8 -18
- package/extensions/llm-wiki/lib/recall.ts +207 -0
- package/extensions/llm-wiki/lib/retro.ts +196 -0
- package/extensions/llm-wiki/lib/tools.ts +11 -10
- package/extensions/llm-wiki/lib/utils.ts +48 -8
- package/mcp/index.ts +487 -0
- package/package.json +9 -2
- package/prompts/wiki-discover.md +6 -6
- package/prompts/wiki-ingest.md +8 -8
- package/prompts/wiki-init.md +11 -11
- package/prompts/wiki-lint.md +5 -5
- package/prompts/wiki-status.md +4 -4
- package/skills/llm-wiki/SKILL.md +67 -30
- package/skills/llm-wiki/templates/pages/source.md +2 -2
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import type { Registry } from "./metadata.js";
|
|
6
|
+
import { type VaultPaths, readJson, resolveVaultPaths } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
// ─── Public API ────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface RecallResult {
|
|
11
|
+
/** Page identifier (folder-qualified, e.g. "concepts/rag") */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Page title */
|
|
14
|
+
title: string;
|
|
15
|
+
/** Page type: source, entity, concept, synthesis, analysis */
|
|
16
|
+
type: string;
|
|
17
|
+
/** First N chars of page content for context */
|
|
18
|
+
preview: string;
|
|
19
|
+
/** Relative path from wiki root */
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Search the wiki registry for pages matching a query.
|
|
25
|
+
* Returns up to `maxResults` matches, each with a content preview.
|
|
26
|
+
*/
|
|
27
|
+
export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): RecallResult[] {
|
|
28
|
+
const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
|
|
29
|
+
version: "1.0",
|
|
30
|
+
last_updated: "",
|
|
31
|
+
pages: {},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const q = query.toLowerCase();
|
|
35
|
+
const terms = q
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.filter((t) => t.length > 2)
|
|
38
|
+
.slice(0, 10);
|
|
39
|
+
|
|
40
|
+
if (terms.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
type Scored = { id: string; entry: Registry["pages"][string]; score: number };
|
|
43
|
+
const scored: Scored[] = [];
|
|
44
|
+
|
|
45
|
+
for (const [id, entry] of Object.entries(registry.pages)) {
|
|
46
|
+
let score = 0;
|
|
47
|
+
const title = String(entry.title || "").toLowerCase();
|
|
48
|
+
const type = String(entry.type || "").toLowerCase();
|
|
49
|
+
|
|
50
|
+
for (const term of terms) {
|
|
51
|
+
if (id.toLowerCase().includes(term)) score += 3;
|
|
52
|
+
if (title.includes(term)) score += 4;
|
|
53
|
+
if (type.includes(term)) score += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Boost if query terms appear in tags/categories
|
|
57
|
+
const tags = String(entry.tags || entry.category || entry.domain || "");
|
|
58
|
+
for (const term of terms) {
|
|
59
|
+
if (tags.toLowerCase().includes(term)) score += 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (score > 0) {
|
|
63
|
+
scored.push({ id, entry, score });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
scored.sort((a, b) => b.score - a.score);
|
|
68
|
+
const top = scored.slice(0, maxResults);
|
|
69
|
+
|
|
70
|
+
return top.map(({ id, entry }) => {
|
|
71
|
+
// Try to read page content for preview
|
|
72
|
+
let preview = "";
|
|
73
|
+
const pagePath = join(paths.wiki, `${id}.md`);
|
|
74
|
+
if (existsSync(pagePath)) {
|
|
75
|
+
const content = readFileSync(pagePath, "utf-8");
|
|
76
|
+
// Strip frontmatter
|
|
77
|
+
const body = content.replace(/^---[\s\S]*?---\n/, "").trim();
|
|
78
|
+
preview = body.slice(0, 200).replace(/\n/g, " ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
title: String(entry.title || id),
|
|
84
|
+
type: String(entry.type || "page"),
|
|
85
|
+
preview,
|
|
86
|
+
path: pagePath,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format recall results as a compact system-prompt section.
|
|
93
|
+
*/
|
|
94
|
+
export function formatRecallContext(results: RecallResult[]): string {
|
|
95
|
+
if (results.length === 0) return "";
|
|
96
|
+
|
|
97
|
+
const lines: string[] = [
|
|
98
|
+
"## Relevant Wiki Knowledge",
|
|
99
|
+
"",
|
|
100
|
+
`_${results.length} page(s) matched your query — reviewed automatically by LLM Wiki._`,
|
|
101
|
+
"",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
for (const r of results) {
|
|
105
|
+
lines.push(`- **[[${r.id}]]** — *${r.type}* — ${r.title}`);
|
|
106
|
+
if (r.preview) {
|
|
107
|
+
// Truncate preview to one line
|
|
108
|
+
const preview = r.preview.length > 120 ? `${r.preview.slice(0, 120)}…` : r.preview;
|
|
109
|
+
lines.push(` ${preview}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lines.push(
|
|
115
|
+
"Use `read` to view full pages. Add new findings via wiki_ensure_page or wiki_retro.",
|
|
116
|
+
"",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Tool Registration ──────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register the `wiki_recall` tool.
|
|
126
|
+
* The model can call this explicitly to search the wiki.
|
|
127
|
+
* It is also called automatically via before_agent_start hook.
|
|
128
|
+
*/
|
|
129
|
+
export function registerWikiRecall(pi: ExtensionAPI): void {
|
|
130
|
+
pi.registerTool({
|
|
131
|
+
name: "wiki_recall",
|
|
132
|
+
label: "Wiki Recall",
|
|
133
|
+
description:
|
|
134
|
+
"Search the wiki for pages relevant to a query. " +
|
|
135
|
+
"Returns matching page IDs, titles, types, and content previews. " +
|
|
136
|
+
"Called automatically at session start — use explicitly to dig deeper.",
|
|
137
|
+
promptSnippet: "Recall wiki knowledge relevant to the current task",
|
|
138
|
+
promptGuidelines: [
|
|
139
|
+
"Use wiki_recall at the START of every task to find relevant wiki knowledge.",
|
|
140
|
+
"The extension auto-calls wiki_recall — but calling it explicitly with specific terms gets better results.",
|
|
141
|
+
],
|
|
142
|
+
parameters: Type.Object({
|
|
143
|
+
query: Type.String({
|
|
144
|
+
description: "Search query — use the user's full request or key terms",
|
|
145
|
+
}),
|
|
146
|
+
max_results: Type.Optional(
|
|
147
|
+
Type.Number({ description: "Max results (default: 5, max: 10)", default: 5 }),
|
|
148
|
+
),
|
|
149
|
+
}),
|
|
150
|
+
async execute(_toolCallId, params) {
|
|
151
|
+
const paths = resolveVaultPaths(process.cwd());
|
|
152
|
+
|
|
153
|
+
if (!existsSync(join(paths.dotWiki, "config.json"))) {
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
details: { error: "no_vault" } as Record<string, unknown>,
|
|
162
|
+
isError: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const maxResults = Math.min(params.max_results ?? 5, 10);
|
|
167
|
+
const results = searchWiki(paths, params.query, maxResults);
|
|
168
|
+
|
|
169
|
+
if (results.length === 0) {
|
|
170
|
+
return {
|
|
171
|
+
content: [
|
|
172
|
+
{
|
|
173
|
+
type: "text",
|
|
174
|
+
text: `No wiki pages found matching "${params.query}". Use wiki_search for broader results.`,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
details: { query: params.query, matches: [] } as Record<string, unknown>,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: [
|
|
186
|
+
`🧠 **${results.length} wiki page(s) relevant** to "${params.query}":`,
|
|
187
|
+
"",
|
|
188
|
+
...results.map(
|
|
189
|
+
(r) =>
|
|
190
|
+
`- [[${r.id}]] — *${r.type}* — ${r.title}${
|
|
191
|
+
r.preview ? `\n > ${r.preview.slice(0, 150)}` : ""
|
|
192
|
+
}`,
|
|
193
|
+
),
|
|
194
|
+
"",
|
|
195
|
+
"Use `read` on any page for full content.",
|
|
196
|
+
"Use `wiki_retro` to save new insights from this task.",
|
|
197
|
+
].join("\n"),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
details: { query: params.query, matches: results.map((r) => r.id) } as Record<
|
|
201
|
+
string,
|
|
202
|
+
unknown
|
|
203
|
+
>,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { appendEvent, rebuildMetadataLight } from "./metadata.js";
|
|
6
|
+
import { type VaultPaths, fmtDate, nextSourceId, resolveVaultPaths } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
// ─── Public API ────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface RetroResult {
|
|
11
|
+
sourceId: string;
|
|
12
|
+
packetPath: string;
|
|
13
|
+
sourcePagePath: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Save an atomic insight into the wiki as a source packet + source page.
|
|
18
|
+
* Returns the source ID, packet path, and source page path.
|
|
19
|
+
*/
|
|
20
|
+
export function saveInsight(
|
|
21
|
+
paths: VaultPaths,
|
|
22
|
+
slug: string,
|
|
23
|
+
title: string,
|
|
24
|
+
body: string,
|
|
25
|
+
category?: string,
|
|
26
|
+
): RetroResult {
|
|
27
|
+
const sourceId = nextSourceId(paths);
|
|
28
|
+
const packetPath = join(paths.rawSources, sourceId);
|
|
29
|
+
mkdirSync(packetPath, { recursive: true });
|
|
30
|
+
mkdirSync(join(packetPath, "attachments"), { recursive: true });
|
|
31
|
+
|
|
32
|
+
const today = fmtDate();
|
|
33
|
+
|
|
34
|
+
// Write manifest
|
|
35
|
+
const manifest = {
|
|
36
|
+
id: sourceId,
|
|
37
|
+
title,
|
|
38
|
+
slug,
|
|
39
|
+
category: category || "uncategorized",
|
|
40
|
+
captured: today,
|
|
41
|
+
format: "insight",
|
|
42
|
+
packet_version: "1.0",
|
|
43
|
+
};
|
|
44
|
+
writeFileSync(
|
|
45
|
+
join(packetPath, "manifest.json"),
|
|
46
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
47
|
+
"utf-8",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Write extracted text (the insight body in markdown)
|
|
51
|
+
const extracted = [
|
|
52
|
+
`# ${title}`,
|
|
53
|
+
"",
|
|
54
|
+
body,
|
|
55
|
+
"",
|
|
56
|
+
"---",
|
|
57
|
+
`*Captured: ${today}*`,
|
|
58
|
+
category ? `*Category: ${category}*` : "",
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join("\n");
|
|
62
|
+
writeFileSync(join(packetPath, "extracted.md"), extracted, "utf-8");
|
|
63
|
+
|
|
64
|
+
// Create source page
|
|
65
|
+
const sourcePageDir = join(paths.wiki, "sources");
|
|
66
|
+
mkdirSync(sourcePageDir, { recursive: true });
|
|
67
|
+
const sourcePagePath = join(sourcePageDir, `${sourceId}.md`);
|
|
68
|
+
|
|
69
|
+
const tagLine = category ? `category: ${category}` : "";
|
|
70
|
+
const sourcePageContent = [
|
|
71
|
+
"---",
|
|
72
|
+
"type: source",
|
|
73
|
+
`title: "${title}"`,
|
|
74
|
+
`source_id: ${sourceId}`,
|
|
75
|
+
"status: insight",
|
|
76
|
+
`created: ${today}`,
|
|
77
|
+
`updated: ${today}`,
|
|
78
|
+
tagLine,
|
|
79
|
+
"---",
|
|
80
|
+
"",
|
|
81
|
+
`# ${title}`,
|
|
82
|
+
"",
|
|
83
|
+
body,
|
|
84
|
+
"",
|
|
85
|
+
"## Source",
|
|
86
|
+
"",
|
|
87
|
+
`- **Packet:** \`${packetPath}\``,
|
|
88
|
+
`- **Captured:** ${today}`,
|
|
89
|
+
category ? `- **Category:** ${category}` : "",
|
|
90
|
+
"",
|
|
91
|
+
"## Related",
|
|
92
|
+
"",
|
|
93
|
+
"_(Add [[wikilinks]] to related pages)_",
|
|
94
|
+
"",
|
|
95
|
+
]
|
|
96
|
+
.filter((l) => l !== "")
|
|
97
|
+
.join("\n");
|
|
98
|
+
writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
|
|
99
|
+
|
|
100
|
+
// Log event
|
|
101
|
+
appendEvent(paths, {
|
|
102
|
+
kind: "retro",
|
|
103
|
+
source_id: sourceId,
|
|
104
|
+
title,
|
|
105
|
+
slug,
|
|
106
|
+
category: category || "uncategorized",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Rebuild metadata
|
|
110
|
+
rebuildMetadataLight(paths);
|
|
111
|
+
|
|
112
|
+
return { sourceId, packetPath, sourcePagePath };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Tool Registration ──────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register the `wiki_retro` tool.
|
|
119
|
+
* The model calls this to save an atomic insight from a completed task.
|
|
120
|
+
* Inspired by the memex_retro pattern.
|
|
121
|
+
*/
|
|
122
|
+
export function registerWikiRetro(pi: ExtensionAPI): void {
|
|
123
|
+
pi.registerTool({
|
|
124
|
+
name: "wiki_retro",
|
|
125
|
+
label: "Wiki Retro",
|
|
126
|
+
description:
|
|
127
|
+
"Save an atomic insight from a completed task into the wiki. " +
|
|
128
|
+
"Creates a source packet and source page. The insight will be " +
|
|
129
|
+
"surfaced automatically by wiki_recall in future sessions.",
|
|
130
|
+
promptSnippet: "Save atomic insights from completed tasks into the wiki",
|
|
131
|
+
promptGuidelines: [
|
|
132
|
+
"Use wiki_retro at the END of every meaningful task to save what you learned.",
|
|
133
|
+
"Write atomic insights — one insight per call. Use multiple calls for multiple insights.",
|
|
134
|
+
"The insight will be auto-surfaced by wiki_recall in future sessions.",
|
|
135
|
+
],
|
|
136
|
+
parameters: Type.Object({
|
|
137
|
+
slug: Type.String({
|
|
138
|
+
description:
|
|
139
|
+
"Unique kebab-case identifier (e.g. 'jwt-revocation-pattern'). Used for lookups.",
|
|
140
|
+
}),
|
|
141
|
+
title: Type.String({
|
|
142
|
+
description: "Short descriptive title (60 chars max). Noun phrase, not a sentence.",
|
|
143
|
+
}),
|
|
144
|
+
body: Type.String({
|
|
145
|
+
description:
|
|
146
|
+
"Markdown body with [[wikilinks]] to related wiki pages. Explain what was learned.",
|
|
147
|
+
}),
|
|
148
|
+
category: Type.Optional(
|
|
149
|
+
Type.String({
|
|
150
|
+
description: "Optional category (e.g. frontend, architecture, devops, bugfix, design)",
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
}),
|
|
154
|
+
async execute(_toolCallId, params) {
|
|
155
|
+
const paths = resolveVaultPaths(process.cwd());
|
|
156
|
+
|
|
157
|
+
if (!existsSync(join(paths.dotWiki, "config.json"))) {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{
|
|
161
|
+
type: "text",
|
|
162
|
+
text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
details: { error: "no_vault" } as Record<string, unknown>,
|
|
166
|
+
isError: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = saveInsight(paths, params.slug, params.title, params.body, params.category);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: [
|
|
177
|
+
`🧠 **Insight saved**: ${params.title}`,
|
|
178
|
+
"",
|
|
179
|
+
`- Source: \`${result.sourceId}\``,
|
|
180
|
+
`- Packet: \`${result.packetPath}\``,
|
|
181
|
+
`- Page: \`${result.sourcePagePath}\``,
|
|
182
|
+
"",
|
|
183
|
+
"This insight will be auto-surfaced by wiki_recall in future sessions.",
|
|
184
|
+
].join("\n"),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
details: {
|
|
188
|
+
sourceId: result.sourceId,
|
|
189
|
+
slug: params.slug,
|
|
190
|
+
title: params.title,
|
|
191
|
+
category: params.category || null,
|
|
192
|
+
} as Record<string, unknown>,
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -13,13 +13,14 @@ import {
|
|
|
13
13
|
import { captureFile, captureText, captureUrl } from "./source-packet.js";
|
|
14
14
|
import {
|
|
15
15
|
type VaultPaths,
|
|
16
|
+
detectVaultFormat,
|
|
16
17
|
ensureVaultStructure,
|
|
17
18
|
extractWikilinks,
|
|
18
19
|
findWikiPages,
|
|
19
20
|
fmtDate,
|
|
20
21
|
getVaultPaths,
|
|
21
22
|
readJson,
|
|
22
|
-
|
|
23
|
+
resolveVaultPaths,
|
|
23
24
|
writeJson,
|
|
24
25
|
} from "./utils.js";
|
|
25
26
|
|
|
@@ -28,12 +29,11 @@ import {
|
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
function getPaths(cwd = process.cwd()): VaultPaths {
|
|
31
|
-
|
|
32
|
-
return getVaultPaths(root);
|
|
32
|
+
return resolveVaultPaths(cwd);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function requireVault(paths: VaultPaths): { ok: true } | { ok: false; reason: string } {
|
|
36
|
-
if (
|
|
36
|
+
if (detectVaultFormat(paths.root) === "none") {
|
|
37
37
|
return { ok: false, reason: `No wiki found at ${paths.root}. Run wiki_bootstrap first.` };
|
|
38
38
|
}
|
|
39
39
|
return { ok: true };
|
|
@@ -83,7 +83,7 @@ export function registerWikiBootstrap(pi: ExtensionAPI): void {
|
|
|
83
83
|
"| raw/** | extension | immutable after capture |",
|
|
84
84
|
"| wiki/** | model + user | editable knowledge pages |",
|
|
85
85
|
"| meta/* | extension | auto-generated |",
|
|
86
|
-
"| .
|
|
86
|
+
"| . | human + explicit request | operating rules |",
|
|
87
87
|
"",
|
|
88
88
|
"## Source Packet Format",
|
|
89
89
|
"",
|
|
@@ -109,7 +109,7 @@ export function registerWikiBootstrap(pi: ExtensionAPI): void {
|
|
|
109
109
|
"- Citation: [[sources/SRC-YYYY-MM-DD-NNN]]",
|
|
110
110
|
"",
|
|
111
111
|
].join("\n");
|
|
112
|
-
writeFileSync(join(paths.
|
|
112
|
+
writeFileSync(join(paths.dotWiki, "WIKI_SCHEMA.md"), schema, "utf-8");
|
|
113
113
|
|
|
114
114
|
rebuildMetadata(paths);
|
|
115
115
|
appendEvent(paths, { kind: "bootstrap", topic: params.topic, mode });
|
|
@@ -122,10 +122,11 @@ export function registerWikiBootstrap(pi: ExtensionAPI): void {
|
|
|
122
122
|
`✅ Wiki bootstrapped at \`${paths.root}\``,
|
|
123
123
|
"",
|
|
124
124
|
"**Structure:**",
|
|
125
|
-
"- raw/sources/ — immutable source packets",
|
|
126
|
-
"- wiki/ — editable knowledge pages",
|
|
127
|
-
"- meta/ — auto-generated metadata",
|
|
128
|
-
"- .wiki/ — config and templates",
|
|
125
|
+
"- .llm-wiki/raw/sources/ — immutable source packets",
|
|
126
|
+
"- .llm-wiki/wiki/ — editable knowledge pages",
|
|
127
|
+
"- .llm-wiki/meta/ — auto-generated metadata",
|
|
128
|
+
"- .llm-wiki/ — config and templates",
|
|
129
|
+
"- .llm-wiki/WIKI_SCHEMA.md — operating rules",
|
|
129
130
|
"",
|
|
130
131
|
"Next: Use wiki_capture_source to add your first source.",
|
|
131
132
|
].join("\n"),
|
|
@@ -17,24 +17,53 @@ export interface VaultPaths {
|
|
|
17
17
|
discoveries: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Detect whether a vault root uses new (.llm-wiki) or legacy (.wiki) layout. */
|
|
21
|
+
export type VaultFormat = "new" | "legacy" | "none";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect the vault format at a given directory.
|
|
25
|
+
* Returns "new" if .llm-wiki/config.json exists,
|
|
26
|
+
* "legacy" if .wiki/config.json exists,
|
|
27
|
+
* "none" otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export function detectVaultFormat(dir: string): VaultFormat {
|
|
30
|
+
if (existsSync(join(dir, ".llm-wiki", "config.json"))) return "new";
|
|
31
|
+
if (existsSync(join(dir, ".wiki", "config.json"))) return "legacy";
|
|
32
|
+
return "none";
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/** Resolve vault root from cwd or find nearest wiki root. */
|
|
21
36
|
export function resolveVaultRoot(cwd: string): string {
|
|
22
|
-
//
|
|
23
|
-
if (
|
|
37
|
+
// Check for any vault format at cwd
|
|
38
|
+
if (detectVaultFormat(cwd) !== "none") return cwd;
|
|
24
39
|
|
|
25
|
-
// Walk up looking for
|
|
40
|
+
// Walk up looking for a vault sentinel (new or legacy)
|
|
26
41
|
let dir = cwd;
|
|
27
42
|
while (dir !== dirname(dir)) {
|
|
28
|
-
if (existsSync(join(dir, ".wiki", "config.json"))) return dir;
|
|
29
43
|
dir = dirname(dir);
|
|
44
|
+
if (detectVaultFormat(dir) !== "none") return dir;
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
// Fallback: cwd itself
|
|
33
48
|
return cwd;
|
|
34
49
|
}
|
|
35
50
|
|
|
36
|
-
/** Get all vault paths. */
|
|
51
|
+
/** Get all vault paths for the new (.llm-wiki) layout. */
|
|
37
52
|
export function getVaultPaths(root: string): VaultPaths {
|
|
53
|
+
return {
|
|
54
|
+
root,
|
|
55
|
+
raw: join(root, ".llm-wiki", "raw"),
|
|
56
|
+
rawSources: join(root, ".llm-wiki", "raw", "sources"),
|
|
57
|
+
wiki: join(root, ".llm-wiki", "wiki"),
|
|
58
|
+
meta: join(root, ".llm-wiki", "meta"),
|
|
59
|
+
dotWiki: join(root, ".llm-wiki"),
|
|
60
|
+
outputs: join(root, ".llm-wiki", "outputs"),
|
|
61
|
+
discoveries: join(root, ".llm-wiki", ".discoveries"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Get all vault paths for the legacy (.wiki) layout. */
|
|
66
|
+
export function getLegacyVaultPaths(root: string): VaultPaths {
|
|
38
67
|
return {
|
|
39
68
|
root,
|
|
40
69
|
raw: join(root, "raw"),
|
|
@@ -47,6 +76,17 @@ export function getVaultPaths(root: string): VaultPaths {
|
|
|
47
76
|
};
|
|
48
77
|
}
|
|
49
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Resolve vault paths, auto-detecting new vs legacy layout.
|
|
81
|
+
* This is the main entry point: use this instead of resolveVaultRoot + getVaultPaths.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveVaultPaths(cwd: string): VaultPaths {
|
|
84
|
+
const root = resolveVaultRoot(cwd);
|
|
85
|
+
const format = detectVaultFormat(root);
|
|
86
|
+
if (format === "legacy") return getLegacyVaultPaths(root);
|
|
87
|
+
return getVaultPaths(root);
|
|
88
|
+
}
|
|
89
|
+
|
|
50
90
|
/** Ensure all vault directories exist. */
|
|
51
91
|
export function ensureVaultStructure(paths: VaultPaths): void {
|
|
52
92
|
const dirs = [
|
|
@@ -199,10 +239,10 @@ export async function exec(
|
|
|
199
239
|
/** Check if a path is inside a protected directory. */
|
|
200
240
|
export function isProtectedPath(
|
|
201
241
|
absPath: string,
|
|
202
|
-
|
|
242
|
+
paths: VaultPaths,
|
|
203
243
|
): { protected: boolean; reason?: string } {
|
|
204
|
-
const rawPath = resolve(
|
|
205
|
-
const metaPath = resolve(
|
|
244
|
+
const rawPath = resolve(paths.raw);
|
|
245
|
+
const metaPath = resolve(paths.meta);
|
|
206
246
|
const norm = resolve(absPath);
|
|
207
247
|
|
|
208
248
|
if (norm.startsWith(`${rawPath}/`) || norm === rawPath) {
|