Phase 2: Read + Write + List + Search

Add core page operations and text search:
- wiki read/write/append for page CRUD via stdin/stdout
- wiki list with --tree and --json output
- wiki search with term-frequency scoring, snippets, --all flag
- WikiManager class and search engine in src/lib/
- 23 new tests (46 total)
This commit is contained in:
doum1004
2026-04-10 01:59:38 -04:00
parent fb39b3b1ff
commit f9c8d47b31
12 changed files with 604 additions and 21 deletions

View File

@@ -85,7 +85,7 @@ bun run typecheck # TypeScript check
See `docs/phase-{1-5}.md` for detailed tracking. Current status:
- Phase 1: **COMPLETE** — init, registry, use
- Phase 2: Not started — read, write, append, list, search
- Phase 2: **COMPLETE** — read, write, append, list, search
- Phase 3: Not started — index, log, commit, history, diff
- Phase 4: Not started — lint, links, backlinks, orphans, status
- Phase 5: Not started — auth, repo, push, pull, sync

View File

@@ -4,6 +4,11 @@ import { Command } from "commander";
import { makeInitCommand } from "../src/commands/init.ts";
import { makeRegistryCommand } from "../src/commands/registry.ts";
import { makeUseCommand } from "../src/commands/use.ts";
import { makeReadCommand } from "../src/commands/read.ts";
import { makeWriteCommand } from "../src/commands/write.ts";
import { makeAppendCommand } from "../src/commands/append.ts";
import { makeListCommand } from "../src/commands/list.ts";
import { makeSearchCommand } from "../src/commands/search.ts";
import { resolveWiki } from "../src/lib/resolver.ts";
import type { GlobalOptions } from "../src/types.ts";
@@ -20,6 +25,13 @@ program.addCommand(makeInitCommand());
program.addCommand(makeRegistryCommand());
program.addCommand(makeUseCommand());
// Commands that require wiki resolution
program.addCommand(makeReadCommand());
program.addCommand(makeWriteCommand());
program.addCommand(makeAppendCommand());
program.addCommand(makeListCommand());
program.addCommand(makeSearchCommand());
// Resolve wiki context for commands that need it
const SKIP_RESOLUTION = new Set(["init", "registry", "use"]);

View File

@@ -1,6 +1,6 @@
# Phase 2: Read + Write + List + Search
**Status**: NOT STARTED
**Status**: COMPLETE
**Goal**: LLM can read, write, and search wiki pages.
@@ -8,17 +8,17 @@
| File | Status | Description |
|------|--------|-------------|
| `src/lib/wiki.ts` | Pending | WikiManager class: readPage, writePage, appendPage, listPages, pageExists |
| `src/lib/search.ts` | Pending | Text search: term-frequency scoring, snippet extraction |
| `src/commands/read.ts` | Pending | `wiki read <path>` — print page to stdout |
| `src/commands/write.ts` | Pending | `wiki write <path>` — read stdin, write page |
| `src/commands/append.ts` | Pending | `wiki append <path>` — read stdin, append to page |
| `src/commands/list.ts` | Pending | `wiki list [dir] [--tree] [--json]` |
| `src/commands/search.ts` | Pending | `wiki search <query> [--limit N] [--all] [--json]` |
| `test/read-write.test.ts` | Pending | Read/write/append/list tests |
| `test/search.test.ts` | Pending | Search scoring, snippets, limits |
| `src/lib/wiki.ts` | Done | WikiManager class: readPage, writePage, appendPage, listPages, pageExists |
| `src/lib/search.ts` | Done | Text search: term-frequency scoring, snippet extraction |
| `src/commands/read.ts` | Done | `wiki read <path>` — print page to stdout |
| `src/commands/write.ts` | Done | `wiki write <path>` — read stdin, write page |
| `src/commands/append.ts` | Done | `wiki append <path>` — read stdin, append to page |
| `src/commands/list.ts` | Done | `wiki list [dir] [--tree] [--json]` |
| `src/commands/search.ts` | Done | `wiki search <query> [--limit N] [--all] [--json]` |
| `test/read-write.test.ts` | Done | 15 tests passing |
| `test/search.test.ts` | Done | 8 tests passing |
## Commands to Add
## Commands Added
```
wiki read <path>
@@ -28,15 +28,17 @@ wiki list [dir] [--tree] [--json]
wiki search <query> [--limit N] [--all] [--json]
```
## Key Design Decisions
## Tests
- `write` and `append` read from stdin (`process.stdin`)
- `write` creates parent directories automatically
- `list` uses glob to find all .md files
- Search uses term-frequency scoring (upgradeable to BM25 later)
- `--all` flag on search iterates all registered wikis
- All structured output supports `--json`
- 23 new tests (15 read-write + 8 search), all passing
- 46 total tests across 3 files
## Entry Points to Update
## Notes
- `bin/wiki.ts` — register new commands
- `write` and `append` read from stdin via `process.stdin`
- `write` auto-creates parent directories
- `append` returns exit 1 if page doesn't exist
- `list` supports `--tree` (visual tree) and `--json` output
- Search uses term-frequency scoring with word-boundary matching
- Search `--all` iterates all registered wikis
- WikiManager normalizes paths to forward slashes on Windows

32
src/commands/append.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Command } from "commander";
import { WikiManager } from "../lib/wiki.ts";
import type { WikiContext } from "../types.ts";
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf-8");
}
export function makeAppendCommand(): Command {
return new Command("append")
.description("Append stdin to an existing page")
.argument("<path>", "relative path to the page")
.action(async function (this: Command, pagePath: string) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
const content = await readStdin();
if (!content) {
console.error("No content provided on stdin.");
process.exit(1);
}
const wiki = new WikiManager(ctx.root);
const ok = await wiki.appendPage(pagePath, content);
if (!ok) {
console.error(`Page not found: ${pagePath}`);
process.exit(1);
}
console.log(`appended to ${pagePath}`);
});
}

66
src/commands/list.ts Normal file
View File

@@ -0,0 +1,66 @@
import { Command } from "commander";
import { WikiManager } from "../lib/wiki.ts";
import type { WikiContext } from "../types.ts";
export function makeListCommand(): Command {
return new Command("list")
.description("List wiki pages")
.argument("[dir]", "subdirectory to list")
.option("--tree", "show as tree structure")
.option("--json", "output as JSON array")
.action(async function (
this: Command,
dir: string | undefined,
options: { tree?: boolean; json?: boolean },
) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
const wiki = new WikiManager(ctx.root);
const pages = await wiki.listPages(dir);
if (options.json) {
console.log(JSON.stringify(pages, null, 2));
return;
}
if (pages.length === 0) {
console.log("No pages found.");
return;
}
if (options.tree) {
printTree(pages);
return;
}
for (const page of pages) {
console.log(page);
}
});
}
function printTree(pages: string[]): void {
const tree: Record<string, string[]> = {};
for (const page of pages) {
const parts = page.split("/");
if (parts.length === 1) {
const dir = ".";
tree[dir] = tree[dir] ?? [];
tree[dir]!.push(parts[0]!);
} else {
const dir = parts.slice(0, -1).join("/");
const file = parts[parts.length - 1]!;
tree[dir] = tree[dir] ?? [];
tree[dir]!.push(file);
}
}
const dirs = Object.keys(tree).sort();
for (const dir of dirs) {
console.log(`${dir}/`);
const files = tree[dir]!;
for (let i = 0; i < files.length; i++) {
const prefix = i === files.length - 1 ? " └── " : " ├── ";
console.log(`${prefix}${files[i]}`);
}
}
}

20
src/commands/read.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Command } from "commander";
import { join } from "path";
import { WikiManager } from "../lib/wiki.ts";
import type { WikiContext } from "../types.ts";
export function makeReadCommand(): Command {
return new Command("read")
.description("Print page content to stdout")
.argument("<path>", "relative path to the page")
.action(async function (this: Command, pagePath: string) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
const wiki = new WikiManager(ctx.root);
const content = await wiki.readPage(pagePath);
if (content === null) {
console.error(`Page not found: ${pagePath}`);
process.exit(1);
}
process.stdout.write(content);
});
}

57
src/commands/search.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Command } from "commander";
import { WikiManager } from "../lib/wiki.ts";
import { search } from "../lib/search.ts";
import { loadRegistry } from "../lib/registry.ts";
import type { WikiContext } from "../types.ts";
import type { SearchResult } from "../lib/search.ts";
export function makeSearchCommand(): Command {
return new Command("search")
.description("Search wiki pages")
.argument("<query>", "search terms")
.option("-l, --limit <n>", "max results", "10")
.option("-a, --all", "search across all registered wikis")
.option("--json", "output as JSON")
.action(async function (
this: Command,
query: string,
options: { limit: string; all?: boolean; json?: boolean },
) {
const limit = parseInt(options.limit, 10);
let results: (SearchResult & { wiki?: string })[] = [];
if (options.all) {
const registry = await loadRegistry();
for (const [id, entry] of Object.entries(registry.wikis)) {
const wiki = new WikiManager(entry.path);
const hits = await search(wiki, query, { limit });
for (const hit of hits) {
results.push({ ...hit, wiki: id });
}
}
results.sort((a, b) => b.score - a.score);
results = results.slice(0, limit);
} else {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
const wiki = new WikiManager(ctx.root);
results = await search(wiki, query, { limit });
}
if (options.json) {
console.log(JSON.stringify(results, null, 2));
return;
}
if (results.length === 0) {
console.log("No results found.");
return;
}
for (const r of results) {
const prefix = r.wiki ? `[${r.wiki}] ` : "";
console.log(`${prefix}${r.path} (score: ${r.score})`);
console.log(` ${r.snippet}`);
console.log();
}
});
}

28
src/commands/write.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Command } from "commander";
import { WikiManager } from "../lib/wiki.ts";
import type { WikiContext } from "../types.ts";
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf-8");
}
export function makeWriteCommand(): Command {
return new Command("write")
.description("Write stdin to a page (create or overwrite)")
.argument("<path>", "relative path to the page")
.action(async function (this: Command, pagePath: string) {
const ctx: WikiContext = this.optsWithGlobals().wikiContext;
const content = await readStdin();
if (!content) {
console.error("No content provided on stdin.");
process.exit(1);
}
const wiki = new WikiManager(ctx.root);
await wiki.writePage(pagePath, content);
console.log(`wrote ${pagePath}`);
});
}

79
src/lib/search.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { WikiManager } from "./wiki.ts";
export interface SearchResult {
path: string;
score: number;
snippet: string;
}
export async function search(
wiki: WikiManager,
query: string,
options?: { limit?: number; dir?: string },
): Promise<SearchResult[]> {
const limit = options?.limit ?? 10;
const terms = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t.length > 0);
if (terms.length === 0) return [];
const pages = await wiki.listPages(options?.dir);
const results: SearchResult[] = [];
for (const path of pages) {
const content = await wiki.readPage(path);
if (!content) continue;
const lower = content.toLowerCase();
let score = 0;
let firstIndex = -1;
for (const term of terms) {
const regex = new RegExp(`\\b${escapeRegex(term)}\\b`, "gi");
const matches = lower.match(regex);
if (matches) {
score += matches.length;
if (firstIndex === -1) {
firstIndex = lower.indexOf(term);
}
}
}
if (score > 0) {
const snippet = extractSnippet(content, firstIndex);
results.push({ path, score, snippet });
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit);
}
function extractSnippet(content: string, matchIndex: number): string {
const snippetLen = 200;
const half = Math.floor(snippetLen / 2);
let start = Math.max(0, matchIndex - half);
let end = Math.min(content.length, matchIndex + half);
// Adjust to word boundaries
if (start > 0) {
const space = content.indexOf(" ", start);
if (space !== -1 && space < start + 20) start = space + 1;
}
if (end < content.length) {
const space = content.lastIndexOf(" ", end);
if (space !== -1 && space > end - 20) end = space;
}
let snippet = content.slice(start, end).replace(/\n+/g, " ").trim();
if (start > 0) snippet = "..." + snippet;
if (end < content.length) snippet = snippet + "...";
return snippet;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

75
src/lib/wiki.ts Normal file
View File

@@ -0,0 +1,75 @@
import { readFile, writeFile, mkdir, readdir, stat } from "fs/promises";
import { join, relative, dirname } from "path";
export class WikiManager {
constructor(public readonly root: string) {}
private resolve(relativePath: string): string {
return join(this.root, relativePath);
}
async readPage(relativePath: string): Promise<string | null> {
try {
return await readFile(this.resolve(relativePath), "utf-8");
} catch (err: unknown) {
if (
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT"
) {
return null;
}
throw err;
}
}
async writePage(relativePath: string, content: string): Promise<void> {
const fullPath = this.resolve(relativePath);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, "utf-8");
}
async appendPage(relativePath: string, content: string): Promise<boolean> {
const fullPath = this.resolve(relativePath);
const existing = await this.readPage(relativePath);
if (existing === null) return false;
const separator = existing.endsWith("\n") ? "" : "\n";
await writeFile(fullPath, existing + separator + content, "utf-8");
return true;
}
async pageExists(relativePath: string): Promise<boolean> {
try {
await stat(this.resolve(relativePath));
return true;
} catch {
return false;
}
}
async listPages(dir?: string): Promise<string[]> {
const base = dir ? this.resolve(dir) : this.root;
const results: string[] = [];
await this.walkDir(base, results);
results.sort();
return results;
}
private async walkDir(dir: string, results: string[]): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === ".git" || entry.name === "node_modules") continue;
await this.walkDir(fullPath, results);
} else if (entry.name.endsWith(".md")) {
results.push(relative(this.root, fullPath).replace(/\\/g, "/"));
}
}
}
}

125
test/read-write.test.ts Normal file
View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtemp, rm, writeFile, readFile, mkdir } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { WikiManager } from "../src/lib/wiki.ts";
let testDir: string;
let wiki: WikiManager;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "llmwiki-rw-"));
wiki = new WikiManager(testDir);
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("WikiManager.writePage", () => {
it("creates file with correct content", async () => {
await wiki.writePage("test.md", "# Hello\n");
const content = await readFile(join(testDir, "test.md"), "utf-8");
expect(content).toBe("# Hello\n");
});
it("creates parent directories automatically", async () => {
await wiki.writePage("wiki/concepts/deep.md", "content");
const content = await readFile(
join(testDir, "wiki/concepts/deep.md"),
"utf-8",
);
expect(content).toBe("content");
});
it("overwrites existing file", async () => {
await wiki.writePage("page.md", "old");
await wiki.writePage("page.md", "new");
const content = await wiki.readPage("page.md");
expect(content).toBe("new");
});
});
describe("WikiManager.readPage", () => {
it("returns file content", async () => {
await writeFile(join(testDir, "hello.md"), "world", "utf-8");
const content = await wiki.readPage("hello.md");
expect(content).toBe("world");
});
it("returns null for missing file", async () => {
const content = await wiki.readPage("nonexistent.md");
expect(content).toBeNull();
});
});
describe("WikiManager.appendPage", () => {
it("appends to existing file", async () => {
await wiki.writePage("page.md", "line1\n");
const ok = await wiki.appendPage("page.md", "line2\n");
expect(ok).toBe(true);
const content = await wiki.readPage("page.md");
expect(content).toBe("line1\nline2\n");
});
it("adds newline separator if missing", async () => {
await wiki.writePage("page.md", "no-newline");
await wiki.appendPage("page.md", "more");
const content = await wiki.readPage("page.md");
expect(content).toBe("no-newline\nmore");
});
it("returns false for missing file", async () => {
const ok = await wiki.appendPage("missing.md", "content");
expect(ok).toBe(false);
});
});
describe("WikiManager.pageExists", () => {
it("returns true for existing file", async () => {
await wiki.writePage("exists.md", "hi");
expect(await wiki.pageExists("exists.md")).toBe(true);
});
it("returns false for missing file", async () => {
expect(await wiki.pageExists("nope.md")).toBe(false);
});
});
describe("WikiManager.listPages", () => {
it("returns all .md files", async () => {
await wiki.writePage("a.md", "a");
await wiki.writePage("b.md", "b");
await wiki.writePage("sub/c.md", "c");
const pages = await wiki.listPages();
expect(pages).toEqual(["a.md", "b.md", "sub/c.md"]);
});
it("filters by subdirectory", async () => {
await wiki.writePage("root.md", "r");
await wiki.writePage("wiki/page.md", "p");
await wiki.writePage("wiki/sub/deep.md", "d");
const pages = await wiki.listPages("wiki");
expect(pages).toEqual(["wiki/page.md", "wiki/sub/deep.md"]);
});
it("returns empty array for empty dir", async () => {
const pages = await wiki.listPages();
expect(pages).toEqual([]);
});
it("skips non-.md files", async () => {
await writeFile(join(testDir, "notes.txt"), "text", "utf-8");
await wiki.writePage("page.md", "md");
const pages = await wiki.listPages();
expect(pages).toEqual(["page.md"]);
});
it("returns sorted results", async () => {
await wiki.writePage("z.md", "z");
await wiki.writePage("a.md", "a");
await wiki.writePage("m.md", "m");
const pages = await wiki.listPages();
expect(pages).toEqual(["a.md", "m.md", "z.md"]);
});
});

87
test/search.test.ts Normal file
View File

@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtemp, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { WikiManager } from "../src/lib/wiki.ts";
import { search } from "../src/lib/search.ts";
let testDir: string;
let wiki: WikiManager;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "llmwiki-search-"));
wiki = new WikiManager(testDir);
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("search", () => {
it("finds pages containing query terms", async () => {
await wiki.writePage("a.md", "The attention mechanism is important.");
await wiki.writePage("b.md", "Unrelated content about cooking.");
const results = await search(wiki, "attention");
expect(results).toHaveLength(1);
expect(results[0]!.path).toBe("a.md");
});
it("ranks by term frequency", async () => {
await wiki.writePage(
"few.md",
"Attention is used once in this document.",
);
await wiki.writePage(
"many.md",
"Attention attention attention. The attention mechanism uses attention.",
);
const results = await search(wiki, "attention");
expect(results).toHaveLength(2);
expect(results[0]!.path).toBe("many.md");
expect(results[0]!.score).toBeGreaterThan(results[1]!.score);
});
it("returns snippets around first match", async () => {
await wiki.writePage("page.md", "Some preamble text. The key concept is transformers. More text after.");
const results = await search(wiki, "transformers");
expect(results).toHaveLength(1);
expect(results[0]!.snippet).toContain("transformers");
});
it("respects --limit", async () => {
for (let i = 0; i < 5; i++) {
await wiki.writePage(`p${i}.md`, `This page mentions topic number ${i}.`);
}
const results = await search(wiki, "topic", { limit: 3 });
expect(results).toHaveLength(3);
});
it("returns empty array for no matches", async () => {
await wiki.writePage("page.md", "Some content here.");
const results = await search(wiki, "nonexistent");
expect(results).toEqual([]);
});
it("handles multi-word queries", async () => {
await wiki.writePage("a.md", "Machine learning is a field of study.");
await wiki.writePage("b.md", "This is about machine design only.");
const results = await search(wiki, "machine learning");
expect(results).toHaveLength(2);
// "a.md" matches both terms, "b.md" matches only one
expect(results[0]!.path).toBe("a.md");
expect(results[0]!.score).toBeGreaterThan(results[1]!.score);
});
it("is case insensitive", async () => {
await wiki.writePage("page.md", "Attention and ATTENTION and attention.");
const results = await search(wiki, "ATTENTION");
expect(results).toHaveLength(1);
expect(results[0]!.score).toBe(3);
});
it("returns empty for empty query", async () => {
await wiki.writePage("page.md", "content");
const results = await search(wiki, " ");
expect(results).toEqual([]);
});
});