@zosmaai/pi-llm-wiki 0.10.7 → 0.11.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 +4 -0
- package/README.de.md +35 -4
- package/README.es.md +260 -170
- package/README.fr.md +35 -4
- package/README.hi.md +35 -4
- package/README.ja.md +35 -4
- package/README.ko.md +35 -4
- package/README.md +38 -3
- package/README.pt.md +35 -4
- package/README.ru.md +35 -4
- package/README.zh.md +260 -170
- package/assets/demo.gif +0 -0
- package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
- package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
- package/dist/extensions/llm-wiki/lib/inject.js +65 -0
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
- package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
- package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
- package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/observation.js +283 -0
- package/dist/extensions/llm-wiki/lib/recall.js +875 -0
- package/dist/extensions/llm-wiki/lib/retro.js +158 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
- package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
- package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
- package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
- package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
- package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
- package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
- package/dist/extensions/llm-wiki/lib/utils.js +347 -0
- package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
- package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
- package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
- package/dist/mcp/exec.js +121 -0
- package/dist/mcp/index.js +229 -0
- package/dist/mcp/operations.js +130 -0
- package/dist/package.json +1 -0
- package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
- package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
- package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
- package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
- package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
- package/extensions/llm-wiki/index.ts +22 -36
- package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
- package/extensions/llm-wiki/lib/embeddings.ts +9 -3
- package/extensions/llm-wiki/lib/guardrails.ts +174 -29
- package/extensions/llm-wiki/lib/indexing.ts +2 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
- package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
- package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
- package/extensions/llm-wiki/lib/metadata.ts +531 -116
- package/extensions/llm-wiki/lib/observation.ts +37 -43
- package/extensions/llm-wiki/lib/recall.ts +61 -33
- package/extensions/llm-wiki/lib/retro.ts +65 -41
- package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
- package/extensions/llm-wiki/lib/source-packet.ts +44 -31
- package/extensions/llm-wiki/lib/tools.ts +406 -348
- package/extensions/llm-wiki/lib/trajectory.ts +15 -1
- package/extensions/llm-wiki/lib/utils.ts +121 -130
- package/extensions/llm-wiki/lib/vault-format.ts +363 -0
- package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
- package/mcp/exec.ts +122 -0
- package/mcp/index.ts +60 -250
- package/mcp/operations.ts +176 -0
- package/package.json +8 -2
- package/scripts/migrate-llm-wiki.js +801 -0
- package/skills/llm-wiki/SKILL.md +8 -6
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { buildResolvedBacklinks } from "./knowledge-links.js";
|
|
5
|
+
import { isPathWithin, readJson } from "./utils.js";
|
|
6
|
+
import { assertWritableVault, compareCodePoint, discoverKnowledgeDocuments, inspectVaultFormat, } from "./vault-format.js";
|
|
7
|
+
/** Rebuild the complete metadata layer with fail-closed semantics. */
|
|
8
|
+
export function rebuildMetadata(paths) {
|
|
9
|
+
// Step 1: Validate vault format and mode
|
|
10
|
+
const vaultState = inspectVaultFormat(paths);
|
|
11
|
+
if (vaultState.blocking) {
|
|
12
|
+
return { ok: false, diagnostics: vaultState.diagnostics };
|
|
13
|
+
}
|
|
14
|
+
// Step 2: Discover all knowledge documents
|
|
15
|
+
const discovery = discoverKnowledgeDocuments(paths);
|
|
16
|
+
if (discovery.blocking) {
|
|
17
|
+
return { ok: false, diagnostics: discovery.diagnostics };
|
|
18
|
+
}
|
|
19
|
+
const documents = discovery.documents;
|
|
20
|
+
const allDiagnostics = [
|
|
21
|
+
...vaultState.diagnostics,
|
|
22
|
+
...discovery.diagnostics,
|
|
23
|
+
];
|
|
24
|
+
// Step 3: Build registry from documents + raw fallbacks
|
|
25
|
+
const registry = buildRegistry(paths, documents);
|
|
26
|
+
// Step 4: Build backlinks from discovered documents
|
|
27
|
+
const knownIds = new Set(documents.map((d) => d.id));
|
|
28
|
+
const backlinks = buildBacklinks(documents, knownIds, allDiagnostics);
|
|
29
|
+
const eventLogResult = buildOkfLog(readText(join(paths.meta, "events.jsonl")));
|
|
30
|
+
allDiagnostics.push(...eventLogResult.diagnostics);
|
|
31
|
+
// Step 5: Build meta/index.md
|
|
32
|
+
const metaIndex = buildIndexMarkdown(registry);
|
|
33
|
+
// Step 6: Build meta/log.md (existing rich format)
|
|
34
|
+
const metaLog = buildLogMarkdown(paths);
|
|
35
|
+
// Step 7: Build OKF projections if in okf-0.2 mode
|
|
36
|
+
const okfIndexes = vaultState.knowledgeFormat === "okf-0.2"
|
|
37
|
+
? buildDirectoryIndexes(documents, readJson(join(paths.dotWiki, "config.json"), {}))
|
|
38
|
+
: null;
|
|
39
|
+
const okfLog = vaultState.knowledgeFormat === "okf-0.2" ? eventLogResult.markdown : null;
|
|
40
|
+
// Step 8: Atomic write all projections
|
|
41
|
+
mkdirSync(paths.meta, { recursive: true });
|
|
42
|
+
const registryJson = `${JSON.stringify(registry, null, 2)}\n`;
|
|
43
|
+
const backlinksJson = `${JSON.stringify(backlinks, null, 2)}\n`;
|
|
44
|
+
atomicWriteFile(join(paths.meta, "registry.json"), registryJson);
|
|
45
|
+
atomicWriteFile(join(paths.meta, "backlinks.json"), backlinksJson);
|
|
46
|
+
atomicWriteFile(join(paths.meta, "index.md"), metaIndex);
|
|
47
|
+
atomicWriteFile(join(paths.meta, "log.md"), metaLog);
|
|
48
|
+
// Step 9: Write OKF projections if applicable
|
|
49
|
+
if (okfIndexes && okfIndexes.size > 0) {
|
|
50
|
+
mkdirSync(paths.wiki, { recursive: true });
|
|
51
|
+
for (const [indexPath, content] of okfIndexes) {
|
|
52
|
+
atomicWriteFile(join(paths.wiki, indexPath), content);
|
|
53
|
+
}
|
|
54
|
+
// Prune obsolete generated indexes
|
|
55
|
+
pruneObsoleteIndexes(paths, okfIndexes);
|
|
56
|
+
}
|
|
57
|
+
if (okfLog !== null) {
|
|
58
|
+
mkdirSync(paths.wiki, { recursive: true });
|
|
59
|
+
atomicWriteFile(join(paths.wiki, "log.md"), okfLog);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
diagnostics: allDiagnostics,
|
|
64
|
+
registry,
|
|
65
|
+
backlinks,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Lightweight rebuild is the same as full rebuild. */
|
|
69
|
+
export const rebuildMetadataLight = rebuildMetadata;
|
|
70
|
+
/** Build registry from discovered documents and raw fallbacks. */
|
|
71
|
+
function buildRegistry(paths, documents) {
|
|
72
|
+
const pages = {};
|
|
73
|
+
// Add discovered documents
|
|
74
|
+
for (const doc of documents) {
|
|
75
|
+
const title = getSemanticTitle(doc);
|
|
76
|
+
const entry = {
|
|
77
|
+
type: doc.frontmatter.type,
|
|
78
|
+
title,
|
|
79
|
+
};
|
|
80
|
+
// Copy known frontmatter fields (excluding type which is already set)
|
|
81
|
+
for (const key of [
|
|
82
|
+
"description",
|
|
83
|
+
"tags",
|
|
84
|
+
"category",
|
|
85
|
+
"domain",
|
|
86
|
+
"aliases",
|
|
87
|
+
"recall_triggers",
|
|
88
|
+
"status",
|
|
89
|
+
"stale_after",
|
|
90
|
+
"resource",
|
|
91
|
+
"generated",
|
|
92
|
+
"verified",
|
|
93
|
+
"summary",
|
|
94
|
+
"raw_path",
|
|
95
|
+
"source_id",
|
|
96
|
+
]) {
|
|
97
|
+
if (doc.frontmatter[key] !== undefined) {
|
|
98
|
+
entry[key] = doc.frontmatter[key];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Copy extension fields
|
|
102
|
+
for (const [key, value] of Object.entries(doc.extensions)) {
|
|
103
|
+
if (!(key in entry)) {
|
|
104
|
+
entry[key] = value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Only include created/updated if they are non-empty strings
|
|
108
|
+
if (typeof doc.frontmatter.created === "string" && doc.frontmatter.created.trim()) {
|
|
109
|
+
entry.created = doc.frontmatter.created;
|
|
110
|
+
}
|
|
111
|
+
if (typeof doc.frontmatter.updated === "string" && doc.frontmatter.updated.trim()) {
|
|
112
|
+
entry.updated = doc.frontmatter.updated;
|
|
113
|
+
}
|
|
114
|
+
pages[doc.id] = entry;
|
|
115
|
+
}
|
|
116
|
+
// Raw source/trajectory fallback entries (registry-only compatibility)
|
|
117
|
+
addRawFallbacks(paths, pages, "sources", "source");
|
|
118
|
+
addRawFallbacks(paths, pages, "trajectories", "trajectory");
|
|
119
|
+
return {
|
|
120
|
+
version: "1.0",
|
|
121
|
+
last_updated: new Date().toISOString(),
|
|
122
|
+
pages,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function addRawFallbacks(paths, pages, dirName, type) {
|
|
126
|
+
const rawDir = dirName === "sources" ? paths.rawSources : paths.rawTrajectories;
|
|
127
|
+
if (!existsSync(rawDir))
|
|
128
|
+
return;
|
|
129
|
+
for (const entry of readdirSync(rawDir)) {
|
|
130
|
+
const manifestPath = join(rawDir, entry, "manifest.json");
|
|
131
|
+
if (!existsSync(manifestPath))
|
|
132
|
+
continue;
|
|
133
|
+
const manifest = readJson(manifestPath, {});
|
|
134
|
+
const id = String(manifest.id || entry);
|
|
135
|
+
const pageKey = `${dirName}/${id}`;
|
|
136
|
+
// Don't overwrite a parsed document
|
|
137
|
+
if (pages[pageKey])
|
|
138
|
+
continue;
|
|
139
|
+
const captured = String(manifest.captured || "");
|
|
140
|
+
pages[pageKey] = {
|
|
141
|
+
type,
|
|
142
|
+
title: String(manifest.title || id),
|
|
143
|
+
...(captured ? { created: captured, updated: captured } : {}),
|
|
144
|
+
...manifest,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function getSemanticTitle(doc) {
|
|
149
|
+
if (typeof doc.frontmatter.title === "string" && doc.frontmatter.title.trim()) {
|
|
150
|
+
return doc.frontmatter.title.trim();
|
|
151
|
+
}
|
|
152
|
+
return doc.id.split("/").pop() || "Untitled";
|
|
153
|
+
}
|
|
154
|
+
/** Build backlinks from discovered documents using shared link resolution. */
|
|
155
|
+
function buildBacklinks(documents, knownIds, diagnostics) {
|
|
156
|
+
const inbound = {};
|
|
157
|
+
// Initialize parsed concept IDs with empty arrays
|
|
158
|
+
for (const doc of documents) {
|
|
159
|
+
inbound[doc.id] = [];
|
|
160
|
+
}
|
|
161
|
+
// Resolve links for each document
|
|
162
|
+
for (const doc of documents) {
|
|
163
|
+
const result = buildResolvedBacklinks(doc.id, doc.body, knownIds);
|
|
164
|
+
diagnostics.push(...result.diagnostics);
|
|
165
|
+
for (const target of result.targets) {
|
|
166
|
+
if (inbound[target] && !inbound[target].includes(doc.id)) {
|
|
167
|
+
inbound[target].push(doc.id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Sort targets for determinism
|
|
172
|
+
for (const [id, targets] of Object.entries(inbound)) {
|
|
173
|
+
inbound[id] = [...targets].sort(compareCodePoint);
|
|
174
|
+
}
|
|
175
|
+
return inbound;
|
|
176
|
+
}
|
|
177
|
+
/** Build meta/index.md in existing rich format. */
|
|
178
|
+
function buildIndexMarkdown(registry) {
|
|
179
|
+
const byType = {};
|
|
180
|
+
for (const [id, entry] of Object.entries(registry.pages)) {
|
|
181
|
+
const t = entry.type;
|
|
182
|
+
if (!byType[t])
|
|
183
|
+
byType[t] = [];
|
|
184
|
+
byType[t].push({ id, entry });
|
|
185
|
+
}
|
|
186
|
+
const sections = [];
|
|
187
|
+
sections.push("# Wiki Index\n\n> Auto-generated from meta/registry.json. Do not edit manually.\n");
|
|
188
|
+
for (const [type, items] of Object.entries(byType).sort()) {
|
|
189
|
+
const label = `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
|
|
190
|
+
sections.push(`## ${label}\n`);
|
|
191
|
+
for (const { id, entry } of items.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
192
|
+
sections.push(`- [[${id}]] — ${entry.title} *(created: ${entry.created || "unknown"})*`);
|
|
193
|
+
}
|
|
194
|
+
sections.push("");
|
|
195
|
+
}
|
|
196
|
+
sections.push(`---\n*Last updated: ${registry.last_updated}* | *Total pages: ${Object.keys(registry.pages).length}*`);
|
|
197
|
+
return `${sections.join("\n")}\n`;
|
|
198
|
+
}
|
|
199
|
+
/** Build meta/log.md in existing rich format. */
|
|
200
|
+
function buildLogMarkdown(paths) {
|
|
201
|
+
const eventsPath = join(paths.meta, "events.jsonl");
|
|
202
|
+
const events = [];
|
|
203
|
+
if (existsSync(eventsPath)) {
|
|
204
|
+
const raw = readText(eventsPath).trim();
|
|
205
|
+
for (const line of raw.split("\n")) {
|
|
206
|
+
if (!line.trim())
|
|
207
|
+
continue;
|
|
208
|
+
try {
|
|
209
|
+
const candidate = JSON.parse(line);
|
|
210
|
+
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
|
|
211
|
+
events.push(candidate);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// skip malformed
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const lines = [];
|
|
220
|
+
lines.push("# Activity Log\n\n> Auto-generated from meta/events.jsonl. Do not edit manually.\n");
|
|
221
|
+
for (const ev of events) {
|
|
222
|
+
const ts = ev.timestamp || "unknown";
|
|
223
|
+
const kind = ev.kind || "event";
|
|
224
|
+
const details = Object.entries(ev)
|
|
225
|
+
.filter(([k]) => k !== "timestamp" && k !== "kind")
|
|
226
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
227
|
+
.join(", ");
|
|
228
|
+
lines.push(`## [${ts}] ${kind}`);
|
|
229
|
+
if (details)
|
|
230
|
+
lines.push(`- ${details}`);
|
|
231
|
+
lines.push("");
|
|
232
|
+
}
|
|
233
|
+
if (events.length === 0) {
|
|
234
|
+
lines.push("_No events recorded yet._\n");
|
|
235
|
+
}
|
|
236
|
+
return `${lines.join("\n")}\n`;
|
|
237
|
+
}
|
|
238
|
+
/** Append an event to events.jsonl. */
|
|
239
|
+
export function appendEvent(paths, event) {
|
|
240
|
+
assertWritableVault(paths);
|
|
241
|
+
const { timestamp: _ignored, kind: rawKind, ...details } = event;
|
|
242
|
+
const kind = typeof rawKind === "string" ? rawKind.trim() : "";
|
|
243
|
+
if (!kind)
|
|
244
|
+
throw new Error("Event kind must be a non-empty string");
|
|
245
|
+
mkdirSync(paths.meta, { recursive: true });
|
|
246
|
+
const eventsPath = join(paths.meta, "events.jsonl");
|
|
247
|
+
const line = JSON.stringify({ ...details, timestamp: new Date().toISOString(), kind });
|
|
248
|
+
writeFileSync(eventsPath, `${line}\n`, { flag: "a", encoding: "utf-8" });
|
|
249
|
+
}
|
|
250
|
+
/** Atomic write: temp file + rename. */
|
|
251
|
+
function atomicWriteFile(path, content) {
|
|
252
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
253
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
254
|
+
writeFileSync(temporary, content, "utf8");
|
|
255
|
+
renameSync(temporary, path);
|
|
256
|
+
}
|
|
257
|
+
/** Read text file or return empty string. */
|
|
258
|
+
function readText(path) {
|
|
259
|
+
try {
|
|
260
|
+
if (!existsSync(path))
|
|
261
|
+
return "";
|
|
262
|
+
return readFileSync(path, "utf8");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Prune obsolete generated indexes in OKF mode. */
|
|
269
|
+
function pruneObsoleteIndexes(paths, currentIndexes) {
|
|
270
|
+
const currentPaths = new Set(currentIndexes.keys());
|
|
271
|
+
function walkDir(dir, relative) {
|
|
272
|
+
const results = [];
|
|
273
|
+
if (!existsSync(dir))
|
|
274
|
+
return results;
|
|
275
|
+
for (const entry of readdirSync(dir)) {
|
|
276
|
+
const fullPath = join(dir, entry);
|
|
277
|
+
let stat;
|
|
278
|
+
try {
|
|
279
|
+
stat = lstatSync(fullPath);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (stat.isSymbolicLink())
|
|
285
|
+
continue;
|
|
286
|
+
const relPath = relative ? `${relative}/${entry}` : entry;
|
|
287
|
+
if (entry.toLowerCase() === "index.md") {
|
|
288
|
+
results.push(relPath);
|
|
289
|
+
}
|
|
290
|
+
else if (stat.isDirectory()) {
|
|
291
|
+
results.push(...walkDir(fullPath, relPath));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return results;
|
|
295
|
+
}
|
|
296
|
+
const allIndexes = walkDir(paths.wiki, "");
|
|
297
|
+
for (const indexPath of allIndexes) {
|
|
298
|
+
// Never prune root index.md
|
|
299
|
+
if (indexPath === "index.md")
|
|
300
|
+
continue;
|
|
301
|
+
if (!currentPaths.has(indexPath)) {
|
|
302
|
+
const fullPath = join(paths.wiki, indexPath);
|
|
303
|
+
try {
|
|
304
|
+
if (lstatSync(fullPath).isSymbolicLink() || !isPathWithin(paths.wiki, fullPath))
|
|
305
|
+
continue;
|
|
306
|
+
rmSync(fullPath);
|
|
307
|
+
// Try to remove parent dir if empty
|
|
308
|
+
const parentDir = dirname(fullPath);
|
|
309
|
+
if (!lstatSync(parentDir).isSymbolicLink() &&
|
|
310
|
+
isPathWithin(paths.wiki, parentDir) &&
|
|
311
|
+
existsSync(parentDir) &&
|
|
312
|
+
readdirSync(parentDir).length === 0) {
|
|
313
|
+
rmSync(parentDir);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Ignore errors during pruning
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// ===== OKF Projection Renderers =====
|
|
323
|
+
function escapeLabel(value) {
|
|
324
|
+
return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
|
325
|
+
}
|
|
326
|
+
function encodeRelativePath(value) {
|
|
327
|
+
return value.split("/").map(encodeURIComponent).join("/");
|
|
328
|
+
}
|
|
329
|
+
function compactDescription(value) {
|
|
330
|
+
if (typeof value !== "string")
|
|
331
|
+
return undefined;
|
|
332
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
333
|
+
return compact || undefined;
|
|
334
|
+
}
|
|
335
|
+
export function buildDirectoryIndexes(documents, config) {
|
|
336
|
+
const indexes = new Map();
|
|
337
|
+
// Build directory tree from concept documents only
|
|
338
|
+
const directories = new Map();
|
|
339
|
+
for (const doc of documents) {
|
|
340
|
+
const parts = doc.id.split("/");
|
|
341
|
+
// Walk the path, tracking parent-child directory relationships
|
|
342
|
+
for (let i = 0; i < parts.length; i++) {
|
|
343
|
+
const parentPath = i === 0 ? "" : parts.slice(0, i).join("/");
|
|
344
|
+
if (!directories.has(parentPath)) {
|
|
345
|
+
directories.set(parentPath, { dirs: new Set(), concepts: [] });
|
|
346
|
+
}
|
|
347
|
+
if (i === parts.length - 1) {
|
|
348
|
+
// Last part is the concept file
|
|
349
|
+
directories.get(parentPath).concepts.push(doc);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// This is a subdirectory
|
|
353
|
+
directories.get(parentPath).dirs.add(parts[i]);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Always emit root index
|
|
358
|
+
const vaultName = typeof config.name === "string" && config.name.trim() ? config.name.trim() : "Wiki";
|
|
359
|
+
if (!directories.has("")) {
|
|
360
|
+
directories.set("", { dirs: new Set(), concepts: [] });
|
|
361
|
+
}
|
|
362
|
+
// Render each index
|
|
363
|
+
for (const [dirPath, { dirs, concepts }] of directories) {
|
|
364
|
+
const indexPath = dirPath ? `${dirPath}/index.md` : "index.md";
|
|
365
|
+
const lines = [];
|
|
366
|
+
if (dirPath === "") {
|
|
367
|
+
lines.push("---");
|
|
368
|
+
lines.push('okf_version: "0.2"');
|
|
369
|
+
lines.push("---");
|
|
370
|
+
lines.push("");
|
|
371
|
+
lines.push(`# ${escapeLabel(vaultName)}`);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const dirName = dirPath.split("/").pop();
|
|
375
|
+
lines.push(`# ${escapeLabel(dirName)}`);
|
|
376
|
+
}
|
|
377
|
+
// List directories first
|
|
378
|
+
if (dirs.size > 0) {
|
|
379
|
+
lines.push("");
|
|
380
|
+
lines.push("## Directories");
|
|
381
|
+
lines.push("");
|
|
382
|
+
for (const subDir of [...dirs].sort(compareCodePoint)) {
|
|
383
|
+
const encoded = `${encodeRelativePath(subDir)}/`;
|
|
384
|
+
lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// List concepts
|
|
388
|
+
if (concepts.length > 0) {
|
|
389
|
+
lines.push("");
|
|
390
|
+
lines.push("## Concepts");
|
|
391
|
+
lines.push("");
|
|
392
|
+
const sorted = [...concepts].sort((a, b) => {
|
|
393
|
+
const aRel = dirPath ? a.id.slice(dirPath.length + 1) : a.id;
|
|
394
|
+
const bRel = dirPath ? b.id.slice(dirPath.length + 1) : b.id;
|
|
395
|
+
return compareCodePoint(aRel, bRel);
|
|
396
|
+
});
|
|
397
|
+
for (const doc of sorted) {
|
|
398
|
+
const relId = dirPath ? doc.id.slice(dirPath.length + 1) : doc.id;
|
|
399
|
+
const title = typeof doc.frontmatter.title === "string" && doc.frontmatter.title.trim()
|
|
400
|
+
? doc.frontmatter.title.trim()
|
|
401
|
+
: relId.split("/").pop();
|
|
402
|
+
const desc = compactDescription(doc.frontmatter.description);
|
|
403
|
+
const encoded = encodeRelativePath(`${relId}.md`);
|
|
404
|
+
const descPart = desc ? ` — ${desc}` : "";
|
|
405
|
+
lines.push(`- [${escapeLabel(title)}](${encoded})${descPart}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
indexes.set(indexPath, `${lines.join("\n")}\n`);
|
|
409
|
+
}
|
|
410
|
+
return indexes;
|
|
411
|
+
}
|
|
412
|
+
function canonicalJsonValue(value) {
|
|
413
|
+
if (Array.isArray(value))
|
|
414
|
+
return value.map(canonicalJsonValue);
|
|
415
|
+
if (value && typeof value === "object") {
|
|
416
|
+
return Object.fromEntries(Object.entries(value)
|
|
417
|
+
.sort(([a], [b]) => compareCodePoint(a, b))
|
|
418
|
+
.map(([key, child]) => [key, canonicalJsonValue(child)]));
|
|
419
|
+
}
|
|
420
|
+
return value;
|
|
421
|
+
}
|
|
422
|
+
function okfDiag(severity, code, path, message) {
|
|
423
|
+
return { severity, code, path, message };
|
|
424
|
+
}
|
|
425
|
+
export function buildOkfLog(eventsJsonl, path = "meta/events.jsonl") {
|
|
426
|
+
const diagnostics = [];
|
|
427
|
+
const events = [];
|
|
428
|
+
const lines = eventsJsonl.split("\n");
|
|
429
|
+
for (let i = 0; i < lines.length; i++) {
|
|
430
|
+
const line = lines[i];
|
|
431
|
+
if (!line.trim())
|
|
432
|
+
continue;
|
|
433
|
+
let parsed;
|
|
434
|
+
try {
|
|
435
|
+
const candidate = JSON.parse(line);
|
|
436
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
437
|
+
throw new Error("event must be a JSON object");
|
|
438
|
+
}
|
|
439
|
+
parsed = candidate;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
diagnostics.push(okfDiag("warning", "event_invalid_json", path, `Invalid JSON at line ${i + 1}`));
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const rawTs = parsed.timestamp;
|
|
446
|
+
if (typeof rawTs !== "string" || Number.isNaN(Date.parse(rawTs))) {
|
|
447
|
+
diagnostics.push(okfDiag("warning", "event_invalid_timestamp", path, `Invalid timestamp at line ${i + 1}`));
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const rawKind = parsed.kind;
|
|
451
|
+
if (typeof rawKind !== "string" || !rawKind.trim()) {
|
|
452
|
+
diagnostics.push(okfDiag("warning", "event_missing_kind", path, `Missing kind at line ${i + 1}`));
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const ts = new Date(rawTs);
|
|
456
|
+
const date = ts.toISOString().split("T")[0];
|
|
457
|
+
const kind = rawKind.trim();
|
|
458
|
+
const detailEntries = Object.entries(parsed).filter(([k]) => k !== "timestamp" && k !== "kind");
|
|
459
|
+
const details = detailEntries.length > 0
|
|
460
|
+
? JSON.stringify(canonicalJsonValue(Object.fromEntries(detailEntries)))
|
|
461
|
+
: "";
|
|
462
|
+
events.push({ seq: i, timestamp: rawTs, epoch: ts.getTime(), date, kind, details });
|
|
463
|
+
}
|
|
464
|
+
// Group by date
|
|
465
|
+
const byDate = new Map();
|
|
466
|
+
for (const ev of events) {
|
|
467
|
+
if (!byDate.has(ev.date))
|
|
468
|
+
byDate.set(ev.date, []);
|
|
469
|
+
byDate.get(ev.date).push(ev);
|
|
470
|
+
}
|
|
471
|
+
const sortedDates = [...byDate.keys()].sort((a, b) => b.localeCompare(a));
|
|
472
|
+
const outLines = ["# Wiki Update Log"];
|
|
473
|
+
for (const date of sortedDates) {
|
|
474
|
+
const dayEvents = byDate.get(date);
|
|
475
|
+
dayEvents.sort((a, b) => b.epoch - a.epoch || b.seq - a.seq);
|
|
476
|
+
outLines.push("");
|
|
477
|
+
outLines.push(`## ${date}`);
|
|
478
|
+
outLines.push("");
|
|
479
|
+
for (const ev of dayEvents) {
|
|
480
|
+
const escapedKind = ev.kind
|
|
481
|
+
.replace(/\\/g, "\\\\")
|
|
482
|
+
.replace(/\*/g, "\\*")
|
|
483
|
+
.replace(/_/g, "\\_")
|
|
484
|
+
.replace(/\[/g, "\\[")
|
|
485
|
+
.replace(/\]/g, "\\]")
|
|
486
|
+
.replace(/\s+/g, " ");
|
|
487
|
+
if (ev.details) {
|
|
488
|
+
outLines.push(`- **${escapedKind}**: ${ev.details}`);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
outLines.push(`- **${escapedKind}**`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
markdown: `${outLines.join("\n")}\n`,
|
|
497
|
+
diagnostics,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { parseModelRef, persistTaskModel } from "./task-config.js";
|
|
2
|
+
/** Words that clear the override and revert to the session model. */
|
|
3
|
+
const CLEAR_WORDS = new Set(["session", "default", "reset", "clear", "none", "unset"]);
|
|
4
|
+
/** The status-bar key for the active-model label (so we can update it in place). */
|
|
5
|
+
export const MODEL_STATUS_KEY = "llm-wiki-model";
|
|
6
|
+
/**
|
|
7
|
+
* Human-readable label for the active background task model. Shows the
|
|
8
|
+
* configured `provider/id` when set, otherwise the session model (with its id
|
|
9
|
+
* when known). Pure — safe to unit test and reuse for the status line.
|
|
10
|
+
*/
|
|
11
|
+
export function formatActiveModelLabel(config, sessionModelId) {
|
|
12
|
+
if (config.taskModel)
|
|
13
|
+
return `${config.taskModel.provider}/${config.taskModel.id}`;
|
|
14
|
+
return sessionModelId ? `session model (${sessionModelId})` : "session model";
|
|
15
|
+
}
|
|
16
|
+
/** "provider/id" ref for a model. */
|
|
17
|
+
function modelRef(m) {
|
|
18
|
+
return `${m.provider}/${m.id}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Register the `/wiki-model` slash command. Lets the user view the active
|
|
22
|
+
* background task model and choose another (or revert to the session model).
|
|
23
|
+
* The choice is persisted to project settings and applied immediately.
|
|
24
|
+
*
|
|
25
|
+
* /wiki-model → interactive picker (lists available models)
|
|
26
|
+
* /wiki-model provider/id → set directly (scriptable / no UI needed)
|
|
27
|
+
* /wiki-model session|clear → clear the override, use the session model
|
|
28
|
+
*/
|
|
29
|
+
export function registerWikiModelCommand(pi, runtime) {
|
|
30
|
+
pi.registerCommand("wiki-model", {
|
|
31
|
+
description: "View or set the model used for LLM Wiki background tasks (default: session model)",
|
|
32
|
+
handler: async (args, ctx) => {
|
|
33
|
+
runtime.ensureConfig(ctx.cwd);
|
|
34
|
+
const sessionId = ctx.model?.id;
|
|
35
|
+
const apply = (model) => {
|
|
36
|
+
persistTaskModel(ctx.cwd, model);
|
|
37
|
+
runtime.config = { ...runtime.config, taskModel: model };
|
|
38
|
+
runtime.configLoaded = true;
|
|
39
|
+
const label = formatActiveModelLabel(runtime.config, sessionId);
|
|
40
|
+
ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${label}`);
|
|
41
|
+
ctx.ui.notify(`LLM Wiki: background tasks now use ${label}`, "info");
|
|
42
|
+
};
|
|
43
|
+
const trimmed = args.trim();
|
|
44
|
+
// Explicit clear → session model.
|
|
45
|
+
if (trimmed && CLEAR_WORDS.has(trimmed.toLowerCase())) {
|
|
46
|
+
apply(undefined);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Direct "provider/id" set (works without UI).
|
|
50
|
+
if (trimmed) {
|
|
51
|
+
const ref = parseModelRef(trimmed);
|
|
52
|
+
if (!ref) {
|
|
53
|
+
ctx.ui.notify(`LLM Wiki: could not parse "${trimmed}". Use provider/id (e.g. anthropic/claude-haiku) or "session".`, "error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const found = ctx.modelRegistry.find(ref.provider, ref.id);
|
|
57
|
+
if (!found) {
|
|
58
|
+
ctx.ui.notify(`LLM Wiki: model ${ref.provider}/${ref.id} is not in the registry (run /wiki-model with no argument to pick from available models).`, "error");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
apply({ provider: found.provider, id: found.id });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// No argument: interactive picker.
|
|
65
|
+
const current = formatActiveModelLabel(runtime.config, sessionId);
|
|
66
|
+
if (!ctx.hasUI) {
|
|
67
|
+
ctx.ui.notify(`LLM Wiki: active background model is ${current}. Pass provider/id to change it (no interactive UI here).`, "info");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const available = ctx.modelRegistry.getAvailable() ?? [];
|
|
71
|
+
const pool = available.length > 0 ? available : ctx.modelRegistry.getAll();
|
|
72
|
+
const sessionOption = "↩ Use session model (clear override)";
|
|
73
|
+
const options = [sessionOption, ...pool.map(modelRef)];
|
|
74
|
+
const picked = await ctx.ui.select(`Wiki background model (current: ${current})`, options);
|
|
75
|
+
if (picked === undefined)
|
|
76
|
+
return; // cancelled
|
|
77
|
+
if (picked === sessionOption) {
|
|
78
|
+
apply(undefined);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const ref = parseModelRef(picked);
|
|
82
|
+
if (ref)
|
|
83
|
+
apply(ref);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|