pi-weave 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -37
- package/package.json +12 -3
- 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/frontmatter.ts +3 -3
- package/src/core/graph/model.ts +2 -2
- package/src/core/graph/wikilinks.ts +1 -1
- 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/types.ts +1 -1
- 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/tui/surface/detail.ts +1 -1
- package/src/pi/viewer/web/run.ts +16 -77
- package/src/web/client/dist/app.js +210 -178
- package/src/web/client/graph/Graph.tsx +18 -0
- package/src/web/client/graph/column.model.ts +19 -2
- package/src/web/client/graph/graph.model.ts +7 -5
- package/src/web/client/graph/positions.ts +1 -1
- package/src/web/client/graph/renderer.ts +9 -1
- package/src/web/client/note/Note.tsx +66 -91
- package/src/web/client/note/drafts.ts +172 -0
- package/src/web/client/note/note.model.ts +2 -34
- package/src/web/client/search/SearchPalette.tsx +9 -8
- package/src/web/client/shell/ContextRail.tsx +8 -9
- package/src/web/client/shell/Shell.tsx +371 -254
- package/src/web/client/shell/StatusBar.tsx +1 -1
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +26 -31
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +31 -107
- package/src/web/client/shell/theme.model.ts +4 -4
- package/src/web/client/shell/theme.ts +12 -93
- package/src/web/client/shell/workspace.css.ts +117 -0
- package/src/web/client/tree/Tree.tsx +43 -14
- package/src/web/client/tree/tree.model.ts +19 -0
- package/src/web/client/workspace.ts +19 -1
- package/src/web/server/controller.ts +79 -0
- package/src/web/server/routes.ts +34 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/workspace.ts +270 -0
- package/src/web/client/selection.storage.ts +0 -69
- package/src/web/client/shell/Columns.tsx +0 -139
- package/src/web/client/shell/Divider.tsx +0 -15
- package/src/web/client/shell/Header.tsx +0 -119
- package/src/web/client/shell/drag.model.ts +0 -35
- package/src/web/client/shell/layout.model.ts +0 -132
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/types.ts
CHANGED
|
@@ -29,7 +29,7 @@ export interface NoteMeta {
|
|
|
29
29
|
* This is how pi-weave keeps front matter **lossless**. `NoteMeta` names the
|
|
30
30
|
* five fields the engine understands; a vault is a directory of files a human
|
|
31
31
|
* also edits, so real notes carry keys the engine has never heard of —
|
|
32
|
-
*
|
|
32
|
+
* `aliases`, `cssclass`, `publish`, whatever the user invented last
|
|
33
33
|
* Tuesday. Modelling those as parsed values would mean re-emitting them, and
|
|
34
34
|
* re-emitting a value the parser only half-understands is how formatting (and
|
|
35
35
|
* then content) gets destroyed.
|
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}`);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { findGitRoot } from "./git";
|
|
4
|
+
import { resolveVaultRoot } from "./paths";
|
|
5
|
+
import { buildRepoIndex, summarizeIndex, writeRepoIndex } from "./repoIndex";
|
|
6
|
+
import { formatDashboard, getWorkspaceStatus } from "./workspace";
|
|
7
|
+
import { DEEP_SCAN_SYSTEM_PROMPT, formatDeepScanResult, runDeepScan, type SummarizeFn } from "./summaries";
|
|
8
|
+
import { formatSessionScanResult, renderSessionDigest, runSessionDigestScan, runSessionScan, SESSION_SCAN_SYSTEM_PROMPT, type SessionDigest } from "./sessions";
|
|
9
|
+
|
|
10
|
+
export interface WorkspaceScanStatus {
|
|
11
|
+
sessionID: string;
|
|
12
|
+
text: string;
|
|
13
|
+
active: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WorkspaceCommand {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
execute(sessionID: string, args: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkspaceCommandHost {
|
|
23
|
+
getSession(sessionID: string): Promise<{ id: string; cwd: string; model: string | null }>;
|
|
24
|
+
getDigest(sessionID: string, signal: AbortSignal): Promise<{ digest: SessionDigest; hash: string }>;
|
|
25
|
+
sessionSource(sessionID: string): string;
|
|
26
|
+
generate(sessionID: string, prompt: string, signal: AbortSignal, model: string): Promise<string>;
|
|
27
|
+
output(sessionID: string, text: string): Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RunningScan {
|
|
31
|
+
controller: AbortController;
|
|
32
|
+
done: Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function summarizer(host: WorkspaceCommandHost, sessionID: string, systemPrompt: string, signal: AbortSignal, model: string): SummarizeFn {
|
|
36
|
+
return async ({ path, content }) => {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
const text = (await host.generate(sessionID, `${systemPrompt}\n\nFile: ${path}\n\n${content}`, signal, model)).trim();
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
if (!text) throw new Error("model returned an empty summary");
|
|
41
|
+
return text;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function historyPath(cwd: string, input: string): string {
|
|
46
|
+
const trimmed = input.trim();
|
|
47
|
+
if (trimmed === "~") return homedir();
|
|
48
|
+
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
|
49
|
+
return resolve(cwd, trimmed);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createWorkspaceCommands(
|
|
53
|
+
host: WorkspaceCommandHost,
|
|
54
|
+
onStatus: (status: WorkspaceScanStatus) => void = () => {},
|
|
55
|
+
viewer?: { run(cwd: string): Promise<{ session: { server: { entryUrl: string } }; started: boolean }> },
|
|
56
|
+
onViewer?: (event: { sessionID: string; url: string; open: boolean; started: boolean }) => void,
|
|
57
|
+
) {
|
|
58
|
+
const running = new Map<string, RunningScan>();
|
|
59
|
+
const commands: WorkspaceCommand[] = [];
|
|
60
|
+
const output = host.output;
|
|
61
|
+
const cwdOf = async (sessionID: string) => (await host.getSession(sessionID)).cwd;
|
|
62
|
+
|
|
63
|
+
const start = (sessionID: string, run: (signal: AbortSignal) => Promise<string>): void => {
|
|
64
|
+
const key = String(sessionID);
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const done = (async () => {
|
|
67
|
+
try {
|
|
68
|
+
const text = await run(controller.signal);
|
|
69
|
+
await output(sessionID, text);
|
|
70
|
+
onStatus({ sessionID: key, text, active: false });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const text = controller.signal.aborted
|
|
73
|
+
? "pi-weave: scan cancelled."
|
|
74
|
+
: `pi-weave: scan failed — ${error instanceof Error ? error.message : String(error)}`;
|
|
75
|
+
await output(sessionID, text).catch(() => {});
|
|
76
|
+
onStatus({ sessionID: key, text, active: false });
|
|
77
|
+
} finally {
|
|
78
|
+
running.delete(key);
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
running.set(key, { controller, done });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
commands.push({
|
|
85
|
+
name: "weave",
|
|
86
|
+
description: "Show the pi-weave workspace dashboard",
|
|
87
|
+
async execute(sessionID) {
|
|
88
|
+
await output(sessionID, formatDashboard(await getWorkspaceStatus(await cwdOf(sessionID))));
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
commands.push({
|
|
93
|
+
name: "weave-scan",
|
|
94
|
+
description: "Refresh the index; use 'deep' or 'sessions [path]' for model-backed scans",
|
|
95
|
+
async execute(sessionID, args) {
|
|
96
|
+
const [mode = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
97
|
+
const key = String(sessionID);
|
|
98
|
+
if (running.has(key)) {
|
|
99
|
+
await output(sessionID, "pi-weave: a scan is already running — use /weave-scan-cancel to stop it.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const cwd = await cwdOf(sessionID);
|
|
103
|
+
if (mode.toLowerCase() === "sessions") {
|
|
104
|
+
const session = await host.getSession(sessionID);
|
|
105
|
+
if (!session.model) {
|
|
106
|
+
await output(sessionID, "pi-weave: session scan needs an active session model — none configured.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
start(sessionID, async (signal) => {
|
|
110
|
+
const status = "🕸️ session scan: starting…";
|
|
111
|
+
onStatus({ sessionID: key, text: status, active: true });
|
|
112
|
+
if (rest.length > 0) {
|
|
113
|
+
const result = await runSessionScan({
|
|
114
|
+
sessionsRoot: historyPath(cwd, rest.join(" ")),
|
|
115
|
+
vaultRoot: resolveVaultRoot(),
|
|
116
|
+
summarize: summarizer(host, sessionID, SESSION_SCAN_SYSTEM_PROMPT, signal, session.model!),
|
|
117
|
+
model: session.model!,
|
|
118
|
+
signal,
|
|
119
|
+
onProgress: ({ current, total, path }) => {
|
|
120
|
+
onStatus({ sessionID: key, text: `🕸️ session scan: ${current}/${total} — ${path}`, active: true });
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return signal.aborted
|
|
124
|
+
? "pi-weave: session scan cancelled."
|
|
125
|
+
: `pi-weave: session scan complete — ${formatSessionScanResult(result)}`;
|
|
126
|
+
}
|
|
127
|
+
const { digest, hash } = await host.getDigest(sessionID, signal);
|
|
128
|
+
const result = await runSessionDigestScan({
|
|
129
|
+
vaultRoot: resolveVaultRoot(),
|
|
130
|
+
digest,
|
|
131
|
+
content: renderSessionDigest(digest),
|
|
132
|
+
source: host.sessionSource(session.id),
|
|
133
|
+
hash,
|
|
134
|
+
summarize: summarizer(host, sessionID, SESSION_SCAN_SYSTEM_PROMPT, signal, session.model!),
|
|
135
|
+
model: session.model!,
|
|
136
|
+
});
|
|
137
|
+
return signal.aborted
|
|
138
|
+
? "pi-weave: session scan cancelled."
|
|
139
|
+
: `pi-weave: session scan complete — ${formatSessionScanResult(result)}`;
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const root = await findGitRoot(cwd);
|
|
145
|
+
if (!root) {
|
|
146
|
+
await output(sessionID, "pi-weave: not inside a git repository.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const index = await buildRepoIndex(root);
|
|
150
|
+
if (!index) {
|
|
151
|
+
await output(sessionID, "pi-weave: cannot index — the repository has no commits yet.");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await writeRepoIndex(root, index);
|
|
155
|
+
await output(sessionID, `pi-weave: index refreshed\n${summarizeIndex(index).join("\n")}`);
|
|
156
|
+
onStatus({ sessionID: key, text: "pi-weave: index refreshed", active: false });
|
|
157
|
+
if (mode.toLowerCase() !== "deep") return;
|
|
158
|
+
|
|
159
|
+
const session = await host.getSession(sessionID);
|
|
160
|
+
if (!session.model) {
|
|
161
|
+
await output(sessionID, "pi-weave: deep scan needs an active session model — none configured. Light index only.");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
start(sessionID, async (signal) => {
|
|
165
|
+
onStatus({ sessionID: key, text: "🕸️ deep scan: starting…", active: true });
|
|
166
|
+
const result = await runDeepScan(root, {
|
|
167
|
+
summarize: summarizer(host, sessionID, DEEP_SCAN_SYSTEM_PROMPT, signal, session.model!),
|
|
168
|
+
model: session.model!,
|
|
169
|
+
signal,
|
|
170
|
+
onProgress: ({ current, total, path }) => {
|
|
171
|
+
const pct = total > 0 ? Math.round((current / total) * 100) : 100;
|
|
172
|
+
onStatus({ sessionID: key, text: `🕸️ deep scan: ${current}/${total} (${pct}%) — ${path}`, active: true });
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
if (signal.aborted) return "pi-weave: deep scan cancelled.";
|
|
176
|
+
return result
|
|
177
|
+
? `pi-weave: deep scan complete — ${formatDeepScanResult(result)}`
|
|
178
|
+
: "pi-weave: deep scan stopped — repository unavailable.";
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
commands.push({
|
|
184
|
+
name: "weave-scan-cancel",
|
|
185
|
+
description: "Cancel the active pi-weave deep or session scan",
|
|
186
|
+
async execute(sessionID) {
|
|
187
|
+
const scan = running.get(String(sessionID));
|
|
188
|
+
if (!scan) {
|
|
189
|
+
await output(sessionID, "pi-weave: no scan is currently running.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
scan.controller.abort();
|
|
193
|
+
await output(sessionID, "pi-weave: scan cancellation requested.");
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (viewer) {
|
|
198
|
+
commands.push({
|
|
199
|
+
name: "weave-view",
|
|
200
|
+
description: "Open the pi-weave browser workspace; use --no-open to only print the URL",
|
|
201
|
+
async execute(sessionID, input) {
|
|
202
|
+
const args = input.trim();
|
|
203
|
+
if (args && args !== "--no-open") {
|
|
204
|
+
await output(sessionID, "usage: /weave-view [--no-open]");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const { session, started } = await viewer.run(await cwdOf(sessionID));
|
|
208
|
+
const url = session.server.entryUrl;
|
|
209
|
+
const open = args !== "--no-open" && onViewer !== undefined;
|
|
210
|
+
onViewer?.({ sessionID: String(sessionID), url, open, started });
|
|
211
|
+
await output(
|
|
212
|
+
sessionID,
|
|
213
|
+
`pi-weave: workspace ${started ? "running" : "already running"} at ${url}${open ? " — opening when the terminal is local." : " — open it in a browser."}`,
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
commands,
|
|
221
|
+
done: (sessionID: string) => running.get(sessionID)?.done,
|
|
222
|
+
async cleanup() {
|
|
223
|
+
for (const scan of running.values()) scan.controller.abort();
|
|
224
|
+
await Promise.all([...running.values()].map((scan) => scan.done));
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { hashContent } from "../core";
|
|
2
|
+
import type { SessionDigest } from "../core";
|
|
3
|
+
import { createWorkspaceCommands, type WorkspaceScanStatus } from "../core/workspaceCommands";
|
|
4
|
+
import type { WorkspaceServerController } from "../web/server/controller";
|
|
5
|
+
|
|
6
|
+
type Context = import("@opencode/plugin").Plugin.Context;
|
|
7
|
+
type SessionID = Parameters<Context["session"]["get"]>[0]["sessionID"];
|
|
8
|
+
export type OpenCodeScanStatus = WorkspaceScanStatus;
|
|
9
|
+
|
|
10
|
+
function record(value: unknown): Record<string, unknown> {
|
|
11
|
+
return value !== null && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function timestamp(value: unknown): string {
|
|
15
|
+
return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clipped(value: unknown, max: number): string {
|
|
19
|
+
if (typeof value !== "string") return "";
|
|
20
|
+
const text = value.trim();
|
|
21
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Convert OpenCode's public session context to the portable session digest. */
|
|
25
|
+
export function openCodeSessionDigest(session: unknown, messages: readonly unknown[]): SessionDigest {
|
|
26
|
+
const info = record(session);
|
|
27
|
+
const time = record(info.time);
|
|
28
|
+
const location = record(info.location);
|
|
29
|
+
const relevant = messages.filter((message) => ["user", "assistant", "compaction", "shell"].includes(String(record(message).type)));
|
|
30
|
+
const digest: SessionDigest = {
|
|
31
|
+
id: String(info.id ?? ""),
|
|
32
|
+
cwd: typeof location.directory === "string" ? location.directory : "",
|
|
33
|
+
parentSession: typeof info.parentID === "string" ? info.parentID : null,
|
|
34
|
+
startedAt: timestamp(time.created),
|
|
35
|
+
endedAt: timestamp(time.updated),
|
|
36
|
+
name: typeof info.title === "string" && info.title.trim() ? info.title.trim() : null,
|
|
37
|
+
models: [],
|
|
38
|
+
userCount: 0,
|
|
39
|
+
assistantCount: 0,
|
|
40
|
+
toolResultCount: 0,
|
|
41
|
+
errors: 0,
|
|
42
|
+
bashCount: 0,
|
|
43
|
+
tools: {},
|
|
44
|
+
firstUserMessage: null,
|
|
45
|
+
userMessages: [],
|
|
46
|
+
compactions: [],
|
|
47
|
+
branchSummaries: [],
|
|
48
|
+
lastAssistantText: null,
|
|
49
|
+
};
|
|
50
|
+
for (const raw of relevant) {
|
|
51
|
+
const message = record(raw);
|
|
52
|
+
if (message.type === "user") {
|
|
53
|
+
digest.userCount += 1;
|
|
54
|
+
const text = clipped(message.text, 400);
|
|
55
|
+
if (text) {
|
|
56
|
+
digest.userMessages.push(text);
|
|
57
|
+
if (digest.userMessages.length > 60) digest.userMessages.shift();
|
|
58
|
+
digest.firstUserMessage ??= clipped(text, 120);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (message.type === "shell") {
|
|
63
|
+
digest.bashCount += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (message.type === "compaction") {
|
|
67
|
+
const summary = clipped(message.summary, 1_200);
|
|
68
|
+
if (summary) {
|
|
69
|
+
digest.compactions.push(summary);
|
|
70
|
+
if (digest.compactions.length > 3) digest.compactions.shift();
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
digest.assistantCount += 1;
|
|
75
|
+
const model = record(message.model);
|
|
76
|
+
if (typeof model.providerID === "string" && typeof model.id === "string") {
|
|
77
|
+
const label = `${model.providerID}/${model.id}`;
|
|
78
|
+
if (!digest.models.includes(label)) digest.models.push(label);
|
|
79
|
+
}
|
|
80
|
+
if (message.finish === "error" || message.error) digest.errors += 1;
|
|
81
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
82
|
+
const text = content
|
|
83
|
+
.filter((part) => record(part).type === "text")
|
|
84
|
+
.map((part) => record(part).text)
|
|
85
|
+
.filter((part): part is string => typeof part === "string")
|
|
86
|
+
.join("\n");
|
|
87
|
+
if (text.trim()) digest.lastAssistantText = clipped(text, 1_200);
|
|
88
|
+
for (const part of content) {
|
|
89
|
+
const tool = record(part);
|
|
90
|
+
if (tool.type !== "tool") continue;
|
|
91
|
+
digest.toolResultCount += 1;
|
|
92
|
+
const name = typeof tool.name === "string" ? tool.name : "unknown";
|
|
93
|
+
digest.tools[name] = (digest.tools[name] ?? 0) + 1;
|
|
94
|
+
if (record(tool.state).status === "error") digest.errors += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return digest;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function registerOpenCodeCommands(
|
|
101
|
+
context: Context,
|
|
102
|
+
onStatus: (status: OpenCodeScanStatus) => void = () => {},
|
|
103
|
+
viewer?: WorkspaceServerController,
|
|
104
|
+
onViewer: (event: { sessionID: string; url: string; open: boolean; started: boolean }) => void = () => {},
|
|
105
|
+
) {
|
|
106
|
+
const shared = createWorkspaceCommands({
|
|
107
|
+
async getSession(id) {
|
|
108
|
+
const session = await context.session.get({ sessionID: id as SessionID });
|
|
109
|
+
return { id: session.id, cwd: session.location.directory, model: session.model ? `${session.model.providerID}/${session.model.id}` : null };
|
|
110
|
+
},
|
|
111
|
+
async getDigest(id, signal) {
|
|
112
|
+
const sessionID = id as SessionID;
|
|
113
|
+
const session = await context.session.get({ sessionID });
|
|
114
|
+
const messages = await context.session.context({ sessionID }, { signal });
|
|
115
|
+
const relevant = messages.filter((message) => ["user", "assistant", "compaction", "shell"].includes(message.type));
|
|
116
|
+
return { digest: openCodeSessionDigest(session, relevant), hash: hashContent(JSON.stringify(relevant)) };
|
|
117
|
+
},
|
|
118
|
+
sessionSource: (id) => `opencode:session:${id}`,
|
|
119
|
+
async generate(id, prompt, signal) {
|
|
120
|
+
return (await context.session.generate({ sessionID: id as SessionID, prompt }, { signal })).text;
|
|
121
|
+
},
|
|
122
|
+
output: (id, text) => context.session.synthetic({ sessionID: id as SessionID, text, description: "pi-weave", resume: false }),
|
|
123
|
+
}, onStatus, viewer, onViewer);
|
|
124
|
+
const registration = await context.command.transform((editor) => {
|
|
125
|
+
for (const command of shared.commands) editor.add({
|
|
126
|
+
name: command.name,
|
|
127
|
+
description: command.description,
|
|
128
|
+
execute: ({ sessionID, prompt }) => command.execute(sessionID, prompt.text),
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
done: shared.done,
|
|
133
|
+
async cleanup() {
|
|
134
|
+
await shared.cleanup();
|
|
135
|
+
await registration.dispose();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { formatStatusLine, getWorkspaceStatus } from "../core";
|
|
4
|
+
import { parseFrontMatter, unquoteField } from "../core/frontmatter";
|
|
5
|
+
import { registerOpenCodeCommands } from "./commands";
|
|
6
|
+
import { weaveTools } from "./tools";
|
|
7
|
+
import { WEAVE_RPC } from "./rpc";
|
|
8
|
+
import { WorkspaceServerController } from "../web/server/controller";
|
|
9
|
+
|
|
10
|
+
async function packagedSkill(relativePath: string): Promise<import("@opencode/plugin").Skill.Info> {
|
|
11
|
+
const path = fileURLToPath(new URL(`../../skills/${relativePath}/SKILL.md`, import.meta.url));
|
|
12
|
+
const content = await readFile(path, "utf8");
|
|
13
|
+
const parsed = parseFrontMatter(content);
|
|
14
|
+
const name = parsed?.fields.get("name");
|
|
15
|
+
if (!parsed || !name) throw new Error(`Invalid packaged skill: ${relativePath}`);
|
|
16
|
+
return {
|
|
17
|
+
id: name,
|
|
18
|
+
name,
|
|
19
|
+
description: unquoteField(parsed.fields.get("description") ?? ""),
|
|
20
|
+
path,
|
|
21
|
+
content: parsed.body,
|
|
22
|
+
} as import("@opencode/plugin").Skill.Info;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const weave = {
|
|
26
|
+
id: "pi-weave",
|
|
27
|
+
server: async (input: import("@opencode-ai/plugin").PluginInput) => (await import("./v1")).server(input),
|
|
28
|
+
async setup(context) {
|
|
29
|
+
const toolRegistration = await context.tool.transform((editor) => {
|
|
30
|
+
for (const tool of weaveTools) editor.add({
|
|
31
|
+
name: tool.name,
|
|
32
|
+
description: tool.description,
|
|
33
|
+
input: tool.input,
|
|
34
|
+
options: { codemode: false },
|
|
35
|
+
async execute(input, toolContext) {
|
|
36
|
+
const session = await context.session.get({ sessionID: toolContext.sessionID });
|
|
37
|
+
const result = await tool.execute(input, session.location.directory, (status) => toolContext.progress({ status }));
|
|
38
|
+
await refreshStatus(toolContext.sessionID);
|
|
39
|
+
return { content: result.text, metadata: result.details };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const skills = await Promise.all([packagedSkill("weave-notepad"), packagedSkill("weave-explore")]);
|
|
45
|
+
const skillRegistration = await context.skill.transform((editor) => {
|
|
46
|
+
for (const skill of skills) editor.add(skill);
|
|
47
|
+
});
|
|
48
|
+
const viewer = new WorkspaceServerController();
|
|
49
|
+
let status: { text: string; active: boolean; sessionID?: string; viewerUrl?: string } = {
|
|
50
|
+
text: formatStatusLine(await getWorkspaceStatus(context.location.directory)),
|
|
51
|
+
active: false,
|
|
52
|
+
};
|
|
53
|
+
const rpc = await context.rpc.register(WEAVE_RPC, {
|
|
54
|
+
status: async () => status,
|
|
55
|
+
});
|
|
56
|
+
const publishStatus = (next: Partial<typeof status>) => {
|
|
57
|
+
status = { ...status, ...next };
|
|
58
|
+
void rpc.events.emit("status", status).catch(() => {});
|
|
59
|
+
};
|
|
60
|
+
const refreshStatus = async (sessionID: Parameters<typeof context.session.get>[0]["sessionID"]) => {
|
|
61
|
+
const session = await context.session.get({ sessionID });
|
|
62
|
+
const text = formatStatusLine(await getWorkspaceStatus(session.location.directory));
|
|
63
|
+
if (!status.active) publishStatus({ sessionID, text, active: false });
|
|
64
|
+
};
|
|
65
|
+
const commands = await registerOpenCodeCommands(
|
|
66
|
+
context,
|
|
67
|
+
(next) => {
|
|
68
|
+
publishStatus(next);
|
|
69
|
+
if (!next.active) void refreshStatus(next.sessionID as Parameters<typeof context.session.get>[0]["sessionID"]).catch(() => {});
|
|
70
|
+
},
|
|
71
|
+
viewer,
|
|
72
|
+
(event) => {
|
|
73
|
+
status = { ...status, viewerUrl: event.url };
|
|
74
|
+
void rpc.events.emit("viewer", event).catch(() => {});
|
|
75
|
+
void rpc.events.emit("status", status).catch(() => {});
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
return async () => {
|
|
79
|
+
await commands.cleanup();
|
|
80
|
+
await viewer.close();
|
|
81
|
+
await rpc.dispose();
|
|
82
|
+
await skillRegistration.dispose();
|
|
83
|
+
await toolRegistration.dispose();
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
} satisfies import("@opencode/plugin").Plugin.Plugin & Pick<import("@opencode-ai/plugin").PluginModule, "server">;
|
|
87
|
+
|
|
88
|
+
export default weave;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const statusSchema = {
|
|
2
|
+
type: "object",
|
|
3
|
+
properties: {
|
|
4
|
+
sessionID: { type: "string" },
|
|
5
|
+
text: { type: "string" },
|
|
6
|
+
active: { type: "boolean" },
|
|
7
|
+
viewerUrl: { type: "string" },
|
|
8
|
+
},
|
|
9
|
+
required: ["text", "active"],
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export const WEAVE_RPC = {
|
|
14
|
+
id: "pi-weave",
|
|
15
|
+
methods: {
|
|
16
|
+
status: {
|
|
17
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
18
|
+
output: statusSchema,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
events: {
|
|
22
|
+
status: { schema: statusSchema },
|
|
23
|
+
viewer: {
|
|
24
|
+
schema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
sessionID: { type: "string" },
|
|
28
|
+
url: { type: "string" },
|
|
29
|
+
open: { type: "boolean" },
|
|
30
|
+
started: { type: "boolean" },
|
|
31
|
+
},
|
|
32
|
+
required: ["sessionID", "url", "open", "started"],
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
} as const;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { executeNoteAction, executeRepoAction, WEAVE_NOTE_DESCRIPTION, WEAVE_REPO_DESCRIPTION, type NoteActionInput, type RepoActionInput } from "../core";
|
|
2
|
+
|
|
3
|
+
const noteInput = {
|
|
4
|
+
type: "object",
|
|
5
|
+
properties: {
|
|
6
|
+
action: { type: "string", enum: ["list", "get", "add", "append", "finalize", "search", "links", "suggest"] },
|
|
7
|
+
title: { type: "string", description: "Note title (add)" },
|
|
8
|
+
text: { type: "string", description: "Markdown body (add), addition (append), or restructured body (finalize)" },
|
|
9
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags (add)" },
|
|
10
|
+
slug: { type: "string", description: "Note slug (get, append, finalize)" },
|
|
11
|
+
raw: { type: "boolean", description: "append: preserve text verbatim in the ## Raw tail" },
|
|
12
|
+
source: { type: "string", enum: ["human", "agent"], description: "Provenance (add; defaults to agent)" },
|
|
13
|
+
query: { type: "string", description: "Search query (search)" },
|
|
14
|
+
fix: { type: "boolean", description: "links: apply unambiguous repairs" },
|
|
15
|
+
limit: { type: "number", description: "suggest: maximum suggestions (default 20)" },
|
|
16
|
+
},
|
|
17
|
+
required: ["action"],
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const repoInput = {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
action: { type: "string", enum: ["status", "scan", "overview"] },
|
|
25
|
+
},
|
|
26
|
+
required: ["action"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const weaveTools = [
|
|
31
|
+
{
|
|
32
|
+
name: "weave_note",
|
|
33
|
+
description: WEAVE_NOTE_DESCRIPTION,
|
|
34
|
+
input: noteInput,
|
|
35
|
+
execute: (input: unknown, _cwd: string, _progress?: (text: string) => Promise<void>) => executeNoteAction(input as NoteActionInput),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "weave_repo",
|
|
39
|
+
description: WEAVE_REPO_DESCRIPTION,
|
|
40
|
+
input: repoInput,
|
|
41
|
+
execute: (input: unknown, cwd: string, progress?: (text: string) => Promise<void>) => executeRepoAction(input as RepoActionInput, cwd, progress),
|
|
42
|
+
},
|
|
43
|
+
];
|