@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.
- package/.coderabbit.yaml +43 -0
- package/.github/workflows/ci.yml +9 -8
- package/.github/workflows/codeql.yml +39 -0
- package/.github/workflows/release.yml +6 -4
- package/AGENTS.md +57 -0
- package/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +43 -0
- package/LICENSE +21 -0
- package/README.md +44 -344
- package/assets/README.md +3 -3
- package/assets/architecture.md +1 -1
- package/biome.json +3 -0
- package/docs/api.md +105 -0
- package/docs/architecture.md +65 -0
- package/docs/commands.md +51 -0
- package/docs/configuration.md +38 -0
- package/docs/obsidian.md +21 -0
- package/extensions/llm-wiki/index.ts +53 -0
- package/extensions/llm-wiki/lib/guardrails.ts +69 -0
- package/extensions/llm-wiki/lib/metadata.ts +218 -0
- package/extensions/llm-wiki/lib/source-packet.ts +292 -0
- package/extensions/llm-wiki/lib/tools.ts +932 -0
- package/extensions/llm-wiki/lib/utils.ts +222 -0
- package/package.json +11 -12
- package/prompts/wiki-digest.md +1 -1
- package/prompts/wiki-discover.md +2 -2
- package/prompts/wiki-ingest.md +2 -2
- package/prompts/wiki-init.md +2 -2
- package/prompts/wiki-lint.md +1 -1
- package/prompts/wiki-query.md +2 -2
- package/prompts/wiki-run.md +6 -6
- package/prompts/wiki-status.md +1 -1
- package/scripts/release.js +72 -0
- package/skills/llm-wiki/SKILL.md +95 -369
- package/skills/llm-wiki/templates/DASHBOARD.md +2 -2
- package/skills/llm-wiki/templates/pages/analysis.md +35 -0
- package/test/llm-wiki.test.ts +18 -9
- package/extensions/llm-wiki-tools.ts +0 -705
|
@@ -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`
|
package/docs/obsidian.md
ADDED
|
@@ -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
|
+
}
|