pi-weave 0.1.1
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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +69 -0
- package/skills/.gitkeep +0 -0
- package/skills/weave-explore/SKILL.md +64 -0
- package/skills/weave-notepad/SKILL.md +77 -0
- package/src/core/frontmatter.ts +119 -0
- package/src/core/git.ts +194 -0
- package/src/core/graph/build.ts +258 -0
- package/src/core/graph/current.ts +107 -0
- package/src/core/graph/model.ts +63 -0
- package/src/core/graph/wikilinks.ts +28 -0
- package/src/core/index.ts +25 -0
- package/src/core/languages.ts +76 -0
- package/src/core/mutex.ts +34 -0
- package/src/core/paths.ts +37 -0
- package/src/core/repoIndex.ts +404 -0
- package/src/core/slug.ts +28 -0
- package/src/core/summaries.ts +300 -0
- package/src/core/types.ts +155 -0
- package/src/core/vault.ts +254 -0
- package/src/core/workspace.ts +85 -0
- package/src/pi/index.ts +191 -0
- package/src/pi/summarize.ts +134 -0
- package/src/pi/tools/noteTool.ts +178 -0
- package/src/pi/tools/repoTool.ts +94 -0
- package/src/pi/viewer/browser.ts +29 -0
- package/src/pi/viewer/page.ts +1316 -0
- package/src/pi/viewer/server.ts +229 -0
- package/src/pi/viewer/tui/explorer.ts +597 -0
- package/src/pi/viewer/tui/model.ts +1011 -0
- package/src/pi/viewer/tui/run.ts +69 -0
- package/src/pi/viewer/tui/theme.ts +90 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep-scan summaries (docs/scan-modes.md): LLM-written, one sidecar
|
|
3
|
+
* Markdown file per summarized source file, stored under
|
|
4
|
+
* `<repo>/.okf/repository/summaries/`. Incremental via content hash.
|
|
5
|
+
*
|
|
6
|
+
* Core never talks to an LLM — `runDeepScan` takes an injected
|
|
7
|
+
* `summarize(path, content)` function. The pi adapter wires the session
|
|
8
|
+
* model (pi-ai completeSimple); tests inject a fake.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { parseFrontMatter, quoteField, unquoteField } from "./frontmatter";
|
|
15
|
+
import { listFiles } from "./git";
|
|
16
|
+
import { repoKnowledgeDir } from "./paths";
|
|
17
|
+
import type { NoteSource } from "./types";
|
|
18
|
+
|
|
19
|
+
export const SUMMARIES_DIR = "summaries";
|
|
20
|
+
export const SUMMARY_SUFFIX = ".summary.md";
|
|
21
|
+
|
|
22
|
+
export const DEEP_SCAN_MAX_FILES = 300;
|
|
23
|
+
export const DEEP_SCAN_MAX_FILE_BYTES = 32_768;
|
|
24
|
+
export const DEEP_SCAN_CONCURRENCY = 4;
|
|
25
|
+
|
|
26
|
+
/** A parsed summary sidecar. */
|
|
27
|
+
export interface SummaryRecord {
|
|
28
|
+
/** Repo-relative source path this summary describes. */
|
|
29
|
+
target: string;
|
|
30
|
+
/** sha1 of the file content at summarize time. */
|
|
31
|
+
contentHash: string;
|
|
32
|
+
summary: string;
|
|
33
|
+
model: string | null;
|
|
34
|
+
at: string;
|
|
35
|
+
source: NoteSource;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One call to the (injected) language model. */
|
|
39
|
+
export type SummarizeFn = (file: { path: string; content: string }) => Promise<string>;
|
|
40
|
+
|
|
41
|
+
export interface DeepScanOptions {
|
|
42
|
+
summarize: SummarizeFn;
|
|
43
|
+
at?: () => Date;
|
|
44
|
+
maxFiles?: number;
|
|
45
|
+
maxFileBytes?: number;
|
|
46
|
+
concurrency?: number;
|
|
47
|
+
/** Model label recorded in sidecar front matter (provenance detail). */
|
|
48
|
+
model?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Called before each candidate file is processed. `current` is 1-based
|
|
51
|
+
* (the file about to be handled), `total` is the candidate count — the
|
|
52
|
+
* adapter renders this as a live progress line.
|
|
53
|
+
*/
|
|
54
|
+
onProgress?: (info: { current: number; total: number; path: string }) => void;
|
|
55
|
+
/** When aborted, stop scheduling new work and return partial results. */
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DeepScanFailure {
|
|
60
|
+
path: string;
|
|
61
|
+
error: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DeepScanResult {
|
|
65
|
+
/** Candidate source files considered (after skip-lists and caps). */
|
|
66
|
+
considered: number;
|
|
67
|
+
written: number;
|
|
68
|
+
/** Unchanged since their summary (content hash match) — no LLM call. */
|
|
69
|
+
skippedFresh: number;
|
|
70
|
+
/** Candidates skipped because they exceed the byte cap. */
|
|
71
|
+
skippedTooBig: number;
|
|
72
|
+
failed: DeepScanFailure[];
|
|
73
|
+
/** Sidecars removed because their target is no longer tracked. */
|
|
74
|
+
pruned: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Deterministic sidecar file name for a repo-relative target path. */
|
|
78
|
+
export function summaryFileName(target: string): string {
|
|
79
|
+
return target.split("/").join("--") + SUMMARY_SUFFIX;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function summariesDir(repoRoot: string): string {
|
|
83
|
+
return join(repoKnowledgeDir(repoRoot), SUMMARIES_DIR);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Absolute sidecar path for a target (name is derived, never user input). */
|
|
87
|
+
export function summaryPath(repoRoot: string, target: string): string {
|
|
88
|
+
return join(summariesDir(repoRoot), summaryFileName(target));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Serialize a summary sidecar: OKF-front-matter + summary body, provenance
|
|
93
|
+
* always "generated" (AGENTS.md rule 4).
|
|
94
|
+
*/
|
|
95
|
+
export function serializeSummary(rec: SummaryRecord): string {
|
|
96
|
+
const lines = [
|
|
97
|
+
"---",
|
|
98
|
+
`target: ${quoteField(rec.target)}`,
|
|
99
|
+
`source: ${rec.source}`,
|
|
100
|
+
`content_hash: ${quoteField(rec.contentHash)}`,
|
|
101
|
+
`at: ${quoteField(rec.at)}`,
|
|
102
|
+
];
|
|
103
|
+
if (rec.model !== null) lines.push(`model: ${quoteField(rec.model)}`);
|
|
104
|
+
lines.push("---", "", rec.summary.replace(/\s+$/, ""), "");
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseSummaryFile(text: string): SummaryRecord | null {
|
|
109
|
+
const parsed = parseFrontMatter(text);
|
|
110
|
+
if (!parsed) return null;
|
|
111
|
+
const target = unquoteField(parsed.fields.get("target") ?? "");
|
|
112
|
+
const contentHash = unquoteField(parsed.fields.get("content_hash") ?? "");
|
|
113
|
+
const at = unquoteField(parsed.fields.get("at") ?? "");
|
|
114
|
+
if (target.length === 0 || contentHash.length === 0 || at.length === 0) return null;
|
|
115
|
+
const rawSource = parsed.fields.get("source") ?? "";
|
|
116
|
+
return {
|
|
117
|
+
target,
|
|
118
|
+
contentHash,
|
|
119
|
+
summary: parsed.body,
|
|
120
|
+
model: parsed.fields.has("model") ? unquoteField(parsed.fields.get("model") ?? "") : null,
|
|
121
|
+
at,
|
|
122
|
+
source: rawSource === "human" || rawSource === "agent" || rawSource === "generated"
|
|
123
|
+
? rawSource
|
|
124
|
+
: "generated",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** All summaries currently on disk. Corrupt sidecars are skipped. */
|
|
129
|
+
export async function readSummaries(repoRoot: string): Promise<SummaryRecord[]> {
|
|
130
|
+
let names: string[];
|
|
131
|
+
try {
|
|
132
|
+
names = await fs.readdir(summariesDir(repoRoot));
|
|
133
|
+
} catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const out: SummaryRecord[] = [];
|
|
137
|
+
for (const name of names.sort()) {
|
|
138
|
+
if (!name.endsWith(SUMMARY_SUFFIX)) continue;
|
|
139
|
+
try {
|
|
140
|
+
const rec = parseSummaryFile(await fs.readFile(join(summariesDir(repoRoot), name), "utf8"));
|
|
141
|
+
if (rec) out.push(rec);
|
|
142
|
+
} catch {
|
|
143
|
+
// unreadable sidecar — skip
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Summaries keyed by target path — the shape buildGraph/dash want. */
|
|
150
|
+
export async function readSummaryMap(repoRoot: string): Promise<Map<string, SummaryRecord>> {
|
|
151
|
+
const map = new Map<string, SummaryRecord>();
|
|
152
|
+
for (const rec of await readSummaries(repoRoot)) map.set(rec.target, rec);
|
|
153
|
+
return map;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/* Deep scan */
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
/** Files that never deserve a summary (lockfiles, minified, snapshots, media). */
|
|
161
|
+
const SKIP_NAME = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|cargo\.lock|go\.sum|gemfile\.lock|poetry\.lock|bun\.lockb?)$/i;
|
|
162
|
+
const SKIP_EXT = /\.(min\.(js|css)|map|snap|png|jpe?g|gif|webp|avif|ico|bmp|svg|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|war|wasm|sqlite3?|db|bin|exe|dll|so|dylib|a|o|class|pyc|mp[34]|mov|webm|wav|flac|ogg)$/i;
|
|
163
|
+
|
|
164
|
+
/** Cheap binary sniff: NUL byte in the first 8 KiB means binary. */
|
|
165
|
+
function looksBinary(buf: Buffer): boolean {
|
|
166
|
+
const limit = Math.min(buf.length, 8192);
|
|
167
|
+
for (let i = 0; i < limit; i++) if (buf[i] === 0) return true;
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Never summarize our own derived artifacts (`.okf/` sidecars etc.). */
|
|
172
|
+
const SKIP_OKF = /(^|\/)\.okf(\/|$)/;
|
|
173
|
+
|
|
174
|
+
export function isSummarizablePath(path: string): boolean {
|
|
175
|
+
return !SKIP_OKF.test(path) && !SKIP_NAME.test(path) && !SKIP_EXT.test(path);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function hashContent(content: string | Buffer): string {
|
|
179
|
+
return createHash("sha1").update(content).digest("hex");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function mapWithConcurrency<T, R>(
|
|
183
|
+
items: readonly T[],
|
|
184
|
+
concurrency: number,
|
|
185
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
186
|
+
shouldStop?: () => boolean,
|
|
187
|
+
): Promise<R[]> {
|
|
188
|
+
const results: R[] = new Array(items.length);
|
|
189
|
+
let next = 0;
|
|
190
|
+
async function worker(): Promise<void> {
|
|
191
|
+
while (next < items.length) {
|
|
192
|
+
if (shouldStop?.()) return;
|
|
193
|
+
const i = next++;
|
|
194
|
+
results[i] = await fn(items[i] as T, i);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Run the deep scan: summarize tracked source files into summary sidecars.
|
|
203
|
+
* Incremental (content-hash skip) and failure-tolerant per file.
|
|
204
|
+
*/
|
|
205
|
+
export async function runDeepScan(repoRoot: string, options: DeepScanOptions): Promise<DeepScanResult | null> {
|
|
206
|
+
const maxFiles = options.maxFiles ?? DEEP_SCAN_MAX_FILES;
|
|
207
|
+
const maxFileBytes = options.maxFileBytes ?? DEEP_SCAN_MAX_FILE_BYTES;
|
|
208
|
+
const concurrency = options.concurrency ?? DEEP_SCAN_CONCURRENCY;
|
|
209
|
+
const now = options.at ?? (() => new Date());
|
|
210
|
+
const signal = options.signal;
|
|
211
|
+
const onProgress = options.onProgress;
|
|
212
|
+
const aborted = () => signal?.aborted === true;
|
|
213
|
+
|
|
214
|
+
const listed = await listFiles(repoRoot);
|
|
215
|
+
if (listed === null) return null; // not a git repository
|
|
216
|
+
|
|
217
|
+
const candidates = listed.filter(isSummarizablePath).slice(0, maxFiles);
|
|
218
|
+
const existing = await readSummaryMap(repoRoot);
|
|
219
|
+
const existingByHash = new Map<string, SummaryRecord>(
|
|
220
|
+
[...existing.values()].map((r) => [r.target, r]),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const result: DeepScanResult = {
|
|
224
|
+
considered: candidates.length,
|
|
225
|
+
written: 0,
|
|
226
|
+
skippedFresh: 0,
|
|
227
|
+
skippedTooBig: 0,
|
|
228
|
+
failed: [],
|
|
229
|
+
pruned: 0,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
await mapWithConcurrency(candidates, concurrency, async (path, index) => {
|
|
233
|
+
onProgress?.({ current: index + 1, total: candidates.length, path });
|
|
234
|
+
let buf: Buffer;
|
|
235
|
+
try {
|
|
236
|
+
buf = await fs.readFile(join(repoRoot, path));
|
|
237
|
+
} catch {
|
|
238
|
+
result.failed.push({ path, error: "unreadable" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (buf.length > maxFileBytes || (buf.length > 0 && looksBinary(buf))) {
|
|
242
|
+
result.skippedTooBig += 1;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const hash = hashContent(buf);
|
|
246
|
+
if (existingByHash.get(path)?.contentHash === hash) {
|
|
247
|
+
result.skippedFresh += 1;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const summary = (await options.summarize({ path, content: buf.toString("utf8") })).trim();
|
|
252
|
+
await writeSummary(repoRoot, {
|
|
253
|
+
target: path,
|
|
254
|
+
contentHash: hash,
|
|
255
|
+
summary,
|
|
256
|
+
model: options.model ?? null,
|
|
257
|
+
at: now().toISOString(),
|
|
258
|
+
source: "generated",
|
|
259
|
+
});
|
|
260
|
+
result.written += 1;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
result.failed.push({ path, error: err instanceof Error ? err.message : String(err) });
|
|
263
|
+
}
|
|
264
|
+
}, aborted);
|
|
265
|
+
|
|
266
|
+
// Prune sidecars whose target left the tracked set.
|
|
267
|
+
const targets = new Set(listed);
|
|
268
|
+
result.pruned = await pruneSummaries(repoRoot, (rec) => targets.has(rec.target));
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Write one summary sidecar (mkdir -p; rename for crash safety). */
|
|
273
|
+
export async function writeSummary(repoRoot: string, rec: SummaryRecord): Promise<string> {
|
|
274
|
+
const dir = summariesDir(repoRoot);
|
|
275
|
+
await fs.mkdir(dir, { recursive: true });
|
|
276
|
+
const file = summaryPath(repoRoot, rec.target);
|
|
277
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
278
|
+
await fs.writeFile(tmp, serializeSummary(rec), "utf8");
|
|
279
|
+
await fs.rename(tmp, file);
|
|
280
|
+
return file;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Delete sidecars whose record fails `keep`. Returns number removed. */
|
|
284
|
+
export async function pruneSummaries(
|
|
285
|
+
repoRoot: string,
|
|
286
|
+
keep: (rec: SummaryRecord) => boolean,
|
|
287
|
+
): Promise<number> {
|
|
288
|
+
const dir = summariesDir(repoRoot);
|
|
289
|
+
let removed = 0;
|
|
290
|
+
for (const rec of await readSummaries(repoRoot)) {
|
|
291
|
+
if (keep(rec)) continue;
|
|
292
|
+
try {
|
|
293
|
+
await fs.unlink(join(dir, summaryFileName(rec.target)));
|
|
294
|
+
removed += 1;
|
|
295
|
+
} catch {
|
|
296
|
+
// already gone
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return removed;
|
|
300
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for pi-weave core.
|
|
3
|
+
*
|
|
4
|
+
* This module — and everything under src/core — must NEVER import from
|
|
5
|
+
* @earendil-works/* or any other harness-specific package. The core is the
|
|
6
|
+
* portable artifact shared by the pi adapter today and the Claude Code /
|
|
7
|
+
* opencode adapters tomorrow (see docs/design.md §21).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Where a piece of knowledge came from. Drives trust display (design §13). */
|
|
11
|
+
export type NoteSource = "human" | "agent" | "generated";
|
|
12
|
+
|
|
13
|
+
export const NOTE_SOURCES: readonly NoteSource[] = ["human", "agent", "generated"];
|
|
14
|
+
|
|
15
|
+
/** Metadata parsed from a vault note's front matter. */
|
|
16
|
+
export interface NoteMeta {
|
|
17
|
+
title: string;
|
|
18
|
+
/** ISO-8601 timestamps. */
|
|
19
|
+
created: string;
|
|
20
|
+
updated: string;
|
|
21
|
+
tags: string[];
|
|
22
|
+
source: NoteSource;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A vault note: metadata plus its Markdown body. */
|
|
26
|
+
export interface Note extends NoteMeta {
|
|
27
|
+
/** File-name slug (no extension). Stable identity of the note. */
|
|
28
|
+
slug: string;
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Summary of one note for list/search output. */
|
|
33
|
+
export interface NoteSummary extends NoteMeta {
|
|
34
|
+
slug: string;
|
|
35
|
+
/** Size of the Markdown body in characters. */
|
|
36
|
+
bodyLength: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A search hit: the note plus a relevance score and a snippet. */
|
|
40
|
+
export interface NoteSearchHit {
|
|
41
|
+
summary: NoteSummary;
|
|
42
|
+
score: number;
|
|
43
|
+
snippet: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Git state snapshot used as the staleness anchor for a repo index. */
|
|
47
|
+
export interface GitState {
|
|
48
|
+
headSha: string;
|
|
49
|
+
branch: string;
|
|
50
|
+
/** Tracked-or-untracked files differing from HEAD (porcelain paths). */
|
|
51
|
+
changedFiles: string[];
|
|
52
|
+
/**
|
|
53
|
+
* sha1 of each changed path's worktree content at capture time; null for
|
|
54
|
+
* paths without file content (deletions, untracked directories). Anchors
|
|
55
|
+
* content, not just paths: re-editing an already-dirty file still moves
|
|
56
|
+
* the anchor. Indexes written before this field existed omit it — readers
|
|
57
|
+
* must tolerate its absence.
|
|
58
|
+
*/
|
|
59
|
+
changedHashes: Record<string, string | null>;
|
|
60
|
+
capturedAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Repository identity (repository/identity.json). */
|
|
64
|
+
export interface RepoIdentity {
|
|
65
|
+
name: string;
|
|
66
|
+
root: string;
|
|
67
|
+
remotes: string[];
|
|
68
|
+
defaultBranch: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One manifest detected in the repository (package boundary). */
|
|
72
|
+
export interface RepoPackage {
|
|
73
|
+
/** Manifest path relative to the repo root, e.g. "packages/core/package.json". */
|
|
74
|
+
manifestPath: string;
|
|
75
|
+
kind: "npm" | "python" | "rust" | "go" | "ruby" | "other";
|
|
76
|
+
/** Package name when it can be read cheaply, else the directory name. */
|
|
77
|
+
name: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A coarse module: a meaningful directory grouping of files. */
|
|
81
|
+
export interface RepoModule {
|
|
82
|
+
path: string;
|
|
83
|
+
fileCount: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Structural picture of the repository (repository/structure.json). */
|
|
87
|
+
export interface RepoStructure {
|
|
88
|
+
capturedAt: string;
|
|
89
|
+
fileCount: number;
|
|
90
|
+
/** Language name -> file count, e.g. { TypeScript: 12 }. */
|
|
91
|
+
languages: Record<string, number>;
|
|
92
|
+
packages: RepoPackage[];
|
|
93
|
+
modules: RepoModule[];
|
|
94
|
+
/** Likely entry points, repo-relative paths. */
|
|
95
|
+
entryPoints: string[];
|
|
96
|
+
/** Top-level directory listing with direct file counts. */
|
|
97
|
+
topLevel: { name: string; fileCount: number }[];
|
|
98
|
+
/**
|
|
99
|
+
* Files under the derived <repo>/.okf/ index (repo-relative, e.g.
|
|
100
|
+
* "repository/git.json"), captured so the viewer can render `.okf` as an
|
|
101
|
+
* expandable subtree. Absent when there is no `.okf` directory.
|
|
102
|
+
*/
|
|
103
|
+
okFiles?: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The full repository index held under <repo>/.okf/repository/. */
|
|
107
|
+
export interface RepoIndex {
|
|
108
|
+
okfVersion: 1;
|
|
109
|
+
scope: "repository";
|
|
110
|
+
generator: string;
|
|
111
|
+
/**
|
|
112
|
+
* Provenance of the whole index (AGENTS.md rule 4). Repository indexes
|
|
113
|
+
* are machine-derived, so this is always "generated" when pi-weave writes
|
|
114
|
+
* them; the reader preserves it so consumers can tell generated knowledge
|
|
115
|
+
* apart from human-authored OKF content.
|
|
116
|
+
*/
|
|
117
|
+
source: NoteSource;
|
|
118
|
+
created: string;
|
|
119
|
+
updated: string;
|
|
120
|
+
identity: RepoIdentity;
|
|
121
|
+
git: GitState;
|
|
122
|
+
structure: RepoStructure;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type StalenessState = "missing" | "fresh" | "stale";
|
|
126
|
+
|
|
127
|
+
export interface StalenessReport {
|
|
128
|
+
state: StalenessState;
|
|
129
|
+
reasons: string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Status of the vault half of the workspace. */
|
|
133
|
+
export interface VaultStatus {
|
|
134
|
+
root: string;
|
|
135
|
+
exists: boolean;
|
|
136
|
+
noteCount: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Status of the repository half of the workspace. */
|
|
140
|
+
export interface RepoStatus {
|
|
141
|
+
root: string;
|
|
142
|
+
name: string;
|
|
143
|
+
indexed: boolean;
|
|
144
|
+
staleness: StalenessReport;
|
|
145
|
+
/** Deep-scan summaries on disk (docs/scan-modes.md); 0 when none indexed. */
|
|
146
|
+
summaryCount: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Combined knowledge-workspace view for the current directory. */
|
|
150
|
+
export interface WorkspaceStatus {
|
|
151
|
+
cwd: string;
|
|
152
|
+
vault: VaultStatus;
|
|
153
|
+
/** null when cwd is not inside a git repository. */
|
|
154
|
+
repository: RepoStatus | null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { parseNoteFile, serializeNote } from "./frontmatter";
|
|
5
|
+
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
6
|
+
import { slugify, uniqueSlug } from "./slug";
|
|
7
|
+
import type { Note, NoteMeta, NoteSearchHit, NoteSource, NoteSummary } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The vault: the "smart notepad" half of pi-weave.
|
|
11
|
+
*
|
|
12
|
+
* Plain Markdown files with front matter under <vault>/notes/. Humans can
|
|
13
|
+
* edit them in any editor; agents read/write them through this layer so the
|
|
14
|
+
* format stays consistent (design §1, §13).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface AddNoteInput {
|
|
18
|
+
title: string;
|
|
19
|
+
body: string;
|
|
20
|
+
tags?: string[];
|
|
21
|
+
source?: NoteSource;
|
|
22
|
+
/** Injectable clock for tests. */
|
|
23
|
+
now?: Date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface VaultManifest {
|
|
27
|
+
okfVersion: 1;
|
|
28
|
+
scope: "vault";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function exists(path: string): Promise<boolean> {
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(path);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Create the vault layout if needed. Idempotent. */
|
|
41
|
+
export async function ensureVault(root: string): Promise<void> {
|
|
42
|
+
await fs.mkdir(join(root, NOTES_DIR), { recursive: true });
|
|
43
|
+
const manifestPath = join(root, OKF_MANIFEST);
|
|
44
|
+
if (!(await exists(manifestPath))) {
|
|
45
|
+
const manifest: VaultManifest = { okfVersion: 1, scope: "vault" };
|
|
46
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function vaultExists(root: string): Promise<boolean> {
|
|
51
|
+
return exists(join(root, OKF_MANIFEST));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function notePath(root: string, slug: string): string {
|
|
55
|
+
return join(root, NOTES_DIR, `${slug}.md`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a note slug to its on-disk path, or null when the slug is unsafe.
|
|
60
|
+
* Slugs arrive from tool parameters, so they are untrusted: `../x`, nested
|
|
61
|
+
* paths, and absolute escapes must never read or write outside the flat
|
|
62
|
+
* <vault>/notes/ directory.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveNotePath(root: string, slug: string): string | null {
|
|
65
|
+
if (slug.trim().length === 0) return null;
|
|
66
|
+
const notesDir = join(root, NOTES_DIR);
|
|
67
|
+
const candidate = join(notesDir, `${slug}.md`);
|
|
68
|
+
const rel = relative(notesDir, candidate);
|
|
69
|
+
if (rel.startsWith("..") || isAbsolute(rel) || rel.includes(sep)) return null;
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Create a note. Returns the written note (with its final, unique slug). */
|
|
74
|
+
export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
|
|
75
|
+
await ensureVault(root);
|
|
76
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
77
|
+
const base = slugify(input.title);
|
|
78
|
+
const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
|
|
79
|
+
|
|
80
|
+
const meta: NoteMeta = {
|
|
81
|
+
title: input.title,
|
|
82
|
+
created: now,
|
|
83
|
+
updated: now,
|
|
84
|
+
tags: input.tags ?? [],
|
|
85
|
+
source: input.source ?? "agent",
|
|
86
|
+
};
|
|
87
|
+
const text = serializeNote(meta, input.body);
|
|
88
|
+
await fs.writeFile(notePath(root, slug), text, "utf8");
|
|
89
|
+
return { slug, ...meta, body: input.body };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read a note by slug. Returns null when missing, malformed, or an unsafe slug. */
|
|
93
|
+
export async function getNote(root: string, slug: string): Promise<Note | null> {
|
|
94
|
+
const path = resolveNotePath(root, slug);
|
|
95
|
+
if (!path) return null;
|
|
96
|
+
let text: string;
|
|
97
|
+
try {
|
|
98
|
+
text = await fs.readFile(path, "utf8");
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const { meta, body } = parseNoteFile(text);
|
|
104
|
+
return { slug, ...meta, body };
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Append Markdown to an existing note and bump `updated`. */
|
|
111
|
+
export async function appendToNote(
|
|
112
|
+
root: string,
|
|
113
|
+
slug: string,
|
|
114
|
+
addition: string,
|
|
115
|
+
now: Date = new Date(),
|
|
116
|
+
): Promise<Note | null> {
|
|
117
|
+
const path = resolveNotePath(root, slug);
|
|
118
|
+
if (!path) return null;
|
|
119
|
+
const note = await getNote(root, slug);
|
|
120
|
+
if (!note) return null;
|
|
121
|
+
const body = note.body.replace(/\s+$/, "") + "\n\n" + addition.trim() + "\n";
|
|
122
|
+
const meta: NoteMeta = { ...note, updated: now.toISOString() };
|
|
123
|
+
await fs.writeFile(path, serializeNote(meta, body), "utf8");
|
|
124
|
+
return { slug, ...meta, body };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The append-only tail where verbatim user scribbles live (docs/notepad.md §4). */
|
|
128
|
+
export const RAW_NOTES_HEADING = "## Raw notes";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extract the `## Raw notes` tail (heading + everything after) verbatim, or
|
|
132
|
+
* "" when the note has no raw tail. Used by finalization so the literal
|
|
133
|
+
* record of the user's words is never rewritten.
|
|
134
|
+
*/
|
|
135
|
+
export function extractRawTail(body: string): string {
|
|
136
|
+
const idx = body.indexOf(RAW_NOTES_HEADING);
|
|
137
|
+
if (idx === -1) return "";
|
|
138
|
+
return body.slice(idx).trimEnd();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface FinalizeNoteInput {
|
|
142
|
+
/**
|
|
143
|
+
* The restructured body ABOVE the raw tail (front-loaded summary, sections,
|
|
144
|
+
* entities, links). The `## Raw notes` tail is preserved verbatim beneath it.
|
|
145
|
+
*/
|
|
146
|
+
body: string;
|
|
147
|
+
/** Injectable clock for tests. */
|
|
148
|
+
now?: Date;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Finalize a note: replace the body above the `## Raw notes` tail with a
|
|
153
|
+
* restructured version, preserving the raw tail verbatim (append-only).
|
|
154
|
+
* Returns null when the note is missing or the slug is unsafe.
|
|
155
|
+
*/
|
|
156
|
+
export async function finalizeNote(
|
|
157
|
+
root: string,
|
|
158
|
+
slug: string,
|
|
159
|
+
input: FinalizeNoteInput,
|
|
160
|
+
): Promise<Note | null> {
|
|
161
|
+
const path = resolveNotePath(root, slug);
|
|
162
|
+
if (!path) return null;
|
|
163
|
+
const note = await getNote(root, slug);
|
|
164
|
+
if (!note) return null;
|
|
165
|
+
const rawTail = extractRawTail(note.body);
|
|
166
|
+
const body = input.body.trim() + (rawTail ? `\n\n${rawTail}` : "");
|
|
167
|
+
const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
|
|
168
|
+
await fs.writeFile(path, serializeNote(meta, body), "utf8");
|
|
169
|
+
return { slug, ...meta, body };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function listNoteFiles(root: string): Promise<string[]> {
|
|
173
|
+
const dir = join(root, NOTES_DIR);
|
|
174
|
+
let entries: string[];
|
|
175
|
+
try {
|
|
176
|
+
entries = await fs.readdir(dir);
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
return entries.filter((name) => name.endsWith(".md")).sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** List all notes with their metadata, newest-updated first. */
|
|
184
|
+
export async function listNotes(root: string): Promise<NoteSummary[]> {
|
|
185
|
+
const files = await listNoteFiles(root);
|
|
186
|
+
const summaries: NoteSummary[] = [];
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
const slug = file.slice(0, -".md".length);
|
|
189
|
+
const note = await getNote(root, slug);
|
|
190
|
+
if (!note) continue; // unreadable/malformed files are skipped, not fatal
|
|
191
|
+
const { body, ...summary } = note;
|
|
192
|
+
summaries.push({ ...summary, bodyLength: body.length });
|
|
193
|
+
}
|
|
194
|
+
return summaries.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function noteCount(root: string): Promise<number> {
|
|
198
|
+
return (await listNoteFiles(root)).length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Substring search over title, tags, and body.
|
|
203
|
+
* Score: title match = 3, tag match = 2, body match = 1 each (capped).
|
|
204
|
+
* Case-insensitive. Deterministic ordering: score desc, then slug asc.
|
|
205
|
+
*/
|
|
206
|
+
export async function searchNotes(root: string, query: string): Promise<NoteSearchHit[]> {
|
|
207
|
+
const q = query.toLowerCase().trim();
|
|
208
|
+
if (q.length === 0) return [];
|
|
209
|
+
|
|
210
|
+
const hits: NoteSearchHit[] = [];
|
|
211
|
+
for (const summary of await listNotes(root)) {
|
|
212
|
+
const note = await getNote(root, summary.slug);
|
|
213
|
+
if (!note) continue;
|
|
214
|
+
|
|
215
|
+
let score = 0;
|
|
216
|
+
if (note.title.toLowerCase().includes(q)) score += 3;
|
|
217
|
+
if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 2;
|
|
218
|
+
|
|
219
|
+
const bodyLower = note.body.toLowerCase();
|
|
220
|
+
let bodyMatches = 0;
|
|
221
|
+
let idx = bodyLower.indexOf(q);
|
|
222
|
+
while (idx >= 0 && bodyMatches < 5) {
|
|
223
|
+
bodyMatches++;
|
|
224
|
+
idx = bodyLower.indexOf(q, idx + q.length);
|
|
225
|
+
}
|
|
226
|
+
score += bodyMatches;
|
|
227
|
+
|
|
228
|
+
if (score === 0) continue;
|
|
229
|
+
hits.push({ summary, score, snippet: makeSnippet(note.body, q) });
|
|
230
|
+
}
|
|
231
|
+
return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function makeSnippet(body: string, q: string, radius = 60): string {
|
|
235
|
+
const idx = body.toLowerCase().indexOf(q);
|
|
236
|
+
if (idx === -1) {
|
|
237
|
+
return body.trim().slice(0, radius * 2);
|
|
238
|
+
}
|
|
239
|
+
const start = Math.max(0, idx - radius);
|
|
240
|
+
const end = Math.min(body.length, idx + q.length + radius);
|
|
241
|
+
const prefix = start > 0 ? "…" : "";
|
|
242
|
+
const suffix = end < body.length ? "…" : "";
|
|
243
|
+
return (prefix + body.slice(start, end) + suffix).replace(/\s+/g, " ").trim();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Re-export so adapters do not need to know about frontmatter details. */
|
|
247
|
+
export { serializeNote, parseNoteFile };
|
|
248
|
+
|
|
249
|
+
/** Render a note as Markdown text for agent/human consumption. */
|
|
250
|
+
export function formatNote(note: Note): string {
|
|
251
|
+
const tags = note.tags.length > 0 ? `, tags: ${note.tags.join(", ")}` : "";
|
|
252
|
+
const header = `# ${note.title}\n(slug: ${note.slug}, updated ${note.updated}${tags}, source: ${note.source})`;
|
|
253
|
+
return `${header}\n\n${note.body.trim()}\n`;
|
|
254
|
+
}
|