pi-weave 0.3.8 → 0.3.10
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/README.md +21 -17
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +22 -2
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/context/context.model.ts +5 -0
- package/src/web/client/dist/app.js +499 -129
- package/src/web/client/graph/Graph.tsx +166 -14
- package/src/web/client/graph/column.model.ts +59 -2
- package/src/web/client/graph/graph.model.ts +183 -7
- package/src/web/client/graph/renderer.ts +39 -9
- package/src/web/client/graph/spotlight.model.ts +472 -0
- package/src/web/client/note/Note.tsx +370 -66
- package/src/web/client/note/Replay.tsx +139 -0
- package/src/web/client/note/drafts.ts +115 -11
- package/src/web/client/note/editbar.model.ts +153 -0
- package/src/web/client/note/note.model.ts +82 -1
- package/src/web/client/note/replay.model.ts +248 -0
- package/src/web/client/search/SearchPalette.tsx +103 -34
- package/src/web/client/search/search.model.ts +533 -26
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +93 -27
- package/src/web/client/shell/StatusBar.tsx +1 -0
- package/src/web/client/shell/icons.model.ts +68 -1
- package/src/web/client/shell/keys.model.ts +31 -8
- package/src/web/client/shell/shell.model.ts +48 -1
- package/src/web/client/shell/theme.model.ts +3 -0
- package/src/web/client/shell/theme.ts +354 -32
- package/src/web/client/shell/workspace.css.ts +40 -9
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/themes.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/web/shared/workspace.ts +10 -2
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { OKF_MANIFEST } from "./paths";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Note history: a copy of every note after every change, so the viewer can
|
|
7
|
+
* replay how a note was written from its first words to now.
|
|
8
|
+
*
|
|
9
|
+
* ## Why a log file and not git
|
|
10
|
+
*
|
|
11
|
+
* The vault is not guaranteed to be a git repository, and when it is, the
|
|
12
|
+
* repository belongs to the user: committing on every append would bury their
|
|
13
|
+
* own history under ours. A plain append-only file per note keeps the
|
|
14
|
+
* project's rule that knowledge lives in readable files, needs no binary, and
|
|
15
|
+
* travels with the vault (the backup ZIP walks the whole vault root).
|
|
16
|
+
*
|
|
17
|
+
* ## Format
|
|
18
|
+
*
|
|
19
|
+
* `<vault>/history/<slug>.jsonl`, one JSON object per revision, oldest first.
|
|
20
|
+
* A revision is either a full copy of the body (`full`) or a delta against
|
|
21
|
+
* the revision before it: keep `keep[0]` characters from the start and
|
|
22
|
+
* `keep[1]` from the end, and put `text` between them. Appends and small
|
|
23
|
+
* edits cost a few bytes. Every {@link KEYFRAME_EVERY}th revision is full, so
|
|
24
|
+
* one damaged line cannot take the rest of the log with it.
|
|
25
|
+
*
|
|
26
|
+
* Bodies only, not front matter: the `updated` stamp changes on every write
|
|
27
|
+
* and would turn every delta into a copy of the whole file.
|
|
28
|
+
*
|
|
29
|
+
* ## Who wrote it
|
|
30
|
+
*
|
|
31
|
+
* Every write path in `vault.ts` says who is writing. An edit made outside
|
|
32
|
+
* pi-weave (an editor, a sync tool) is noticed the next time pi-weave writes
|
|
33
|
+
* or replays the note: the body on disk no longer matches the last revision,
|
|
34
|
+
* so it is recorded as an `outside` revision before anything else.
|
|
35
|
+
*
|
|
36
|
+
* ## Size limits
|
|
37
|
+
*
|
|
38
|
+
* A note's log is compacted once it holds more than {@link MAX_REVISIONS}
|
|
39
|
+
* revisions or {@link MAX_LOG_BYTES} bytes. Compaction drops the oldest
|
|
40
|
+
* revisions until the log is back under {@link COMPACT_TO} of both limits
|
|
41
|
+
* (so it does not run again on the next write), rewrites what is left with
|
|
42
|
+
* a full copy first, and marks that line `trimmed`. The newest revision is
|
|
43
|
+
* always kept, however large the note.
|
|
44
|
+
*
|
|
45
|
+
* ## Off switch
|
|
46
|
+
*
|
|
47
|
+
* `"history": false` in the vault's `okf.json` stops recording. Existing logs
|
|
48
|
+
* are kept, and nothing is recorded while it is off — including outside edits.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export const HISTORY_DIR = "history";
|
|
52
|
+
const LOG_EXT = ".jsonl";
|
|
53
|
+
/** A full copy every this many revisions bounds the damage of a bad line. */
|
|
54
|
+
export const KEYFRAME_EVERY = 32;
|
|
55
|
+
/** Most revisions one note's log keeps. */
|
|
56
|
+
export const MAX_REVISIONS = 500;
|
|
57
|
+
/** Largest one note's log may grow, in bytes, before it is compacted. */
|
|
58
|
+
export const MAX_LOG_BYTES = 4 * 1024 * 1024;
|
|
59
|
+
/** Compaction brings a log back to this share of both limits. */
|
|
60
|
+
export const COMPACT_TO = 0.8;
|
|
61
|
+
|
|
62
|
+
/** Who made a change. `outside` is any editor other than pi-weave. */
|
|
63
|
+
export type HistoryAuthor = "human" | "agent" | "outside" | "system";
|
|
64
|
+
export const HISTORY_AUTHORS: readonly HistoryAuthor[] = ["human", "agent", "outside", "system"];
|
|
65
|
+
|
|
66
|
+
/** What kind of change it was. `baseline` is a note as history first found it. */
|
|
67
|
+
export type HistoryOp =
|
|
68
|
+
| "create"
|
|
69
|
+
| "edit"
|
|
70
|
+
| "append"
|
|
71
|
+
| "raw"
|
|
72
|
+
| "finalize"
|
|
73
|
+
| "generate"
|
|
74
|
+
| "repair"
|
|
75
|
+
| "outside"
|
|
76
|
+
| "baseline";
|
|
77
|
+
export const HISTORY_OPS: readonly HistoryOp[] = [
|
|
78
|
+
"create",
|
|
79
|
+
"edit",
|
|
80
|
+
"append",
|
|
81
|
+
"raw",
|
|
82
|
+
"finalize",
|
|
83
|
+
"generate",
|
|
84
|
+
"repair",
|
|
85
|
+
"outside",
|
|
86
|
+
"baseline",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** One revision of a note, with its body reconstructed. */
|
|
90
|
+
export interface NoteRevision {
|
|
91
|
+
/** ISO-8601 time of the change. */
|
|
92
|
+
at: string;
|
|
93
|
+
by: HistoryAuthor;
|
|
94
|
+
op: HistoryOp;
|
|
95
|
+
body: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A line of the log, as stored, and the shape the viewer receives. */
|
|
99
|
+
export interface LogEntry {
|
|
100
|
+
at: string;
|
|
101
|
+
by: HistoryAuthor;
|
|
102
|
+
op: HistoryOp;
|
|
103
|
+
full?: string;
|
|
104
|
+
keep?: [number, number];
|
|
105
|
+
text?: string;
|
|
106
|
+
/** On the first line only: older revisions were dropped to keep the log small. */
|
|
107
|
+
trimmed?: true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The note body as it stood before a write, and when it got that way. */
|
|
111
|
+
export interface PriorBody {
|
|
112
|
+
body: string;
|
|
113
|
+
at: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RecordInput {
|
|
117
|
+
by: HistoryAuthor;
|
|
118
|
+
op: HistoryOp;
|
|
119
|
+
/** The body before this write; null for a brand-new note. */
|
|
120
|
+
before: PriorBody | null;
|
|
121
|
+
/** The body after this write. */
|
|
122
|
+
after: string;
|
|
123
|
+
at: Date;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve a note's log path, or null when the slug would escape the log directory. */
|
|
127
|
+
export function historyPath(root: string, slug: string): string | null {
|
|
128
|
+
if (slug.trim().length === 0) return null;
|
|
129
|
+
const dir = join(root, HISTORY_DIR);
|
|
130
|
+
const candidate = join(dir, `${slug}${LOG_EXT}`);
|
|
131
|
+
const rel = relative(dir, candidate);
|
|
132
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function folderPath(root: string, folder: string): string | null {
|
|
137
|
+
const parts = folder.split("/");
|
|
138
|
+
if (parts.some((part) => part === "" || part === "." || part === "..")) return null;
|
|
139
|
+
return join(root, HISTORY_DIR, ...parts);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- settings ----------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
async function readManifest(root: string): Promise<Record<string, unknown> | null> {
|
|
145
|
+
try {
|
|
146
|
+
const parsed: unknown = JSON.parse(await fs.readFile(join(root, OKF_MANIFEST), "utf8"));
|
|
147
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether revisions are being recorded. On unless `okf.json` says `"history": false`. */
|
|
154
|
+
export async function historyEnabled(root: string): Promise<boolean> {
|
|
155
|
+
return (await readManifest(root))?.["history"] !== false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Turn recording on or off, keeping every other manifest field. */
|
|
159
|
+
export async function setHistoryEnabled(root: string, enabled: boolean): Promise<void> {
|
|
160
|
+
const manifest = (await readManifest(root)) ?? { okfVersion: 1, scope: "vault" };
|
|
161
|
+
manifest["history"] = enabled;
|
|
162
|
+
await fs.mkdir(root, { recursive: true });
|
|
163
|
+
await fs.writeFile(join(root, OKF_MANIFEST), JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- the log -------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function isEntry(value: unknown): value is LogEntry {
|
|
169
|
+
if (typeof value !== "object" || value === null) return false;
|
|
170
|
+
const entry = value as Record<string, unknown>;
|
|
171
|
+
if (typeof entry["at"] !== "string") return false;
|
|
172
|
+
if (!HISTORY_AUTHORS.includes(entry["by"] as HistoryAuthor)) return false;
|
|
173
|
+
if (!HISTORY_OPS.includes(entry["op"] as HistoryOp)) return false;
|
|
174
|
+
if (typeof entry["full"] === "string") return true;
|
|
175
|
+
const keep = entry["keep"];
|
|
176
|
+
return (
|
|
177
|
+
Array.isArray(keep) &&
|
|
178
|
+
keep.length === 2 &&
|
|
179
|
+
keep.every((n) => Number.isInteger(n) && (n as number) >= 0) &&
|
|
180
|
+
typeof entry["text"] === "string"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Replay a log into revisions. A line that is not JSON, or a delta that does
|
|
186
|
+
* not fit the body before it, is skipped, and so is every delta after it up
|
|
187
|
+
* to the next full copy: guessing would show the user text nobody wrote.
|
|
188
|
+
*
|
|
189
|
+
* With `lastOnly`, only the newest revision is kept. Recording a write needs
|
|
190
|
+
* nothing else, and keeping every body would cost the sum of all of them.
|
|
191
|
+
*/
|
|
192
|
+
function replay(text: string, lastOnly = false): NoteRevision[] {
|
|
193
|
+
const revisions: NoteRevision[] = [];
|
|
194
|
+
let body: string | null = null;
|
|
195
|
+
for (const line of text.split("\n")) {
|
|
196
|
+
if (line.trim() === "") continue;
|
|
197
|
+
let entry: unknown;
|
|
198
|
+
try {
|
|
199
|
+
entry = JSON.parse(line);
|
|
200
|
+
} catch {
|
|
201
|
+
body = null;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!isEntry(entry)) {
|
|
205
|
+
body = null;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (entry.full !== undefined) {
|
|
209
|
+
body = entry.full;
|
|
210
|
+
} else if (body !== null) {
|
|
211
|
+
const [head, tail] = entry.keep as [number, number];
|
|
212
|
+
if (head + tail > body.length) {
|
|
213
|
+
body = null;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
body = body.slice(0, head) + (entry.text as string) + body.slice(body.length - tail);
|
|
217
|
+
} else {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (lastOnly) revisions.length = 0;
|
|
221
|
+
revisions.push({ at: entry.at, by: entry.by, op: entry.op, body });
|
|
222
|
+
}
|
|
223
|
+
return revisions;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readLog(path: string, lastOnly = false): Promise<{ revisions: NoteRevision[]; lines: number }> {
|
|
227
|
+
let text: string;
|
|
228
|
+
try {
|
|
229
|
+
text = await fs.readFile(path, "utf8");
|
|
230
|
+
} catch {
|
|
231
|
+
return { revisions: [], lines: 0 };
|
|
232
|
+
}
|
|
233
|
+
return { revisions: replay(text, lastOnly), lines: text.split("\n").filter((line) => line.trim() !== "").length };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The smallest prefix/suffix-preserving edit that turns `from` into `to`. */
|
|
237
|
+
export function diffBodies(from: string, to: string): { keep: [number, number]; text: string } {
|
|
238
|
+
const max = Math.min(from.length, to.length);
|
|
239
|
+
let head = 0;
|
|
240
|
+
while (head < max && from.charCodeAt(head) === to.charCodeAt(head)) head++;
|
|
241
|
+
let tail = 0;
|
|
242
|
+
while (tail < max - head && from.charCodeAt(from.length - 1 - tail) === to.charCodeAt(to.length - 1 - tail)) tail++;
|
|
243
|
+
return { keep: [head, tail], text: to.slice(head, to.length - tail) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function entryFor(
|
|
247
|
+
meta: { at: string; by: HistoryAuthor; op: HistoryOp },
|
|
248
|
+
previous: string | null,
|
|
249
|
+
body: string,
|
|
250
|
+
index: number,
|
|
251
|
+
): LogEntry {
|
|
252
|
+
if (previous === null || index % KEYFRAME_EVERY === 0) return { ...meta, full: body };
|
|
253
|
+
return { ...meta, ...diffBodies(previous, body) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Times never run backwards in a log, even when a clock or mtime does. */
|
|
257
|
+
function notBefore(at: string, last: NoteRevision | undefined): string {
|
|
258
|
+
return last !== undefined && at < last.at ? last.at : at;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function appendEntries(
|
|
262
|
+
path: string,
|
|
263
|
+
log: { revisions: NoteRevision[]; lines: number },
|
|
264
|
+
pending: Array<{ at: string; by: HistoryAuthor; op: HistoryOp; body: string }>,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
if (pending.length === 0) return;
|
|
267
|
+
let previous = log.revisions.at(-1)?.body ?? null;
|
|
268
|
+
let index = log.lines;
|
|
269
|
+
const lines: string[] = [];
|
|
270
|
+
for (const { body, ...meta } of pending) {
|
|
271
|
+
lines.push(JSON.stringify(entryFor(meta, previous, body, index)));
|
|
272
|
+
previous = body;
|
|
273
|
+
index++;
|
|
274
|
+
}
|
|
275
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
276
|
+
await fs.appendFile(path, lines.join("\n") + "\n", "utf8");
|
|
277
|
+
if (index > MAX_REVISIONS || (await fs.stat(path)).size > MAX_LOG_BYTES) await compact(path);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Encode revisions as log lines: a full copy first, deltas after, a full
|
|
282
|
+
* copy every {@link KEYFRAME_EVERY}. The viewer receives the same shape, so
|
|
283
|
+
* the download is about as small as the file.
|
|
284
|
+
*/
|
|
285
|
+
export function encodeRevisions(revisions: readonly NoteRevision[], trimmed = false): LogEntry[] {
|
|
286
|
+
let previous: string | null = null;
|
|
287
|
+
return revisions.map(({ body, ...meta }, index) => {
|
|
288
|
+
const entry = entryFor(meta, previous, body, index);
|
|
289
|
+
previous = body;
|
|
290
|
+
return index === 0 && trimmed ? { ...entry, trimmed: true } : entry;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Whether a log's oldest revisions were dropped by compaction. */
|
|
295
|
+
function isTrimmed(text: string): boolean {
|
|
296
|
+
const first = text.slice(0, text.indexOf("\n") + 1 || text.length);
|
|
297
|
+
try {
|
|
298
|
+
return (JSON.parse(first) as { trimmed?: unknown }).trimmed === true;
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Drop the oldest revisions until the log fits {@link COMPACT_TO} of both limits. */
|
|
305
|
+
async function compact(path: string): Promise<void> {
|
|
306
|
+
const revisions = replay(await fs.readFile(path, "utf8"));
|
|
307
|
+
const maxBytes = MAX_LOG_BYTES * COMPACT_TO;
|
|
308
|
+
let keep = Math.min(revisions.length, Math.floor(MAX_REVISIONS * COMPACT_TO));
|
|
309
|
+
let text = "";
|
|
310
|
+
for (;;) {
|
|
311
|
+
text = encodeRevisions(revisions.slice(-keep), true).map((entry) => JSON.stringify(entry)).join("\n") + "\n";
|
|
312
|
+
if (keep === 1 || Buffer.byteLength(text, "utf8") <= maxBytes) break;
|
|
313
|
+
keep = Math.max(1, Math.floor(keep * 0.75));
|
|
314
|
+
}
|
|
315
|
+
// A temporary file and a rename: a crash mid-write leaves the old log, not half a new one.
|
|
316
|
+
const temp = `${path}.compact`;
|
|
317
|
+
await fs.writeFile(temp, text, "utf8");
|
|
318
|
+
await fs.rename(temp, path);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Whether a note's history has had its oldest revisions dropped. */
|
|
322
|
+
export async function historyTrimmed(root: string, slug: string): Promise<boolean> {
|
|
323
|
+
const path = historyPath(root, slug);
|
|
324
|
+
if (path === null) return false;
|
|
325
|
+
try {
|
|
326
|
+
return isTrimmed(await fs.readFile(path, "utf8"));
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Record one write. Called by `vault.ts` while it holds the vault lock, after
|
|
334
|
+
* the note file has been written. A failure here never fails the write that
|
|
335
|
+
* caused it: losing a revision is better than losing the user's edit.
|
|
336
|
+
*/
|
|
337
|
+
export async function recordRevision(root: string, slug: string, input: RecordInput): Promise<void> {
|
|
338
|
+
try {
|
|
339
|
+
const path = historyPath(root, slug);
|
|
340
|
+
if (path === null || !(await historyEnabled(root))) return;
|
|
341
|
+
const log = await readLog(path, true);
|
|
342
|
+
const pending: Array<{ at: string; by: HistoryAuthor; op: HistoryOp; body: string }> = [];
|
|
343
|
+
let last = log.revisions.at(-1);
|
|
344
|
+
if (input.before !== null && last?.body !== input.before.body) {
|
|
345
|
+
const op: HistoryOp = last === undefined ? "baseline" : "outside";
|
|
346
|
+
const outside = { at: notBefore(input.before.at, last), by: "outside" as const, op, body: input.before.body };
|
|
347
|
+
pending.push(outside);
|
|
348
|
+
last = outside;
|
|
349
|
+
}
|
|
350
|
+
if (last?.body !== input.after) {
|
|
351
|
+
pending.push({ at: notBefore(input.at.toISOString(), last), by: input.by, op: input.op, body: input.after });
|
|
352
|
+
}
|
|
353
|
+
await appendEntries(path, log, pending);
|
|
354
|
+
} catch {
|
|
355
|
+
// Deliberately silent; see the doc comment.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Every revision of a note, oldest first. When `current` is given and differs
|
|
361
|
+
* from the last revision, the difference was made outside pi-weave; it is
|
|
362
|
+
* recorded (if history is on) and returned as the final revision either way,
|
|
363
|
+
* so the slider always ends on what the note says now.
|
|
364
|
+
*/
|
|
365
|
+
export async function readHistory(root: string, slug: string, current?: PriorBody): Promise<NoteRevision[]> {
|
|
366
|
+
const path = historyPath(root, slug);
|
|
367
|
+
if (path === null) return [];
|
|
368
|
+
const log = await readLog(path);
|
|
369
|
+
const last = log.revisions.at(-1);
|
|
370
|
+
if (current === undefined || last?.body === current.body) return log.revisions;
|
|
371
|
+
const outside: NoteRevision = {
|
|
372
|
+
at: notBefore(current.at, last),
|
|
373
|
+
by: "outside",
|
|
374
|
+
op: last === undefined ? "baseline" : "outside",
|
|
375
|
+
body: current.body,
|
|
376
|
+
};
|
|
377
|
+
if (await historyEnabled(root)) {
|
|
378
|
+
try {
|
|
379
|
+
await appendEntries(path, log, [outside]);
|
|
380
|
+
} catch {
|
|
381
|
+
// Still shown; recording is best effort.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return [...log.revisions, outside];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function move(from: string | null, to: string | null): Promise<void> {
|
|
388
|
+
if (from === null || to === null || from === to) return;
|
|
389
|
+
try {
|
|
390
|
+
await fs.mkdir(dirname(to), { recursive: true });
|
|
391
|
+
await fs.rename(from, to);
|
|
392
|
+
} catch {
|
|
393
|
+
// No log yet, or nothing to move.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Carry a note's log to its new slug after a rename or move. */
|
|
398
|
+
export async function moveHistory(root: string, from: string, to: string): Promise<void> {
|
|
399
|
+
await move(historyPath(root, from), historyPath(root, to));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Carry a folder's logs along when the folder is renamed. */
|
|
403
|
+
export async function moveHistoryFolder(root: string, from: string, to: string): Promise<void> {
|
|
404
|
+
await move(folderPath(root, from), folderPath(root, to));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Drop a deleted note's log, so a new note with the same name starts clean. */
|
|
408
|
+
export async function removeHistory(root: string, slug: string): Promise<void> {
|
|
409
|
+
const path = historyPath(root, slug);
|
|
410
|
+
if (path !== null) await fs.rm(path, { force: true });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Drop the logs of every note in a deleted folder. */
|
|
414
|
+
export async function removeHistoryFolder(root: string, folder: string): Promise<void> {
|
|
415
|
+
const path = folderPath(root, folder);
|
|
416
|
+
if (path !== null) await fs.rm(path, { recursive: true, force: true });
|
|
417
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { findGitRoot } from "./git";
|
|
|
9
9
|
export {
|
|
10
10
|
assessStaleness,
|
|
11
11
|
buildRepoIndex,
|
|
12
|
+
ensureRepoIndex,
|
|
12
13
|
readRepoIndex,
|
|
13
14
|
summarizeIndex,
|
|
14
15
|
writeRepoIndex,
|
|
@@ -92,6 +93,14 @@ export {
|
|
|
92
93
|
type LinkFix,
|
|
93
94
|
type UnresolvableLink,
|
|
94
95
|
} from "./links/repair";
|
|
96
|
+
export {
|
|
97
|
+
historyEnabled,
|
|
98
|
+
readHistory,
|
|
99
|
+
setHistoryEnabled,
|
|
100
|
+
type HistoryAuthor,
|
|
101
|
+
type HistoryOp,
|
|
102
|
+
type NoteRevision,
|
|
103
|
+
} from "./history";
|
|
95
104
|
export type { HtmlArtifact } from "./types";
|
|
96
105
|
export { withMutationQueue } from "./mutex";
|
|
97
106
|
export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
|
|
@@ -102,6 +111,16 @@ export {
|
|
|
102
111
|
type NoteActionInput,
|
|
103
112
|
type NoteActionResult,
|
|
104
113
|
} from "./noteAction";
|
|
114
|
+
export {
|
|
115
|
+
RecallTrail,
|
|
116
|
+
recallTrail,
|
|
117
|
+
TRAIL_CAPACITY,
|
|
118
|
+
TURN_GAP_MS,
|
|
119
|
+
type TrailEntry,
|
|
120
|
+
type TrailEvent,
|
|
121
|
+
type TrailKind,
|
|
122
|
+
type TrailSnapshot,
|
|
123
|
+
} from "./trail";
|
|
105
124
|
export {
|
|
106
125
|
executeRepoAction,
|
|
107
126
|
WEAVE_REPO_DESCRIPTION,
|
package/src/core/links/repair.ts
CHANGED
|
@@ -42,15 +42,14 @@
|
|
|
42
42
|
|
|
43
43
|
import { slugify } from "../slug";
|
|
44
44
|
import type { HtmlArtifact, Note } from "../types";
|
|
45
|
+
import { fenceRanges, rawTailStart } from "../view/rawTail";
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* The
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* API is unchanged) because repair is the module that must *not* cross it,
|
|
51
|
-
* and vault imports repair. One definition, no cycle.
|
|
48
|
+
* The tail helpers live in `../view/rawTail`, a node-free module the browser
|
|
49
|
+
* editor can reach through `src/web/shared/view.ts`. Re-exported here so
|
|
50
|
+
* repair's public surface is unchanged.
|
|
52
51
|
*/
|
|
53
|
-
export
|
|
52
|
+
export { RAW_NOTES_HEADING, fenceRanges, rawTailStart } from "../view/rawTail";
|
|
54
53
|
|
|
55
54
|
/** One `[[wiki-link]]` found in a body, with enough context to rewrite it. */
|
|
56
55
|
export interface LinkOccurrence {
|
|
@@ -85,68 +84,6 @@ export function normalizeTarget(raw: string): string {
|
|
|
85
84
|
}
|
|
86
85
|
|
|
87
86
|
const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\]]*))?\]\]/g;
|
|
88
|
-
const FENCE_RE = /^[ \t]*(`{3,}|~{3,})(.*)$/gm;
|
|
89
|
-
/** A `## Raw` heading on a line of its own — not `## Rawhide`. */
|
|
90
|
-
const RAW_HEADING_RE = new RegExp(`^${RAW_NOTES_HEADING}[ \\t]*\\r?$`, "m");
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Half-open `[start, end)` ranges covering fenced code blocks.
|
|
94
|
-
*
|
|
95
|
-
* CommonMark, not "every third-backtick-line toggles": a fence is closed only
|
|
96
|
-
* by the **same marker character**, at **at least the opening length**, and
|
|
97
|
-
* with no info string. Treating fences as a blind alternation gets two cases
|
|
98
|
-
* backwards — a `~~~` nested inside a ``` block would close it, and a ```
|
|
99
|
-
* inside a ```` block would too — and each mistake ends the protected region
|
|
100
|
-
* early, exposing example links in documentation to rewriting.
|
|
101
|
-
*/
|
|
102
|
-
export function fenceRanges(body: string): [number, number][] {
|
|
103
|
-
const ranges: [number, number][] = [];
|
|
104
|
-
let open: { at: number; marker: string; length: number } | null = null;
|
|
105
|
-
for (const match of body.matchAll(FENCE_RE)) {
|
|
106
|
-
const run = match[1]!;
|
|
107
|
-
// Group 2 is `(.*)`, which always participates: the info string.
|
|
108
|
-
const info = match[2]!;
|
|
109
|
-
if (open === null) {
|
|
110
|
-
open = { at: match.index, marker: run[0]!, length: run.length };
|
|
111
|
-
} else if (run[0] === open.marker && run.length >= open.length && info.trim() === "") {
|
|
112
|
-
ranges.push([open.at, match.index + match[0].length]);
|
|
113
|
-
open = null;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
// An unterminated fence runs to the end of the body, the way a renderer
|
|
117
|
-
// treats it.
|
|
118
|
-
if (open !== null) ranges.push([open.at, body.length]);
|
|
119
|
-
return ranges;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The byte offset where the `## Raw` tail begins, or the body length when
|
|
124
|
-
* there is none.
|
|
125
|
-
*
|
|
126
|
-
* The tail is append-only and verbatim — "NEVER edit below this line" is
|
|
127
|
-
* written into every note that has one. A link inside a user's dictation is
|
|
128
|
-
* *their* text, quoted; repairing it would edit words the vault promises not
|
|
129
|
-
* to touch.
|
|
130
|
-
*
|
|
131
|
-
* Fence-aware, and for the opposite reason to everything else here: a note
|
|
132
|
-
* *documenting* the raw-tail convention contains `## Raw` inside a code
|
|
133
|
-
* sample, and treating that as the real tail would silently protect — and so
|
|
134
|
-
* refuse to repair — every link below it. Matching a whole heading line also
|
|
135
|
-
* keeps `## Rawhide` from starting a tail.
|
|
136
|
-
*/
|
|
137
|
-
export function rawTailStart(body: string, fences: readonly [number, number][]): number {
|
|
138
|
-
let from = 0;
|
|
139
|
-
for (;;) {
|
|
140
|
-
const rest = body.slice(from);
|
|
141
|
-
const hit = RAW_HEADING_RE.exec(rest);
|
|
142
|
-
if (hit === null) return body.length;
|
|
143
|
-
const at = from + hit.index;
|
|
144
|
-
const fence = fences.find(([a, b]) => at >= a && at < b);
|
|
145
|
-
if (fence === undefined) return at;
|
|
146
|
-
from = fence[1]; // skip the whole fenced block and keep looking
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
87
|
/**
|
|
151
88
|
* Every repairable `[[wiki-link]]` in a body, in order.
|
|
152
89
|
*
|
package/src/core/noteAction.ts
CHANGED
|
@@ -16,12 +16,13 @@ import {
|
|
|
16
16
|
import { relatedNotes, suggestLinks, type RelatedNote, type SuggestionReport } from "./links/similar";
|
|
17
17
|
import { NOTES_DIR, resolveVaultRoot } from "./paths";
|
|
18
18
|
import { withMutationQueue } from "./mutex";
|
|
19
|
+
import { recallTrail, type RecallTrail, type TrailEntry } from "./trail";
|
|
19
20
|
import type { Note, NoteSearchHit } from "./types";
|
|
20
21
|
|
|
21
22
|
export const WEAVE_NOTE_DESCRIPTION =
|
|
22
23
|
"Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
|
|
23
24
|
"of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
|
|
24
|
-
"append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
|
|
25
|
+
"append (extend a note; raw=true appends verbatim dictation into the ## Raw tail; add with raw=true starts the note there), " +
|
|
25
26
|
"finalize (restructure a note above its raw tail), search (ranked slug/title/tags/body matches plus linked, tagged, and lexically related notes; returns the full note when one result or one exact identity resolves), " +
|
|
26
27
|
"links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
|
|
27
28
|
"suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
|
|
@@ -186,10 +187,71 @@ function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
|
|
|
186
187
|
return lines.join("\n");
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Run one `weave_note` action and record what it touched on the recall trail
|
|
192
|
+
* (`./trail.ts`). Recording never fails the action: the trail is presentation,
|
|
193
|
+
* and a tool call must not break because a viewer would have liked to know.
|
|
194
|
+
*/
|
|
189
195
|
export async function executeNoteAction(
|
|
190
196
|
params: NoteActionInput,
|
|
191
197
|
vault = resolveVaultRoot(),
|
|
192
198
|
now: () => Date = () => new Date(),
|
|
199
|
+
trail: RecallTrail | null = recallTrail,
|
|
200
|
+
): Promise<NoteActionResult> {
|
|
201
|
+
const result = await runNoteAction(params, vault, now);
|
|
202
|
+
if (trail !== null) {
|
|
203
|
+
const at = now();
|
|
204
|
+
for (const step of trailSteps(params, result)) trail.record({ ...step, vault, at });
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type TrailStep = Omit<TrailEntry, "vault" | "at">;
|
|
210
|
+
|
|
211
|
+
function slugOf(value: unknown): string | null {
|
|
212
|
+
return typeof value === "object" && value !== null && typeof (value as { slug?: unknown }).slug === "string"
|
|
213
|
+
? (value as { slug: string }).slug
|
|
214
|
+
: null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** What an action's result says it touched. List and suggest touch nothing worth showing. */
|
|
218
|
+
export function trailSteps(params: NoteActionInput, result: NoteActionResult): TrailStep[] {
|
|
219
|
+
const { details } = result;
|
|
220
|
+
switch (params.action) {
|
|
221
|
+
case "get": {
|
|
222
|
+
const slug = slugOf(details.note);
|
|
223
|
+
return slug === null ? [] : [{ kind: "read", action: "get", slugs: [slug] }];
|
|
224
|
+
}
|
|
225
|
+
case "search": {
|
|
226
|
+
const hits = Array.isArray(details.hits) ? details.hits : [];
|
|
227
|
+
const slugs = hits.flatMap((hit: unknown) => {
|
|
228
|
+
const slug = slugOf((hit as { summary?: unknown }).summary);
|
|
229
|
+
return slug === null ? [] : [slug];
|
|
230
|
+
});
|
|
231
|
+
const steps: TrailStep[] = [{ kind: "searched", action: "search", slugs, query: params.query }];
|
|
232
|
+
const resolved = slugOf(details.resolved);
|
|
233
|
+
if (resolved !== null) steps.push({ kind: "read", action: "search", slugs: [resolved] });
|
|
234
|
+
return steps;
|
|
235
|
+
}
|
|
236
|
+
case "add":
|
|
237
|
+
case "append":
|
|
238
|
+
case "finalize": {
|
|
239
|
+
const slug = slugOf(details.note);
|
|
240
|
+
return slug === null ? [] : [{ kind: "wrote", action: params.action, slugs: [slug] }];
|
|
241
|
+
}
|
|
242
|
+
case "links": {
|
|
243
|
+
const notes = Array.isArray(details.notes) ? details.notes.filter((slug): slug is string => typeof slug === "string") : [];
|
|
244
|
+
return notes.length === 0 ? [] : [{ kind: "wrote", action: "links", slugs: notes }];
|
|
245
|
+
}
|
|
246
|
+
default:
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function runNoteAction(
|
|
252
|
+
params: NoteActionInput,
|
|
253
|
+
vault: string,
|
|
254
|
+
now: () => Date,
|
|
193
255
|
): Promise<NoteActionResult> {
|
|
194
256
|
switch (params.action) {
|
|
195
257
|
case "list": {
|
|
@@ -222,9 +284,17 @@ export async function executeNoteAction(
|
|
|
222
284
|
body: params.text!,
|
|
223
285
|
...(params.tags ? { tags: params.tags } : {}),
|
|
224
286
|
...(params.source ? { source: params.source } : {}),
|
|
287
|
+
...(params.raw ? { raw: true, now: now() } : {}),
|
|
288
|
+
// The agent wrote the file even when the words are the user's.
|
|
289
|
+
by: "agent",
|
|
225
290
|
}),
|
|
226
291
|
);
|
|
227
|
-
return {
|
|
292
|
+
return {
|
|
293
|
+
text: params.raw
|
|
294
|
+
? `Note created: ${note.slug} (${vault}), text preserved verbatim in its ## Raw tail.`
|
|
295
|
+
: `Note created: ${note.slug} (${vault})`,
|
|
296
|
+
details: { action: "add", note },
|
|
297
|
+
};
|
|
228
298
|
}
|
|
229
299
|
|
|
230
300
|
case "append": {
|
package/src/core/openInEditor.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Open a vault note in the user's editor.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
* it is a pure fan-out over `core/vault` plus one `execFile`, with no
|
|
8
|
-
* harness and no terminal concept in it, and the same reasoning that put
|
|
9
|
-
* `readNoteForView` in core applies unchanged.
|
|
10
|
-
*
|
|
11
|
-
* The old path re-exports these two names, so no TUI file changed.
|
|
4
|
+
* Lives in core because the browser workspace's server uses it and
|
|
5
|
+
* `src/web/server/**` may not import `src/pi`. It is a pure fan-out over
|
|
6
|
+
* `core/vault` plus one `execFile`, with no harness in it.
|
|
12
7
|
*
|
|
13
8
|
* Security: the slug is validated against the vault (traversal-safe) before
|
|
14
9
|
* any shell-out, and the command is invoked with `execFile`, not a shell, so
|