pi-weave 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -21
- package/package.json +12 -2
- package/skills/weave-explore/SKILL.md +17 -33
- package/skills/weave-notepad/SKILL.md +57 -120
- package/skills/weave-notepad/references/link-repair.md +25 -73
- package/src/core/index.ts +25 -1
- package/src/core/noteAction.ts +343 -0
- package/src/core/repoAction.ts +84 -0
- package/src/core/sessions.ts +80 -1
- package/src/core/summaries.ts +16 -0
- package/src/core/workspace.ts +1 -1
- package/src/core/workspaceCommands.ts +227 -0
- package/src/opencode/commands.ts +138 -0
- package/src/opencode/index.ts +88 -0
- package/src/opencode/rpc.ts +37 -0
- package/src/opencode/tools.ts +43 -0
- package/src/opencode/tui.ts +96 -0
- package/src/opencode/v1.ts +161 -0
- package/src/pi/sessionScan.ts +4 -28
- package/src/pi/summarize.ts +10 -25
- package/src/pi/tools/noteTool.ts +7 -406
- package/src/pi/tools/repoTool.ts +8 -75
- package/src/pi/viewer/web/run.ts +16 -77
- package/src/web/client/dist/app.js +13 -13
- package/src/web/client/graph/graph.model.ts +6 -4
- package/src/web/server/controller.ts +79 -0
package/src/core/index.ts
CHANGED
|
@@ -13,11 +13,20 @@ export {
|
|
|
13
13
|
summarizeIndex,
|
|
14
14
|
writeRepoIndex,
|
|
15
15
|
} from "./repoIndex";
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
DEEP_SCAN_SYSTEM_PROMPT,
|
|
18
|
+
formatDeepScanResult,
|
|
19
|
+
hashContent,
|
|
20
|
+
runDeepScan,
|
|
21
|
+
type DeepScanOptions,
|
|
22
|
+
type DeepScanResult,
|
|
23
|
+
type SummarizeFn,
|
|
24
|
+
} from "./summaries";
|
|
17
25
|
export {
|
|
18
26
|
DEFAULT_SESSIONS_ROOT,
|
|
19
27
|
SESSIONS_ENV_VAR,
|
|
20
28
|
deriveSessionTitle,
|
|
29
|
+
formatSessionScanResult,
|
|
21
30
|
listSessionFiles,
|
|
22
31
|
migrateLegacySessionNotes,
|
|
23
32
|
parseSessionDigest,
|
|
@@ -27,6 +36,8 @@ export {
|
|
|
27
36
|
renderSessionDigest,
|
|
28
37
|
resolveSessionsRoot,
|
|
29
38
|
runSessionScan,
|
|
39
|
+
runSessionDigestScan,
|
|
40
|
+
SESSION_SCAN_SYSTEM_PROMPT,
|
|
30
41
|
sessionHasContent,
|
|
31
42
|
sessionNoteBody,
|
|
32
43
|
sessionNoteFields,
|
|
@@ -34,6 +45,7 @@ export {
|
|
|
34
45
|
writeSessionNote,
|
|
35
46
|
type SessionChain,
|
|
36
47
|
type SessionDigest,
|
|
48
|
+
type SessionDigestScanOptions,
|
|
37
49
|
type SessionScanOptions,
|
|
38
50
|
type SessionScanResult,
|
|
39
51
|
} from "./sessions";
|
|
@@ -84,6 +96,18 @@ export type { HtmlArtifact } from "./types";
|
|
|
84
96
|
export { withMutationQueue } from "./mutex";
|
|
85
97
|
export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
|
|
86
98
|
export { WorkspaceCache } from "./cache/workspace";
|
|
99
|
+
export {
|
|
100
|
+
executeNoteAction,
|
|
101
|
+
WEAVE_NOTE_DESCRIPTION,
|
|
102
|
+
type NoteActionInput,
|
|
103
|
+
type NoteActionResult,
|
|
104
|
+
} from "./noteAction";
|
|
105
|
+
export {
|
|
106
|
+
executeRepoAction,
|
|
107
|
+
WEAVE_REPO_DESCRIPTION,
|
|
108
|
+
type RepoActionInput,
|
|
109
|
+
type RepoActionResult,
|
|
110
|
+
} from "./repoAction";
|
|
87
111
|
export {
|
|
88
112
|
buildCurrentGraph,
|
|
89
113
|
readNoteForView,
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
addNote,
|
|
4
|
+
appendToNote,
|
|
5
|
+
extractRawTail,
|
|
6
|
+
finalizeNote,
|
|
7
|
+
formatNote,
|
|
8
|
+
getNote,
|
|
9
|
+
listNotes,
|
|
10
|
+
readVault,
|
|
11
|
+
repairVaultLinks,
|
|
12
|
+
resolveNotePath,
|
|
13
|
+
searchNotes,
|
|
14
|
+
type LinkRepairResult,
|
|
15
|
+
} from "./vault";
|
|
16
|
+
import { relatedNotes, suggestLinks, type RelatedNote, type SuggestionReport } from "./links/similar";
|
|
17
|
+
import { NOTES_DIR, resolveVaultRoot } from "./paths";
|
|
18
|
+
import { withMutationQueue } from "./mutex";
|
|
19
|
+
import type { Note, NoteSearchHit } from "./types";
|
|
20
|
+
|
|
21
|
+
export const WEAVE_NOTE_DESCRIPTION =
|
|
22
|
+
"Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
|
|
23
|
+
"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
|
+
"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
|
+
"links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
|
|
27
|
+
"suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
|
|
28
|
+
"Use it to remember decisions, facts, and user preferences across sessions.";
|
|
29
|
+
|
|
30
|
+
export interface NoteActionInput {
|
|
31
|
+
action: "list" | "get" | "add" | "append" | "finalize" | "search" | "links" | "suggest";
|
|
32
|
+
title?: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
tags?: string[];
|
|
35
|
+
slug?: string;
|
|
36
|
+
raw?: boolean;
|
|
37
|
+
source?: "human" | "agent";
|
|
38
|
+
query?: string;
|
|
39
|
+
fix?: boolean;
|
|
40
|
+
limit?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface NoteActionResult {
|
|
44
|
+
text: string;
|
|
45
|
+
details: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const LINK_REPORT_CAP = 20;
|
|
49
|
+
const LIST_CAP = 50;
|
|
50
|
+
const SEARCH_REPORT_CAP = 10;
|
|
51
|
+
const RELATED_REPORT_CAP = 5;
|
|
52
|
+
const COMPLETE_BODY_CAP = 2_000;
|
|
53
|
+
const COMPLETE_BODY_RESULTS = 3;
|
|
54
|
+
|
|
55
|
+
function capped<T>(items: readonly T[], render: (item: T) => string): string[] {
|
|
56
|
+
const lines = items.slice(0, LINK_REPORT_CAP).map(render);
|
|
57
|
+
if (items.length > LINK_REPORT_CAP) lines.push(` … and ${items.length - LINK_REPORT_CAP} more`);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSuggestions(report: SuggestionReport, focus: string | undefined): string {
|
|
62
|
+
if (report.suggestions.length === 0) {
|
|
63
|
+
return focus === undefined
|
|
64
|
+
? `No unlinked notes share enough distinctive vocabulary to suggest a connection (${report.considered} note(s) considered).`
|
|
65
|
+
: `Nothing unlinked looks related to '${focus}' (${report.considered} note(s) considered).`;
|
|
66
|
+
}
|
|
67
|
+
const head = focus === undefined
|
|
68
|
+
? `${report.suggestions.length} suggested connection(s) across ${report.considered} note(s):`
|
|
69
|
+
: `${report.suggestions.length} note(s) look related to '${focus}':`;
|
|
70
|
+
const rows = report.suggestions.map((suggestion) => {
|
|
71
|
+
const pair = focus === undefined
|
|
72
|
+
? `${suggestion.a} ↔ ${suggestion.b}`
|
|
73
|
+
: suggestion.a === focus ? suggestion.b : suggestion.a;
|
|
74
|
+
return ` ${suggestion.score.toFixed(3)} ${pair}\n shared: ${suggestion.shared.join(", ")}`;
|
|
75
|
+
});
|
|
76
|
+
return [
|
|
77
|
+
head,
|
|
78
|
+
...rows,
|
|
79
|
+
"",
|
|
80
|
+
"These are suggestions, not links — nothing was written. Add a [[wikilink]] to any pair worth keeping.",
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function searchReasons(hit: NoteSearchHit, query: string): string[] {
|
|
85
|
+
const q = query.trim().toLowerCase();
|
|
86
|
+
const title = hit.summary.title.toLowerCase();
|
|
87
|
+
const slug = hit.summary.slug.toLowerCase();
|
|
88
|
+
const reasons: string[] = [];
|
|
89
|
+
if (title === q) reasons.push("exact title");
|
|
90
|
+
else if (slug === q) reasons.push("exact slug");
|
|
91
|
+
else if (title.includes(q)) reasons.push("title");
|
|
92
|
+
else if (slug.includes(q)) reasons.push("slug");
|
|
93
|
+
const tags = hit.summary.tags.filter((tag) => tag.toLowerCase().includes(q));
|
|
94
|
+
if (tags.length > 0) reasons.push(`tags: ${tags.join(", ")}`);
|
|
95
|
+
if (hit.snippet.toLowerCase().includes(q)) reasons.push("body");
|
|
96
|
+
if (reasons.length === 0) {
|
|
97
|
+
const haystack = `${title} ${slug} ${hit.summary.tags.join(" ").toLowerCase()} ${hit.snippet.toLowerCase()}`;
|
|
98
|
+
const terms = [...new Set(q.match(/[\p{L}\p{N}_-]{2,}/gu) ?? [])].filter((term) => haystack.includes(term));
|
|
99
|
+
if (terms.length > 0) reasons.push(`query terms: ${terms.join(", ")}`);
|
|
100
|
+
}
|
|
101
|
+
return reasons;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function searchContent(note: Note | undefined, snippet: string, complete: boolean): string {
|
|
105
|
+
const body = note?.body.trim() ?? "";
|
|
106
|
+
if (complete && body.length <= COMPLETE_BODY_CAP) return `[complete body]\n ${body}`;
|
|
107
|
+
return `[excerpt${body.length > COMPLETE_BODY_CAP ? "; use get for the full note" : ""}]\n ${snippet}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatSearchHits(
|
|
111
|
+
hits: readonly NoteSearchHit[],
|
|
112
|
+
query: string,
|
|
113
|
+
relations: ReadonlyMap<string, RelatedNote>,
|
|
114
|
+
notes: ReadonlyMap<string, Note>,
|
|
115
|
+
): string {
|
|
116
|
+
const shown = hits.slice(0, SEARCH_REPORT_CAP);
|
|
117
|
+
const lines = shown.map((hit, index) => {
|
|
118
|
+
const tags = hit.summary.tags.length > 0 ? `; tags: ${hit.summary.tags.join(", ")}` : "";
|
|
119
|
+
const relation = relations.get(hit.summary.slug);
|
|
120
|
+
const connected = relation ? `; connected: ${relation.reasons.join("; ")}` : "";
|
|
121
|
+
const content = searchContent(notes.get(hit.summary.slug), hit.snippet, index < COMPLETE_BODY_RESULTS);
|
|
122
|
+
return `- ${hit.summary.slug}: ${hit.summary.title}\n matched: ${searchReasons(hit, query).join(", ")}${connected}; source: ${hit.summary.source}; updated: ${hit.summary.updated}${tags}\n ${content}`;
|
|
123
|
+
});
|
|
124
|
+
if (hits.length > shown.length) lines.push(`… and ${hits.length - shown.length} more direct match(es).`);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function preview(body: string): string {
|
|
129
|
+
const flat = body.trim().replace(/\s+/g, " ");
|
|
130
|
+
return flat.length > 180 ? `${flat.slice(0, 180)}…` : flat;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatRelatedNotes(
|
|
134
|
+
relations: readonly RelatedNote[],
|
|
135
|
+
notes: ReadonlyMap<string, Note>,
|
|
136
|
+
direct: ReadonlySet<string>,
|
|
137
|
+
anchor: Note,
|
|
138
|
+
): string {
|
|
139
|
+
const shown = relations
|
|
140
|
+
.filter((relation) => !direct.has(relation.slug))
|
|
141
|
+
.flatMap((relation) => {
|
|
142
|
+
const note = notes.get(relation.slug);
|
|
143
|
+
return note ? [{ relation, note }] : [];
|
|
144
|
+
})
|
|
145
|
+
.slice(0, RELATED_REPORT_CAP);
|
|
146
|
+
if (shown.length === 0) return "";
|
|
147
|
+
const lines = shown.map(({ relation, note }, index) => {
|
|
148
|
+
const tags = note.tags.length > 0 ? `; tags: ${note.tags.join(", ")}` : "";
|
|
149
|
+
const content = searchContent(note, preview(note.body), index < COMPLETE_BODY_RESULTS);
|
|
150
|
+
return `- ${note.slug}: ${note.title}\n connected: ${relation.reasons.join("; ")}; source: ${note.source}; updated: ${note.updated}${tags}\n ${content}`;
|
|
151
|
+
});
|
|
152
|
+
return `Connected notes to '${anchor.title}' (discovery context only — not direct query matches or evidence about the query):\n${lines.join("\n")}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
|
|
156
|
+
const { audit } = result;
|
|
157
|
+
const lines = [`${audit.total} wiki-link(s): ${audit.resolved} resolved, ${audit.total - audit.resolved} stale.`];
|
|
158
|
+
if (applied) {
|
|
159
|
+
lines.push(
|
|
160
|
+
result.applied.length === 0
|
|
161
|
+
? "Nothing to repair automatically."
|
|
162
|
+
: `Repaired ${result.applied.length} link(s) across ${result.notes.length} note(s):`,
|
|
163
|
+
...capped(result.applied, (fix) => ` ${fix.slug}: [[${fix.from}]] → [[${fix.to}]] (${fix.rule})`),
|
|
164
|
+
);
|
|
165
|
+
} else if (audit.fixable.length > 0) {
|
|
166
|
+
lines.push(
|
|
167
|
+
`${audit.fixable.length} auto-fixable (re-run with fix: true):`,
|
|
168
|
+
...capped(audit.fixable, (fix) => ` ${fix.slug}: [[${fix.from}]] → [[${fix.to}]] (${fix.rule})`),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (audit.ambiguous.length > 0) {
|
|
172
|
+
lines.push(
|
|
173
|
+
`${audit.ambiguous.length} ambiguous link(s) (several candidates — pick one and edit the note):`,
|
|
174
|
+
...capped(audit.ambiguous, (link) => ` ${link.slug}: [[${link.target}]] → ${link.candidates.join(" | ")}`),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (audit.unresolvable.length > 0) {
|
|
178
|
+
lines.push(
|
|
179
|
+
`${audit.unresolvable.length} unresolvable target(s) (no such note — write it or drop the link):`,
|
|
180
|
+
...capped(audit.unresolvable, (link) => ` [[${link.target}]] ← ${link.notes.join(", ")}`),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (audit.fixable.length === 0 && audit.ambiguous.length === 0 && audit.unresolvable.length === 0) {
|
|
184
|
+
lines.push("Every link resolves.");
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function executeNoteAction(
|
|
190
|
+
params: NoteActionInput,
|
|
191
|
+
vault = resolveVaultRoot(),
|
|
192
|
+
now: () => Date = () => new Date(),
|
|
193
|
+
): Promise<NoteActionResult> {
|
|
194
|
+
switch (params.action) {
|
|
195
|
+
case "list": {
|
|
196
|
+
const notes = await listNotes(vault);
|
|
197
|
+
if (notes.length === 0) return { text: `The vault at ${vault} has no notes yet.`, details: { action: "list", notes: [] } };
|
|
198
|
+
const shown = notes.slice(0, LIST_CAP);
|
|
199
|
+
const lines = shown.map(
|
|
200
|
+
(note) => `- ${note.slug}: ${note.title}${note.tags.length > 0 ? ` [${note.tags.join(", ")}]` : ""} (updated ${note.updated}, source: ${note.source})`,
|
|
201
|
+
);
|
|
202
|
+
if (notes.length > shown.length) {
|
|
203
|
+
lines.push(`… and ${notes.length - shown.length} more (newest ${shown.length} shown) — use action=search to find a specific note.`);
|
|
204
|
+
}
|
|
205
|
+
return { text: `${notes.length} note(s) in ${vault}:\n${lines.join("\n")}`, details: { action: "list", notes } };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case "get": {
|
|
209
|
+
if (!params.slug) throw new Error("weave_note(get) requires 'slug'");
|
|
210
|
+
const note = await getNote(vault, params.slug);
|
|
211
|
+
return note
|
|
212
|
+
? { text: formatNote(note), details: { action: "get", found: true, note } }
|
|
213
|
+
: { text: `No note found with slug '${params.slug}'.`, details: { action: "get", found: false } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case "add": {
|
|
217
|
+
if (!params.title) throw new Error("weave_note(add) requires 'title'");
|
|
218
|
+
if (!params.text) throw new Error("weave_note(add) requires 'text'");
|
|
219
|
+
const note = await withMutationQueue(join(vault, NOTES_DIR), () =>
|
|
220
|
+
addNote(vault, {
|
|
221
|
+
title: params.title!,
|
|
222
|
+
body: params.text!,
|
|
223
|
+
...(params.tags ? { tags: params.tags } : {}),
|
|
224
|
+
...(params.source ? { source: params.source } : {}),
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
return { text: `Note created: ${note.slug} (${vault})`, details: { action: "add", note } };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
case "append": {
|
|
231
|
+
if (!params.slug) throw new Error("weave_note(append) requires 'slug'");
|
|
232
|
+
if (!params.text) throw new Error("weave_note(append) requires 'text'");
|
|
233
|
+
const path = resolveNotePath(vault, params.slug);
|
|
234
|
+
if (!path) {
|
|
235
|
+
return {
|
|
236
|
+
text: `Invalid note slug '${params.slug}' — notes are flat files inside the vault (no path separators or '..').`,
|
|
237
|
+
details: { action: "append", found: false },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const note = await withMutationQueue(path, () =>
|
|
241
|
+
appendToNote(vault, params.slug!, params.text!, now(), params.raw ? { raw: true } : {}),
|
|
242
|
+
);
|
|
243
|
+
if (!note) return { text: `No note found with slug '${params.slug}'.`, details: { action: "append", found: false } };
|
|
244
|
+
return {
|
|
245
|
+
text: params.raw
|
|
246
|
+
? `Appended verbatim to the ## Raw tail of ${note.slug} (updated ${note.updated}).`
|
|
247
|
+
: `Appended to ${note.slug} (updated ${note.updated}).`,
|
|
248
|
+
details: { action: "append", found: true, note },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case "finalize": {
|
|
253
|
+
if (!params.slug) throw new Error("weave_note(finalize) requires 'slug'");
|
|
254
|
+
if (!params.text) throw new Error("weave_note(finalize) requires 'text'");
|
|
255
|
+
const path = resolveNotePath(vault, params.slug);
|
|
256
|
+
if (!path) {
|
|
257
|
+
return {
|
|
258
|
+
text: `Invalid note slug '${params.slug}' — notes are flat files inside the vault (no path separators or '..').`,
|
|
259
|
+
details: { action: "finalize", found: false },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const note = await withMutationQueue(path, () => finalizeNote(vault, params.slug!, { body: params.text! }));
|
|
263
|
+
if (!note) return { text: `No note found with slug '${params.slug}'.`, details: { action: "finalize", found: false } };
|
|
264
|
+
const preserved = extractRawTail(note.body) !== "";
|
|
265
|
+
return {
|
|
266
|
+
text: `Finalized ${note.slug} (updated ${note.updated}). ${preserved ? "Raw tail preserved beneath the structured body." : "Note body was empty — nothing to preserve."}`,
|
|
267
|
+
details: { action: "finalize", found: true, note },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case "search": {
|
|
272
|
+
if (!params.query) throw new Error("weave_note(search) requires 'query'");
|
|
273
|
+
const hits = await searchNotes(vault, params.query);
|
|
274
|
+
if (hits.length === 0) return { text: `No notes matched '${params.query}'.`, details: { action: "search", hits: [] } };
|
|
275
|
+
const { notes } = await readVault(vault);
|
|
276
|
+
const bySlug = new Map(notes.map((note) => [note.slug, note]));
|
|
277
|
+
const direct = new Set(hits.map((hit) => hit.summary.slug));
|
|
278
|
+
const anchor = bySlug.get(hits[0]!.summary.slug);
|
|
279
|
+
const related = anchor ? relatedNotes({ notes }, anchor.slug) : [];
|
|
280
|
+
const relatedBySlug = new Map(related.map((relation) => [relation.slug, relation]));
|
|
281
|
+
const connected = anchor ? formatRelatedNotes(related, bySlug, direct, anchor) : "";
|
|
282
|
+
const query = params.query.trim().toLowerCase();
|
|
283
|
+
const exact = hits.filter((hit) =>
|
|
284
|
+
hit.summary.title.trim().toLowerCase() === query || hit.summary.slug.toLowerCase() === query
|
|
285
|
+
);
|
|
286
|
+
const resolved = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : undefined;
|
|
287
|
+
if (resolved) {
|
|
288
|
+
const note = bySlug.get(resolved.summary.slug);
|
|
289
|
+
if (note) {
|
|
290
|
+
const others = hits.filter((hit) => hit.summary.slug !== note.slug);
|
|
291
|
+
const otherMatches = others.length > 0
|
|
292
|
+
? `Other direct matches (${others.length}):\n${formatSearchHits(others, params.query, relatedBySlug, bySlug)}`
|
|
293
|
+
: "";
|
|
294
|
+
return {
|
|
295
|
+
text: [formatNote(note).trimEnd(), otherMatches, connected].filter(Boolean).join("\n\n") + "\n",
|
|
296
|
+
details: { action: "search", hits, resolved: note, related },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
text: [
|
|
302
|
+
`${hits.length} direct match(es) for '${params.query}', strongest first:\n${formatSearchHits(hits, params.query, relatedBySlug, bySlug)}`,
|
|
303
|
+
connected,
|
|
304
|
+
].filter(Boolean).join("\n\n"),
|
|
305
|
+
details: { action: "search", hits, related },
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case "suggest": {
|
|
310
|
+
const { notes } = await readVault(vault);
|
|
311
|
+
const report = suggestLinks(
|
|
312
|
+
{ notes },
|
|
313
|
+
{
|
|
314
|
+
...(params.slug ? { slug: params.slug } : {}),
|
|
315
|
+
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
return {
|
|
319
|
+
text: formatSuggestions(report, params.slug),
|
|
320
|
+
details: { action: "suggest", considered: report.considered, suggestions: report.suggestions },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case "links": {
|
|
325
|
+
const apply = params.fix === true;
|
|
326
|
+
const result = await repairVaultLinks(vault, apply ? { apply: true } : {});
|
|
327
|
+
return {
|
|
328
|
+
text: formatLinkReport(result, apply),
|
|
329
|
+
details: {
|
|
330
|
+
action: "links",
|
|
331
|
+
fixed: apply,
|
|
332
|
+
total: result.audit.total,
|
|
333
|
+
resolved: result.audit.resolved,
|
|
334
|
+
fixable: result.audit.fixable,
|
|
335
|
+
ambiguous: result.audit.ambiguous,
|
|
336
|
+
unresolvable: result.audit.unresolvable,
|
|
337
|
+
applied: result.applied,
|
|
338
|
+
notes: result.notes,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assessStaleness,
|
|
3
|
+
buildRepoIndex,
|
|
4
|
+
readRepoIndex,
|
|
5
|
+
summarizeIndex,
|
|
6
|
+
writeRepoIndex,
|
|
7
|
+
} from "./repoIndex";
|
|
8
|
+
import { findGitRoot } from "./git";
|
|
9
|
+
import { repoIndexDir } from "./paths";
|
|
10
|
+
|
|
11
|
+
export const WEAVE_REPO_DESCRIPTION =
|
|
12
|
+
"Explore the current git repository through its pi-weave knowledge index (.okf). " +
|
|
13
|
+
"Actions: status (index freshness vs git state), scan (build/refresh the index), " +
|
|
14
|
+
"overview (read the indexed structure: languages, packages, modules, entry points). " +
|
|
15
|
+
"The index is derived and rebuildable; scanning is always safe.";
|
|
16
|
+
|
|
17
|
+
export interface RepoActionInput {
|
|
18
|
+
action: "status" | "scan" | "overview";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RepoActionResult {
|
|
22
|
+
text: string;
|
|
23
|
+
details: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function executeRepoAction(
|
|
27
|
+
params: RepoActionInput,
|
|
28
|
+
cwd: string,
|
|
29
|
+
onProgress?: (text: string) => void | Promise<void>,
|
|
30
|
+
): Promise<RepoActionResult> {
|
|
31
|
+
const root = await findGitRoot(cwd);
|
|
32
|
+
if (!root) {
|
|
33
|
+
return {
|
|
34
|
+
text: "Not inside a git repository — repository knowledge is unavailable here.",
|
|
35
|
+
details: { action: params.action, inRepo: false },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
switch (params.action) {
|
|
40
|
+
case "status": {
|
|
41
|
+
const staleness = await assessStaleness(root);
|
|
42
|
+
const index = staleness.state !== "missing" ? await readRepoIndex(root) : null;
|
|
43
|
+
const lines = [`Index state: ${staleness.state}`];
|
|
44
|
+
for (const reason of staleness.reasons) lines.push(`- ${reason}`);
|
|
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
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/core/sessions.ts
CHANGED
|
@@ -48,6 +48,15 @@ export const SESSION_SCAN_MAX_SESSIONS = 100;
|
|
|
48
48
|
export const SESSION_SCAN_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
49
49
|
export const SESSION_SCAN_CONCURRENCY = 2;
|
|
50
50
|
|
|
51
|
+
export const SESSION_SCAN_SYSTEM_PROMPT = [
|
|
52
|
+
"You write durable memory notes that compact coding-agent sessions into their bottom line.",
|
|
53
|
+
"First summarize what happened, what shipped (features, files, commands, decisions), and",
|
|
54
|
+
"what went less well (dead ends, breakage, unfinished work) using concrete technical details.",
|
|
55
|
+
"Then add a '## Takeaways' section with 2–4 reusable lessons: gotchas, root causes of tricky",
|
|
56
|
+
"failures, non-obvious syntax rules, or architecture patterns a future agent can apply.",
|
|
57
|
+
"Past tense. No preamble or code fences. Omit lessons unsupported by the transcript.",
|
|
58
|
+
].join("\n");
|
|
59
|
+
|
|
51
60
|
const DIGEST_MAX_CHARS = 12_000;
|
|
52
61
|
const USER_MSG_MAX_CHARS = 400;
|
|
53
62
|
const MAX_USER_MESSAGES = 60;
|
|
@@ -810,6 +819,76 @@ export interface SessionScanResult {
|
|
|
810
819
|
failed: SessionScanFailure[];
|
|
811
820
|
}
|
|
812
821
|
|
|
822
|
+
export function formatSessionScanResult(result: SessionScanResult): string {
|
|
823
|
+
const parts = [
|
|
824
|
+
`${result.written} summarized (${result.created} new, ${result.updated} updated)`,
|
|
825
|
+
`${result.skippedFresh} unchanged`,
|
|
826
|
+
];
|
|
827
|
+
if (result.skippedEmpty > 0) parts.push(`${result.skippedEmpty} empty`);
|
|
828
|
+
if (result.skippedTooBig > 0) parts.push(`${result.skippedTooBig} skipped (size)`);
|
|
829
|
+
if (result.skippedUnreadable > 0) parts.push(`${result.skippedUnreadable} unreadable`);
|
|
830
|
+
let text = `${parts.join(", ")} — ${result.considered} sessions considered`;
|
|
831
|
+
const first = result.failed[0];
|
|
832
|
+
if (first) text += `; ${result.failed.length} failed, first: ${first.path}: ${first.error}`;
|
|
833
|
+
return text;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export interface SessionDigestScanOptions {
|
|
837
|
+
vaultRoot: string;
|
|
838
|
+
digest: SessionDigest;
|
|
839
|
+
content: string;
|
|
840
|
+
source: string;
|
|
841
|
+
hash: string;
|
|
842
|
+
summarize: SummarizeFn;
|
|
843
|
+
model?: string;
|
|
844
|
+
now?: () => Date;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Summarize one in-memory public session without reading a harness database. */
|
|
848
|
+
export async function runSessionDigestScan(options: SessionDigestScanOptions): Promise<SessionScanResult> {
|
|
849
|
+
const result: SessionScanResult = {
|
|
850
|
+
discovered: 1,
|
|
851
|
+
considered: 1,
|
|
852
|
+
written: 0,
|
|
853
|
+
created: 0,
|
|
854
|
+
updated: 0,
|
|
855
|
+
skippedFresh: 0,
|
|
856
|
+
skippedEmpty: 0,
|
|
857
|
+
skippedTooBig: 0,
|
|
858
|
+
skippedUnreadable: 0,
|
|
859
|
+
migrated: await migrateLegacySessionNotes(options.vaultRoot),
|
|
860
|
+
failed: [],
|
|
861
|
+
};
|
|
862
|
+
const pointer = (await readSessionNoteIndex(options.vaultRoot)).get(options.digest.id);
|
|
863
|
+
if (pointer?.hash === options.hash) {
|
|
864
|
+
result.skippedFresh = 1;
|
|
865
|
+
return result;
|
|
866
|
+
}
|
|
867
|
+
if (!sessionHasContent(options.digest)) {
|
|
868
|
+
result.skippedEmpty = 1;
|
|
869
|
+
return result;
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
const summary = (await options.summarize({ path: options.source, content: options.content })).trim();
|
|
873
|
+
if (summary.length === 0) throw new Error("model returned an empty summary");
|
|
874
|
+
await writeSessionNote(options.vaultRoot, {
|
|
875
|
+
digest: options.digest,
|
|
876
|
+
file: { path: options.source },
|
|
877
|
+
hash: options.hash,
|
|
878
|
+
summary,
|
|
879
|
+
model: options.model ?? null,
|
|
880
|
+
at: (options.now ?? (() => new Date()))().toISOString(),
|
|
881
|
+
existingSlug: pointer?.slug ?? null,
|
|
882
|
+
});
|
|
883
|
+
result.written = 1;
|
|
884
|
+
if (pointer) result.updated = 1;
|
|
885
|
+
else result.created = 1;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
result.failed.push({ path: options.source, error: error instanceof Error ? error.message : String(error) });
|
|
888
|
+
}
|
|
889
|
+
return result;
|
|
890
|
+
}
|
|
891
|
+
|
|
813
892
|
/**
|
|
814
893
|
* Run the session scan, in three phases:
|
|
815
894
|
*
|
|
@@ -960,4 +1039,4 @@ export async function runSessionScan(options: SessionScanOptions): Promise<Sessi
|
|
|
960
1039
|
}, aborted);
|
|
961
1040
|
|
|
962
1041
|
return result;
|
|
963
|
-
}
|
|
1042
|
+
}
|
package/src/core/summaries.ts
CHANGED
|
@@ -24,6 +24,12 @@ export const DEEP_SCAN_MAX_FILES = 300;
|
|
|
24
24
|
export const DEEP_SCAN_MAX_FILE_BYTES = 32_768;
|
|
25
25
|
export const DEEP_SCAN_CONCURRENCY = 4;
|
|
26
26
|
|
|
27
|
+
export const DEEP_SCAN_SYSTEM_PROMPT = [
|
|
28
|
+
"You write terse, navigation-oriented summaries of source files for a codebase index.",
|
|
29
|
+
"Rules: 1–3 sentences. What the file does, its outward surface (exports/routes/commands),",
|
|
30
|
+
"anything surprising (globals, side effects, generated sections). No preamble, no headings, no code fences.",
|
|
31
|
+
].join("\n");
|
|
32
|
+
|
|
27
33
|
/** A parsed summary sidecar. */
|
|
28
34
|
export interface SummaryRecord {
|
|
29
35
|
/** Repo-relative source path this summary describes. */
|
|
@@ -75,6 +81,16 @@ export interface DeepScanResult {
|
|
|
75
81
|
pruned: number;
|
|
76
82
|
}
|
|
77
83
|
|
|
84
|
+
export function formatDeepScanResult(result: DeepScanResult): string {
|
|
85
|
+
const parts = [`${result.written} summarized`, `${result.skippedFresh} unchanged`];
|
|
86
|
+
if (result.skippedTooBig > 0) parts.push(`${result.skippedTooBig} skipped (size/type)`);
|
|
87
|
+
if (result.pruned > 0) parts.push(`${result.pruned} pruned`);
|
|
88
|
+
let text = `${parts.join(", ")} — ${result.considered} files considered`;
|
|
89
|
+
const first = result.failed[0];
|
|
90
|
+
if (first) text += `; ${result.failed.length} failed, first: ${first.path}: ${first.error}`;
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
/** Deterministic sidecar file name for a repo-relative target path. */
|
|
79
95
|
export function summaryFileName(target: string): string {
|
|
80
96
|
return target.split("/").join("--") + SUMMARY_SUFFIX;
|
package/src/core/workspace.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function formatDashboard(status: WorkspaceStatus): string {
|
|
|
71
71
|
const repo = status.repository;
|
|
72
72
|
lines.push(`Repository (${repo.name} @ ${repo.root}):`);
|
|
73
73
|
if (!repo.indexed) {
|
|
74
|
-
lines.push(" not indexed — run
|
|
74
|
+
lines.push(" not indexed — run /weave-scan or ask your agent to explore");
|
|
75
75
|
return lines.join("\n");
|
|
76
76
|
}
|
|
77
77
|
lines.push(` index: ${repo.staleness.state}`);
|