@zosmaai/pi-llm-wiki 0.1.2 → 0.2.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.
@@ -0,0 +1,38 @@
1
+ # Configuration
2
+
3
+ Wiki configuration lives in `.wiki/config.json`.
4
+
5
+ ## Modes
6
+
7
+ ### Personal
8
+
9
+ - Extra folders: `wiki/journal/`, `wiki/goals/`
10
+ - Track: learning, books, health, reflections
11
+
12
+ ### Company
13
+
14
+ - Extra folders: `wiki/changes/`, `wiki/decisions/`
15
+ - Track: competitors, market, strategy
16
+ - Frontmatter: `confidence: high | medium | low`
17
+
18
+ ## Settings
19
+
20
+ | Setting | Default | Description |
21
+ | -------------------------- | ------- | ---------------------------------- |
22
+ | `max_sources_per_discover` | 8 | Sources fetched per discovery run |
23
+ | `auto_fix_lint` | false | Auto-fix lint issues |
24
+ | `batch_ingest_size` | 3 | Sources processed per ingest batch |
25
+
26
+ ## Page Frontmatter
27
+
28
+ ```yaml
29
+ ---
30
+ type: entity | concept | source | synthesis | analysis
31
+ created: YYYY-MM-DD
32
+ updated: YYYY-MM-DD
33
+ sources: [sources/SRC-YYYY-MM-DD-NNN]
34
+ ---
35
+ ```
36
+
37
+ Entity: add `category: person | organization | tool | project | product`
38
+ Concept: add `domain: ai | engineering | business | product | design | personal`
@@ -0,0 +1,21 @@
1
+ # Obsidian Integration
2
+
3
+ ## Setup
4
+
5
+ 1. Open `wiki/` as an Obsidian vault
6
+ 2. The extension generates `meta/index.md` as a browsable catalog
7
+ 3. `meta/backlinks.json` is available for graph plugins
8
+
9
+ ## Recommended Plugins
10
+
11
+ - [Dataview](https://github.com/blacksmithgu/obsidian-dataview) — Query pages by frontmatter
12
+ - [Graph View](https://obsidian.md) (built-in) — Visualize `[[wikilink]]` connections
13
+ - [Backlinks](https://obsidian.md) (built-in) — See inbound links
14
+
15
+ ## Web Clipper
16
+
17
+ Use [Obsidian Web Clipper](https://obsidian.md/clipper) to save articles directly into `raw/articles/`.
18
+
19
+ ## Dataview Dashboard
20
+
21
+ The extension creates `meta/index.md` with page listings. For custom dashboards, use Dataview queries against frontmatter fields like `type`, `domain`, `category`, `sources`.
@@ -0,0 +1,53 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { installGuardrails } from "./lib/guardrails.js";
3
+ import {
4
+ registerWikiBootstrap,
5
+ registerWikiCaptureSource,
6
+ registerWikiEnsurePage,
7
+ registerWikiIngest,
8
+ registerWikiLint,
9
+ registerWikiLogEvent,
10
+ registerWikiRebuildMeta,
11
+ registerWikiSearch,
12
+ registerWikiStatus,
13
+ registerWikiWatch,
14
+ } from "./lib/tools.js";
15
+
16
+ /**
17
+ * @zosmaai/pi-llm-wiki — LLM Wiki extension for Pi
18
+ *
19
+ * Registers 10 custom tools and installs guardrails:
20
+ * - wiki_bootstrap Initialize a new vault
21
+ * - wiki_capture_source Capture URL/file/text into source packet
22
+ * - wiki_ingest Get batch of sources needing synthesis
23
+ * - wiki_ensure_page Create canonical page from template
24
+ * - wiki_search Search generated registry
25
+ * - wiki_lint Health check with auto-fix
26
+ * - wiki_status Instant stats from registry
27
+ * - wiki_rebuild_meta Force metadata rebuild
28
+ * - wiki_log_event Append event and regenerate log
29
+ * - wiki_watch Schedule auto-updates
30
+ *
31
+ * Guardrails:
32
+ * - Blocks direct edits to raw/** and meta/**
33
+ * - Auto-rebuilds metadata after wiki/** edits
34
+ */
35
+
36
+ export default function (pi: ExtensionAPI) {
37
+ registerWikiBootstrap(pi);
38
+ registerWikiCaptureSource(pi);
39
+ registerWikiIngest(pi);
40
+ registerWikiEnsurePage(pi);
41
+ registerWikiSearch(pi);
42
+ registerWikiLint(pi);
43
+ registerWikiStatus(pi);
44
+ registerWikiRebuildMeta(pi);
45
+ registerWikiLogEvent(pi);
46
+ registerWikiWatch(pi);
47
+
48
+ installGuardrails(pi);
49
+
50
+ pi.on("session_start", async (_event, ctx) => {
51
+ ctx.ui.setStatus("llm-wiki", "🧠 LLM Wiki (10 tools, guardrails active)");
52
+ });
53
+ }
@@ -0,0 +1,69 @@
1
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { rebuildMetadataLight } from "./metadata.js";
4
+ import { isProtectedPath, resolveVaultRoot } from "./utils.js";
5
+
6
+ /**
7
+ * Guardrails and auto-rebuild hooks for the LLM Wiki extension.
8
+ */
9
+
10
+ let pendingRebuild = false;
11
+
12
+ /** Install guardrails on the extension API. */
13
+ export function installGuardrails(pi: ExtensionAPI): void {
14
+ // Block direct edits to raw/ and meta/
15
+ pi.on("tool_call", async (event) => {
16
+ if (isToolCallEventType("write", event)) {
17
+ const path = event.input.path as string;
18
+ const root = resolveVaultRoot(process.cwd());
19
+ const check = isProtectedPath(path, root);
20
+ if (check.protected) {
21
+ return { block: true, reason: check.reason };
22
+ }
23
+ }
24
+
25
+ if (isToolCallEventType("edit", event)) {
26
+ const path = event.input.path as string;
27
+ const root = resolveVaultRoot(process.cwd());
28
+ const check = isProtectedPath(path, root);
29
+ if (check.protected) {
30
+ return { block: true, reason: check.reason };
31
+ }
32
+ }
33
+ });
34
+
35
+ // Track wiki edits for auto-rebuild
36
+ pi.on("tool_result", async (event) => {
37
+ if (event.toolName === "write" || event.toolName === "edit") {
38
+ const path = event.input.path as string;
39
+ const root = resolveVaultRoot(process.cwd());
40
+ const wikiPath = `${root}/wiki/`;
41
+ if (path?.startsWith(wikiPath)) {
42
+ pendingRebuild = true;
43
+ }
44
+ }
45
+ });
46
+
47
+ // Rebuild metadata at end of turn if wiki was modified
48
+ pi.on("turn_end", async (_event) => {
49
+ if (pendingRebuild) {
50
+ pendingRebuild = false;
51
+ try {
52
+ const root = resolveVaultRoot(process.cwd());
53
+ const paths = {
54
+ root,
55
+ raw: `${root}/raw`,
56
+ rawSources: `${root}/raw/sources`,
57
+ wiki: `${root}/wiki`,
58
+ meta: `${root}/meta`,
59
+ dotWiki: `${root}/.wiki`,
60
+ outputs: `${root}/outputs`,
61
+ discoveries: `${root}/.discoveries`,
62
+ };
63
+ rebuildMetadataLight(paths);
64
+ } catch {
65
+ // Silently fail — metadata rebuild is best-effort
66
+ }
67
+ }
68
+ });
69
+ }
@@ -0,0 +1,218 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import {
4
+ type VaultPaths,
5
+ extractWikilinks,
6
+ findWikiPages,
7
+ fmtDate,
8
+ parseFrontmatter,
9
+ readJson,
10
+ readText,
11
+ writeJson,
12
+ } from "./utils.js";
13
+
14
+ /**
15
+ * Metadata generation for the LLM Wiki.
16
+ *
17
+ * Rebuilds registry.json, backlinks.json, index.md, log.md, and lint-report.md
18
+ * deterministically from the current state of raw/ and wiki/.
19
+ */
20
+
21
+ export interface RegistryEntry {
22
+ type: "source" | "entity" | "concept" | "synthesis" | "analysis";
23
+ title: string;
24
+ created: string;
25
+ updated: string;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export interface Registry {
30
+ version: string;
31
+ last_updated: string;
32
+ pages: Record<string, RegistryEntry>;
33
+ }
34
+
35
+ export interface Backlinks {
36
+ [pageId: string]: string[];
37
+ }
38
+
39
+ export interface WikiEvent {
40
+ timestamp: string;
41
+ kind: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ /** Rebuild the complete metadata layer. */
46
+ export function rebuildMetadata(paths: VaultPaths): void {
47
+ mkdirSync(paths.meta, { recursive: true });
48
+
49
+ const registry = buildRegistry(paths);
50
+ const backlinks = buildBacklinks(paths, registry);
51
+
52
+ writeJson(join(paths.meta, "registry.json"), registry);
53
+ writeJson(join(paths.meta, "backlinks.json"), backlinks);
54
+ writeFileSync(join(paths.meta, "index.md"), buildIndexMarkdown(registry), "utf-8");
55
+
56
+ const log = buildLogMarkdown(paths);
57
+ writeFileSync(join(paths.meta, "log.md"), log, "utf-8");
58
+ }
59
+
60
+ /** Build registry from wiki/ and raw/ state. */
61
+ export function buildRegistry(paths: VaultPaths): Registry {
62
+ const pages: Record<string, RegistryEntry> = {};
63
+
64
+ // Scan wiki pages
65
+ for (const page of findWikiPages(paths.wiki)) {
66
+ const { frontmatter } = parseFrontmatter(page.content);
67
+ const type = String(frontmatter.type || "page") as RegistryEntry["type"];
68
+ const title = String(frontmatter.title || page.relative.split("/").pop() || "Untitled");
69
+
70
+ pages[page.relative] = {
71
+ type,
72
+ title,
73
+ created: String(frontmatter.created || fmtDate()),
74
+ updated: String(frontmatter.updated || frontmatter.created || fmtDate()),
75
+ ...frontmatter,
76
+ };
77
+ }
78
+
79
+ // Scan raw source packets
80
+ if (existsSync(paths.rawSources)) {
81
+ for (const entry of readdirSync(paths.rawSources)) {
82
+ const manifestPath = join(paths.rawSources, entry, "manifest.json");
83
+ if (!existsSync(manifestPath)) continue;
84
+
85
+ const manifest = readJson<Record<string, unknown>>(manifestPath, {});
86
+ const id = String(manifest.id || entry);
87
+ const sourcePage = `sources/${id}`;
88
+
89
+ if (!pages[sourcePage]) {
90
+ pages[sourcePage] = {
91
+ type: "source",
92
+ title: String(manifest.title || id),
93
+ created: String(manifest.captured || fmtDate()),
94
+ updated: String(manifest.captured || fmtDate()),
95
+ ...manifest,
96
+ };
97
+ }
98
+ }
99
+ }
100
+
101
+ return {
102
+ version: "1.0",
103
+ last_updated: new Date().toISOString(),
104
+ pages,
105
+ };
106
+ }
107
+
108
+ /** Build backlinks map from all wiki pages. */
109
+ export function buildBacklinks(paths: VaultPaths, registry: Registry): Backlinks {
110
+ const inbound: Backlinks = {};
111
+
112
+ // Initialize all pages with empty arrays
113
+ for (const id of Object.keys(registry.pages)) {
114
+ inbound[id] = [];
115
+ }
116
+
117
+ // Count inbound links
118
+ for (const page of findWikiPages(paths.wiki)) {
119
+ const links = extractWikilinks(page.content);
120
+ for (const link of links) {
121
+ if (inbound[link] && !inbound[link].includes(page.relative)) {
122
+ inbound[link].push(page.relative);
123
+ }
124
+ }
125
+ }
126
+
127
+ return inbound;
128
+ }
129
+
130
+ /** Build index markdown from registry. */
131
+ export function buildIndexMarkdown(registry: Registry): string {
132
+ const byType: Record<string, Array<{ id: string; entry: RegistryEntry }>> = {};
133
+
134
+ for (const [id, entry] of Object.entries(registry.pages)) {
135
+ const t = entry.type;
136
+ if (!byType[t]) byType[t] = [];
137
+ byType[t].push({ id, entry });
138
+ }
139
+
140
+ const sections: string[] = [];
141
+ sections.push(
142
+ "# Wiki Index\n\n> Auto-generated from meta/registry.json. Do not edit manually.\n",
143
+ );
144
+
145
+ for (const [type, items] of Object.entries(byType).sort()) {
146
+ const label = `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
147
+ sections.push(`## ${label}\n`);
148
+ for (const { id, entry } of items.sort((a, b) => a.id.localeCompare(b.id))) {
149
+ sections.push(`- [[${id}]] — ${entry.title} *(created: ${entry.created})*`);
150
+ }
151
+ sections.push("");
152
+ }
153
+
154
+ sections.push(
155
+ `---\n*Last updated: ${registry.last_updated}* | *Total pages: ${Object.keys(registry.pages).length}*`,
156
+ );
157
+ return `${sections.join("\n")}\n`;
158
+ }
159
+
160
+ /** Build log markdown from events.jsonl. */
161
+ export function buildLogMarkdown(paths: VaultPaths): string {
162
+ const eventsPath = join(paths.meta, "events.jsonl");
163
+ const events: WikiEvent[] = [];
164
+
165
+ if (existsSync(eventsPath)) {
166
+ const raw = readFileSync(eventsPath, "utf-8").trim();
167
+ for (const line of raw.split("\n")) {
168
+ if (!line.trim()) continue;
169
+ try {
170
+ events.push(JSON.parse(line) as WikiEvent);
171
+ } catch {
172
+ // skip malformed
173
+ }
174
+ }
175
+ }
176
+
177
+ const lines: string[] = [];
178
+ lines.push("# Activity Log\n\n> Auto-generated from meta/events.jsonl. Do not edit manually.\n");
179
+
180
+ for (const ev of events) {
181
+ const ts = ev.timestamp || "unknown";
182
+ const kind = ev.kind || "event";
183
+ const details = Object.entries(ev)
184
+ .filter(([k]) => k !== "timestamp" && k !== "kind")
185
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
186
+ .join(", ");
187
+
188
+ lines.push(`## [${ts}] ${kind}`);
189
+ if (details) lines.push(`- ${details}`);
190
+ lines.push("");
191
+ }
192
+
193
+ if (events.length === 0) {
194
+ lines.push("_No events recorded yet._\n");
195
+ }
196
+
197
+ return `${lines.join("\n")}\n`;
198
+ }
199
+
200
+ /** Append an event to events.jsonl. */
201
+ export function appendEvent(paths: VaultPaths, event: Omit<WikiEvent, "timestamp">): void {
202
+ mkdirSync(paths.meta, { recursive: true });
203
+ const eventsPath = join(paths.meta, "events.jsonl");
204
+ const line = JSON.stringify({ timestamp: new Date().toISOString(), ...event });
205
+ writeFileSync(eventsPath, `${line}\n`, { flag: "a", encoding: "utf-8" });
206
+ }
207
+
208
+ /** Quick lightweight metadata rebuild (backlinks + index + log only). */
209
+ export function rebuildMetadataLight(paths: VaultPaths): void {
210
+ const registry = buildRegistry(paths);
211
+ const backlinks = buildBacklinks(paths, registry);
212
+ writeJson(join(paths.meta, "registry.json"), registry);
213
+ writeJson(join(paths.meta, "backlinks.json"), backlinks);
214
+ writeFileSync(join(paths.meta, "index.md"), buildIndexMarkdown(registry), "utf-8");
215
+
216
+ const log = buildLogMarkdown(paths);
217
+ writeFileSync(join(paths.meta, "log.md"), log, "utf-8");
218
+ }
@@ -0,0 +1,292 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { appendEvent } from "./metadata.js";
5
+ import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
6
+
7
+ /**
8
+ * Source packet capture and management.
9
+ *
10
+ * Each source is stored as a structured packet:
11
+ * raw/sources/SRC-YYYY-MM-DD-NNN/
12
+ * manifest.json — capture metadata
13
+ * original/ — original artifact (if file/URL)
14
+ * extracted.md — normalized markdown text
15
+ * attachments/ — downloaded images, PDFs, etc.
16
+ */
17
+
18
+ export interface CaptureResult {
19
+ sourceId: string;
20
+ packetPath: string;
21
+ sourcePagePath: string;
22
+ extracted: string;
23
+ }
24
+
25
+ /** Capture a URL into a source packet. */
26
+ export async function captureUrl(
27
+ pi: ExtensionAPI,
28
+ paths: VaultPaths,
29
+ url: string,
30
+ signal?: AbortSignal,
31
+ ): Promise<CaptureResult> {
32
+ const sourceId = nextSourceId(paths);
33
+ const packetPath = join(paths.rawSources, sourceId);
34
+ mkdirSync(packetPath, { recursive: true });
35
+ mkdirSync(join(packetPath, "original"), { recursive: true });
36
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
37
+
38
+ // Try to fetch and extract content
39
+ let extracted = "";
40
+ let title = url;
41
+
42
+ // Try markitdown first
43
+ const markitdown = await exec(
44
+ pi,
45
+ "sh",
46
+ ["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
47
+ { signal },
48
+ );
49
+
50
+ if (markitdown.stdout.trim() === "yes") {
51
+ try {
52
+ const mdResult = await exec(
53
+ pi,
54
+ "sh",
55
+ ["-c", `uvx --from 'markitdown[pdf]' markitdown "${url}" 2>/dev/null || echo ""`],
56
+ { signal, timeout: 30_000 },
57
+ );
58
+ if (mdResult.stdout.trim()) {
59
+ extracted = mdResult.stdout;
60
+ // Try to extract title from first h1
61
+ const h1Match = extracted.match(/^#\s+(.+)$/m);
62
+ if (h1Match) title = h1Match[1].trim();
63
+ }
64
+ } catch {
65
+ // markitdown failed, fall through
66
+ }
67
+ }
68
+
69
+ // Fallback: try fetch_content equivalent via curl
70
+ if (!extracted) {
71
+ try {
72
+ const curlResult = await exec(pi, "curl", ["-sL", "--max-time", "30", url], {
73
+ signal,
74
+ timeout: 35_000,
75
+ });
76
+ if (curlResult.stdout) {
77
+ extracted = curlResult.stdout;
78
+ // Try to extract title from HTML
79
+ const titleMatch = extracted.match(/<title>([^<]*)<\/title>/i);
80
+ if (titleMatch) title = titleMatch[1].trim();
81
+ }
82
+ } catch {
83
+ // curl failed too
84
+ }
85
+ }
86
+
87
+ // Write extracted text
88
+ writeFileSync(
89
+ join(packetPath, "extracted.md"),
90
+ extracted || `_Content could not be extracted from ${url}_\n`,
91
+ "utf-8",
92
+ );
93
+
94
+ // Write manifest
95
+ const manifest = {
96
+ id: sourceId,
97
+ title,
98
+ url,
99
+ captured: fmtDate(),
100
+ format: "web",
101
+ packet_version: "1.0",
102
+ };
103
+ writeJson(join(packetPath, "manifest.json"), manifest);
104
+
105
+ // Create skeleton source page in wiki
106
+ const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
107
+ const sourcePageContent = buildSourcePageSkeleton(manifest, extracted);
108
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
109
+
110
+ // Log event
111
+ appendEvent(paths, { kind: "capture", source_id: sourceId, url, format: "web" });
112
+
113
+ return { sourceId, packetPath, sourcePagePath, extracted };
114
+ }
115
+
116
+ /** Capture a local file into a source packet. */
117
+ export async function captureFile(
118
+ pi: ExtensionAPI,
119
+ paths: VaultPaths,
120
+ filePath: string,
121
+ signal?: AbortSignal,
122
+ ): Promise<CaptureResult> {
123
+ const sourceId = nextSourceId(paths);
124
+ const packetPath = join(paths.rawSources, sourceId);
125
+ mkdirSync(packetPath, { recursive: true });
126
+ mkdirSync(join(packetPath, "original"), { recursive: true });
127
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
128
+
129
+ const content = readText(filePath);
130
+ const fileName = filePath.split("/").pop() || "unknown";
131
+
132
+ // Try markitdown for PDFs
133
+ let extracted = content;
134
+ if (filePath.toLowerCase().endsWith(".pdf")) {
135
+ const markitdown = await exec(
136
+ pi,
137
+ "sh",
138
+ ["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
139
+ { signal },
140
+ );
141
+
142
+ if (markitdown.stdout.trim() === "yes") {
143
+ try {
144
+ const mdResult = await exec(
145
+ pi,
146
+ "sh",
147
+ ["-c", `uvx --from 'markitdown[pdf]' markitdown "${filePath}" 2>/dev/null || echo ""`],
148
+ { signal, timeout: 30_000 },
149
+ );
150
+ if (mdResult.stdout.trim()) extracted = mdResult.stdout;
151
+ } catch {
152
+ // fallback to original
153
+ }
154
+ }
155
+ }
156
+
157
+ // Copy original to packet
158
+ try {
159
+ await exec(pi, "cp", [filePath, join(packetPath, "original", fileName)], { signal });
160
+ } catch {
161
+ // If cp fails, just write the content
162
+ writeFileSync(join(packetPath, "original", fileName), content, "utf-8");
163
+ }
164
+
165
+ // Write extracted text
166
+ writeFileSync(join(packetPath, "extracted.md"), extracted, "utf-8");
167
+
168
+ // Write manifest
169
+ const manifest = {
170
+ id: sourceId,
171
+ title: fileName,
172
+ file_path: filePath,
173
+ captured: fmtDate(),
174
+ format: guessFormat(filePath),
175
+ packet_version: "1.0",
176
+ };
177
+ writeJson(join(packetPath, "manifest.json"), manifest);
178
+
179
+ // Create skeleton source page
180
+ const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
181
+ const sourcePageContent = buildSourcePageSkeleton(manifest, extracted);
182
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
183
+
184
+ // Log event
185
+ appendEvent(paths, {
186
+ kind: "capture",
187
+ source_id: sourceId,
188
+ file_path: filePath,
189
+ format: manifest.format,
190
+ });
191
+
192
+ return { sourceId, packetPath, sourcePagePath, extracted };
193
+ }
194
+
195
+ /** Capture pasted text into a source packet. */
196
+ export function captureText(paths: VaultPaths, text: string, title?: string): CaptureResult {
197
+ const sourceId = nextSourceId(paths);
198
+ const packetPath = join(paths.rawSources, sourceId);
199
+ mkdirSync(packetPath, { recursive: true });
200
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
201
+
202
+ // Write extracted text
203
+ writeFileSync(join(packetPath, "extracted.md"), text, "utf-8");
204
+
205
+ // Write manifest
206
+ const manifest = {
207
+ id: sourceId,
208
+ title: title || `Pasted text — ${fmtDate()}`,
209
+ captured: fmtDate(),
210
+ format: "text",
211
+ packet_version: "1.0",
212
+ };
213
+ writeJson(join(packetPath, "manifest.json"), manifest);
214
+
215
+ // Create skeleton source page
216
+ const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
217
+ const sourcePageContent = buildSourcePageSkeleton(manifest, text);
218
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
219
+
220
+ // Log event
221
+ appendEvent(paths, { kind: "capture", source_id: sourceId, format: "text" });
222
+
223
+ return { sourceId, packetPath, sourcePagePath, extracted: text };
224
+ }
225
+
226
+ /** Build a skeleton source page from manifest and extracted text. */
227
+ function buildSourcePageSkeleton(manifest: Record<string, unknown>, extracted: string): string {
228
+ const id = String(manifest.id);
229
+ const title = String(manifest.title || id);
230
+ const url = manifest.url ? `\n> _Original: ${manifest.url}_` : "";
231
+ const format = String(manifest.format || "unknown");
232
+ const captured = String(manifest.captured || fmtDate());
233
+
234
+ // Generate a brief auto-summary (first 500 chars)
235
+ const preview = extracted
236
+ .replace(/[#*_`]/g, "")
237
+ .replace(/\s+/g, " ")
238
+ .trim()
239
+ .slice(0, 500);
240
+
241
+ return `---
242
+ type: source
243
+ format: ${format}
244
+ source_id: ${id}
245
+ raw_path: raw/sources/${id}/extracted.md
246
+ captured: ${captured}
247
+ status: skeleton
248
+ ---
249
+
250
+ # ${title}${url}
251
+
252
+ ## Summary
253
+
254
+ [LLM: Replace with 2-3 paragraph summary of key content]
255
+
256
+ > _Auto-preview: ${preview}${extracted.length > 500 ? "..." : ""}_
257
+
258
+ ## Key Takeaways
259
+
260
+ - [LLM: Most important point]
261
+ - [LLM: Second important point]
262
+ - [LLM: Third important point]
263
+
264
+ ## Entities Mentioned
265
+
266
+ - [[entity-name]]
267
+
268
+ ## Concepts Mentioned
269
+
270
+ - [[concept-name]]
271
+
272
+ ## Notable Quotes
273
+
274
+ > [LLM: Important quote] — attribution
275
+
276
+ ## Source Packet
277
+
278
+ - **ID:** \`[[sources/${id}]]\`
279
+ - **Extracted:** [raw/sources/${id}/extracted.md](../raw/sources/${id}/extracted.md)
280
+ - **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)
281
+ `;
282
+ }
283
+
284
+ function guessFormat(filePath: string): string {
285
+ const lower = filePath.toLowerCase();
286
+ if (lower.endsWith(".pdf")) return "pdf";
287
+ if (lower.endsWith(".md")) return "markdown";
288
+ if (lower.endsWith(".txt")) return "text";
289
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
290
+ if (lower.endsWith(".docx")) return "docx";
291
+ return "file";
292
+ }