pi-weave 0.3.7 → 0.3.9
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 +17 -16
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +30 -4
- 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/dist/app.js +183 -52
- package/src/web/client/graph/Graph.tsx +23 -2
- package/src/web/client/graph/column.model.ts +1 -1
- package/src/web/client/graph/graph.model.ts +69 -7
- package/src/web/client/graph/renderer.ts +19 -3
- package/src/web/client/note/Note.tsx +171 -20
- package/src/web/client/note/Replay.tsx +128 -0
- package/src/web/client/note/drafts.ts +50 -9
- package/src/web/client/note/note.model.ts +17 -0
- package/src/web/client/note/replay.model.ts +173 -0
- 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 +34 -10
- package/src/web/client/shell/keys.model.ts +20 -7
- package/src/web/client/shell/theme.ts +118 -1
- package/src/web/client/shell/workspace.css.ts +4 -0
- 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/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- 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
package/src/core/repoAction.ts
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
assessStaleness,
|
|
3
|
-
buildRepoIndex,
|
|
4
|
-
readRepoIndex,
|
|
5
|
-
summarizeIndex,
|
|
6
|
-
writeRepoIndex,
|
|
7
|
-
} from "./repoIndex";
|
|
1
|
+
import { ensureRepoIndex, summarizeIndex } from "./repoIndex";
|
|
8
2
|
import { findGitRoot } from "./git";
|
|
9
|
-
import {
|
|
3
|
+
import { readSummaries } from "./summaries";
|
|
10
4
|
|
|
11
5
|
export const WEAVE_REPO_DESCRIPTION =
|
|
12
6
|
"Explore the current git repository through its pi-weave knowledge index (.okf). " +
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"The index is derived and rebuildable; scanning is always safe.";
|
|
7
|
+
"Action: overview (languages, packages, modules, entry points, and how many files have deep-scan summaries). " +
|
|
8
|
+
"The index is derived, rebuilt automatically when the repository changes, and never needs a manual scan.";
|
|
16
9
|
|
|
17
10
|
export interface RepoActionInput {
|
|
18
|
-
action: "
|
|
11
|
+
action: "overview";
|
|
19
12
|
}
|
|
20
13
|
|
|
21
14
|
export interface RepoActionResult {
|
|
@@ -23,11 +16,7 @@ export interface RepoActionResult {
|
|
|
23
16
|
details: Record<string, unknown>;
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
export async function executeRepoAction(
|
|
27
|
-
params: RepoActionInput,
|
|
28
|
-
cwd: string,
|
|
29
|
-
onProgress?: (text: string) => void | Promise<void>,
|
|
30
|
-
): Promise<RepoActionResult> {
|
|
19
|
+
export async function executeRepoAction(params: RepoActionInput, cwd: string): Promise<RepoActionResult> {
|
|
31
20
|
const root = await findGitRoot(cwd);
|
|
32
21
|
if (!root) {
|
|
33
22
|
return {
|
|
@@ -36,49 +25,19 @@ export async function executeRepoAction(
|
|
|
36
25
|
};
|
|
37
26
|
}
|
|
38
27
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (index) lines.push(`Indexed at: ${index.updated} by ${index.generator}`);
|
|
46
|
-
return {
|
|
47
|
-
text: `Repository ${root}\n${lines.join("\n")}`,
|
|
48
|
-
details: { action: "status", inRepo: true, staleness, indexed: index !== null },
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
case "scan": {
|
|
53
|
-
await onProgress?.(`Scanning ${root}…`);
|
|
54
|
-
const index = await buildRepoIndex(root);
|
|
55
|
-
if (!index) {
|
|
56
|
-
return {
|
|
57
|
-
text: "Cannot build index: the repository has no commits yet.",
|
|
58
|
-
details: { action: "scan", inRepo: true, scanned: false },
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
const dir = await writeRepoIndex(root, index);
|
|
62
|
-
return {
|
|
63
|
-
text: `Knowledge index written to ${dir}\n\n${summarizeIndex(index).join("\n")}`,
|
|
64
|
-
details: { action: "scan", inRepo: true, scanned: true, index },
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
case "overview": {
|
|
69
|
-
const index = await readRepoIndex(root);
|
|
70
|
-
if (!index) {
|
|
71
|
-
return {
|
|
72
|
-
text: `No knowledge index at ${repoIndexDir(root)} yet. Use action=scan to build one.`,
|
|
73
|
-
details: { action: "overview", inRepo: true, indexed: false },
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
const staleness = await assessStaleness(root);
|
|
77
|
-
const header = staleness.state === "fresh" ? "" : `⚠ index is ${staleness.state} (consider rescanning)\n`;
|
|
78
|
-
return {
|
|
79
|
-
text: header + summarizeIndex(index).join("\n"),
|
|
80
|
-
details: { action: "overview", inRepo: true, indexed: true, staleness, index },
|
|
81
|
-
};
|
|
82
|
-
}
|
|
28
|
+
const ensured = await ensureRepoIndex(root);
|
|
29
|
+
if (!ensured) {
|
|
30
|
+
return {
|
|
31
|
+
text: "The repository has no commits yet, so there is no structure to describe.",
|
|
32
|
+
details: { action: "overview", inRepo: true, indexed: false },
|
|
33
|
+
};
|
|
83
34
|
}
|
|
35
|
+
const summaryCount = (await readSummaries(root)).length;
|
|
36
|
+
const footer = summaryCount > 0
|
|
37
|
+
? `Summaries: ${summaryCount} file(s) in .okf/repository/summaries/`
|
|
38
|
+
: "Summaries: none yet — /weave-scan writes them with the session model";
|
|
39
|
+
return {
|
|
40
|
+
text: [...summarizeIndex(ensured.index), footer].join("\n"),
|
|
41
|
+
details: { action: "overview", inRepo: true, indexed: true, index: ensured.index, summaryCount },
|
|
42
|
+
};
|
|
84
43
|
}
|
package/src/core/repoIndex.ts
CHANGED
|
@@ -368,6 +368,44 @@ export async function assessStaleness(repoRoot: string): Promise<StalenessReport
|
|
|
368
368
|
return { state: reasons.length === 0 ? "fresh" : "stale", reasons };
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* The index as plumbing: read it, and rebuild it on disk when it is missing
|
|
373
|
+
* or stale. Nothing asks the user to run a structure scan any more; whoever
|
|
374
|
+
* needs the index (deep scan, graph, `weave_repo overview`) calls this.
|
|
375
|
+
* Null when the repository has no commits yet.
|
|
376
|
+
*/
|
|
377
|
+
export async function ensureRepoIndex(
|
|
378
|
+
root: string,
|
|
379
|
+
): Promise<{ index: RepoIndex; staleness: StalenessReport } | null> {
|
|
380
|
+
const staleness = await assessStaleness(root);
|
|
381
|
+
if (staleness.state === "fresh") {
|
|
382
|
+
const index = await readRepoIndex(root);
|
|
383
|
+
// `.okf` is excluded from git, so summaries written or pruned by a deep
|
|
384
|
+
// scan never make the index stale; compare the derived-file inventory
|
|
385
|
+
// directly so the graph's `.okf` subtree keeps up.
|
|
386
|
+
if (index && sameFiles(index.structure.okFiles ?? [], await listOkfFiles(root))) return { index, staleness };
|
|
387
|
+
}
|
|
388
|
+
let index = await buildRepoIndex(root);
|
|
389
|
+
if (!index) return null;
|
|
390
|
+
try {
|
|
391
|
+
await writeRepoIndex(root, index);
|
|
392
|
+
// The first write creates the very files the inventory lists; capture
|
|
393
|
+
// them once more so the next call finds the index settled.
|
|
394
|
+
if (!sameFiles(index.structure.okFiles ?? [], await listOkfFiles(root))) {
|
|
395
|
+
index = (await buildRepoIndex(root)) ?? index;
|
|
396
|
+
await writeRepoIndex(root, index);
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// A read-only checkout still gets a current in-memory index; only the
|
|
400
|
+
// on-disk cache is skipped.
|
|
401
|
+
}
|
|
402
|
+
return { index, staleness: { state: "fresh", reasons: [] } };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function sameFiles(a: readonly string[], b: readonly string[]): boolean {
|
|
406
|
+
return a.length === b.length && a.every((file, i) => file === b[i]);
|
|
407
|
+
}
|
|
408
|
+
|
|
371
409
|
/**
|
|
372
410
|
* Read a manifest provenance field, defaulting unknown/missing values to
|
|
373
411
|
* "generated": every repository index written before the field existed was
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recall trail: which notes the agent searched, read and wrote, in order.
|
|
3
|
+
*
|
|
4
|
+
* Agent memory is normally invisible — you cannot tell whether the agent
|
|
5
|
+
* remembered, guessed, or missed the note you wrote last week. Every vault
|
|
6
|
+
* operation already passes through `executeNoteAction`, so recording there
|
|
7
|
+
* gives both harnesses (Pi and OpenCode) the same trail with no hooks.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately **in memory only**. The trail is session presentation, not
|
|
10
|
+
* knowledge: nothing here touches the vault, and a restart forgets it. The
|
|
11
|
+
* browser workspace runs in the same process as the tools, so it reads the
|
|
12
|
+
* buffer directly (`GET /api/trail`).
|
|
13
|
+
*
|
|
14
|
+
* ## Turns
|
|
15
|
+
*
|
|
16
|
+
* A turn is one user prompt's worth of agent work. Pi says so explicitly
|
|
17
|
+
* (`agent_start` calls {@link RecallTrail.beginTurn}); a harness that never
|
|
18
|
+
* does falls back to a time gap, so a pause of {@link TURN_GAP_MS} between
|
|
19
|
+
* operations starts a new turn.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** How the agent touched a note. */
|
|
23
|
+
export type TrailKind = "searched" | "read" | "wrote";
|
|
24
|
+
|
|
25
|
+
/** One recorded operation. `slugs` may be empty for a search with no hits. */
|
|
26
|
+
export interface TrailEvent {
|
|
27
|
+
/** Monotonic per buffer; the client's stable key. */
|
|
28
|
+
seq: number;
|
|
29
|
+
turn: number;
|
|
30
|
+
/** ISO timestamp. */
|
|
31
|
+
at: string;
|
|
32
|
+
kind: TrailKind;
|
|
33
|
+
/** The `weave_note` action that produced it (`search`, `append`, …). */
|
|
34
|
+
action: string;
|
|
35
|
+
slugs: string[];
|
|
36
|
+
query?: string;
|
|
37
|
+
/** The vault the operation ran against, so a server can filter to its own. */
|
|
38
|
+
vault: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a reader gets: the buffer plus a stamp that moves whenever it does. */
|
|
42
|
+
export interface TrailSnapshot {
|
|
43
|
+
stamp: string;
|
|
44
|
+
turn: number;
|
|
45
|
+
events: TrailEvent[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a caller hands to {@link RecallTrail.record}. */
|
|
49
|
+
export interface TrailEntry {
|
|
50
|
+
kind: TrailKind;
|
|
51
|
+
action: string;
|
|
52
|
+
slugs: readonly string[];
|
|
53
|
+
query?: string | undefined;
|
|
54
|
+
vault: string;
|
|
55
|
+
at: Date;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Old events fall off the front; a few hundred is far more than any viewer shows. */
|
|
59
|
+
export const TRAIL_CAPACITY = 200;
|
|
60
|
+
|
|
61
|
+
/** Without explicit turn boundaries, this much quiet starts a new turn. */
|
|
62
|
+
export const TURN_GAP_MS = 120_000;
|
|
63
|
+
|
|
64
|
+
export class RecallTrail {
|
|
65
|
+
private events: TrailEvent[] = [];
|
|
66
|
+
private seq = 0;
|
|
67
|
+
private turn = 0;
|
|
68
|
+
private explicit = false;
|
|
69
|
+
private lastAt = Number.NEGATIVE_INFINITY;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly capacity = TRAIL_CAPACITY,
|
|
73
|
+
private readonly gapMs = TURN_GAP_MS,
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
/** Start a new turn. Once called, the time-gap fallback is off for good. */
|
|
77
|
+
beginTurn(): void {
|
|
78
|
+
this.explicit = true;
|
|
79
|
+
this.turn += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
record(entry: TrailEntry): void {
|
|
83
|
+
const at = entry.at.getTime();
|
|
84
|
+
if (this.turn === 0 || (!this.explicit && at - this.lastAt > this.gapMs)) this.turn += 1;
|
|
85
|
+
this.lastAt = at;
|
|
86
|
+
this.seq += 1;
|
|
87
|
+
this.events.push({
|
|
88
|
+
seq: this.seq,
|
|
89
|
+
turn: this.turn,
|
|
90
|
+
at: entry.at.toISOString(),
|
|
91
|
+
kind: entry.kind,
|
|
92
|
+
action: entry.action,
|
|
93
|
+
slugs: [...new Set(entry.slugs)],
|
|
94
|
+
...(entry.query !== undefined ? { query: entry.query } : {}),
|
|
95
|
+
vault: entry.vault,
|
|
96
|
+
});
|
|
97
|
+
if (this.events.length > this.capacity) this.events.splice(0, this.events.length - this.capacity);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Distinct notes read or written in the current turn — the "used N notes" count. */
|
|
101
|
+
usedThisTurn(): number {
|
|
102
|
+
const used = new Set<string>();
|
|
103
|
+
for (const event of this.events) {
|
|
104
|
+
if (event.turn === this.turn && event.kind !== "searched") for (const slug of event.slugs) used.add(`${event.vault}\0${slug}`);
|
|
105
|
+
}
|
|
106
|
+
return used.size;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
snapshot(): TrailSnapshot {
|
|
110
|
+
return { stamp: `${this.turn}.${this.seq}`, turn: this.turn, events: this.events.map((event) => ({ ...event, slugs: [...event.slugs] })) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Forget everything. For tests and a fresh session. */
|
|
114
|
+
clear(): void {
|
|
115
|
+
this.events = [];
|
|
116
|
+
this.seq = 0;
|
|
117
|
+
this.turn = 0;
|
|
118
|
+
this.explicit = false;
|
|
119
|
+
this.lastAt = Number.NEGATIVE_INFINITY;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The process-wide trail the tools write and the workspace server reads. */
|
|
124
|
+
export const recallTrail = new RecallTrail();
|
package/src/core/types.ts
CHANGED
|
@@ -194,9 +194,7 @@ export interface VaultStatus {
|
|
|
194
194
|
export interface RepoStatus {
|
|
195
195
|
root: string;
|
|
196
196
|
name: string;
|
|
197
|
-
|
|
198
|
-
staleness: StalenessReport;
|
|
199
|
-
/** Deep-scan summaries on disk (docs/scan-modes.md); 0 when none indexed. */
|
|
197
|
+
/** Deep-scan summaries on disk (docs/scan-modes.md); 0 before the first /weave-scan. */
|
|
200
198
|
summaryCount: number;
|
|
201
199
|
}
|
|
202
200
|
|
package/src/core/vault.ts
CHANGED
|
@@ -10,7 +10,19 @@ import {
|
|
|
10
10
|
unquoteField,
|
|
11
11
|
upsertFrontMatterFields,
|
|
12
12
|
} from "./frontmatter";
|
|
13
|
-
import { auditLinks,
|
|
13
|
+
import { auditLinks, rewriteLinks, RAW_NOTES_HEADING, type LinkAudit, type LinkFix } from "./links/repair";
|
|
14
|
+
import { joinRawTail, rawTailBoundary, splitRawTail } from "./view/rawTail";
|
|
15
|
+
import {
|
|
16
|
+
moveHistory,
|
|
17
|
+
readHistory,
|
|
18
|
+
moveHistoryFolder,
|
|
19
|
+
recordRevision,
|
|
20
|
+
removeHistory,
|
|
21
|
+
removeHistoryFolder,
|
|
22
|
+
type HistoryAuthor,
|
|
23
|
+
type HistoryOp,
|
|
24
|
+
type NoteRevision,
|
|
25
|
+
} from "./history";
|
|
14
26
|
import { withMutationQueue } from "./mutex";
|
|
15
27
|
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
16
28
|
import { slugify, uniqueSlug } from "./slug";
|
|
@@ -37,6 +49,14 @@ export interface AddNoteInput {
|
|
|
37
49
|
body: string;
|
|
38
50
|
tags?: string[];
|
|
39
51
|
source?: NoteSource;
|
|
52
|
+
/**
|
|
53
|
+
* Start the note with `body` as its first verbatim block in the `## Raw`
|
|
54
|
+
* tail instead of as editorial body. Dictation starts here, so a later
|
|
55
|
+
* `finalize` cannot replace the user's opening words.
|
|
56
|
+
*/
|
|
57
|
+
raw?: boolean;
|
|
58
|
+
/** Who is creating it, for note history. Defaults from `source`. */
|
|
59
|
+
by?: HistoryAuthor;
|
|
40
60
|
/** Injectable clock for tests. */
|
|
41
61
|
now?: Date;
|
|
42
62
|
}
|
|
@@ -104,7 +124,8 @@ export function resolveNotePath(root: string, slug: string): string | null {
|
|
|
104
124
|
export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
|
|
105
125
|
await ensureVault(root);
|
|
106
126
|
return withVaultLock(root, async () => {
|
|
107
|
-
const
|
|
127
|
+
const date = input.now ?? new Date();
|
|
128
|
+
const now = date.toISOString();
|
|
108
129
|
const base = slugify(input.title);
|
|
109
130
|
const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
|
|
110
131
|
|
|
@@ -117,7 +138,12 @@ export async function addNote(root: string, input: AddNoteInput): Promise<Note>
|
|
|
117
138
|
};
|
|
118
139
|
// No `frontMatter`: a brand-new note has no prior layout to respect, so
|
|
119
140
|
// the serializer writes its canonical block.
|
|
120
|
-
|
|
141
|
+
const body = input.raw && input.body.trim() !== ""
|
|
142
|
+
? `${rawTailOpening()}\n\n${formatRawAppend(input.body, date)}\n`
|
|
143
|
+
: input.body;
|
|
144
|
+
const note = await writeNote(notePath(root, slug), slug, meta, body, undefined);
|
|
145
|
+
await record(root, note, input.by ?? (meta.source === "human" ? "human" : "agent"), "create", null, now);
|
|
146
|
+
return note;
|
|
121
147
|
});
|
|
122
148
|
}
|
|
123
149
|
|
|
@@ -175,6 +201,24 @@ async function writeNote(
|
|
|
175
201
|
return { slug, ...parsed.meta, body: parsed.body, frontMatter: parsed.frontMatter };
|
|
176
202
|
}
|
|
177
203
|
|
|
204
|
+
/** The body a write is about to replace, and when it was last written. */
|
|
205
|
+
async function priorBody(path: string, note: Note): Promise<{ body: string; at: string }> {
|
|
206
|
+
const st = await fs.stat(path).catch(() => null);
|
|
207
|
+
return { body: note.body, at: st?.mtime.toISOString() ?? note.updated };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Add the written note to its history (see `history.ts`). */
|
|
211
|
+
function record(
|
|
212
|
+
root: string,
|
|
213
|
+
note: Note,
|
|
214
|
+
by: HistoryAuthor,
|
|
215
|
+
op: HistoryOp,
|
|
216
|
+
before: { body: string; at: string } | null,
|
|
217
|
+
at: string,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
return recordRevision(root, note.slug, { by, op, before, after: note.body, at: new Date(at) });
|
|
220
|
+
}
|
|
221
|
+
|
|
178
222
|
/**
|
|
179
223
|
* Namespace prefix for this module's mutation-queue keys.
|
|
180
224
|
*
|
|
@@ -213,6 +257,8 @@ export interface AppendToNoteOptions {
|
|
|
213
257
|
* editorial body above the tail.
|
|
214
258
|
*/
|
|
215
259
|
raw?: boolean;
|
|
260
|
+
/** Who is appending, for note history. Defaults to `agent`. */
|
|
261
|
+
by?: HistoryAuthor;
|
|
216
262
|
}
|
|
217
263
|
|
|
218
264
|
/** Append Markdown to an existing note and bump `updated`. */
|
|
@@ -228,6 +274,7 @@ export async function appendToNote(
|
|
|
228
274
|
return withVaultLock(root, async () => {
|
|
229
275
|
const note = await getNote(root, slug);
|
|
230
276
|
if (!note) return null;
|
|
277
|
+
const before = await priorBody(path, note);
|
|
231
278
|
const tail = extractRawTail(note.body);
|
|
232
279
|
let body: string;
|
|
233
280
|
if (options.raw) {
|
|
@@ -248,7 +295,9 @@ export async function appendToNote(
|
|
|
248
295
|
const head = note.body.slice(0, idx).replace(/\s+$/, "");
|
|
249
296
|
body = (head ? head + "\n\n" : "") + addition.trim() + "\n\n" + tail + "\n";
|
|
250
297
|
}
|
|
251
|
-
|
|
298
|
+
const written = await writeNote(path, slug, { ...note, updated: now.toISOString() }, body, note.frontMatter);
|
|
299
|
+
await record(root, written, options.by ?? "agent", options.raw ? "raw" : "append", before, written.updated);
|
|
300
|
+
return written;
|
|
252
301
|
});
|
|
253
302
|
}
|
|
254
303
|
|
|
@@ -321,6 +370,8 @@ export interface FinalizeNoteInput {
|
|
|
321
370
|
* entities, links). The `## Raw` tail is preserved verbatim beneath it.
|
|
322
371
|
*/
|
|
323
372
|
body: string;
|
|
373
|
+
/** Who is finalizing, for note history. Defaults to `agent`. */
|
|
374
|
+
by?: HistoryAuthor;
|
|
324
375
|
/** Injectable clock for tests. */
|
|
325
376
|
now?: Date;
|
|
326
377
|
}
|
|
@@ -354,7 +405,10 @@ export async function finalizeNote(
|
|
|
354
405
|
? ""
|
|
355
406
|
: `\n\n${rawTailOpening()}\n\n${fenceFor(note.body)}\n${note.body.trim()}\n${fenceFor(note.body)}`);
|
|
356
407
|
const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
|
|
357
|
-
|
|
408
|
+
const before = await priorBody(path, note);
|
|
409
|
+
const written = await writeNote(path, slug, meta, body, note.frontMatter);
|
|
410
|
+
await record(root, written, input.by ?? "agent", "finalize", before, meta.updated);
|
|
411
|
+
return written;
|
|
358
412
|
});
|
|
359
413
|
}
|
|
360
414
|
|
|
@@ -373,7 +427,8 @@ export interface UpsertNoteInput {
|
|
|
373
427
|
export async function upsertNote(root: string, input: UpsertNoteInput): Promise<Note> {
|
|
374
428
|
await ensureVault(root);
|
|
375
429
|
return withVaultLock(root, async () => {
|
|
376
|
-
const
|
|
430
|
+
const date = input.now ?? new Date();
|
|
431
|
+
const now = date.toISOString();
|
|
377
432
|
const identity = input.identity;
|
|
378
433
|
let existing = await getNote(root, input.slug);
|
|
379
434
|
if (existing !== null && identity && frontMatterField(existing.frontMatter, identity.field) !== identity.value) {
|
|
@@ -400,7 +455,9 @@ export async function upsertNote(root: string, input: UpsertNoteInput): Promise<
|
|
|
400
455
|
`source: ${meta.source}`,
|
|
401
456
|
...upsertFrontMatterFields([], fields),
|
|
402
457
|
];
|
|
403
|
-
|
|
458
|
+
const created = await writeNote(notePath(root, slug), slug, meta, input.body, frontMatter);
|
|
459
|
+
await record(root, created, "agent", "generate", null, now);
|
|
460
|
+
return created;
|
|
404
461
|
}
|
|
405
462
|
const fields = safeGeneratedFields(input.fields);
|
|
406
463
|
const frontMatter = upsertFrontMatterFields(existing.frontMatter ?? [], fields);
|
|
@@ -410,13 +467,16 @@ export async function upsertNote(root: string, input: UpsertNoteInput): Promise<
|
|
|
410
467
|
// verbatim tail.
|
|
411
468
|
const tail = extractRawTail(existing.body);
|
|
412
469
|
const body = tail === "" ? input.body.trim() : `${input.body.trim()}\n\n${tail}`;
|
|
413
|
-
|
|
470
|
+
const before = await priorBody(notePath(root, input.slug), existing);
|
|
471
|
+
const refreshed = await writeNote(
|
|
414
472
|
notePath(root, input.slug),
|
|
415
473
|
input.slug,
|
|
416
474
|
{ ...existing, updated: now },
|
|
417
475
|
body,
|
|
418
476
|
frontMatter,
|
|
419
477
|
);
|
|
478
|
+
await record(root, refreshed, "agent", "generate", before, now);
|
|
479
|
+
return refreshed;
|
|
420
480
|
});
|
|
421
481
|
}
|
|
422
482
|
|
|
@@ -515,6 +575,7 @@ async function rewriteVaultLinks(
|
|
|
515
575
|
} catch {
|
|
516
576
|
continue; // raced a delete
|
|
517
577
|
}
|
|
578
|
+
const before = await priorBody(path, note);
|
|
518
579
|
const at = original.lastIndexOf(note.body);
|
|
519
580
|
if (at === -1) continue; // body not found verbatim; refuse rather than guess
|
|
520
581
|
const next = original.slice(0, at) + body + original.slice(at + note.body.length);
|
|
@@ -532,6 +593,8 @@ async function rewriteVaultLinks(
|
|
|
532
593
|
await fs.writeFile(tmp, next, "utf8");
|
|
533
594
|
await fs.rename(tmp, path);
|
|
534
595
|
}
|
|
596
|
+
const repaired = await getNote(root, slug);
|
|
597
|
+
if (repaired !== null) await record(root, repaired, "system", "repair", before, new Date().toISOString());
|
|
535
598
|
touched.push(slug);
|
|
536
599
|
links += changed;
|
|
537
600
|
}
|
|
@@ -598,30 +661,24 @@ export async function repairVaultLinks(root: string, options: { apply?: boolean
|
|
|
598
661
|
|
|
599
662
|
/**
|
|
600
663
|
* Keep the on-disk `## Raw` tail whatever the body says — it is append-only.
|
|
601
|
-
* `
|
|
664
|
+
* `rawTailBoundary`, not `extractRawTail`: fence-aware, for untrusted input.
|
|
602
665
|
*/
|
|
603
666
|
function preserveRawTail(currentBody: string, nextBody: string): string {
|
|
604
|
-
const tail = currentBody.slice(
|
|
605
|
-
|
|
606
|
-
if (tail === "") return head;
|
|
607
|
-
// An emptied head leaves the tail alone, not a leading blank line.
|
|
608
|
-
return head === "" ? tail : `${head}\n\n${tail}`;
|
|
667
|
+
const tail = currentBody.slice(rawTailBoundary(currentBody)).trimEnd();
|
|
668
|
+
return joinRawTail(nextBody.slice(0, rawTailBoundary(nextBody)).trim(), tail);
|
|
609
669
|
}
|
|
610
670
|
|
|
611
|
-
/** A `---` rule, alone on the line, with only blank space after it. */
|
|
612
|
-
const TAIL_RULE_RE = /(?:^|\n)-{3,}[ \t]*\r?\n\s*$/;
|
|
613
|
-
|
|
614
671
|
/**
|
|
615
|
-
*
|
|
616
|
-
*
|
|
672
|
+
* What a body save does with the `## Raw` tail. `keep` (the default) holds
|
|
673
|
+
* the on-disk tail whatever the body says. `replace` writes the body's own
|
|
674
|
+
* tail: the person editing the note changed their own Raw words on purpose.
|
|
617
675
|
*/
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
return rule.index === 0 && !body.startsWith("\n") ? 0 : rule.index + 1;
|
|
676
|
+
export type RawTailMode = "keep" | "replace";
|
|
677
|
+
|
|
678
|
+
/** The body as sent, its tail included, with the same spacing `preserveRawTail` writes. */
|
|
679
|
+
function normalizeRawTail(body: string): string {
|
|
680
|
+
const { head, tail } = splitRawTail(body);
|
|
681
|
+
return joinRawTail(head.trim(), tail);
|
|
625
682
|
}
|
|
626
683
|
|
|
627
684
|
/**
|
|
@@ -629,18 +686,43 @@ function tailBoundary(body: string): number {
|
|
|
629
686
|
* `note.frontMatter` and only `updated` moves. Last write wins — no
|
|
630
687
|
* `expectedRevision`; docs/weave-workspace.md §11 P5.3 argues the trade.
|
|
631
688
|
*/
|
|
632
|
-
export async function setNoteBody(
|
|
689
|
+
export async function setNoteBody(
|
|
690
|
+
root: string,
|
|
691
|
+
slug: string,
|
|
692
|
+
body: string,
|
|
693
|
+
now = new Date(),
|
|
694
|
+
by: HistoryAuthor = "human",
|
|
695
|
+
raw: RawTailMode = "keep",
|
|
696
|
+
): Promise<VaultMutationResult> {
|
|
633
697
|
const path = resolveNotePath(root, slug);
|
|
634
698
|
if (path === null) return { ok: false, reason: "invalid" };
|
|
635
699
|
return withVaultLock(root, async () => {
|
|
636
700
|
const note = await getNote(root, slug);
|
|
637
701
|
if (note === null) return { ok: false, reason: "missing" };
|
|
638
|
-
const next = preserveRawTail(note.body, body);
|
|
639
|
-
|
|
702
|
+
const next = raw === "replace" ? normalizeRawTail(body) : preserveRawTail(note.body, body);
|
|
703
|
+
const before = await priorBody(path, note);
|
|
704
|
+
const written = await writeNote(path, slug, { ...note, updated: now.toISOString() }, next, note.frontMatter);
|
|
705
|
+
await record(root, written, by, "edit", before, written.updated);
|
|
640
706
|
return { ok: true, slug };
|
|
641
707
|
});
|
|
642
708
|
}
|
|
643
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Every saved revision of a note, ending on what the note says now, or null
|
|
712
|
+
* when the note is missing. Under the vault lock, so a write cannot land
|
|
713
|
+
* between reading the note and comparing it with its log: that window would
|
|
714
|
+
* record the old body as an outside edit.
|
|
715
|
+
*/
|
|
716
|
+
export async function readNoteHistory(root: string, slug: string): Promise<NoteRevision[] | null> {
|
|
717
|
+
const path = resolveNotePath(root, slug);
|
|
718
|
+
if (path === null) return null;
|
|
719
|
+
return withVaultLock(root, async () => {
|
|
720
|
+
const note = await getNote(root, slug);
|
|
721
|
+
if (note === null) return null;
|
|
722
|
+
return readHistory(root, slug, await priorBody(path, note));
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
644
726
|
/** Rename a note in place and keep its front-matter title in sync. */
|
|
645
727
|
export async function renameNote(root: string, slug: string, name: string, now = new Date()): Promise<VaultMutationResult> {
|
|
646
728
|
const from = resolveNotePath(root, slug);
|
|
@@ -656,6 +738,7 @@ export async function renameNote(root: string, slug: string, name: string, now =
|
|
|
656
738
|
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
657
739
|
if (from !== to) await fs.rename(from, to);
|
|
658
740
|
await writeNote(to, target, { ...note, title, updated: now.toISOString() }, note.body, note.frontMatter);
|
|
741
|
+
if (from !== to) await moveHistory(root, slug, target);
|
|
659
742
|
if (from !== to) await repointBacklinks(root, new Map([[slug, target]]));
|
|
660
743
|
return { ok: true, slug: target };
|
|
661
744
|
});
|
|
@@ -675,6 +758,7 @@ export async function moveNote(root: string, slug: string, folder: string | null
|
|
|
675
758
|
if (from === to) return { ok: true, slug };
|
|
676
759
|
if (await exists(to)) return { ok: false, reason: "collision" };
|
|
677
760
|
await fs.rename(from, to);
|
|
761
|
+
await moveHistory(root, slug, target);
|
|
678
762
|
await repointBacklinks(root, new Map([[slug, target]]));
|
|
679
763
|
return { ok: true, slug: target };
|
|
680
764
|
});
|
|
@@ -687,6 +771,7 @@ export async function deleteNote(root: string, slug: string): Promise<VaultMutat
|
|
|
687
771
|
return withVaultLock(root, async () => {
|
|
688
772
|
if (!(await exists(path))) return { ok: false, reason: "missing" };
|
|
689
773
|
await fs.unlink(path);
|
|
774
|
+
await removeHistory(root, slug);
|
|
690
775
|
return { ok: true };
|
|
691
776
|
});
|
|
692
777
|
}
|
|
@@ -711,6 +796,7 @@ export async function renameFolder(root: string, folder: string, name: string):
|
|
|
711
796
|
.filter((f) => isMarkdown(f) && f.startsWith(`${folder}/`))
|
|
712
797
|
.map((f) => f.slice(0, -".md".length));
|
|
713
798
|
await fs.rename(from, to);
|
|
799
|
+
await moveHistoryFolder(root, folder, target);
|
|
714
800
|
await repointBacklinks(root, new Map(moved.map((s) => [s, `${target}/${s.slice(folder.length + 1)}`])));
|
|
715
801
|
return { ok: true, path: target };
|
|
716
802
|
});
|
|
@@ -723,6 +809,7 @@ export async function deleteFolder(root: string, folder: string): Promise<VaultM
|
|
|
723
809
|
return withVaultLock(root, async () => {
|
|
724
810
|
if (!(await isDirectory(path))) return { ok: false, reason: "missing" };
|
|
725
811
|
await fs.rm(path, { recursive: true });
|
|
812
|
+
await removeHistoryFolder(root, folder);
|
|
726
813
|
return { ok: true };
|
|
727
814
|
});
|
|
728
815
|
}
|
package/src/core/view/focus.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* focusModel — the 1-hop neighborhood of a node, grouped (weave-workspace §3).
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
4
|
+
* The browser workspace uses `focusNeighborhood` for the graph's
|
|
5
|
+
* neighborhood highlight.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../graph/model";
|
package/src/core/view/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/core/view — portable, renderer-agnostic view-models (weave-workspace §3).
|
|
3
3
|
*
|
|
4
|
-
* One implementation of the tree, detail, focus, and
|
|
5
|
-
*
|
|
4
|
+
* One implementation of the tree, detail, focus, and cluster projections,
|
|
5
|
+
* written once so every surface that renders them agrees. Pure
|
|
6
6
|
* functions over `GraphModel`; no harness, no DOM, no terminal concepts.
|
|
7
7
|
*/
|
|
8
8
|
export * from "./types";
|
|
@@ -11,5 +11,4 @@ export * from "./tree";
|
|
|
11
11
|
export * from "./detail";
|
|
12
12
|
export * from "./focus";
|
|
13
13
|
export * from "./links";
|
|
14
|
-
export * from "./health";
|
|
15
14
|
export * from "./cluster";
|