pi-weave 0.2.3 → 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.
@@ -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
+ }
@@ -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/vault.ts CHANGED
@@ -978,21 +978,51 @@ export async function noteCount(root: string): Promise<number> {
978
978
  return (await listNoteFiles(root)).filter(isMarkdown).length;
979
979
  }
980
980
 
981
+ const SEARCH_TERM_RE = /[\p{L}\p{N}]+(?:[._/:\-][\p{L}\p{N}]+)*/gu;
982
+
983
+ /** Terms models naturally put in a search query, including code-ish tokens. */
984
+ function searchTerms(value: string): string[] {
985
+ const terms = new Set<string>();
986
+ for (const match of value.toLowerCase().matchAll(SEARCH_TERM_RE)) {
987
+ const term = match[0];
988
+ if (term.length > 1) terms.add(term);
989
+ for (const part of term.split(/[._/:\-]+/)) {
990
+ if (part.length > 1) terms.add(part);
991
+ }
992
+ }
993
+ return [...terms];
994
+ }
995
+
996
+ function matchingTerm(terms: ReadonlySet<string>, query: string): string | undefined {
997
+ if (terms.has(query)) return query;
998
+ if (query.length < 5 || !/^\p{L}+$/u.test(query)) return undefined;
999
+ // ponytail: cheap inflection tolerance; use stemming only if false matches become measurable.
1000
+ const prefix = query.slice(0, 4);
1001
+ return [...terms].find((term) => term.length >= 5 && /^\p{L}+$/u.test(term) && term.startsWith(prefix));
1002
+ }
1003
+
981
1004
  /**
982
- * Substring search over title, tags, and body.
983
- * Score: title match = 3, tag match = 2, body match = 1 each (capped).
984
- * Case-insensitive. Deterministic ordering: score desc, then slug asc.
1005
+ * Search slug, title, tags, and body.
1006
+ *
1007
+ * Exact substring behaviour stays first and unchanged. When a natural
1008
+ * multi-term query has no literal match, an IDF-weighted lexical fallback
1009
+ * ranks notes by term coverage: title/slug above tags above body. This makes
1010
+ * one model-style query useful without adding a second search mode.
985
1011
  */
986
1012
  export async function searchNotes(root: string, query: string): Promise<NoteSearchHit[]> {
987
1013
  const q = query.toLowerCase().trim();
988
1014
  if (q.length === 0) return [];
989
1015
 
1016
+ const notes = (await readVault(root)).notes;
990
1017
  const hits: NoteSearchHit[] = [];
991
1018
  // One pass: `listNotes` + a `getNote` per slug would read every file twice.
992
- for (const note of (await readVault(root)).notes) {
1019
+ for (const note of notes) {
993
1020
  let score = 0;
994
- if (note.title.toLowerCase().includes(q)) score += 3;
995
- if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 2;
1021
+ const title = note.title.toLowerCase();
1022
+ const slug = note.slug.toLowerCase();
1023
+ if (title === q || slug === q) score += 100;
1024
+ else if (title.includes(q) || slug.includes(q)) score += 30;
1025
+ if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 20;
996
1026
 
997
1027
  const bodyLower = note.body.toLowerCase();
998
1028
  let bodyMatches = 0;
@@ -1006,6 +1036,48 @@ export async function searchNotes(root: string, query: string): Promise<NoteSear
1006
1036
  if (score === 0) continue;
1007
1037
  hits.push({ summary: summarizeNote(note), score, snippet: makeSnippet(note.body, q) });
1008
1038
  }
1039
+ if (hits.length > 0) return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
1040
+
1041
+ const indexed = notes.map((note) => {
1042
+ const title = new Set(searchTerms(note.title));
1043
+ const slug = new Set(searchTerms(note.slug));
1044
+ const tags = new Set(note.tags.flatMap(searchTerms));
1045
+ const body = new Set(searchTerms(note.body));
1046
+ return { note, title, slug, tags, body, all: new Set([...title, ...slug, ...tags, ...body]) };
1047
+ });
1048
+ const terms = searchTerms(q).flatMap((term) => {
1049
+ const frequency = indexed.filter((entry) => matchingTerm(entry.all, term) !== undefined).length;
1050
+ return frequency === 0 ? [] : [{ term, frequency }];
1051
+ });
1052
+ if (terms.length === 0) return [];
1053
+
1054
+ for (const entry of indexed) {
1055
+ let score = 0;
1056
+ let matched = 0;
1057
+ let snippetTerm = "";
1058
+ for (const { term, frequency } of terms) {
1059
+ const idf = Math.log((indexed.length + 1) / (frequency + 1)) + 1;
1060
+ const bodyTerm = matchingTerm(entry.body, term);
1061
+ const fieldScore = matchingTerm(entry.title, term) !== undefined || matchingTerm(entry.slug, term) !== undefined
1062
+ ? 30
1063
+ : matchingTerm(entry.tags, term) !== undefined
1064
+ ? 20
1065
+ : bodyTerm !== undefined
1066
+ ? 5
1067
+ : 0;
1068
+ if (fieldScore === 0) continue;
1069
+ score += fieldScore * idf;
1070
+ matched++;
1071
+ if (snippetTerm === "" && bodyTerm !== undefined) snippetTerm = bodyTerm;
1072
+ }
1073
+ if (matched === 0) continue;
1074
+ score += 20 * matched / terms.length;
1075
+ hits.push({
1076
+ summary: summarizeNote(entry.note),
1077
+ score,
1078
+ snippet: makeSnippet(entry.note.body, snippetTerm || q),
1079
+ });
1080
+ }
1009
1081
  return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
1010
1082
  }
1011
1083
 
@@ -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 a repository scan or ask pi to explore");
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
+ }