pi-brain 0.1.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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Extracts OTA log input from a TurnEndEvent.
3
+ * Pure function — no dependency on the extension runtime.
4
+ */
5
+
6
+ import type { OtaEntryInput } from "./types.js";
7
+
8
+ interface ContentItem {
9
+ type: string;
10
+ text?: string;
11
+ thinking?: string;
12
+ name?: string;
13
+ arguments?: Record<string, unknown>;
14
+ }
15
+
16
+ interface TurnEndEventLike {
17
+ turnIndex: number;
18
+ message: {
19
+ role: string;
20
+ content?: unknown;
21
+ provider?: string;
22
+ model?: string;
23
+ timestamp?: number;
24
+ };
25
+ toolResults: {
26
+ toolName: string;
27
+ isError: boolean;
28
+ }[];
29
+ }
30
+
31
+ function formatArgValue(value: unknown): string {
32
+ if (typeof value === "string") {
33
+ return `"${value}"`;
34
+ }
35
+ return String(value);
36
+ }
37
+
38
+ function formatToolCallArgs(args: Record<string, unknown>): string {
39
+ const pairs = Object.entries(args).map(
40
+ ([key, val]) => `${key}: ${formatArgValue(val)}`
41
+ );
42
+ return pairs.join(", ");
43
+ }
44
+
45
+ function normalizeContent(content: unknown): ContentItem[] {
46
+ if (!Array.isArray(content)) {
47
+ return [];
48
+ }
49
+
50
+ return content.filter((item): item is ContentItem => {
51
+ if (typeof item !== "object" || item === null) {
52
+ return false;
53
+ }
54
+
55
+ const candidate = item as { type?: unknown };
56
+ return typeof candidate.type === "string";
57
+ });
58
+ }
59
+
60
+ function extractTexts(content: ContentItem[]): string {
61
+ const texts = content
62
+ .filter(
63
+ (item): item is ContentItem & { text: string } =>
64
+ item.type === "text" && typeof item.text === "string"
65
+ )
66
+ .map((item) => item.text);
67
+ return texts.join("\n\n");
68
+ }
69
+
70
+ function extractThinking(content: ContentItem[]): string {
71
+ const blocks = content
72
+ .filter(
73
+ (item): item is ContentItem & { thinking: string } =>
74
+ item.type === "thinking" && typeof item.thinking === "string"
75
+ )
76
+ .map((item) => item.thinking);
77
+ return blocks.join("\n\n");
78
+ }
79
+
80
+ function extractActions(content: ContentItem[]): string[] {
81
+ return content
82
+ .filter(
83
+ (item): item is ContentItem & { name: string } =>
84
+ item.type === "toolCall" && typeof item.name === "string"
85
+ )
86
+ .map((item) => {
87
+ const argsStr = item.arguments ? formatToolCallArgs(item.arguments) : "";
88
+ return argsStr ? `${item.name}(${argsStr})` : `${item.name}()`;
89
+ });
90
+ }
91
+
92
+ function extractObservations(
93
+ toolResults: TurnEndEventLike["toolResults"]
94
+ ): string[] {
95
+ return toolResults.map(
96
+ (tr) => `${tr.toolName}: ${tr.isError ? "error" : "success"}`
97
+ );
98
+ }
99
+
100
+ export function extractOtaInput(event: TurnEndEventLike): OtaEntryInput | null {
101
+ const { message, turnIndex, toolResults } = event;
102
+
103
+ if (message.role !== "assistant") {
104
+ return null;
105
+ }
106
+
107
+ const content = normalizeContent(message.content);
108
+ const thought = extractTexts(content);
109
+ const thinking = extractThinking(content);
110
+ const actions = extractActions(content);
111
+
112
+ // Skip empty turns — no text and no tool calls
113
+ if (!thought && !thinking && actions.length === 0) {
114
+ return null;
115
+ }
116
+
117
+ const observations = extractObservations(toolResults);
118
+ const model = `${message.provider ?? "unknown"}/${message.model ?? "unknown"}`;
119
+ const timestamp = message.timestamp
120
+ ? new Date(message.timestamp).toISOString()
121
+ : new Date().toISOString();
122
+
123
+ return {
124
+ turnNumber: turnIndex + 1,
125
+ timestamp,
126
+ model,
127
+ thought,
128
+ thinking,
129
+ actions,
130
+ observations,
131
+ };
132
+ }
package/src/state.ts ADDED
@@ -0,0 +1,143 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import { parseYaml, serializeYaml } from "./yaml.js";
5
+
6
+ interface LastCommit {
7
+ branch: string;
8
+ hash: string;
9
+ timestamp: string;
10
+ summary: string;
11
+ }
12
+
13
+ interface SessionRecord {
14
+ file: string;
15
+ branch: string;
16
+ started: string;
17
+ }
18
+
19
+ type PersistedValue =
20
+ | string
21
+ | Record<string, string>
22
+ | Array<Record<string, string>>;
23
+
24
+ export class MemoryState {
25
+ private readonly statePath: string;
26
+ private readonly memoryDir: string;
27
+ activeBranch = "main";
28
+ initialized = "";
29
+ lastCommit: LastCommit | null = null;
30
+ sessions: SessionRecord[] = [];
31
+
32
+ constructor(projectDir: string) {
33
+ this.memoryDir = path.join(projectDir, ".memory");
34
+ this.statePath = path.join(this.memoryDir, "state.yaml");
35
+ }
36
+
37
+ get isInitialized(): boolean {
38
+ return this.initialized !== "";
39
+ }
40
+
41
+ load(): void {
42
+ if (!fs.existsSync(this.statePath)) {
43
+ return;
44
+ }
45
+
46
+ const content = fs.readFileSync(this.statePath, "utf8");
47
+ if (content.trim() === "") {
48
+ return;
49
+ }
50
+
51
+ const data = parseYaml(content);
52
+
53
+ if (typeof data.active_branch === "string") {
54
+ this.activeBranch = data.active_branch;
55
+ }
56
+
57
+ if (typeof data.initialized === "string") {
58
+ this.initialized = data.initialized;
59
+ }
60
+
61
+ if (typeof data.last_commit === "object" && data.last_commit !== null) {
62
+ const lastCommit = data.last_commit;
63
+ if (!Array.isArray(lastCommit)) {
64
+ this.lastCommit = {
65
+ branch: lastCommit.branch ?? "",
66
+ hash: lastCommit.hash ?? "",
67
+ timestamp: lastCommit.timestamp ?? "",
68
+ summary: lastCommit.summary ?? "",
69
+ };
70
+ }
71
+ }
72
+
73
+ if (Array.isArray(data.sessions)) {
74
+ this.sessions = data.sessions
75
+ .map((item) => {
76
+ const file = item.file ?? "";
77
+ const branch = item.branch ?? "";
78
+ const started = item.started ?? "";
79
+ if (file === "" || branch === "" || started === "") {
80
+ return null;
81
+ }
82
+
83
+ return { file, branch, started };
84
+ })
85
+ .filter((item): item is SessionRecord => item !== null);
86
+ }
87
+ }
88
+
89
+ setActiveBranch(branch: string): void {
90
+ this.activeBranch = branch;
91
+ }
92
+
93
+ setLastCommit(
94
+ branch: string,
95
+ hash: string,
96
+ timestamp: string,
97
+ summary: string
98
+ ): void {
99
+ this.lastCommit = { branch, hash, timestamp, summary };
100
+ }
101
+
102
+ upsertSession(file: string, branch: string, started: string): void {
103
+ const existing = this.sessions.find((session) => session.file === file);
104
+ if (existing) {
105
+ existing.branch = branch;
106
+ if (existing.started === "") {
107
+ existing.started = started;
108
+ }
109
+ return;
110
+ }
111
+
112
+ this.sessions.push({ file, branch, started });
113
+ }
114
+
115
+ save(): void {
116
+ const data: Record<string, PersistedValue> = {
117
+ active_branch: this.activeBranch,
118
+ };
119
+
120
+ if (this.initialized) {
121
+ data.initialized = this.initialized;
122
+ }
123
+
124
+ if (this.lastCommit) {
125
+ data.last_commit = {
126
+ branch: this.lastCommit.branch,
127
+ hash: this.lastCommit.hash,
128
+ timestamp: this.lastCommit.timestamp,
129
+ summary: this.lastCommit.summary,
130
+ };
131
+ }
132
+
133
+ if (this.sessions.length > 0) {
134
+ data.sessions = this.sessions.map((session) => ({
135
+ file: session.file,
136
+ branch: session.branch,
137
+ started: session.started,
138
+ }));
139
+ }
140
+
141
+ fs.writeFileSync(this.statePath, serializeYaml(data));
142
+ }
143
+ }
@@ -0,0 +1,342 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import type { SubagentResult } from "./types.js";
7
+ import { parseYaml } from "./yaml.js";
8
+
9
+ const COMMITTER_MODEL = "google-antigravity/gemini-3-flash";
10
+ const COMMITTER_TOOLS = "read,grep,find,ls";
11
+
12
+ interface AgentDefinition {
13
+ prompt: string;
14
+ model: string;
15
+ tools: string;
16
+ skills: string;
17
+ extensions: string;
18
+ }
19
+
20
+ /**
21
+ * Resolve the memory-committer agent definition from the agent definition file.
22
+ * Checks multiple locations to support both local development and npm installs.
23
+ * Parses the YAML frontmatter for properties and uses the body as the system prompt.
24
+ */
25
+ function resolveAgentPrompt(): AgentDefinition {
26
+ const currentFile = new URL(import.meta.url).pathname;
27
+ const currentDir = path.dirname(currentFile);
28
+
29
+ // Possible locations for the agent definition file
30
+ const candidates = [
31
+ // Installed package: dist/ or src/ -> ../agents/ (bundled in package)
32
+ path.resolve(currentDir, "../agents/memory-committer.md"),
33
+ // Local development: src/ -> ../.pi/agents/
34
+ path.resolve(currentDir, "../.pi/agents/memory-committer.md"),
35
+ // Fallback: check if bundled alongside source
36
+ path.resolve(currentDir, "./agents/memory-committer.md"),
37
+ ];
38
+
39
+ for (const agentFile of candidates) {
40
+ try {
41
+ const content = fs.readFileSync(agentFile, "utf8");
42
+
43
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
44
+ let frontmatter = "";
45
+ let prompt = content.trim();
46
+
47
+ if (match) {
48
+ const [, matchedFrontmatter, matchedPrompt] = match;
49
+ frontmatter = matchedFrontmatter;
50
+ prompt = matchedPrompt.trim();
51
+ }
52
+
53
+ let parsed: Record<string, unknown> = {};
54
+ if (frontmatter) {
55
+ try {
56
+ parsed = parseYaml(frontmatter) as Record<string, unknown>;
57
+ } catch {
58
+ // Ignore parse errors, fallback to defaults
59
+ }
60
+ }
61
+
62
+ return {
63
+ prompt,
64
+ model:
65
+ typeof parsed.model === "string" ? parsed.model : COMMITTER_MODEL,
66
+ tools:
67
+ typeof parsed.tools === "string" ? parsed.tools : COMMITTER_TOOLS,
68
+ skills: typeof parsed.skills === "string" ? parsed.skills : "",
69
+ extensions:
70
+ typeof parsed.extensions === "string" ? parsed.extensions : "",
71
+ };
72
+ } catch {
73
+ continue;
74
+ }
75
+ }
76
+
77
+ throw new Error("Could not locate memory-committer.md agent definition file");
78
+ }
79
+
80
+ export function buildCommitterTask(branch: string, summary: string): string {
81
+ return [
82
+ `Distill a memory commit for branch "${branch}".`,
83
+ `Summary: ${summary}`,
84
+ "",
85
+ "Read these files:",
86
+ "- .memory/AGENTS.md (protocol reference — read first)",
87
+ `- .memory/branches/${branch}/log.md (OTA trace to distill)`,
88
+ `- .memory/branches/${branch}/commits.md (previous commits for rolling summary)`,
89
+ "",
90
+ "Produce the three commit blocks.",
91
+ ].join("\n");
92
+ }
93
+
94
+ /**
95
+ * Extract the last assistant text from pi's JSON-mode stdout.
96
+ * Each line is a JSON event; we want the last message_end with role=assistant.
97
+ */
98
+ export function extractFinalText(stdout: string): string {
99
+ let lastText = "";
100
+ for (const line of stdout.split("\n")) {
101
+ if (!line.trim()) {
102
+ continue;
103
+ }
104
+ try {
105
+ const evt = JSON.parse(line) as {
106
+ type?: string;
107
+ message?: {
108
+ role?: string;
109
+ content?: { type?: string; text?: string }[];
110
+ };
111
+ };
112
+ if (evt.type === "message_end" && evt.message?.role === "assistant") {
113
+ const texts = (evt.message.content ?? [])
114
+ .filter((c) => c.type === "text" && typeof c.text === "string")
115
+ .map((c) => c.text as string);
116
+ if (texts.length > 0) {
117
+ lastText = texts.join("\n\n");
118
+ }
119
+ }
120
+ } catch {
121
+ // Not JSON — skip
122
+ }
123
+ }
124
+ return lastText;
125
+ }
126
+
127
+ /**
128
+ * Extract the three commit blocks from subagent response text.
129
+ * Returns the text from "### Branch Purpose" through the end of
130
+ * "### This Commit's Contribution" content, stripping preamble
131
+ * and trailing prose.
132
+ */
133
+ export function extractCommitBlocks(text: string): string | null {
134
+ const branchPurposeIndex = text.indexOf("### Branch Purpose");
135
+ if (branchPurposeIndex === -1) {
136
+ return null;
137
+ }
138
+
139
+ const progressIndex = text.indexOf("### Previous Progress Summary");
140
+ if (progressIndex === -1) {
141
+ return null;
142
+ }
143
+
144
+ const contributionIndex = text.indexOf("### This Commit's Contribution");
145
+ if (contributionIndex === -1) {
146
+ return null;
147
+ }
148
+
149
+ // Extract from "### Branch Purpose" onward
150
+ const fromStart = text.slice(branchPurposeIndex);
151
+ const lines = fromStart.split("\n");
152
+
153
+ // Find where "### This Commit's Contribution" starts, then collect
154
+ // content lines until we hit a blank line followed by non-content.
155
+ let inContribution = false;
156
+ let lastContentLine = 0;
157
+
158
+ for (let i = 0; i < lines.length; i++) {
159
+ const line = lines[i];
160
+ if (line.startsWith("### This Commit's Contribution")) {
161
+ inContribution = true;
162
+ lastContentLine = i;
163
+ continue;
164
+ }
165
+
166
+ if (!inContribution) {
167
+ lastContentLine = i;
168
+ continue;
169
+ }
170
+
171
+ // In contribution block: keep content lines, stop at blank+non-blank
172
+ if (line.trim() === "") {
173
+ continue;
174
+ }
175
+
176
+ // Non-empty line in contribution section — is it still contribution content?
177
+ // If there was a blank line gap since lastContentLine, check if this
178
+ // looks like trailing prose (doesn't start with -, *, or indent).
179
+ const gapHasBlank = lines
180
+ .slice(lastContentLine + 1, i)
181
+ .some((l) => l.trim() === "");
182
+
183
+ if (
184
+ gapHasBlank &&
185
+ !line.startsWith("-") &&
186
+ !line.startsWith("*") &&
187
+ !line.startsWith(" ")
188
+ ) {
189
+ // Trailing text after the contribution block — stop here
190
+ break;
191
+ }
192
+
193
+ lastContentLine = i;
194
+ }
195
+
196
+ return lines
197
+ .slice(0, lastContentLine + 1)
198
+ .join("\n")
199
+ .trimEnd();
200
+ }
201
+
202
+ function writePromptToTempFile(prompt: string): {
203
+ dir: string;
204
+ filePath: string;
205
+ } {
206
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memory-committer-"));
207
+ const filePath = path.join(tmpDir, "system-prompt.md");
208
+ fs.writeFileSync(filePath, prompt, { encoding: "utf8", mode: 0o600 });
209
+ return { dir: tmpDir, filePath };
210
+ }
211
+
212
+ export function spawnCommitter(
213
+ cwd: string,
214
+ task: string,
215
+ signal?: AbortSignal
216
+ ): Promise<SubagentResult> {
217
+ return new Promise((resolve) => {
218
+ let agentDef: AgentDefinition;
219
+ try {
220
+ agentDef = resolveAgentPrompt();
221
+ } catch (error: unknown) {
222
+ resolve({
223
+ text: "",
224
+ exitCode: 1,
225
+ error:
226
+ error instanceof Error
227
+ ? error.message
228
+ : "Failed to resolve agent definition",
229
+ });
230
+ return;
231
+ }
232
+
233
+ const args = [
234
+ "--mode",
235
+ "json",
236
+ "--no-session",
237
+ "--model",
238
+ agentDef.model,
239
+ "--tools",
240
+ agentDef.tools,
241
+ "-p",
242
+ `Task: ${task}`,
243
+ ];
244
+
245
+ if (agentDef.skills) {
246
+ const skills = agentDef.skills
247
+ .split(",")
248
+ .map((s) => s.trim())
249
+ .filter(Boolean);
250
+ for (const skill of skills) {
251
+ args.push("--skill", skill);
252
+ }
253
+ }
254
+
255
+ if (agentDef.extensions) {
256
+ const exts = agentDef.extensions
257
+ .split(",")
258
+ .map((e) => e.trim())
259
+ .filter(Boolean);
260
+ for (const ext of exts) {
261
+ args.push("--extension", ext);
262
+ }
263
+ }
264
+
265
+ let tmpPromptDir: string | null = null;
266
+ let tmpPromptPath: string | null = null;
267
+
268
+ if (agentDef.prompt) {
269
+ const tmp = writePromptToTempFile(agentDef.prompt);
270
+ tmpPromptDir = tmp.dir;
271
+ tmpPromptPath = tmp.filePath;
272
+ args.push("--append-system-prompt", tmpPromptPath);
273
+ }
274
+
275
+ const proc = spawn("pi", args, {
276
+ cwd,
277
+ stdio: ["ignore", "pipe", "pipe"],
278
+ });
279
+
280
+ let stdout = "";
281
+ let stderr = "";
282
+
283
+ proc.stdout.on("data", (d: Buffer) => {
284
+ stdout += d.toString();
285
+ });
286
+ proc.stderr.on("data", (d: Buffer) => {
287
+ stderr += d.toString();
288
+ });
289
+
290
+ const cleanup = () => {
291
+ if (tmpPromptPath) {
292
+ try {
293
+ fs.unlinkSync(tmpPromptPath);
294
+ } catch {
295
+ /* ignore */
296
+ }
297
+ }
298
+ if (tmpPromptDir) {
299
+ try {
300
+ fs.rmdirSync(tmpPromptDir);
301
+ } catch {
302
+ /* ignore */
303
+ }
304
+ }
305
+ };
306
+
307
+ proc.on("close", (code) => {
308
+ cleanup();
309
+ const text = extractFinalText(stdout);
310
+ resolve({
311
+ text,
312
+ exitCode: code ?? 1,
313
+ error:
314
+ code === 0
315
+ ? undefined
316
+ : stderr.trim() || "Subagent exited with non-zero code",
317
+ });
318
+ });
319
+
320
+ proc.on("error", (err) => {
321
+ cleanup();
322
+ resolve({
323
+ text: "",
324
+ exitCode: 1,
325
+ error: `Failed to spawn subagent: ${err.message}`,
326
+ });
327
+ });
328
+
329
+ if (signal) {
330
+ const kill = () => {
331
+ proc.kill("SIGTERM");
332
+ setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
333
+ };
334
+ if (signal.aborted) {
335
+ kill();
336
+ } else {
337
+ signal.addEventListener("abort", kill, { once: true });
338
+ proc.on("close", () => signal.removeEventListener("abort", kill));
339
+ }
340
+ }
341
+ });
342
+ }
package/src/types.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface OtaEntryInput {
2
+ turnNumber: number;
3
+ timestamp: string;
4
+ model: string;
5
+ thought: string;
6
+ thinking: string;
7
+ actions: string[];
8
+ observations: string[];
9
+ }
10
+
11
+ export interface MemoryStatusParams {
12
+ level?: string;
13
+ branch?: string;
14
+ commit?: string;
15
+ segment?: string;
16
+ }
17
+
18
+ export interface SubagentResult {
19
+ text: string;
20
+ exitCode: number;
21
+ error?: string;
22
+ }