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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Pure graph builder: knowledge workspace inputs → GraphModel.
3
+ *
4
+ * No I/O, no clock access, no harness imports (design §21,
5
+ * docs/weave-view.md §3). Stability contract: identical inputs produce
6
+ * byte-identical JSON (ids derive from slugs/paths only; `generatedAt` is
7
+ * derived from input timestamps, never from the wall clock) — that is what
8
+ * makes the page's refresh-polling cheap.
9
+ */
10
+
11
+ import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
12
+ import type { SummaryRecord } from "../summaries";
13
+ import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
14
+ import { extractWikilinks } from "./wikilinks";
15
+
16
+ /** Hard cap on note nodes (docs/weave-view.md M3 guard). */
17
+ export const DEFAULT_MAX_NOTES = 500;
18
+
19
+ export interface BuildGraphInput {
20
+ vault: VaultStatus;
21
+ /** Full notes including bodies (for wiki-link extraction). */
22
+ notes: Note[];
23
+ /** Repository half; null when cwd is not an indexed git repository. */
24
+ repository: { index: RepoIndex; staleness: StalenessReport } | null;
25
+ /** Deep-scan summaries keyed by repo-relative path (docs/scan-modes.md). */
26
+ summaries?: ReadonlyMap<string, SummaryRecord>;
27
+ }
28
+
29
+ const SHORT_SHA_LEN = 7;
30
+ const PREVIEW_LEN = 240;
31
+
32
+ function moduleDetail(
33
+ path: string,
34
+ fileCount: number,
35
+ summaries: ReadonlyMap<string, SummaryRecord> | undefined,
36
+ ): Record<string, string> {
37
+ const detail: Record<string, string> = { path, files: String(fileCount) };
38
+ if (summaries) {
39
+ const prefix = path === "." ? "" : `${path}/`;
40
+ let count = 0;
41
+ for (const target of summaries.keys()) {
42
+ if (target.startsWith(prefix)) count += 1;
43
+ }
44
+ if (count > 0) detail["summarized files"] = String(count);
45
+ }
46
+ return detail;
47
+ }
48
+
49
+ function preview(body: string): string {
50
+ const flat = body.trim().replace(/\s+/g, " ");
51
+ return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
52
+ }
53
+
54
+ /** Data-as-of marker: newest timestamp present in the inputs (ISO strings compare lexicographically). */
55
+ export function dataTimestamp(input: BuildGraphInput): string {
56
+ let max = "";
57
+ for (const note of input.notes) {
58
+ if (note.updated > max) max = note.updated;
59
+ }
60
+ const repoStamp = input.repository?.index.updated ?? "";
61
+ if (repoStamp > max) max = repoStamp;
62
+ if (input.summaries) {
63
+ for (const rec of input.summaries.values()) {
64
+ if (rec.at > max) max = rec.at;
65
+ }
66
+ }
67
+ return max;
68
+ }
69
+
70
+ /**
71
+ * Parse a remote into display label + canonical url. Core `remotes()` emits
72
+ * bare deduped URLs, but hand-written fixtures (or future sources) may use
73
+ * "name url"; both are accepted. Bare URLs get their label from the last
74
+ * path segment (works for scp-style `git@host:org/repo.git` too).
75
+ */
76
+ function parseRemote(raw: string): { label: string; url: string } {
77
+ const s = raw.trim();
78
+ const named = /^\s*(\S+)\s+(\S.*)$/.exec(s);
79
+ if (named && named[1] !== undefined && named[2] !== undefined && !named[1].includes("://") && !named[1].includes("@")) {
80
+ return { label: named[1], url: named[2].trim() };
81
+ }
82
+ const noTrail = s.replace(/\/+$/, "");
83
+ const afterColon = noTrail.split(":").pop() ?? noTrail;
84
+ const tail = afterColon.split("/").pop() ?? afterColon;
85
+ return { label: tail.replace(/\.git$/, ""), url: s };
86
+ }
87
+
88
+ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNode[], edges: GraphEdge[]): string[] {
89
+ const truncated = input.notes.length > maxNotes;
90
+ const kept = input.notes.slice(0, maxNotes);
91
+
92
+ const vaultDetail: Record<string, string> = {
93
+ root: input.vault.root,
94
+ notes: String(input.vault.noteCount),
95
+ };
96
+ if (truncated) {
97
+ vaultDetail.warning = `Graph shows the ${maxNotes} most recent notes — the vault holds ${input.vault.noteCount}. Wiki-links to older notes are omitted.`;
98
+ }
99
+ nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
100
+
101
+ const keptSlugs = new Set(kept.map((n) => n.slug));
102
+ for (const note of kept) {
103
+ const links = extractWikilinks(note.body);
104
+ const resolved = links.filter((slug) => keptSlugs.has(slug));
105
+ const detail: Record<string, string> = {
106
+ slug: note.slug,
107
+ source: note.source,
108
+ updated: note.updated,
109
+ };
110
+ if (note.tags.length > 0) detail.tags = note.tags.join(", ");
111
+ const dangling = links.length - resolved.length;
112
+ if (dangling > 0) detail["dangling links"] = String(dangling);
113
+ detail.preview = preview(note.body);
114
+
115
+ nodes.push({ id: `note:${note.slug}`, kind: "note", label: note.title, provenance: note.source, detail });
116
+ edges.push({ source: "vault", target: `note:${note.slug}`, kind: "contains" });
117
+ for (const target of resolved) {
118
+ edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
119
+ }
120
+ }
121
+ return [...keptSlugs];
122
+ }
123
+
124
+ function buildRepositorySide(
125
+ repository: NonNullable<BuildGraphInput["repository"]>,
126
+ summaries: ReadonlyMap<string, SummaryRecord> | undefined,
127
+ nodes: GraphNode[],
128
+ edges: GraphEdge[],
129
+ ): void {
130
+ const { index, staleness } = repository;
131
+ const { identity, git, structure } = index;
132
+
133
+ const repoDetail: Record<string, string> = {
134
+ root: identity.root,
135
+ files: String(structure.fileCount),
136
+ state: staleness.state,
137
+ };
138
+ const languages = Object.entries(structure.languages)
139
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
140
+ .slice(0, 3)
141
+ .map(([name, count]) => `${name} (${count})`)
142
+ .join(", ");
143
+ if (languages.length > 0) repoDetail.languages = languages;
144
+ if (staleness.reasons.length > 0) repoDetail.stale = staleness.reasons[0] ?? "";
145
+ nodes.push({ id: "repository", kind: "repository", label: identity.name, provenance: null, detail: repoDetail });
146
+
147
+ const branch = git.branch.length > 0 ? git.branch : "(detached)";
148
+ nodes.push({
149
+ id: "gitState",
150
+ kind: "gitState",
151
+ label: `${branch} @ ${git.headSha.slice(0, SHORT_SHA_LEN)}`,
152
+ provenance: null,
153
+ detail: {
154
+ branch,
155
+ commit: git.headSha,
156
+ "uncommitted changes": String(git.changedFiles.length),
157
+ captured: git.capturedAt,
158
+ },
159
+ });
160
+ edges.push({ source: "repository", target: "gitState", kind: "anchored-at" });
161
+
162
+ for (const remote of identity.remotes) {
163
+ const { label, url } = parseRemote(remote);
164
+ // Id keys off the URL (the actual identity — two names can collide).
165
+ nodes.push({ id: `external:${url}`, kind: "external", label, provenance: null, detail: { url } });
166
+ edges.push({ source: "repository", target: `external:${url}`, kind: "contains" });
167
+ }
168
+
169
+ for (const pkg of structure.packages) {
170
+ nodes.push({
171
+ id: `package:${pkg.manifestPath}`,
172
+ kind: "package",
173
+ label: pkg.name,
174
+ provenance: null,
175
+ detail: { manifest: pkg.manifestPath, kind: pkg.kind },
176
+ });
177
+ edges.push({ source: "repository", target: `package:${pkg.manifestPath}`, kind: "contains" });
178
+ }
179
+
180
+ for (const mod of structure.modules) {
181
+ const label = mod.path === "(root)" ? "./ (root files)" : mod.path;
182
+ nodes.push({
183
+ id: `module:${mod.path}`,
184
+ kind: "module",
185
+ label,
186
+ provenance: null,
187
+ detail: moduleDetail(mod.path, mod.fileCount, summaries),
188
+ });
189
+ edges.push({ source: "repository", target: `module:${mod.path}`, kind: "contains" });
190
+ }
191
+
192
+ for (const entry of structure.entryPoints) {
193
+ const detail: Record<string, string> = { path: entry };
194
+ const sum = summaries?.get(entry);
195
+ if (sum) {
196
+ detail.summary = sum.summary;
197
+ detail["summarized at"] = sum.at;
198
+ if (sum.model !== null) detail["summarized by"] = sum.model;
199
+ }
200
+ nodes.push({ id: `entryPoint:${entry}`, kind: "entryPoint", label: entry, provenance: null, detail });
201
+ edges.push({ source: "repository", target: `entryPoint:${entry}`, kind: "contains" });
202
+ }
203
+
204
+ // Render the derived .okf index as an expandable subtree under its module.
205
+ // Files nest by directory: okf.json at the root, repository/*.json under a
206
+ // "repository" folder, and summary files under a "summaries" folder so a
207
+ // large summary set never explodes the tree. Ids are path-derived (stable).
208
+ if (structure.okFiles && structure.okFiles.length > 0) {
209
+ const okRoot = "module:.okf";
210
+ const repoDir = "module:.okf/repository";
211
+ const sumDir = "module:.okf/repository/summaries";
212
+ const okChildren: string[] = [];
213
+ const repoChildren: string[] = [];
214
+ const sumChildren: string[] = [];
215
+ for (const f of structure.okFiles) {
216
+ const rel = f.replace(/^\.okf\//, "");
217
+ const id = `okf:${rel}`;
218
+ const label = rel.split("/").pop() ?? rel;
219
+ nodes.push({ id, kind: "file", label, provenance: null, detail: { path: rel } });
220
+ if (rel.startsWith("repository/summaries/")) sumChildren.push(id);
221
+ else if (rel.startsWith("repository/")) repoChildren.push(id);
222
+ else okChildren.push(id);
223
+ }
224
+ okChildren.forEach((c) => edges.push({ source: okRoot, target: c, kind: "contains" }));
225
+ if (repoChildren.length > 0 || sumChildren.length > 0) {
226
+ nodes.push({ id: repoDir, kind: "module", label: "repository", provenance: null, detail: { path: ".okf/repository" } });
227
+ edges.push({ source: okRoot, target: repoDir, kind: "contains" });
228
+ repoChildren.forEach((c) => edges.push({ source: repoDir, target: c, kind: "contains" }));
229
+ if (sumChildren.length > 0) {
230
+ nodes.push({ id: sumDir, kind: "module", label: "summaries", provenance: null, detail: { path: ".okf/repository/summaries" } });
231
+ edges.push({ source: repoDir, target: sumDir, kind: "contains" });
232
+ sumChildren.forEach((c) => edges.push({ source: sumDir, target: c, kind: "contains" }));
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Build the graph model for the viewer. Notes are capped at `maxNotes`
240
+ * (docs/weave-view.md M3); the vault node carries a warning when truncated.
241
+ */
242
+ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number } = {}): GraphModel {
243
+ const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
244
+ const nodes: GraphNode[] = [];
245
+ const edges: GraphEdge[] = [];
246
+ buildVaultSide(input, maxNotes, nodes, edges);
247
+ if (input.repository !== null) {
248
+ buildRepositorySide(input.repository, input.summaries, nodes, edges);
249
+ }
250
+ return {
251
+ generatedAt: dataTimestamp(input),
252
+ staleness: input.repository?.staleness ?? null,
253
+ nodes,
254
+ edges,
255
+ };
256
+ }
257
+
258
+ export type { EdgeKind, GraphEdge, GraphModel, GraphNode, NodeKind } from "./model";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Workspace assembly readers for the viewers (docs/weave-view.md §2,
3
+ * weave-view-tui-design §3.1).
4
+ *
5
+ * These functions are *workspace assembly*, symmetric to `getWorkspaceStatus`
6
+ * (already in core): pure fan-out over `core/vault`, `core/repoIndex`,
7
+ * `core/summaries`, `core/git`, then the pure `buildGraph`. They import only
8
+ * core + node builtins, so a future Claude Code / opencode adapter can
9
+ * assemble the same graph without importing pi's viewer directory.
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { join, resolve, sep } from "node:path";
14
+ import {
15
+ assessStaleness,
16
+ buildGraph,
17
+ DEFAULT_MAX_NOTES,
18
+ findGitRoot,
19
+ getNote,
20
+ listNotes,
21
+ noteCount,
22
+ readRepoIndex,
23
+ readSummaryMap,
24
+ resolveNotePath,
25
+ resolveVaultRoot,
26
+ type BuildGraphInput,
27
+ type GraphModel,
28
+ type Note,
29
+ } from "../index";
30
+
31
+ /** A note read for the viewers (read-only; never cached). Mirrors the vault `Note` shape. */
32
+ export interface ViewNote {
33
+ slug: string;
34
+ title: string;
35
+ body: string;
36
+ created: string;
37
+ updated: string;
38
+ tags: string[];
39
+ source: Note["source"];
40
+ }
41
+
42
+ /**
43
+ * Live-read one note for the viewer's side panel / TUI detail (read-only;
44
+ * never caches). Traversal-safe: an unsafe slug returns null without
45
+ * touching disk.
46
+ */
47
+ export async function readNoteForView(vaultRoot: string, slug: string): Promise<ViewNote | null> {
48
+ if (resolveNotePath(vaultRoot, slug) === null) return null; // traversal-safe
49
+ const note = await getNote(vaultRoot, slug);
50
+ if (note === null) return null;
51
+ return {
52
+ slug: note.slug,
53
+ title: note.title,
54
+ body: note.body,
55
+ created: note.created,
56
+ updated: note.updated,
57
+ tags: note.tags,
58
+ source: note.source,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Read one derived index file under <cwd>/.okf for the viewers
64
+ * (traversal-safe). The `rel` path is anchored to <cwd>/.okf, so
65
+ * summary/identity/structure bodies can be shown instead of "(no body)".
66
+ */
67
+ export async function readOkfFileForView(cwd: string, rel: string): Promise<{ path: string; body: string } | null> {
68
+ const okfRoot = join(cwd, ".okf");
69
+ const resolved = resolve(okfRoot, rel);
70
+ if (resolved !== okfRoot && !resolved.startsWith(okfRoot + sep)) return null; // traversal-safe
71
+ try {
72
+ const body = await readFile(resolved, "utf8");
73
+ return { path: rel, body };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Assemble the fresh graph from disk. Called on every viewer fetch
81
+ * (no caching — docs/weave-view.md §2). Reads the vault (capped at
82
+ * DEFAULT_MAX_NOTES) and, when cwd is an indexed git repository, the repo
83
+ * index + deep-scan summary sidecars. Degrades to a vault-only graph when
84
+ * the repo has no index or the index is corrupt.
85
+ */
86
+ export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolveVaultRoot()): Promise<GraphModel> {
87
+ const noteSummaries = (await listNotes(vaultRoot)).slice(0, DEFAULT_MAX_NOTES);
88
+ const loaded = await Promise.all(noteSummaries.map((s) => getNote(vaultRoot, s.slug)));
89
+ const notes = loaded.filter((n): n is Note => n !== null);
90
+
91
+ const input: BuildGraphInput = {
92
+ vault: { root: vaultRoot, exists: true, noteCount: await noteCount(vaultRoot) },
93
+ notes,
94
+ repository: null,
95
+ };
96
+
97
+ const repoRoot = await findGitRoot(cwd);
98
+ if (repoRoot !== null) {
99
+ const index = await readRepoIndex(repoRoot);
100
+ if (index !== null) {
101
+ input.repository = { index, staleness: await assessStaleness(repoRoot) };
102
+ input.summaries = await readSummaryMap(repoRoot); // deep-scan sidecars, read live
103
+ }
104
+ }
105
+ return buildGraph(input);
106
+ }
107
+
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Graph model shared between the builder (core) and the viewer (adapter).
3
+ * Pure data — no harness imports (design §21). See docs/weave-view.md §3.
4
+ */
5
+
6
+ import type { NoteSource, StalenessReport } from "../types";
7
+
8
+ export type NodeKind =
9
+ | "vault"
10
+ | "note"
11
+ | "repository"
12
+ | "module"
13
+ | "package"
14
+ | "entryPoint"
15
+ | "gitState"
16
+ | "external"
17
+ | "file";
18
+
19
+ export type EdgeKind = "contains" | "anchored-at" | "links-to" | "mentions";
20
+
21
+ /** All node kinds — the viewer legend and tests iterate this list. */
22
+ export const NODE_KINDS: readonly NodeKind[] = [
23
+ "vault",
24
+ "note",
25
+ "repository",
26
+ "module",
27
+ "package",
28
+ "entryPoint",
29
+ "gitState",
30
+ "external",
31
+ "file",
32
+ ];
33
+
34
+ /** All edge kinds — the viewer legend and tests iterate this list. */
35
+ export const EDGE_KINDS: readonly EdgeKind[] = ["contains", "anchored-at", "links-to", "mentions"];
36
+
37
+ /** A single graph node. `id` is stable: derived from slugs/paths only. */
38
+ export interface GraphNode {
39
+ id: string;
40
+ kind: NodeKind;
41
+ label: string;
42
+ /** Trust provenance for knowledge nodes; null for structural nodes. */
43
+ provenance: NoteSource | null;
44
+ /** Pre-formatted side-panel payload. */
45
+ detail: Record<string, string>;
46
+ }
47
+
48
+ export interface GraphEdge {
49
+ source: string;
50
+ target: string;
51
+ kind: EdgeKind;
52
+ }
53
+
54
+ export interface GraphModel {
55
+ /**
56
+ * Data-as-of marker derived from inputs (max note updated / index stamp),
57
+ * so two builds of unchanged inputs produce byte-identical JSON.
58
+ */
59
+ generatedAt: string;
60
+ staleness: StalenessReport | null;
61
+ nodes: GraphNode[];
62
+ edges: GraphEdge[];
63
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Obsidian-compatible [[wiki-link]] extraction from note bodies.
3
+ * Pure module — see docs/weave-view.md §3.
4
+ */
5
+
6
+ import { slugify } from "../slug";
7
+
8
+ const WIKILINK_RE = /\[\[([^\][|]+)(?:\|[^\]]*)?\]\]/g;
9
+
10
+ /**
11
+ * Extract wiki-link targets from a note body as slugs. Handles
12
+ * `[[some-note]]` and aliased `[[Some Note|alias]]` (alias ignored for
13
+ * linking). Targets are slugified so `[[Release Plan]]` matches the note
14
+ * `release-plan`. Duplicates are removed, order of first appearance kept.
15
+ */
16
+ export function extractWikilinks(body: string): string[] {
17
+ const out: string[] = [];
18
+ const seen = new Set<string>();
19
+ for (const match of body.matchAll(WIKILINK_RE)) {
20
+ const raw = (match[1] ?? "").trim();
21
+ if (raw.length === 0) continue;
22
+ const slug = slugify(raw);
23
+ if (seen.has(slug)) continue;
24
+ seen.add(slug);
25
+ out.push(slug);
26
+ }
27
+ return out;
28
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * pi-weave core — the portable knowledge engine.
3
+ *
4
+ * NO harness imports allowed in this tree (see docs/design.md §21).
5
+ */
6
+ export * from "./types";
7
+ export * from "./slug";
8
+ export * from "./frontmatter";
9
+ export * from "./languages";
10
+ export * from "./mutex";
11
+ export * from "./paths";
12
+ export * from "./git";
13
+ export * from "./vault";
14
+ export * from "./repoIndex";
15
+ export * from "./summaries";
16
+ export * from "./workspace";
17
+ export * from "./graph/model";
18
+ export * from "./graph/wikilinks";
19
+ export { buildGraph, dataTimestamp, DEFAULT_MAX_NOTES, type BuildGraphInput } from "./graph/build";
20
+ export {
21
+ buildCurrentGraph,
22
+ readNoteForView,
23
+ readOkfFileForView,
24
+ type ViewNote,
25
+ } from "./graph/current";
@@ -0,0 +1,76 @@
1
+ /** File-extension -> language name used for the Level 0 language histogram. */
2
+
3
+ const EXTENSION_TO_LANGUAGE: Record<string, string> = {
4
+ ".ts": "TypeScript",
5
+ ".tsx": "TypeScript",
6
+ ".mts": "TypeScript",
7
+ ".cts": "TypeScript",
8
+ ".js": "JavaScript",
9
+ ".jsx": "JavaScript",
10
+ ".mjs": "JavaScript",
11
+ ".cjs": "JavaScript",
12
+ ".py": "Python",
13
+ ".pyi": "Python",
14
+ ".go": "Go",
15
+ ".rs": "Rust",
16
+ ".rb": "Ruby",
17
+ ".java": "Java",
18
+ ".kt": "Kotlin",
19
+ ".kts": "Kotlin",
20
+ ".swift": "Swift",
21
+ ".c": "C",
22
+ ".h": "C",
23
+ ".cpp": "C++",
24
+ ".cc": "C++",
25
+ ".cxx": "C++",
26
+ ".hpp": "C++",
27
+ ".cs": "C#",
28
+ ".fs": "F#",
29
+ ".php": "PHP",
30
+ ".lua": "Lua",
31
+ ".zig": "Zig",
32
+ ".ex": "Elixir",
33
+ ".exs": "Elixir",
34
+ ".erl": "Erlang",
35
+ ".hrl": "Erlang",
36
+ ".clj": "Clojure",
37
+ ".cljs": "Clojure",
38
+ ".scala": "Scala",
39
+ ".hs": "Haskell",
40
+ ".ml": "OCaml",
41
+ ".sh": "Shell",
42
+ ".bash": "Shell",
43
+ ".zsh": "Shell",
44
+ ".fish": "Shell",
45
+ ".ps1": "PowerShell",
46
+ ".sql": "SQL",
47
+ ".html": "HTML",
48
+ ".htm": "HTML",
49
+ ".css": "CSS",
50
+ ".scss": "SCSS",
51
+ ".less": "Less",
52
+ ".md": "Markdown",
53
+ ".mdx": "Markdown",
54
+ ".json": "JSON",
55
+ ".jsonc": "JSON",
56
+ ".yaml": "YAML",
57
+ ".yml": "YAML",
58
+ ".toml": "TOML",
59
+ ".xml": "XML",
60
+ ".vue": "Vue",
61
+ ".svelte": "Svelte",
62
+ ".tf": "Terraform",
63
+ ".proto": "Protobuf",
64
+ ".graphql": "GraphQL",
65
+ ".gql": "GraphQL",
66
+ ".r": "R",
67
+ ".jl": "Julia",
68
+ ".dart": "Dart",
69
+ ".dockerfile": "Dockerfile",
70
+ };
71
+
72
+ /** Look up a language by file extension (with or without leading dot). */
73
+ export function languageForExtension(ext: string): string | undefined {
74
+ const key = ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`;
75
+ return EXTENSION_TO_LANGUAGE[key];
76
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Keyed async mutation queue (core-owned; no harness dependencies).
3
+ *
4
+ * Serializes async mutators that touch the same resource key (e.g. a note
5
+ * file path) so concurrent callers — parallel tool calls, multiple tools
6
+ * writing at once — can't interleave writes. Replaces the pi harness's
7
+ * `withFileMutationQueue` with a portable equivalent, per AGENTS.md rule 3
8
+ * (logic lives in core; adapters stay thin).
9
+ *
10
+ * Properties:
11
+ * - Tasks sharing a key run strictly one-after-another, in call order.
12
+ * - Tasks with different keys run concurrently.
13
+ * - A rejected task does NOT wedge the queue: its error propagates to its
14
+ * own caller, and later tasks for the same key still run.
15
+ * - The value each task resolves with is passed through untouched.
16
+ */
17
+
18
+ const queues = new Map<string, Promise<unknown>>();
19
+
20
+ export function withMutationQueue<T>(key: string, task: () => Promise<T>): Promise<T> {
21
+ const prior = queues.get(key) ?? Promise.resolve();
22
+ // `prior` (as stored below) never rejects; the dual callbacks are
23
+ // belt-and-braces so even a foreign promise in the map can't wedge us.
24
+ const result = prior.then(() => task(), () => task());
25
+ // Store a non-rejecting tail so a failure can't poison later tasks.
26
+ queues.set(
27
+ key,
28
+ result.then(
29
+ () => undefined,
30
+ () => undefined,
31
+ ),
32
+ );
33
+ return result;
34
+ }
@@ -0,0 +1,37 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Canonical locations for pi-weave knowledge (design Appendix A).
6
+ *
7
+ * - Vault: ~/.okf/ (human/agent persistent knowledge)
8
+ * - Repository index: <git root>/.okf/ (derived machine knowledge)
9
+ *
10
+ * The vault location can be overridden with PI_WEAVE_VAULT, primarily for
11
+ * tests and for users who keep their vault somewhere unusual.
12
+ */
13
+
14
+ export const OKF_DIR = ".okf";
15
+ export const OKF_MANIFEST = "okf.json";
16
+ export const NOTES_DIR = "notes";
17
+ export const REPOSITORY_DIR = "repository";
18
+ export const VAULT_ENV_VAR = "PI_WEAVE_VAULT";
19
+
20
+ /** Resolve the vault root. `env` is injectable for tests. */
21
+ export function resolveVaultRoot(env: NodeJS.ProcessEnv = process.env): string {
22
+ const override = env[VAULT_ENV_VAR];
23
+ if (override && override.trim().length > 0) {
24
+ return override;
25
+ }
26
+ return join(homedir(), OKF_DIR);
27
+ }
28
+
29
+ /** The repository index directory for a given repo root. */
30
+ export function repoIndexDir(repoRoot: string): string {
31
+ return join(repoRoot, OKF_DIR);
32
+ }
33
+
34
+ /** Subdirectory of an .okf index holding repository knowledge. */
35
+ export function repoKnowledgeDir(repoRoot: string): string {
36
+ return join(repoRoot, OKF_DIR, REPOSITORY_DIR);
37
+ }