pi-mem 0.1.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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Observer agent for pi-mem.
3
+ * Spawns a headless pi sub-agent per tool execution to extract
4
+ * structured observations from full tool output.
5
+ */
6
+
7
+ import { spawn, type ChildProcess } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import { CODE_MODE } from "./mode-config.js";
12
+ import { parseObservation, type ParsedObservation } from "./xml-parser.js";
13
+ import type { PiMemConfig } from "./config.js";
14
+
15
+ const DEBUG_LOG_PATH = path.join(os.homedir(), ".pi-mem", "debug-observer.log");
16
+
17
+ function debugLog(msg: string) {
18
+ try {
19
+ fs.appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${msg}\n`);
20
+ } catch {}
21
+ }
22
+
23
+ // Tools that are pi-mem's own — skip to avoid meta-observations
24
+ const SKIP_TOOLS = new Set([
25
+ "search",
26
+ "timeline",
27
+ "get_observations",
28
+ "save_memory",
29
+ ]);
30
+
31
+ const MIN_OUTPUT_LENGTH = 50;
32
+
33
+ // ─── Skip Logic ───────────────────────────────────────────────
34
+
35
+ /**
36
+ * Check if a tool execution should be skipped (no observer call).
37
+ */
38
+ export function shouldSkipObservation(
39
+ toolName: string,
40
+ output: string,
41
+ ): boolean {
42
+ if (SKIP_TOOLS.has(toolName)) return true;
43
+ if (output.length < MIN_OUTPUT_LENGTH) return true;
44
+ return false;
45
+ }
46
+
47
+ // ─── Prompt Building ──────────────────────────────────────────
48
+
49
+ /**
50
+ * Build the observer system prompt from CODE_MODE config.
51
+ */
52
+ function buildSystemPrompt(): string {
53
+ const p = CODE_MODE.prompts;
54
+ return [
55
+ p.system_identity,
56
+ "",
57
+ p.observer_role,
58
+ "",
59
+ p.recording_focus,
60
+ "",
61
+ p.skip_guidance,
62
+ "",
63
+ p.type_guidance,
64
+ "",
65
+ p.concept_guidance,
66
+ "",
67
+ p.field_guidance,
68
+ "",
69
+ p.xml_format,
70
+ "",
71
+ p.footer,
72
+ ].join("\n");
73
+ }
74
+
75
+ /**
76
+ * Build the user prompt for a single tool execution.
77
+ */
78
+ export function buildObserverPrompt(
79
+ toolName: string,
80
+ input: Record<string, unknown>,
81
+ output: string,
82
+ ): string {
83
+ return `<observed_from_primary_session>
84
+ <what_happened>${toolName}</what_happened>
85
+ <occurred_at>${new Date().toISOString()}</occurred_at>
86
+ <parameters>${JSON.stringify(input, null, 2)}</parameters>
87
+ <outcome>${output}</outcome>
88
+ </observed_from_primary_session>`;
89
+ }
90
+
91
+ // ─── Sub-Agent Spawn ──────────────────────────────────────────
92
+
93
+ function killProcess(proc: ChildProcess): void {
94
+ try {
95
+ proc.kill("SIGTERM");
96
+ } catch {}
97
+ setTimeout(() => {
98
+ try {
99
+ proc.kill("SIGKILL");
100
+ } catch {}
101
+ }, 2000);
102
+ }
103
+
104
+ /**
105
+ * Run a pi sub-agent and return the response text.
106
+ */
107
+ function runSubAgent(
108
+ prompt: string,
109
+ systemPrompt: string,
110
+ model: string,
111
+ thinkingLevel: string,
112
+ ): Promise<{ ok: true; response: string } | { ok: false; error: string }> {
113
+ return new Promise((resolve) => {
114
+ const proc = spawn(
115
+ "pi",
116
+ [
117
+ "--mode", "json",
118
+ "-p",
119
+ "--no-session",
120
+ "--no-tools",
121
+ "--system-prompt", systemPrompt,
122
+ "--model", model,
123
+ "--thinking", thinkingLevel,
124
+ prompt,
125
+ ],
126
+ {
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ env: { ...process.env, PI_MEM_SUB_AGENT: "1" },
129
+ },
130
+ );
131
+
132
+ let buffer = "";
133
+ let lastAssistantText = "";
134
+ let stderr = "";
135
+
136
+ const timeout = setTimeout(() => {
137
+ killProcess(proc);
138
+ resolve({ ok: false, error: "Observer timeout (30s)" });
139
+ }, 30_000);
140
+
141
+ const processLine = (line: string) => {
142
+ if (!line.trim()) return;
143
+ try {
144
+ const event = JSON.parse(line);
145
+ if (
146
+ event.type === "message_end" &&
147
+ event.message?.role === "assistant"
148
+ ) {
149
+ for (const part of event.message.content) {
150
+ if (part.type === "text") {
151
+ lastAssistantText = part.text;
152
+ }
153
+ }
154
+ }
155
+ } catch {
156
+ // ignore non-JSON lines
157
+ }
158
+ };
159
+
160
+ proc.stdout!.on("data", (data: Buffer) => {
161
+ buffer += data.toString();
162
+ const lines = buffer.split("\n");
163
+ buffer = lines.pop() || "";
164
+ for (const line of lines) processLine(line);
165
+ });
166
+
167
+ proc.stderr!.on("data", (data: Buffer) => {
168
+ stderr += data.toString();
169
+ });
170
+
171
+ proc.on("close", (code) => {
172
+ clearTimeout(timeout);
173
+ if (buffer.trim()) processLine(buffer);
174
+
175
+ if (lastAssistantText) {
176
+ resolve({ ok: true, response: lastAssistantText });
177
+ } else if (code !== 0) {
178
+ resolve({
179
+ ok: false,
180
+ error: `Sub-agent failed (exit ${code}): ${stderr.trim().slice(0, 500) || "(no output)"}`,
181
+ });
182
+ } else {
183
+ resolve({ ok: false, error: "Sub-agent returned no response" });
184
+ }
185
+ });
186
+
187
+ proc.on("error", (err) => {
188
+ clearTimeout(timeout);
189
+ resolve({ ok: false, error: `Failed to spawn pi: ${err.message}` });
190
+ });
191
+ });
192
+ }
193
+
194
+ // ─── Main Entry Point ─────────────────────────────────────────
195
+
196
+ export interface ObserverContext {
197
+ /** Current session model */
198
+ model: any;
199
+ /** Current session thinking level */
200
+ thinkingLevel: string;
201
+ }
202
+
203
+ /**
204
+ * Extract a structured observation from a tool execution.
205
+ * Spawns observer LLM, parses XML output.
206
+ * Returns null if extraction fails or is skipped.
207
+ */
208
+ export async function extractObservation(
209
+ toolName: string,
210
+ input: Record<string, unknown>,
211
+ output: string,
212
+ config: PiMemConfig,
213
+ context: ObserverContext,
214
+ ): Promise<ParsedObservation | null> {
215
+ // Skip logic
216
+ if (shouldSkipObservation(toolName, output)) {
217
+ return null;
218
+ }
219
+
220
+ // Resolve model: observerModel → summaryModel → session model
221
+ const model =
222
+ config.observerModel ||
223
+ config.summaryModel ||
224
+ (context.model
225
+ ? `${context.model.provider}/${context.model.id}`
226
+ : undefined);
227
+
228
+ if (!model) {
229
+ debugLog("No model available for observer extraction");
230
+ return null;
231
+ }
232
+
233
+ const thinkingLevel = config.thinkingLevel || context.thinkingLevel || "none";
234
+
235
+ const systemPrompt = buildSystemPrompt();
236
+ const userPrompt = buildObserverPrompt(toolName, input, output);
237
+
238
+ debugLog(
239
+ `Extracting observation: ${toolName} (output: ${output.length} chars, model: ${model})`,
240
+ );
241
+
242
+ const result = await runSubAgent(
243
+ userPrompt,
244
+ systemPrompt,
245
+ model,
246
+ thinkingLevel,
247
+ );
248
+
249
+ if (!result.ok) {
250
+ debugLog(`Observer failed: ${result.error}`);
251
+ return null;
252
+ }
253
+
254
+ debugLog(
255
+ `Observer response: ${result.response.length} chars`,
256
+ );
257
+
258
+ const parsed = parseObservation(result.response);
259
+ if (!parsed) {
260
+ debugLog("Observer returned no parseable observation XML");
261
+ return null;
262
+ }
263
+
264
+ debugLog(`Extracted: [${parsed.type}] ${parsed.title}`);
265
+ return parsed;
266
+ }
package/observer.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Observation utilities for pi-mem.
3
+ * Privacy filtering helper.
4
+ */
5
+
6
+ /**
7
+ * Strip <private>...</private> tags from text, replacing with [REDACTED].
8
+ */
9
+ export function stripPrivateTags(text: string): string {
10
+ return text.replace(/<private>[\s\S]*?<\/private>/g, "[REDACTED]");
11
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-mem",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Persistent memory extension for pi — captures observations, compresses them into searchable memories, and injects context into future sessions",
6
+ "keywords": ["pi-package"],
7
+ "author": "George Bashi",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/georgebashi/pi-mem.git"
12
+ },
13
+ "homepage": "https://github.com/georgebashi/pi-mem",
14
+ "bugs": {
15
+ "url": "https://github.com/georgebashi/pi-mem/issues"
16
+ },
17
+ "pi": {
18
+ "extensions": [
19
+ "./index.ts"
20
+ ]
21
+ },
22
+ "files": [
23
+ "*.ts",
24
+ "README.md",
25
+ "LICENSE",
26
+ "!test-*.ts",
27
+ "!project.test.ts",
28
+ "!*.mjs"
29
+ ],
30
+ "peerDependencies": {
31
+ "@mariozechner/pi-coding-agent": "*",
32
+ "@sinclair/typebox": "*"
33
+ },
34
+ "dependencies": {
35
+ "@lancedb/lancedb": "^0.26.2"
36
+ }
37
+ }
package/privacy.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Privacy filtering for pi-mem.
3
+ * Loads .pi-mem-ignore patterns and checks file paths.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { PI_MEM_DIR } from "./config.js";
9
+
10
+ /**
11
+ * Load ignore patterns from .pi-mem-ignore files.
12
+ * Checks project root and ~/.pi-mem/ for patterns.
13
+ */
14
+ export function loadIgnorePatterns(cwd: string): string[] {
15
+ const patterns: string[] = [];
16
+
17
+ const locations = [
18
+ path.join(cwd, ".pi-mem-ignore"),
19
+ path.join(PI_MEM_DIR, ".pi-mem-ignore"),
20
+ ];
21
+
22
+ for (const loc of locations) {
23
+ try {
24
+ if (fs.existsSync(loc)) {
25
+ const content = fs.readFileSync(loc, "utf-8");
26
+ const lines = content.split("\n")
27
+ .map((l) => l.trim())
28
+ .filter((l) => l && !l.startsWith("#"));
29
+ patterns.push(...lines);
30
+ }
31
+ } catch {
32
+ // Ignore read errors
33
+ }
34
+ }
35
+
36
+ return patterns;
37
+ }
38
+
39
+ /**
40
+ * Check if a file path matches any ignore patterns.
41
+ * Uses simple glob-style matching (*.ext, exact names).
42
+ */
43
+ export function shouldIgnorePath(filePath: string, patterns: string[]): boolean {
44
+ if (patterns.length === 0) return false;
45
+
46
+ const basename = path.basename(filePath);
47
+
48
+ for (const pattern of patterns) {
49
+ // Exact match
50
+ if (basename === pattern || filePath === pattern) return true;
51
+
52
+ // Glob match: *.ext
53
+ if (pattern.startsWith("*.")) {
54
+ const ext = pattern.slice(1); // ".ext"
55
+ if (basename.endsWith(ext)) return true;
56
+ }
57
+
58
+ // Directory match: dir/
59
+ if (pattern.endsWith("/")) {
60
+ const dir = pattern.slice(0, -1);
61
+ if (filePath.includes(`/${dir}/`) || filePath.startsWith(`${dir}/`)) return true;
62
+ }
63
+
64
+ // Contains match
65
+ if (filePath.includes(pattern)) return true;
66
+ }
67
+
68
+ return false;
69
+ }
package/project.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Project identity detection.
3
+ * Derives a project slug from git remote or cwd basename.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+ import * as path from "node:path";
8
+
9
+ /**
10
+ * Normalize a git remote URL to a filesystem-safe slug.
11
+ *
12
+ * Examples:
13
+ * git@github.com:user/repo.git → github.com-user-repo
14
+ * https://github.com/user/repo → github.com-user-repo
15
+ * ssh://git@github.com/user/repo → github.com-user-repo
16
+ */
17
+ export function normalizeRemoteUrl(url: string): string {
18
+ let normalized = url.trim();
19
+
20
+ // Remove .git suffix
21
+ normalized = normalized.replace(/\.git$/, "");
22
+
23
+ // Handle SSH format: git@host:user/repo
24
+ const sshMatch = normalized.match(/^[\w.-]+@([\w.-]+):(.+)$/);
25
+ if (sshMatch) {
26
+ normalized = `${sshMatch[1]}/${sshMatch[2]}`;
27
+ } else {
28
+ // Handle protocol-based URLs: https://host/path, ssh://git@host/path
29
+ normalized = normalized.replace(/^[a-zA-Z+]+:\/\//, "");
30
+ // Remove user@ prefix
31
+ normalized = normalized.replace(/^[^@]+@/, "");
32
+ }
33
+
34
+ // Replace / and : with -
35
+ normalized = normalized.replace(/[/:]/g, "-");
36
+
37
+ // Remove leading/trailing dashes
38
+ normalized = normalized.replace(/^-+|-+$/g, "");
39
+
40
+ return normalized;
41
+ }
42
+
43
+ /**
44
+ * Get the project slug for the given working directory.
45
+ * Tries git remote first, falls back to directory basename.
46
+ */
47
+ export async function getProjectSlug(cwd: string): Promise<string> {
48
+ try {
49
+ const remote = execSync("git remote get-url origin", {
50
+ cwd,
51
+ encoding: "utf-8",
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ timeout: 5000,
54
+ }).trim();
55
+
56
+ if (remote) {
57
+ return normalizeRemoteUrl(remote);
58
+ }
59
+ } catch {
60
+ // Not a git repo or no remote
61
+ }
62
+
63
+ return path.basename(cwd);
64
+ }
package/tools.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Tool registration for pi-mem.
3
+ * Registers search, timeline, get_observations, and save_memory tools.
4
+ * Implements a 3-layer progressive disclosure pattern for memory search.
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import { Type } from "@sinclair/typebox";
9
+ import { StringEnum } from "@mariozechner/pi-ai";
10
+ import type { IndexResult, TimelineResult, FullResult } from "./observation-store.js";
11
+
12
+ // ─── Result Formatters ────────────────────────────────────────
13
+
14
+ function formatIndexResults(results: IndexResult[]): string {
15
+ if (results.length === 0) {
16
+ return "No results found. Try broader search terms or different filters.";
17
+ }
18
+
19
+ const header = "| id | timestamp | type | tool | title |";
20
+ const sep = "|---|---|---|---|---|";
21
+ const rows = results.map(
22
+ (r) =>
23
+ `| ${r.id} | ${r.timestamp.slice(0, 16)} | ${r.obs_type || r.type} | ${r.tool_name || "-"} | ${r.title.slice(0, 60)} |`,
24
+ );
25
+
26
+ return [
27
+ `Found ${results.length} result(s):`,
28
+ "",
29
+ header,
30
+ sep,
31
+ ...rows,
32
+ "",
33
+ "Use `timeline(anchor=ID)` for context or `get_observations(ids=[...])` for full details.",
34
+ ].join("\n");
35
+ }
36
+
37
+ function formatTimelineResults(results: TimelineResult[]): string {
38
+ if (results.length === 0) {
39
+ return "No timeline results found.";
40
+ }
41
+
42
+ const lines = results.map((r) => {
43
+ const tool = r.tool_name ? ` [${r.tool_name}]` : "";
44
+ const preview = r.narrative_preview ? `\n ${r.narrative_preview}` : "";
45
+ const sub = r.subtitle ? ` — ${r.subtitle}` : "";
46
+ return `**#${r.id}** ${r.timestamp.slice(0, 16)} ${r.obs_type || r.type}${tool} — ${r.title}${sub}${preview}`;
47
+ });
48
+
49
+ return [
50
+ `Timeline (${results.length} entries):`,
51
+ "",
52
+ ...lines,
53
+ "",
54
+ "Use `get_observations(ids=[...])` for full details on specific entries.",
55
+ ].join("\n");
56
+ }
57
+
58
+ function formatFullResults(results: FullResult[]): string {
59
+ if (results.length === 0) {
60
+ return "No observations found for the given IDs.";
61
+ }
62
+
63
+ const entries = results.map((r) => {
64
+ const parts = [
65
+ `## #${r.id} — ${r.title}`,
66
+ `**Type:** ${r.obs_type || r.type} | **Session:** ${r.session_id} | **Project:** ${r.project}`,
67
+ `**Timestamp:** ${r.timestamp}`,
68
+ ];
69
+ if (r.tool_name) parts.push(`**Tool:** ${r.tool_name}`);
70
+ if (r.subtitle) parts.push(`**Subtitle:** ${r.subtitle}`);
71
+ if (r.concepts.length > 0) parts.push(`**Concepts:** ${r.concepts.join(", ")}`);
72
+ if (r.files_read.length > 0) parts.push(`**Files Read:** ${r.files_read.join(", ")}`);
73
+ if (r.files_modified.length > 0) parts.push(`**Files Modified:** ${r.files_modified.join(", ")}`);
74
+ if (r.facts.length > 0) {
75
+ parts.push("", "**Facts:**");
76
+ for (const fact of r.facts) {
77
+ parts.push(`- ${fact}`);
78
+ }
79
+ }
80
+ if (r.narrative) parts.push("", r.narrative);
81
+ return parts.join("\n");
82
+ });
83
+
84
+ return entries.join("\n\n---\n\n");
85
+ }
86
+
87
+ // ─── Tool Registration ───────────────────────────────────────
88
+
89
+ export interface ToolCallbacks {
90
+ onSearch: (params: any) => Promise<IndexResult[]>;
91
+ onTimeline: (params: any) => Promise<TimelineResult[]>;
92
+ onGetObservations: (params: any) => Promise<FullResult[]>;
93
+ onSaveMemory: (params: any) => Promise<string>;
94
+ }
95
+
96
+ export function registerTools(pi: ExtensionAPI, callbacks: ToolCallbacks): void {
97
+ // ─── search ───────────────────────────────────────────────
98
+ pi.registerTool({
99
+ name: "search",
100
+ label: "Memory Search",
101
+ description:
102
+ "Step 1: Search memory. Returns compact index with IDs. " +
103
+ "Params: query, limit, project, type, obs_type, dateStart, dateEnd, offset, orderBy",
104
+ parameters: Type.Object({
105
+ query: Type.String({ description: "Full-text search query (supports AND, OR, NOT)" }),
106
+ limit: Type.Optional(Type.Number({ description: "Max results, default 20", default: 20 })),
107
+ offset: Type.Optional(Type.Number({ description: "Skip first N results for pagination", default: 0 })),
108
+ project: Type.Optional(Type.String({ description: "Filter by project slug" })),
109
+ obs_type: Type.Optional(
110
+ StringEnum(["observation", "summary", "prompt", "manual"] as const, {
111
+ description: "Filter by record type",
112
+ }),
113
+ ),
114
+ dateStart: Type.Optional(Type.String({ description: "Filter by start date (YYYY-MM-DD)" })),
115
+ dateEnd: Type.Optional(Type.String({ description: "Filter by end date (YYYY-MM-DD)" })),
116
+ orderBy: Type.Optional(
117
+ StringEnum(["date_desc", "date_asc", "relevance"] as const, {
118
+ description: "Sort order (default: relevance for FTS)",
119
+ }),
120
+ ),
121
+ }),
122
+
123
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
124
+ try {
125
+ const results = await callbacks.onSearch(params);
126
+ return {
127
+ content: [{ type: "text", text: formatIndexResults(results) }],
128
+ details: {},
129
+ };
130
+ } catch (e: any) {
131
+ return {
132
+ content: [{ type: "text", text: `Error searching: ${e.message}` }],
133
+ isError: true,
134
+ details: {},
135
+ };
136
+ }
137
+ },
138
+ });
139
+
140
+ // ─── timeline ─────────────────────────────────────────────
141
+ pi.registerTool({
142
+ name: "timeline",
143
+ label: "Memory Timeline",
144
+ description:
145
+ "Step 2: Get chronological context around a result. " +
146
+ "Provide anchor (observation ID) OR query to find the anchor automatically.",
147
+ parameters: Type.Object({
148
+ anchor: Type.Optional(Type.String({ description: "Observation ID to center timeline around" })),
149
+ query: Type.Optional(Type.String({ description: "Search query to find anchor automatically" })),
150
+ depth_before: Type.Optional(Type.Number({ description: "Observations before anchor, default 3", default: 3 })),
151
+ depth_after: Type.Optional(Type.Number({ description: "Observations after anchor, default 3", default: 3 })),
152
+ project: Type.Optional(Type.String({ description: "Filter by project slug" })),
153
+ }),
154
+
155
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
156
+ if (!params.anchor && !params.query) {
157
+ return {
158
+ content: [{ type: "text", text: "Provide either 'anchor' (observation ID) or 'query' to find the anchor." }],
159
+ isError: true,
160
+ details: {},
161
+ };
162
+ }
163
+
164
+ try {
165
+ const results = await callbacks.onTimeline(params);
166
+ return {
167
+ content: [{ type: "text", text: formatTimelineResults(results) }],
168
+ details: {},
169
+ };
170
+ } catch (e: any) {
171
+ return {
172
+ content: [{ type: "text", text: `Error building timeline: ${e.message}` }],
173
+ isError: true,
174
+ details: {},
175
+ };
176
+ }
177
+ },
178
+ });
179
+
180
+ // ─── get_observations ─────────────────────────────────────
181
+ pi.registerTool({
182
+ name: "get_observations",
183
+ label: "Get Observations",
184
+ description:
185
+ "Step 3: Fetch full details for specific IDs. Always batch multiple IDs in a single call.",
186
+ parameters: Type.Object({
187
+ ids: Type.Array(Type.String(), {
188
+ description: "Array of observation IDs to fetch (required)",
189
+ }),
190
+ orderBy: Type.Optional(
191
+ StringEnum(["date_desc", "date_asc"] as const, {
192
+ description: "Sort order",
193
+ }),
194
+ ),
195
+ limit: Type.Optional(Type.Number({ description: "Maximum observations to return" })),
196
+ project: Type.Optional(Type.String({ description: "Filter by project slug" })),
197
+ }),
198
+
199
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
200
+ if (!params.ids || params.ids.length === 0) {
201
+ return {
202
+ content: [{ type: "text", text: "Provide at least one observation ID." }],
203
+ isError: true,
204
+ details: {},
205
+ };
206
+ }
207
+
208
+ try {
209
+ const results = await callbacks.onGetObservations(params);
210
+ return {
211
+ content: [{ type: "text", text: formatFullResults(results) }],
212
+ details: {},
213
+ };
214
+ } catch (e: any) {
215
+ return {
216
+ content: [{ type: "text", text: `Error fetching observations: ${e.message}` }],
217
+ isError: true,
218
+ details: {},
219
+ };
220
+ }
221
+ },
222
+ });
223
+
224
+ // ─── save_memory ──────────────────────────────────────────
225
+ pi.registerTool({
226
+ name: "save_memory",
227
+ label: "Save Memory",
228
+ description:
229
+ "Save important information to memory for future sessions. " +
230
+ "Use for decisions, discoveries, or context that should be remembered.",
231
+ parameters: Type.Object({
232
+ text: Type.String({ description: "Content to remember (required)" }),
233
+ title: Type.Optional(Type.String({ description: "Short title (auto-generated if omitted)" })),
234
+ project: Type.Optional(Type.String({ description: "Project slug (defaults to current)" })),
235
+ concepts: Type.Optional(
236
+ Type.Array(Type.String(), {
237
+ description: 'Concept tags, e.g. ["decision", "architecture"]',
238
+ }),
239
+ ),
240
+ }),
241
+
242
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
243
+ try {
244
+ const result = await callbacks.onSaveMemory(params);
245
+ return {
246
+ content: [{ type: "text", text: result }],
247
+ details: {},
248
+ };
249
+ } catch (e: any) {
250
+ return {
251
+ content: [{ type: "text", text: `Error saving memory: ${e.message}` }],
252
+ isError: true,
253
+ details: {},
254
+ };
255
+ }
256
+ },
257
+ });
258
+ }