@rohirik/pi-ltm 2.0.2 → 2.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.
- package/dist/index.js +9 -0
- package/package.json +10 -6
- package/src/__tests__/plugin.test.ts +52 -39
- package/src/hooks.ts +22 -18
- package/src/index.ts +241 -6
- package/src/tools.ts +84 -63
- package/tsconfig.json +0 -10
- package/types/pi-ai.d.ts +0 -43
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.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "LTM (Long-Term Memory) native extension for Pi — share memories with Claude Code and OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./
|
|
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.
|
|
30
|
+
"@rohirik/ltm-core": "^2.1.1"
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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["
|
|
65
|
-
expect(pi.handlers["
|
|
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("
|
|
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({
|
|
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
|
|
77
|
-
const ctx = {
|
|
96
|
+
const result = await pi.handlers["before_agent_start"]![0]!({
|
|
78
97
|
cwd: "/tmp/pi-test-proj",
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
await pi.handlers["session:start"]![0]!(ctx);
|
|
98
|
+
systemPrompt: "",
|
|
99
|
+
}) as { systemPrompt?: string } | undefined;
|
|
84
100
|
|
|
85
|
-
expect(
|
|
86
|
-
expect(appended.some(b => b.includes("Prior Knowledge"))).toBe(true);
|
|
101
|
+
expect(result?.systemPrompt).toContain("Prior Knowledge");
|
|
87
102
|
});
|
|
88
103
|
|
|
89
|
-
it("
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
131
|
+
}) as { content: Array<{ type: string; text: string }> };
|
|
121
132
|
|
|
122
|
-
|
|
123
|
-
expect(
|
|
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
|
|
137
|
+
const recallResult = await recallTool.execute("call-2", {
|
|
126
138
|
query: "Pi roundtrip verify types",
|
|
127
|
-
}) as Array<{
|
|
139
|
+
}) as { content: Array<{ type: string; text: string }> };
|
|
128
140
|
|
|
129
|
-
|
|
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:
|
|
16
|
-
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
36
|
+
// Learn from session summary after compact
|
|
37
|
+
pi.on("session_compact", (event: PiAny) => {
|
|
35
38
|
try {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
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
|
-
}
|