src / wikiCore.ts
import * as path from "node:path";
import * as fs from "node:fs/promises";
import {
PAGES_DIR,
SOURCES_DIR,
WikiError,
WikiLayout,
pageFileName,
pathExists,
sourceFileName,
sourceMetaPath,
} from "./wikiPaths";
const PAGES_MARK_OPEN = "<!-- wiki:pages:auto -->";
const PAGES_MARK_CLOSE = "<!-- /wiki:pages:auto -->";
const SOURCES_MARK_OPEN = "<!-- wiki:sources:auto -->";
const SOURCES_MARK_CLOSE = "<!-- /wiki:sources:auto -->";
const SCHEMA_TEMPLATE = `# Wiki schema and conventions
This file describes how this wiki is organized. Edit it to encode your own
conventions; the model should read it before contributing.
## Structure
- \`pages/\` — wiki pages (markdown), one topic per file. Editable.
- \`sources/\` — raw immutable sources (articles, transcripts, papers). Treat
as read-only references; do not rewrite them.
- \`index.md\` — auto-maintained catalog of pages and sources. Plugin owns the
content between \`<!-- wiki:pages:auto -->\` markers; everything outside is
free for the user to edit.
- \`log.md\` — append-only activity log.
- \`.wiki-backup/\` — automatic snapshots of overwritten/deleted files.
## Naming
Pages: kebab-case, \`.md\` extension. One canonical name per concept.
## Page structure
A page may begin with a YAML frontmatter block (optional):
\`\`\`yaml
---
category: <category>
tags: [tag1, tag2]
summary: One-sentence summary used in the index.
---
\`\`\`
## Cross-references
Use \`[link text](other-page.md)\` to link between pages. The lint tool will
flag broken links and pages that are never linked from anywhere.
## Workflows (Karpathy gist)
- **Ingest**: a new source arrives → save to \`sources/\` (with origin) →
summarize → create or update the relevant pages → cross-link.
- **Query**: search pages → synthesize an answer from what is found → cite
sources/pages by link → optionally file the answer as a new page or as a
log entry.
- **Lint**: periodically run \`wiki_lint\` to find broken links, orphans,
duplicate names, and index drift.
`;
export function buildIndexTemplate(name: string, description: string): string {
const title = name.trim() || "Wiki";
const desc = description.trim() ? description.trim() + "\n\n" : "";
return `# ${title}
${desc}## Pages
${PAGES_MARK_OPEN}
_(no pages yet — create one with wiki_write_page)_
${PAGES_MARK_CLOSE}
## Sources
${SOURCES_MARK_OPEN}
_(no sources yet)_
${SOURCES_MARK_CLOSE}
## See also
- [Schema and conventions](WIKI.md)
- [Activity log](log.md)
`;
}
export function buildLogTemplate(): string {
return `# Activity log
Append-only record of operations on this wiki. One line per event:
\`<ISO timestamp> [<kind>] <subject> — <message>\`
`;
}
export function buildSchemaTemplate(custom?: string): string {
if (custom && custom.trim()) {
return `# Wiki schema and conventions
${custom.trim()}
`;
}
return SCHEMA_TEMPLATE;
}
export interface InitOptions {
name?: string;
description?: string;
schema?: string;
overwrite?: boolean;
}
export interface InitResult {
root: string;
created: string[];
skipped: string[];
}
export async function initWiki(
layout: WikiLayout,
opts: InitOptions,
): Promise<InitResult> {
const created: string[] = [];
const skipped: string[] = [];
await fs.mkdir(layout.root, { recursive: true });
for (const dir of [layout.pagesDir, layout.sourcesDir]) {
const existed = await pathExists(dir);
await fs.mkdir(dir, { recursive: true });
if (!existed) created.push(path.relative(layout.root, dir) + "/");
}
const name = opts.name?.trim() || path.basename(layout.root);
const description = opts.description?.trim() ?? "";
const writes: Array<{ p: string; content: string; rel: string }> = [
{ p: layout.indexPath, content: buildIndexTemplate(name, description), rel: "index.md" },
{ p: layout.logPath, content: buildLogTemplate(), rel: "log.md" },
{ p: layout.schemaPath, content: buildSchemaTemplate(opts.schema), rel: "WIKI.md" },
];
for (const w of writes) {
if (await pathExists(w.p)) {
if (!opts.overwrite) {
skipped.push(w.rel);
continue;
}
}
await fs.writeFile(w.p, w.content, "utf-8");
created.push(w.rel);
}
await appendLog(layout, "init", name, `wiki initialized at ${layout.root}`);
return { root: layout.root, created, skipped };
}
export async function appendLog(
layout: WikiLayout,
kind: string,
subject: string,
message: string,
): Promise<void> {
if (!(await pathExists(layout.logPath))) return;
const ts = new Date().toISOString();
const safeSubject = subject.replace(/\n/g, " ").trim();
const safeMessage = message.replace(/\n/g, " ").trim();
const line = `${ts} [${kind}] ${safeSubject} — ${safeMessage}\n`;
await fs.appendFile(layout.logPath, line, "utf-8");
}
export async function readLogTail(
layout: WikiLayout,
n: number,
): Promise<string[]> {
if (!(await pathExists(layout.logPath))) return [];
const content = await fs.readFile(layout.logPath, "utf-8");
const lines = content.split("\n").filter((l) => /^\d{4}-\d{2}-\d{2}T/.test(l));
return lines.slice(-n);
}
async function readPageSummary(absPath: string): Promise<string | null> {
try {
const buf = await fs.readFile(absPath, "utf-8");
const lines = buf.split("\n");
let i = 0;
if (lines[0]?.trim() === "---") {
const end = lines.indexOf("---", 1);
if (end > 0) {
for (let j = 1; j < end; j++) {
const m = lines[j].match(/^\s*(summary|description)\s*:\s*(.+?)\s*$/i);
if (m) return m[2].replace(/^["']|["']$/g, "").trim();
}
i = end + 1;
}
}
for (; i < lines.length; i++) {
const t = lines[i].trim();
if (!t) continue;
if (t.startsWith("#")) continue;
return t.length > 140 ? t.slice(0, 140) + "…" : t;
}
return null;
} catch {
return null;
}
}
async function readSourceOrigin(layout: WikiLayout, name: string): Promise<string | null> {
try {
const meta = await fs.readFile(sourceMetaPath(layout, name), "utf-8");
const obj = JSON.parse(meta);
if (typeof obj?.origin === "string") return obj.origin;
} catch {
/* no meta */
}
return null;
}
export interface PageEntry {
name: string;
file: string;
summary: string | null;
}
export async function listPageEntries(layout: WikiLayout): Promise<PageEntry[]> {
if (!(await pathExists(layout.pagesDir))) return [];
const entries = await fs.readdir(layout.pagesDir, { withFileTypes: true });
const pages: PageEntry[] = [];
for (const e of entries) {
if (!e.isFile()) continue;
if (!e.name.toLowerCase().endsWith(".md")) continue;
const abs = path.join(layout.pagesDir, e.name);
const summary = await readPageSummary(abs);
const baseName = e.name.replace(/\.md$/i, "");
pages.push({ name: baseName, file: e.name, summary });
}
pages.sort((a, b) => a.name.localeCompare(b.name));
return pages;
}
export interface SourceEntry {
name: string;
origin: string | null;
}
export async function listSourceEntries(layout: WikiLayout): Promise<SourceEntry[]> {
if (!(await pathExists(layout.sourcesDir))) return [];
const entries = await fs.readdir(layout.sourcesDir, { withFileTypes: true });
const sources: SourceEntry[] = [];
for (const e of entries) {
if (!e.isFile()) continue;
if (e.name.endsWith(".meta.json")) continue;
const origin = await readSourceOrigin(layout, e.name);
sources.push({ name: e.name, origin });
}
sources.sort((a, b) => a.name.localeCompare(b.name));
return sources;
}
function renderPagesBlock(pages: PageEntry[]): string {
if (!pages.length) return "_(no pages yet — create one with wiki_write_page)_";
return pages
.map((p) =>
p.summary
? `- [${p.name}](${PAGES_DIR}/${p.file}) — ${p.summary}`
: `- [${p.name}](${PAGES_DIR}/${p.file})`,
)
.join("\n");
}
function renderSourcesBlock(sources: SourceEntry[]): string {
if (!sources.length) return "_(no sources yet)_";
return sources
.map((s) =>
s.origin
? `- [${s.name}](${SOURCES_DIR}/${s.name}) — ${s.origin}`
: `- [${s.name}](${SOURCES_DIR}/${s.name})`,
)
.join("\n");
}
function replaceMarkedRegion(
content: string,
open: string,
close: string,
body: string,
): { content: string; replaced: boolean } {
const oi = content.indexOf(open);
const ci = content.indexOf(close);
if (oi < 0 || ci < 0 || ci < oi) return { content, replaced: false };
const before = content.slice(0, oi + open.length);
const after = content.slice(ci);
return { content: `${before}\n${body}\n${after}`, replaced: true };
}
export async function refreshIndex(layout: WikiLayout): Promise<{
pages: number;
sources: number;
rebuilt: boolean;
}> {
if (!(await pathExists(layout.indexPath))) {
return { pages: 0, sources: 0, rebuilt: false };
}
const pages = await listPageEntries(layout);
const sources = await listSourceEntries(layout);
let content = await fs.readFile(layout.indexPath, "utf-8");
let rebuilt = false;
const r1 = replaceMarkedRegion(content, PAGES_MARK_OPEN, PAGES_MARK_CLOSE, renderPagesBlock(pages));
if (r1.replaced) {
content = r1.content;
rebuilt = true;
}
const r2 = replaceMarkedRegion(content, SOURCES_MARK_OPEN, SOURCES_MARK_CLOSE, renderSourcesBlock(sources));
if (r2.replaced) {
content = r2.content;
rebuilt = true;
}
if (rebuilt) {
await fs.writeFile(layout.indexPath, content, "utf-8");
}
return { pages: pages.length, sources: sources.length, rebuilt };
}
export async function getStatus(layout: WikiLayout): Promise<{
initialized: boolean;
root: string;
pages: number;
sources: number;
recent_log: string[];
}> {
const initialized = await pathExists(layout.indexPath);
if (!initialized) {
return { initialized: false, root: layout.root, pages: 0, sources: 0, recent_log: [] };
}
const pages = await listPageEntries(layout);
const sources = await listSourceEntries(layout);
const tail = await readLogTail(layout, 10);
return {
initialized: true,
root: layout.root,
pages: pages.length,
sources: sources.length,
recent_log: tail,
};
}
export { WikiError, pageFileName, sourceFileName };