@rohirik/pi-ltm 2.0.1 → 2.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.
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import{spawn as w,execSync as b}from"node:child_process";import{createRequire as y}from"node:module";import{resolve as x,dirname as T,join as p}from"node:path";import{homedir as v}from"node:os";import{existsSync as u,readdirSync as k}from"node:fs";var m="LTM_BRIDGE_DEPTH";function P(){let c=[process.env.BUN_INSTALL?p(process.env.BUN_INSTALL,"bin","bun"):null,p(v(),".bun","bin","bun"),"/opt/homebrew/bin/bun","/usr/local/bin/bun","/usr/bin/bun"].filter(t=>t!==null&&u(t));if(c.length>0)return c[0];for(let t of["which bun","command -v bun"])try{let e=b(t,{encoding:"utf-8",stdio:"pipe"}).trim();if(e&&u(e)&&!/\bpi\b/.test(e))return e}catch{}return null}function E(){let c=p(v(),".claude","plugins","cache","ltm","ltm");if(u(c))try{let t=k(c).filter(e=>/^\d/.test(e)).sort().reverse();for(let e of t){let n=p(c,e,"src","mcp-server.ts");if(u(n))return n}}catch{}try{let e=y(import.meta.url).resolve("@rohirik/ltm-core/package.json"),n=x(T(e),"src","mcp-server.ts");if(u(n))return n}catch{}return null}var f=class{constructor(t,e){this.runtime=t;this.script=e}child=null;pending=new Map;buffer="";nextId=1;exited=!1;start(){if(this.child)return;let t=parseInt(process.env[m]??"0",10),e={...process.env,[m]:String(t+1)};this.child=w(this.runtime,[this.script],{stdio:["pipe","pipe","pipe"],env:e}),this.child.stdout?.on("data",n=>{this.buffer+=n.toString("utf-8");let r=this.buffer.split(`
2
+ `);this.buffer=r.pop()??"";for(let o of r){let l=o.trim();if(l)try{let i=JSON.parse(l);if(i.id!==void 0){let s=this.pending.get(i.id);s&&(this.pending.delete(i.id),i.error?s.reject(new Error(i.error.message)):s.resolve(i.result))}}catch{}}}),this.child.on("exit",()=>{this.exited=!0;for(let n of this.pending.values())n.reject(new Error("LTM MCP server exited"));this.pending.clear()}),this.child.on("error",()=>{this.exited=!0;for(let n of this.pending.values())n.reject(new Error("LTM MCP server error"));this.pending.clear()})}send(t,e,n){let r=JSON.stringify({jsonrpc:"2.0",id:n,method:t,params:e})+`
3
+ `;this.child?.stdin?.write(r)}request(t,e,n=3e4){let r=this.nextId++;return new Promise((o,l)=>{let i=setTimeout(()=>{this.pending.delete(r),l(new Error(`LTM MCP timeout: ${t}`))},n);this.pending.set(r,{resolve:s=>{clearTimeout(i),o(s)},reject:s=>{clearTimeout(i),l(s)}}),this.send(t,e,r)})}async initialize(){await this.request("initialize",{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"pi-ltm-bridge",version:"1.0.0"}}),this.send("notifications/initialized")}async listTools(){return(await this.request("tools/list"))?.tools??[]}async callTool(t,e){let n=await this.request("tools/call",{name:t,arguments:e},6e4),r=(n?.content??[]).filter(o=>o?.type==="text"&&o.text).map(o=>o.text).join(`
4
+ `);if(n?.isError)throw new Error(r||`${t} returned an error`);return r}};function S(c){let t=c;if(parseInt(process.env[m]??"0",10)>0)return;let e=P(),n=E();if(!e||!n)return;let r=new f(e,n);r.start();let o=(async()=>{await r.initialize();let l=await r.listTools();for(let i of l){let s=i.name;t.registerTool({name:s,label:s,description:i.description??"",parameters:i.inputSchema??{type:"object",properties:{}},async execute(g,a){return{content:[{type:"text",text:await r.callTool(s,a??{})}],details:{}}}})}})().catch(()=>{});t.on("before_agent_start",async l=>{await o;let i=l;try{let g=String(i?.cwd??process.cwd()).replace(/\/$/,"").split("/").pop()??"",a=await r.callTool("ltm_recall",{project:g,limit:8,sort_by:"relevance"});if(!a||a==="[]")return;let d=`## Prior Knowledge (LTM)
5
+
6
+ ${a}
7
+ `,h=String(i?.systemPrompt??"");return{systemPrompt:h?`${h}
8
+
9
+ ${d}`:d}}catch{}})}export{S as default};
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@rohirik/pi-ltm",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "LTM (Long-Term Memory) native extension for Pi — share memories with Claude Code and OpenCode",
5
5
  "type": "module",
6
- "main": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
7
  "exports": {
8
- ".": "./src/index.ts"
8
+ ".": "./dist/index.js"
9
+ },
10
+ "files": ["dist", "src"],
11
+ "pi": {
12
+ "extensions": ["./dist/index.js"]
9
13
  },
10
14
  "scripts": {
15
+ "build": "bunx esbuild src/index.ts --bundle --platform=node --format=esm --target=esnext --outfile=dist/index.js --external:bun:sqlite --external:bun:test --external:@rohirik/ltm-core --minify",
16
+ "prepublishOnly": "bun run build",
11
17
  "typecheck": "bunx tsc --noEmit",
12
18
  "test": "bun test"
13
19
  },
@@ -21,14 +27,12 @@
21
27
  "access": "public"
22
28
  },
23
29
  "dependencies": {
24
- "@rohirik/ltm-core": "^2.0.1"
30
+ "@rohirik/ltm-core": "^2.1.0"
25
31
  },
26
32
  "peerDependencies": {
27
- "@earendil-works/pi-ai": ">=1.0.0",
28
33
  "bun-types": ">=1.0.0"
29
34
  },
30
35
  "peerDependenciesMeta": {
31
- "@earendil-works/pi-ai": { "optional": true },
32
36
  "bun-types": { "optional": true }
33
37
  },
34
38
  "devDependencies": {
@@ -2,22 +2,26 @@ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
2
  import { readFileSync, unlinkSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { Database } from "bun:sqlite";
5
- import type { PiExtensionAPI, PiToolDefinition } from "@earendil-works/pi-ai";
6
5
 
7
6
  const dbPath = `/tmp/test-pi-ltm-${process.pid}-${Date.now()}.db`;
8
7
  const SCHEMA_PATH = join(import.meta.dir, "..", "..", "..", "ltm-core", "src", "schema.sql");
9
8
 
10
- // Mock Pi API
11
- function createMockPi(): PiExtensionAPI & { tools: PiToolDefinition[]; handlers: Record<string, Function[]> } {
12
- const tools: PiToolDefinition[] = [];
13
- const handlers: Record<string, Function[]> = {};
9
+ function createMockPi() {
10
+ const tools: Array<{
11
+ name: string;
12
+ label: string;
13
+ description: string;
14
+ parameters: unknown;
15
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
16
+ }> = [];
17
+ const handlers: Record<string, Array<(...args: unknown[]) => unknown>> = {};
14
18
  return {
15
19
  tools,
16
20
  handlers,
17
- registerTool(def) { tools.push(def); },
18
- on(event, handler) {
21
+ registerTool(def: typeof tools[0]) { tools.push(def); },
22
+ on(event: string, handler: (...args: unknown[]) => unknown) {
19
23
  if (!handlers[event]) handlers[event] = [];
20
- handlers[event]!.push(handler as Function);
24
+ handlers[event]!.push(handler);
21
25
  },
22
26
  };
23
27
  }
@@ -54,53 +58,60 @@ describe("Pi LTM extension — registerTools()", () => {
54
58
  expect(names).toContain("ltm_learn");
55
59
  expect(names).toContain("ltm_forget");
56
60
  });
61
+
62
+ it("tools have label and description", async () => {
63
+ const { registerTools } = await import("../tools.js");
64
+ const pi = createMockPi();
65
+ registerTools(pi);
66
+ for (const tool of pi.tools) {
67
+ expect(tool.label).toBeTruthy();
68
+ expect(tool.description).toBeTruthy();
69
+ }
70
+ });
57
71
  });
58
72
 
59
73
  describe("Pi LTM extension — registerHooks()", () => {
60
- it("registers session:start and compact hooks", async () => {
74
+ it("registers before_agent_start and session_compact hooks", async () => {
61
75
  const { registerHooks } = await import("../hooks.js");
62
76
  const pi = createMockPi();
63
77
  registerHooks(pi);
64
- expect(pi.handlers["session:start"]?.length).toBeGreaterThan(0);
65
- expect(pi.handlers["compact"]?.length).toBeGreaterThan(0);
78
+ expect(pi.handlers["before_agent_start"]?.length).toBeGreaterThan(0);
79
+ expect(pi.handlers["session_compact"]?.length).toBeGreaterThan(0);
66
80
  });
67
81
 
68
- it("session:start injects Prior Knowledge block when memories exist", async () => {
82
+ it("before_agent_start returns systemPrompt with Prior Knowledge when memories exist", async () => {
69
83
  const { learn } = await import("@rohirik/ltm-core");
70
- learn({ content: "Pi test memory — use strict types", category: "pattern", importance: 3, project_scope: "pi-test-proj", skipExport: true });
84
+ learn({
85
+ content: "Pi test memory — use strict types",
86
+ category: "pattern",
87
+ importance: 3,
88
+ project_scope: "pi-test-proj",
89
+ skipExport: true,
90
+ });
71
91
 
72
92
  const { registerHooks } = await import("../hooks.js");
73
93
  const pi = createMockPi();
74
94
  registerHooks(pi);
75
95
 
76
- const appended: string[] = [];
77
- const ctx = {
96
+ const result = await pi.handlers["before_agent_start"]![0]!({
78
97
  cwd: "/tmp/pi-test-proj",
79
- sessionID: "pi-test-session-1",
80
- appendToSystemPrompt: (block: string) => { appended.push(block); },
81
- };
82
-
83
- await pi.handlers["session:start"]![0]!(ctx);
98
+ systemPrompt: "",
99
+ }) as { systemPrompt?: string } | undefined;
84
100
 
85
- expect(appended.length).toBeGreaterThan(0);
86
- expect(appended.some(b => b.includes("Prior Knowledge"))).toBe(true);
101
+ expect(result?.systemPrompt).toContain("Prior Knowledge");
87
102
  });
88
103
 
89
- it("session:start does not inject when no memories for project", async () => {
104
+ it("before_agent_start returns undefined when no memories for project", async () => {
90
105
  const { registerHooks } = await import("../hooks.js");
91
106
  const pi = createMockPi();
92
107
  registerHooks(pi);
93
108
 
94
- const appended: string[] = [];
95
- const ctx = {
96
- cwd: "/tmp/empty-pi-project",
97
- sessionID: "pi-test-session-2",
98
- appendToSystemPrompt: (block: string) => { appended.push(block); },
99
- };
100
-
101
- await pi.handlers["session:start"]![0]!(ctx);
109
+ const result = await pi.handlers["before_agent_start"]![0]!({
110
+ cwd: "/tmp/empty-pi-project-xyz-no-memories",
111
+ systemPrompt: "",
112
+ });
102
113
 
103
- expect(appended.length).toBe(0);
114
+ expect(result).toBeUndefined();
104
115
  });
105
116
  });
106
117
 
@@ -113,19 +124,21 @@ describe("ltm_learn / ltm_recall roundtrip via Pi tools", () => {
113
124
  const learnTool = pi.tools.find(t => t.name === "ltm_learn")!;
114
125
  const recallTool = pi.tools.find(t => t.name === "ltm_recall")!;
115
126
 
116
- const result = await learnTool.handler({
127
+ const learnResult = await learnTool.execute("call-1", {
117
128
  content: "Pi roundtrip test — always verify types",
118
129
  category: "gotcha",
119
130
  importance: 4,
120
- }) as { id: number; action: string };
131
+ }) as { content: Array<{ type: string; text: string }> };
121
132
 
122
- expect(result.id).toBeGreaterThan(0);
123
- expect(result.action).toBe("created");
133
+ const learned = JSON.parse(learnResult.content[0]!.text) as { id: number; action: string };
134
+ expect(learned.id).toBeGreaterThan(0);
135
+ expect(learned.action).toBe("created");
124
136
 
125
- const memories = await recallTool.handler({
137
+ const recallResult = await recallTool.execute("call-2", {
126
138
  query: "Pi roundtrip verify types",
127
- }) as Array<{ id: number; content: string }>;
139
+ }) as { content: Array<{ type: string; text: string }> };
128
140
 
129
- expect(memories.some(m => m.id === result.id)).toBe(true);
141
+ const memories = JSON.parse(recallResult.content[0]!.text) as Array<{ id: number }>;
142
+ expect(memories.some(m => m.id === learned.id)).toBe(true);
130
143
  });
131
144
  });
package/src/hooks.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { PiExtensionAPI } from "@earendil-works/pi-ai";
2
1
  import { recall, learn } from "@rohirik/ltm-core";
3
2
 
3
+ type PiAny = any;
4
+
4
5
  const MAX_CONTEXT_MEMORIES = 10;
5
6
 
6
7
  function formatContextBlock(memories: Array<{ id: number; content: string; category: string }>): string {
7
- const lines = memories.map(m => `- [${m.id}] (${m.category}) ${m.content}`);
8
+ const lines = memories.map((m) => `- [${m.id}] (${m.category}) ${m.content}`);
8
9
  return "## Prior Knowledge (LTM)\n\n" + lines.join("\n") + "\n";
9
10
  }
10
11
 
@@ -12,29 +13,32 @@ function projectFromCwd(cwd: string): string {
12
13
  return cwd.replace(/\/$/, "").split("/").pop() ?? "";
13
14
  }
14
15
 
15
- export function registerHooks(pi: PiExtensionAPI): void {
16
- pi.on("session:start", async (ctx) => {
16
+ export function registerHooks(pi: PiAny): void {
17
+ // Inject relevant memories into the system prompt before each agent turn
18
+ pi.on("before_agent_start", async (event: PiAny) => {
17
19
  try {
18
- const project = projectFromCwd(ctx.cwd);
19
- const memories = await recall({
20
- project,
21
- limit: MAX_CONTEXT_MEMORIES,
22
- sort_by: "relevance",
23
- });
24
- if (memories.length > 0) {
25
- ctx.appendToSystemPrompt(formatContextBlock(
26
- memories.map(m => ({ id: m.id, content: m.content, category: m.category })),
27
- ));
28
- }
20
+ const cwd = String(event?.cwd ?? process.cwd());
21
+ const project = projectFromCwd(cwd);
22
+ const memories = await recall({ project, limit: MAX_CONTEXT_MEMORIES, sort_by: "relevance" });
23
+ if (memories.length === 0) return;
24
+
25
+ const block = formatContextBlock(
26
+ memories.map((m) => ({ id: m.id, content: m.content, category: m.category })),
27
+ );
28
+ const existing = String(event?.systemPrompt ?? "");
29
+ const parts = existing ? [existing, block] : [block];
30
+ return { systemPrompt: parts.join("\n\n") };
29
31
  } catch {
30
32
  // Non-fatal — session continues without LTM context
31
33
  }
32
34
  });
33
35
 
34
- pi.on("compact", async (ctx) => {
36
+ // Learn from session summary after compact
37
+ pi.on("session_compact", (event: PiAny) => {
35
38
  try {
36
- const project = projectFromCwd(ctx.cwd);
37
- const summary = ctx.getSessionSummary();
39
+ const cwd = String(event?.cwd ?? process.cwd());
40
+ const project = projectFromCwd(cwd);
41
+ const summary = String(event?.summary ?? "");
38
42
  if (summary.trim().length > 50) {
39
43
  learn({
40
44
  content: summary.slice(0, 500),
package/src/index.ts CHANGED
@@ -1,8 +1,243 @@
1
- import type { PiExtensionAPI } from "@earendil-works/pi-ai";
2
- import { registerTools } from "./tools.js";
3
- import { registerHooks } from "./hooks.js";
1
+ /**
2
+ * Pi extension entry point.
3
+ *
4
+ * Pi loads extensions with Node.js, not Bun — so bun:sqlite is unavailable.
5
+ * We bridge LTM tools by spawning the ltm-core MCP server as a Bun child
6
+ * process and proxying calls via newline-delimited JSON-RPC (MCP stdio transport).
7
+ *
8
+ * Pattern adapted from context-mode's Pi adapter (MIT).
9
+ */
10
+ import { spawn, execSync } from "node:child_process";
11
+ import { createRequire } from "node:module";
12
+ import { resolve, dirname, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { existsSync, readdirSync } from "node:fs";
4
15
 
5
- export default function ltmExtension(pi: PiExtensionAPI): void {
6
- registerTools(pi);
7
- registerHooks(pi);
16
+ // ── Fork-bomb prevention ──────────────────────────────────────────────────────
17
+
18
+ const BRIDGE_DEPTH_ENV = "LTM_BRIDGE_DEPTH";
19
+
20
+ // ── Runtime discovery ─────────────────────────────────────────────────────────
21
+
22
+ function findBun(): string | null {
23
+ const candidates = [
24
+ process.env["BUN_INSTALL"] ? join(process.env["BUN_INSTALL"]!, "bin", "bun") : null,
25
+ join(homedir(), ".bun", "bin", "bun"),
26
+ "/opt/homebrew/bin/bun",
27
+ "/usr/local/bin/bun",
28
+ "/usr/bin/bun",
29
+ ].filter((p): p is string => p !== null && existsSync(p));
30
+
31
+ if (candidates.length > 0) return candidates[0]!;
32
+
33
+ // PATH fallback — try `which` which works even from Node.js
34
+ for (const cmd of ["which bun", "command -v bun"]) {
35
+ try {
36
+ const out = execSync(cmd, { encoding: "utf-8", stdio: "pipe" }).trim();
37
+ if (out && existsSync(out) && !/\bpi\b/.test(out)) return out;
38
+ } catch {
39
+ // try next
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function findMcpServer(): string | null {
46
+ // 1. Plugin cache — newest version first (Claude Code users)
47
+ const cacheBase = join(homedir(), ".claude", "plugins", "cache", "ltm", "ltm");
48
+ if (existsSync(cacheBase)) {
49
+ try {
50
+ const versions = readdirSync(cacheBase)
51
+ .filter((v) => /^\d/.test(v))
52
+ .sort()
53
+ .reverse();
54
+ for (const v of versions) {
55
+ const s = join(cacheBase, v, "src", "mcp-server.ts");
56
+ if (existsSync(s)) return s;
57
+ }
58
+ } catch {
59
+ // continue to next strategy
60
+ }
61
+ }
62
+ // 2. ltm-core package (if mcp-server is added in a future version)
63
+ try {
64
+ const req = createRequire(import.meta.url);
65
+ const pkgJson = req.resolve("@rohirik/ltm-core/package.json");
66
+ const script = resolve(dirname(pkgJson), "src", "mcp-server.ts");
67
+ if (existsSync(script)) return script;
68
+ } catch {
69
+ // not available
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // ── Minimal MCP stdio client ──────────────────────────────────────────────────
75
+
76
+ interface MCPTool {
77
+ name: string;
78
+ description?: string;
79
+ inputSchema?: Record<string, unknown>;
80
+ }
81
+
82
+ class LtmMcpClient {
83
+ private child: ReturnType<typeof spawn> | null = null;
84
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
85
+ private buffer = "";
86
+ private nextId = 1;
87
+ private exited = false;
88
+
89
+ constructor(
90
+ private readonly runtime: string,
91
+ private readonly script: string,
92
+ ) {}
93
+
94
+ start(): void {
95
+ if (this.child) return;
96
+ const depth = parseInt(process.env[BRIDGE_DEPTH_ENV] ?? "0", 10);
97
+ const env = { ...process.env, [BRIDGE_DEPTH_ENV]: String(depth + 1) };
98
+
99
+ this.child = spawn(this.runtime, [this.script], { stdio: ["pipe", "pipe", "pipe"], env });
100
+
101
+ this.child.stdout?.on("data", (chunk: Buffer) => {
102
+ this.buffer += chunk.toString("utf-8");
103
+ const lines = this.buffer.split("\n");
104
+ this.buffer = lines.pop() ?? "";
105
+ for (const line of lines) {
106
+ const trimmed = line.trim();
107
+ if (!trimmed) continue;
108
+ try {
109
+ const msg = JSON.parse(trimmed) as { id?: number; result?: unknown; error?: { message: string } };
110
+ if (msg.id !== undefined) {
111
+ const handler = this.pending.get(msg.id);
112
+ if (handler) {
113
+ this.pending.delete(msg.id);
114
+ if (msg.error) handler.reject(new Error(msg.error.message));
115
+ else handler.resolve(msg.result);
116
+ }
117
+ }
118
+ } catch {
119
+ // ignore malformed lines
120
+ }
121
+ }
122
+ });
123
+
124
+ this.child.on("exit", () => {
125
+ this.exited = true;
126
+ for (const h of this.pending.values()) h.reject(new Error("LTM MCP server exited"));
127
+ this.pending.clear();
128
+ });
129
+ this.child.on("error", () => {
130
+ this.exited = true;
131
+ for (const h of this.pending.values()) h.reject(new Error("LTM MCP server error"));
132
+ this.pending.clear();
133
+ });
134
+ }
135
+
136
+ private send(method: string, params?: unknown, id?: number): void {
137
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
138
+ this.child?.stdin?.write(msg);
139
+ }
140
+
141
+ private request(method: string, params?: unknown, timeoutMs = 30_000): Promise<unknown> {
142
+ const id = this.nextId++;
143
+ return new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ this.pending.delete(id);
146
+ reject(new Error(`LTM MCP timeout: ${method}`));
147
+ }, timeoutMs);
148
+
149
+ this.pending.set(id, {
150
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
151
+ reject: (e) => { clearTimeout(timer); reject(e); },
152
+ });
153
+ this.send(method, params, id);
154
+ });
155
+ }
156
+
157
+ async initialize(): Promise<void> {
158
+ await this.request("initialize", {
159
+ protocolVersion: "2024-11-05",
160
+ capabilities: {},
161
+ clientInfo: { name: "pi-ltm-bridge", version: "1.0.0" },
162
+ });
163
+ this.send("notifications/initialized");
164
+ }
165
+
166
+ async listTools(): Promise<MCPTool[]> {
167
+ const result = await this.request("tools/list") as { tools?: MCPTool[] };
168
+ return result?.tools ?? [];
169
+ }
170
+
171
+ async callTool(name: string, args: Record<string, unknown>): Promise<string> {
172
+ const result = await this.request("tools/call", { name, arguments: args }, 60_000) as {
173
+ content?: Array<{ type: string; text?: string }>;
174
+ isError?: boolean;
175
+ };
176
+ const text = (result?.content ?? [])
177
+ .filter((c) => c?.type === "text" && c.text)
178
+ .map((c) => c.text!)
179
+ .join("\n");
180
+ if (result?.isError) throw new Error(text || `${name} returned an error`);
181
+ return text;
182
+ }
183
+ }
184
+
185
+ // ── Extension entry point ─────────────────────────────────────────────────────
186
+
187
+ export default function ltmExtension(pi: unknown): void {
188
+ const p = pi as {
189
+ registerTool: (def: {
190
+ name: string; label: string; description: string;
191
+ parameters: unknown;
192
+ execute: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
193
+ }) => void;
194
+ on: (event: string, handler: (...args: unknown[]) => unknown) => void;
195
+ };
196
+
197
+ // Guard: skip if already inside a spawned bridge child
198
+ if (parseInt(process.env[BRIDGE_DEPTH_ENV] ?? "0", 10) > 0) return;
199
+
200
+ const bun = findBun();
201
+ const script = findMcpServer();
202
+ if (!bun || !script) return; // degrade gracefully — no bun or server found
203
+
204
+ const client = new LtmMcpClient(bun, script);
205
+ client.start();
206
+
207
+ // Bootstrap runs async — tools are registered once handshake completes.
208
+ // Pi allows registerTool after extension load; before_agent_start awaits ready.
209
+ const ready = (async () => {
210
+ await client.initialize();
211
+ const tools = await client.listTools();
212
+ for (const tool of tools) {
213
+ const name = tool.name;
214
+ p.registerTool({
215
+ name,
216
+ label: name,
217
+ description: tool.description ?? "",
218
+ parameters: tool.inputSchema ?? { type: "object", properties: {} },
219
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
220
+ const text = await client.callTool(name, params ?? {});
221
+ return { content: [{ type: "text", text }], details: {} };
222
+ },
223
+ });
224
+ }
225
+ })().catch(() => {});
226
+
227
+ // Inject relevant memories — wait for bootstrap so tools are live first
228
+ p.on("before_agent_start", async (event: unknown) => {
229
+ await ready;
230
+ const ev = event as { cwd?: string; systemPrompt?: string } | null;
231
+ try {
232
+ const cwd = String(ev?.cwd ?? process.cwd());
233
+ const project = cwd.replace(/\/$/, "").split("/").pop() ?? "";
234
+ const text = await client.callTool("ltm_recall", { project, limit: 8, sort_by: "relevance" });
235
+ if (!text || text === "[]") return;
236
+ const block = `## Prior Knowledge (LTM)\n\n${text}\n`;
237
+ const existing = String(ev?.systemPrompt ?? "");
238
+ return { systemPrompt: existing ? `${existing}\n\n${block}` : block };
239
+ } catch {
240
+ // non-fatal
241
+ }
242
+ });
8
243
  }
package/src/tools.ts CHANGED
@@ -1,76 +1,97 @@
1
- import type { PiExtensionAPI, PiToolDefinition } from "@earendil-works/pi-ai";
2
1
  import { recall, learn, forget } from "@rohirik/ltm-core";
3
2
 
4
- export function registerTools(pi: PiExtensionAPI): void {
5
- const tools: PiToolDefinition[] = [
6
- {
7
- name: "ltm_recall",
8
- description: "Search long-term memories by query, category, or project scope. Call before starting any non-trivial task.",
9
- parameters: {
10
- type: "object",
11
- properties: {
12
- query: { type: "string", description: "Full-text search query" },
13
- project: { type: "string", description: "Filter by project scope" },
14
- limit: { type: "number", description: "Max results (default 10)", minimum: 1, maximum: 50 },
15
- category: { type: "string", enum: ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"] },
3
+ type PiAny = any; // Pi's extension API — no published types package
4
+
5
+ function toText(value: unknown): string {
6
+ return JSON.stringify(value, null, 2);
7
+ }
8
+
9
+ export function registerTools(pi: PiAny): void {
10
+ pi.registerTool({
11
+ name: "ltm_recall",
12
+ label: "ltm_recall",
13
+ description:
14
+ "Search long-term memories by query, category, or project scope. Call before starting any non-trivial task.",
15
+ parameters: {
16
+ type: "object",
17
+ properties: {
18
+ query: { type: "string", description: "Full-text search query" },
19
+ project: { type: "string", description: "Filter by project scope" },
20
+ limit: { type: "number", description: "Max results (default 10)", minimum: 1, maximum: 50 },
21
+ category: {
22
+ type: "string",
23
+ enum: ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"],
16
24
  },
17
25
  },
18
- async handler({ query, project, limit, category }) {
19
- const results = await recall({
20
- query: query as string | undefined,
21
- project: project as string | undefined,
22
- limit: limit as number | undefined,
23
- category: category as string | undefined,
24
- });
25
- return results.map(m => ({ id: m.id, content: m.content, category: m.category, importance: m.importance }));
26
- },
27
26
  },
27
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
28
+ const results = await recall({
29
+ query: params["query"] as string | undefined,
30
+ project: params["project"] as string | undefined,
31
+ limit: params["limit"] as number | undefined,
32
+ category: params["category"] as string | undefined,
33
+ });
34
+ return {
35
+ content: [{ type: "text", text: toText(results.map(m => ({ id: m.id, content: m.content, category: m.category, importance: m.importance }))) }],
36
+ details: {},
37
+ };
38
+ },
39
+ });
28
40
 
29
- {
30
- name: "ltm_learn",
31
- description: "Store or reinforce a memory. Call after discovering a non-obvious pattern, gotcha, or architectural decision.",
32
- parameters: {
33
- type: "object",
34
- properties: {
35
- content: { type: "string", description: "The insight, pattern, or decision to store" },
36
- category: { type: "string", enum: ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"] },
37
- importance: { type: "number", description: "Importance 1-5 (default 3)", minimum: 1, maximum: 5 },
38
- project: { type: "string", description: "Scope to a specific project" },
41
+ pi.registerTool({
42
+ name: "ltm_learn",
43
+ label: "ltm_learn",
44
+ description:
45
+ "Store or reinforce a memory. Call after discovering a non-obvious pattern, gotcha, or architectural decision. Always provide a title: a concise noun-phrase label (≤60 chars).",
46
+ parameters: {
47
+ type: "object",
48
+ properties: {
49
+ content: { type: "string", description: "The insight, pattern, or decision to store" },
50
+ title: { type: "string", description: "Short label (≤60 chars, noun-phrase). Always provide this.", maxLength: 60 },
51
+ category: {
52
+ type: "string",
53
+ enum: ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"],
39
54
  },
40
- required: ["content"],
41
- },
42
- async handler({ content, category, importance, project }) {
43
- const result = learn({
44
- content: content as string,
45
- category: category as string | undefined,
46
- importance: importance as number | undefined,
47
- project_scope: project as string | undefined,
48
- actor: "pi:ltm_learn",
49
- skipExport: true,
50
- });
51
- return { id: result.id, action: result.action, category: result.category };
55
+ importance: { type: "number", description: "Importance 1-5 (default 3)", minimum: 1, maximum: 5 },
56
+ project: { type: "string", description: "Scope to a specific project" },
52
57
  },
58
+ required: ["content"],
53
59
  },
60
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
61
+ const result = learn({
62
+ content: params["content"] as string,
63
+ title: params["title"] as string | undefined,
64
+ category: params["category"] as string | undefined,
65
+ importance: params["importance"] as number | undefined,
66
+ project_scope: params["project"] as string | undefined,
67
+ actor: "pi:ltm_learn",
68
+ skipExport: true,
69
+ });
70
+ return {
71
+ content: [{ type: "text", text: toText({ id: result.id, action: result.action, category: result.category }) }],
72
+ details: {},
73
+ };
74
+ },
75
+ });
54
76
 
55
- {
56
- name: "ltm_forget",
57
- description: "Delete a memory by ID. Call when a memory is wrong or outdated.",
58
- parameters: {
59
- type: "object",
60
- properties: {
61
- id: { type: "number", description: "Memory ID to delete" },
62
- reason: { type: "string", description: "Why this memory is being removed" },
63
- },
64
- required: ["id"],
65
- },
66
- async handler({ id, reason }) {
67
- forget({ id: id as number, reason: reason as string | undefined, actor: "pi:ltm_forget" });
68
- return { ok: true, id };
77
+ pi.registerTool({
78
+ name: "ltm_forget",
79
+ label: "ltm_forget",
80
+ description: "Delete a memory by ID. Call when a memory is wrong or outdated.",
81
+ parameters: {
82
+ type: "object",
83
+ properties: {
84
+ id: { type: "number", description: "Memory ID to delete" },
85
+ reason: { type: "string", description: "Why this memory is being removed" },
69
86
  },
87
+ required: ["id"],
70
88
  },
71
- ];
72
-
73
- for (const tool of tools) {
74
- pi.registerTool(tool);
75
- }
89
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
90
+ forget({ id: params["id"] as number, reason: params["reason"] as string | undefined, actor: "pi:ltm_forget" });
91
+ return {
92
+ content: [{ type: "text", text: toText({ ok: true, id: params["id"] }) }],
93
+ details: {},
94
+ };
95
+ },
96
+ });
76
97
  }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "rootDir": ".",
5
- "outDir": "dist",
6
- "typeRoots": ["./types", "../../node_modules/@types"]
7
- },
8
- "include": ["src/**/*", "types/**/*"],
9
- "exclude": ["node_modules", "dist"]
10
- }
package/types/pi-ai.d.ts DELETED
@@ -1,43 +0,0 @@
1
- declare module "@earendil-works/pi-ai" {
2
- export interface PiToolParameter {
3
- type: string;
4
- description?: string;
5
- enum?: string[];
6
- minimum?: number;
7
- maximum?: number;
8
- default?: unknown;
9
- }
10
-
11
- export interface PiToolDefinition {
12
- name: string;
13
- description: string;
14
- parameters: {
15
- type: "object";
16
- properties: Record<string, PiToolParameter>;
17
- required?: string[];
18
- };
19
- handler(params: Record<string, unknown>): Promise<unknown>;
20
- }
21
-
22
- export interface SessionStartContext {
23
- cwd: string;
24
- sessionID: string;
25
- appendToSystemPrompt(block: string): void;
26
- }
27
-
28
- export interface CompactContext {
29
- cwd: string;
30
- sessionID: string;
31
- getSessionSummary(): string;
32
- }
33
-
34
- type EventMap = {
35
- "session:start": SessionStartContext;
36
- compact: CompactContext;
37
- };
38
-
39
- export interface PiExtensionAPI {
40
- on<E extends keyof EventMap>(event: E, handler: (ctx: EventMap[E]) => void | Promise<void>): void;
41
- registerTool(definition: PiToolDefinition): void;
42
- }
43
- }