@rohirik/pi-ltm 2.16.2 → 2.17.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/README.md CHANGED
@@ -26,12 +26,14 @@ The current Pi adapter does not register a compaction hook.
26
26
 
27
27
  ## Shared memory
28
28
 
29
- Default database path:
29
+ When the Claude Code plugin is installed, Pi uses its database, so both agents share memories:
30
30
 
31
31
  ```text
32
- ~/.claude/plugins/data/OpenLtm-openltm/openltm.db
32
+ ~/.claude/plugins/data/openltm-OpenLtm/openltm.db
33
33
  ```
34
34
 
35
+ (An older install's `OpenLtm-openltm/openltm.db` is used if that is the one that exists.)
36
+
35
37
  Override it before starting Pi:
36
38
 
37
39
  ```bash
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import{spawn as k,execSync as E}from"node:child_process";import{join as d}from"node:path";import{homedir as x}from"node:os";import{existsSync as g,readdirSync as M}from"node:fs";import{existsSync as T}from"node:fs";import{createRequire as P}from"node:module";import{dirname as h,resolve as S}from"node:path";function y(o){let n;try{n=P(o).resolve("@rohirik/openltm-core")}catch{return null}for(let t=h(n);t!==h(t);t=h(t)){let r=S(t,"src","cli","bin.ts");if(T(r))return{script:r,args:["mcp-serve"]}}return null}var v="LTM_BRIDGE_DEPTH";function C(){let o=[process.env.BUN_INSTALL?d(process.env.BUN_INSTALL,"bin","bun"):null,d(x(),".bun","bin","bun"),"/opt/homebrew/bin/bun","/usr/local/bin/bun","/usr/bin/bun"].filter(n=>n!==null&&g(n));if(o.length>0)return o[0];for(let n of["which bun","command -v bun"])try{let t=E(n,{encoding:"utf-8",stdio:"pipe"}).trim();if(t&&g(t)&&!/\bpi\b/.test(t))return t}catch{}return null}function _(){let o=d(x(),".claude","plugins","cache","ltm","ltm");if(g(o))try{let n=M(o).filter(t=>/^\d/.test(t)).sort().reverse();for(let t of n){let r=d(o,t,"src","mcp-server.ts");if(g(r))return{script:r,args:[]}}}catch{}return y(import.meta.url)}var w=class{constructor(n,t,r=[]){this.runtime=n;this.script=t;this.args=r}child=null;pending=new Map;buffer="";nextId=1;exited=!1;start(){if(this.child)return;let n=parseInt(process.env[v]??"0",10),t={...process.env,[v]:String(n+1)};this.child=k(this.runtime,[this.script,...this.args],{stdio:["pipe","pipe","pipe"],env:t}),this.child.stdout?.on("data",r=>{this.buffer+=r.toString("utf-8");let e=this.buffer.split(`
2
- `);this.buffer=e.pop()??"";for(let i of e){let c=i.trim();if(c)try{let l=JSON.parse(c);if(l.id!==void 0){let s=this.pending.get(l.id);s&&(this.pending.delete(l.id),l.error?s.reject(new Error(l.error.message)):s.resolve(l.result))}}catch{}}}),this.child.on("exit",()=>{this.exited=!0;for(let r of this.pending.values())r.reject(new Error("LTM MCP server exited"));this.pending.clear()}),this.child.on("error",()=>{this.exited=!0;for(let r of this.pending.values())r.reject(new Error("LTM MCP server error"));this.pending.clear()})}send(n,t,r){let e=JSON.stringify({jsonrpc:"2.0",id:r,method:n,params:t})+`
3
- `;this.child?.stdin?.write(e)}request(n,t,r=3e4){let e=this.nextId++;return new Promise((i,c)=>{let l=setTimeout(()=>{this.pending.delete(e),c(new Error(`LTM MCP timeout: ${n}`))},r);this.pending.set(e,{resolve:s=>{clearTimeout(l),i(s)},reject:s=>{clearTimeout(l),c(s)}}),this.send(n,t,e)})}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(n,t){let r=await this.request("tools/call",{name:n,arguments:t},6e4),e=(r?.content??[]).filter(i=>i?.type==="text"&&i.text).map(i=>i.text).join(`
4
- `);if(r?.isError)throw new Error(e||`${n} returned an error`);return e}};function j(o,n){try{let t=JSON.parse(n),r=Array.isArray(t.globals)?t.globals:[],e=Array.isArray(t.scoped)?t.scoped:[];if(r.length===0&&e.length===0)return"";let i=["## Prior Knowledge (LTM)",""];if(r.length>0){i.push("Global:");for(let c of r.slice(0,3))i.push(`- [${c.id}] ${String(c.content).replace(/\s+/g," ").trim()}`);i.push("")}if(e.length>0){i.push(`Project (${o}):`);for(let c of e.slice(0,7))i.push(`- [${c.id}] ${String(c.content).replace(/\s+/g," ").trim()}`);i.push("")}return i.join(`
1
+ import{spawn as it,execSync as ct}from"node:child_process";import{dirname as at,join as p}from"node:path";import{homedir as lt}from"node:os";import{existsSync as b,readdirSync as ut}from"node:fs";import{existsSync as q}from"node:fs";import{createRequire as B}from"node:module";import{dirname as v,resolve as G}from"node:path";function _(r){let e;try{e=B(r).resolve("@rohirik/openltm-core")}catch{return null}for(let t=v(e);t!==v(t);t=v(t)){let o=G(t,"src","cli","bin.ts");if(q(o))return{script:o,args:["mcp-serve"]}}return null}import{copyFileSync as vt,constants as wt,existsSync as m,mkdirSync as Dt,readFileSync as O,statSync as W}from"node:fs";import{createRequire as V}from"node:module";import{homedir as z}from"node:os";import{basename as J,dirname as w,isAbsolute as X,join as g,resolve as D,sep as h}from"node:path";function k(){let r=process.platform==="win32"?process.env.USERPROFILE:process.env.HOME;return r&&r.trim()?r:z()}function y(){return g(k(),".claude")}var C=["openltm-OpenLtm","OpenLtm-openltm"];function S(r=process.env.CLAUDE_CONFIG_DIR||y()){let e=g(r,"plugins","data"),t=C.find(o=>m(g(e,o,"openltm.db")));return g(e,t??C[0])}function A(r){if(m(r))return r;let e=g(S(),"openltm.db");return m(e)?e:r}function K(){return g(y(),"projects")}function Y(){return g(K(),"registry.json")}function N(r){return r.normalize("NFC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu,"-").replace(/^-+|-+$/g,"")}function j(r){return r.length>1?r.replace(/[\\/]+$/,""):r}function E(r){return j(r).split(/[\\/]/).pop()??""}function Q(r){if(!r||!X(r))return null;let e=D(k()),t=D(r);for(;;){if(m(g(t,".git"))){let n=w(t);return t===e||n===t?null:t}let o=w(t);if(o===t)return null;t=o}}function Z(r){let e=g(r,".git");try{if(!W(e).isFile())return r;let t=/^gitdir:\s*(.+)\s*$/m.exec(O(e,"utf-8"));if(!t)return r;let o=D(r,t[1].trim()),n=`${h}.git${h}worktrees${h}`,s=o.lastIndexOf(n);return s>0?o.slice(0,s):r}catch{return r}}function tt(r,e){if(!e)return null;let t=j(r);for(let n of[r,t])if(e[n])return{name:e[n],path:n,exact:!0};let o=Object.keys(e).sort((n,s)=>s.length-n.length);for(let n of o){let s=j(n);if(e[n]&&(t.startsWith(s+"/")||t.startsWith(s+h)))return{name:e[n],path:n,exact:!1}}return null}function et(r){let e=Q(r);if(e){let t=N(J(Z(e)));if(t)return{name:t,source:"repo-root"}}return{name:N(E(r)),source:"cwd"}}function rt(r,e={}){let t=tt(r,e.registry),o=t?null:et(r),n=t?{name:t.name,source:t.exact?"registry":"registry-prefix",registeredPath:t.path,candidate:t.name,legacyVerified:!1}:{name:o.name,source:o.source,registeredPath:null,candidate:o.name,legacyVerified:!1},s=e.legacyName?.trim()||null,a=u=>({...n,name:s,source:"legacy",registeredPath:null,legacyVerified:u});if(!s||s===n.name)return n;if(!n.name)return a(!1);if(t&&e.legacyScope!=="all")return n;let c=e.hasProjectData??(e.dbPath?u=>st(u,e.dbPath):null);if(!c)return n;let i=c(s);if(i===!1)return n;let l=c(n.name);return l===!0?n:a(i===!0&&l===!1)}function M(r,e={}){return rt(r,e).name}function I(r){try{if(!m(r))return{};let e=JSON.parse(O(r,"utf-8"));if(!e||typeof e!="object"||Array.isArray(e))return{};let t={};for(let[o,n]of Object.entries(e))typeof n=="string"&&n&&(t[o]=n);return t}catch{return{}}}function $(r,e=Y()){let t=I(r);return!e||e===r?t:{...I(e),...t}}var d;function nt(){if(d!==void 0)return d;if(d=null,process.versions?.bun)return null;let r=process.emitWarning;try{process.emitWarning=((t,...o)=>{let n=typeof t=="string"?t:t?.message;if(!/sqlite/i.test(String(n)))return r.call(process,t,...o)});let{DatabaseSync:e}=V(import.meta.url)("node:sqlite");d=t=>new e(t,{readOnly:!0})}catch{d=null}finally{process.emitWarning=r}return d}var ot=["SELECT 1 FROM memories WHERE project_scope = ? LIMIT 1","SELECT 1 FROM context_items WHERE project_name = ? LIMIT 1"];function st(r,e,t=nt()){if(!r||!e||!m(e))return!1;if(!t)return null;let o;try{o=t(e)}catch{return null}try{for(let n of ot)try{if(o.prepare(n).get(r)!=null)return!0}catch(s){if(!/no such table/i.test(String(s)))return null}return!1}finally{try{o.close()}catch{}}}function F(r){return process.env.LTM_DATA_DIR||process.env.CLAUDE_PLUGIN_DATA||w(r)}var L="LTM_BRIDGE_DEPTH";function pt(){let r=[process.env.BUN_INSTALL?p(process.env.BUN_INSTALL,"bin","bun"):null,p(lt(),".bun","bin","bun"),"/opt/homebrew/bin/bun","/usr/local/bin/bun","/usr/bin/bun"].filter(e=>e!==null&&b(e));if(r.length>0)return r[0];for(let e of["which bun","command -v bun"])try{let t=ct(e,{encoding:"utf-8",stdio:"pipe"}).trim();if(t&&b(t)&&!/\bpi\b/.test(t))return t}catch{}return null}function gt(r,e){let t=r.split(/[.-]/).map(n=>parseInt(n,10)||0),o=e.split(/[.-]/).map(n=>parseInt(n,10)||0);for(let n=0;n<Math.max(t.length,o.length);n++){let s=(t[n]??0)-(o[n]??0);if(s!==0)return s}return 0}function ft(){let r=process.env.LTM_DB_PATH||null,e=process.env.CLAUDE_CONFIG_DIR||y(),t=p(e,"plugins");for(let[s,a,c]of[["OpenLtm","openltm",S(e)],["ltm","ltm",p(t,"data","ltm-ltm")]]){let i=p(t,"cache",s,a);if(b(i))try{let l=ut(i).filter(u=>/^\d/.test(u)).sort(gt).reverse();for(let u of l){let f=p(i,u,"src","mcp-server.ts");if(b(f))return{script:f,args:[],dbPath:r??p(c,"openltm.db")}}}catch{}}let o=_(import.meta.url);if(!o)return null;let n=r??(process.env.CLAUDE_PLUGIN_DATA?p(process.env.CLAUDE_PLUGIN_DATA,"openltm.db"):null)??A(p(at(o.script),"..","..","..","..","data","openltm.db"));return{...o,dbPath:n}}var T=class{constructor(e,t,o=[],n={}){this.runtime=e;this.script=t;this.args=o;this.extraEnv=n}child=null;pending=new Map;buffer="";nextId=1;exited=!1;start(){if(this.child)return;let e=parseInt(process.env[L]??"0",10),t={...process.env,...this.extraEnv,[L]:String(e+1)};this.child=it(this.runtime,[this.script,...this.args],{stdio:["pipe","pipe","pipe"],env:t}),this.child.stdout?.on("data",o=>{this.buffer+=o.toString("utf-8");let n=this.buffer.split(`
2
+ `);this.buffer=n.pop()??"";for(let s of n){let a=s.trim();if(a)try{let c=JSON.parse(a);if(c.id!==void 0){let i=this.pending.get(c.id);i&&(this.pending.delete(c.id),c.error?i.reject(new Error(c.error.message)):i.resolve(c.result))}}catch{}}}),this.child.on("exit",()=>{this.exited=!0;for(let o of this.pending.values())o.reject(new Error("LTM MCP server exited"));this.pending.clear()}),this.child.on("error",()=>{this.exited=!0;for(let o of this.pending.values())o.reject(new Error("LTM MCP server error"));this.pending.clear()})}send(e,t,o){let n=JSON.stringify({jsonrpc:"2.0",id:o,method:e,params:t})+`
3
+ `;this.child?.stdin?.write(n)}request(e,t,o=3e4){let n=this.nextId++;return new Promise((s,a)=>{let c=setTimeout(()=>{this.pending.delete(n),a(new Error(`LTM MCP timeout: ${e}`))},o);this.pending.set(n,{resolve:i=>{clearTimeout(c),s(i)},reject:i=>{clearTimeout(c),a(i)}}),this.send(e,t,n)})}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(e,t){let o=await this.request("tools/call",{name:e,arguments:t},6e4),n=(o?.content??[]).filter(s=>s?.type==="text"&&s.text).map(s=>s.text).join(`
4
+ `);if(o?.isError)throw new Error(n||`${e} returned an error`);return n}};function dt(r,e){try{let t=JSON.parse(e),o=Array.isArray(t.globals)?t.globals:[],n=Array.isArray(t.scoped)?t.scoped:[];if(o.length===0&&n.length===0)return"";let s=["## Prior Knowledge (LTM)",""];if(o.length>0){s.push("Global:");for(let a of o.slice(0,3))s.push(`- [${a.id}] ${String(a.content).replace(/\s+/g," ").trim()}`);s.push("")}if(n.length>0){s.push(`Project (${r}):`);for(let a of n.slice(0,7))s.push(`- [${a.id}] ${String(a.content).replace(/\s+/g," ").trim()}`);s.push("")}return s.join(`
5
5
  `).trimEnd()+`
6
- `}catch{return""}}function I(o){let n=o;if(parseInt(process.env[v]??"0",10)>0)return;let t=C(),r=_();if(!t||!r)return;let e=new w(t,r.script,r.args);e.start();let i=new Set,c=(async()=>{await e.initialize();let l=await e.listTools();for(let s of l){let a=s.name;i.add(a),n.registerTool({name:a,label:a,description:s.description??"",parameters:s.inputSchema??{type:"object",properties:{}},async execute(p,u){return{content:[{type:"text",text:await e.callTool(a,u??{})}],details:{}}}})}})().catch(()=>{});n.on("before_agent_start",async l=>{await c;let s=l;try{let p=String(s?.cwd??process.cwd()).replace(/\/$/,"").split("/").pop()??"",u=i.has("context")?"context":i.has("recall")?"recall":"";if(!u)return;let m=u==="context"?await e.callTool("context",{project:p}):await e.callTool("recall",{project:p,limit:8,sort_by:"relevance"}),f=u==="context"?j(p,m):`## Prior Knowledge (LTM)
6
+ `}catch{return""}}var U=new Map;function H(r,e){let t=U.get(r);return t===void 0&&(t=M(r,{registry:e?$(p(F(e),"projects","registry.json")):null,legacyName:E(r),legacyScope:"all",dbPath:e}),U.set(r,t)),t}function mt(r){let e=r;if(parseInt(process.env[L]??"0",10)>0)return;let t=pt(),o=ft();if(!t||!o)return;let n=new T(t,o.script,o.args,o.dbPath?{LTM_DB_PATH:o.dbPath}:{});n.start();let s=new Set,a=(async()=>{await n.initialize();let c=await n.listTools();for(let i of c){let l=i.name;s.add(l),e.registerTool({name:l,label:l,description:i.description??"",parameters:i.inputSchema??{type:"object",properties:{}},async execute(u,f){return{content:[{type:"text",text:await n.callTool(l,f??{})}],details:{}}}})}})().catch(()=>{});e.on("before_agent_start",async c=>{await a;let i=c;try{let l=String(i?.cwd??process.cwd()),u=H(l,o.dbPath),f=s.has("context")?"context":s.has("recall")?"recall":"";if(!f)return;let x=f==="context"?await n.callTool("context",{project:u}):await n.callTool("recall",{project:u,limit:8,sort_by:"relevance"}),P=f==="context"?dt(u,x):`## Prior Knowledge (LTM)
7
7
 
8
- ${m}
9
- `;if(!f.trim())return;let b=String(s?.systemPrompt??"");return{systemPrompt:b?`${b}
8
+ ${x}
9
+ `;if(!P.trim())return;let R=String(i?.systemPrompt??"");return{systemPrompt:R?`${R}
10
10
 
11
- ${f}`:f}}catch{}})}export{I as default};
11
+ ${P}`:P}}catch{}}),e.on("session_compact",async c=>{await a;let i=c;try{let l=String(i?.summary??"").replace(/\s+/g," ").trim();if(l.length<=50||!s.has("context_add"))return;let u=new Date().toISOString().split("T")[0];await n.callTool("context_add",{type:"progress",content:`\u2713 [${u}] Compacted: ${l.slice(0,300)}`,project:H(String(i?.cwd??process.cwd()),o.dbPath)})}catch{}})}export{mt as default,ft as findMcpServer};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohirik/pi-ltm",
3
- "version": "2.16.2",
3
+ "version": "2.17.0",
4
4
  "description": "LTM (Long-Term Memory) native extension for Pi \u2014 share memories with Claude Code and OpenCode",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@rohirik/openltm-core": "^2.16.2"
44
+ "@rohirik/openltm-core": "^2.17.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "bun-types": ">=1.0.0"
@@ -0,0 +1,69 @@
1
+ /**
2
+ * find-mcp-server.test.ts — Pi shares the database of the Claude Code plugin.
3
+ *
4
+ * Claude Code keeps an installed plugin's data in plugins/data/<plugin>-<marketplace>
5
+ * (measured: openltm@OpenLtm → openltm-OpenLtm). Pi must open that database, not a
6
+ * second one under the name OpenLTM ≤ 2.16 assumed (OpenLtm-openltm).
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { findMcpServer } from "../index.js";
13
+
14
+ let home: string;
15
+ const saved: Record<string, string | undefined> = {};
16
+ const KEYS = ["HOME", "LTM_DB_PATH", "CLAUDE_CONFIG_DIR", "CLAUDE_PLUGIN_DATA"];
17
+
18
+ function plugin(version: string): void {
19
+ const src = join(home, ".claude", "plugins", "cache", "OpenLtm", "openltm", version, "src");
20
+ mkdirSync(src, { recursive: true });
21
+ writeFileSync(join(src, "mcp-server.ts"), "");
22
+ }
23
+ function db(dirName: string): string {
24
+ const dir = join(home, ".claude", "plugins", "data", dirName);
25
+ mkdirSync(dir, { recursive: true });
26
+ writeFileSync(join(dir, "openltm.db"), "");
27
+ return join(dir, "openltm.db");
28
+ }
29
+
30
+ beforeEach(() => {
31
+ for (const k of KEYS) saved[k] = process.env[k];
32
+ for (const k of KEYS) delete process.env[k];
33
+ home = mkdtempSync(join(tmpdir(), "pi-find-mcp-"));
34
+ process.env["HOME"] = home;
35
+ });
36
+ afterEach(() => {
37
+ for (const k of KEYS) saved[k] === undefined ? delete process.env[k] : (process.env[k] = saved[k]);
38
+ rmSync(home, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("Pi finds the Claude Code plugin's MCP server and database", () => {
42
+ it("opens the database in openltm-OpenLtm, where Claude Code keeps it", () => {
43
+ plugin("2.17.0");
44
+ const real = db("openltm-OpenLtm");
45
+ const found = findMcpServer();
46
+ expect(found?.script).toBe(join(home, ".claude", "plugins", "cache", "OpenLtm", "openltm", "2.17.0", "src", "mcp-server.ts"));
47
+ expect(found?.dbPath).toBe(real);
48
+ });
49
+
50
+ it("keeps using a database under the older OpenLtm-openltm name when that is the one that exists", () => {
51
+ plugin("2.17.0");
52
+ const legacy = db("OpenLtm-openltm");
53
+ expect(findMcpServer()?.dbPath).toBe(legacy);
54
+ });
55
+
56
+ it("prefers openltm-OpenLtm when both exist", () => {
57
+ plugin("2.17.0");
58
+ db("OpenLtm-openltm");
59
+ const real = db("openltm-OpenLtm");
60
+ expect(findMcpServer()?.dbPath).toBe(real);
61
+ });
62
+
63
+ it("an explicit LTM_DB_PATH still wins", () => {
64
+ plugin("2.17.0");
65
+ db("openltm-OpenLtm");
66
+ process.env["LTM_DB_PATH"] = join(home, "explicit.db");
67
+ expect(findMcpServer()?.dbPath).toBe(join(home, "explicit.db"));
68
+ });
69
+ });
@@ -1,144 +1,89 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
- import { readFileSync, unlinkSync } from "fs";
1
+ /**
2
+ * Pi extension — the real entry point (src/index.ts, what dist/index.js is built
3
+ * from), driven with a stub Pi host. It bridges to a real `mcp-serve` child over
4
+ * stdio against a temp DB, exactly as Pi runs it. (The old tests exercised
5
+ * hooks.ts/tools.ts, which Pi never loaded.)
6
+ */
7
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync } from "fs";
9
+ import { tmpdir } from "os";
3
10
  import { join } from "path";
4
- import { Database } from "bun:sqlite";
5
11
 
6
- const dbPath = `/tmp/test-pi-ltm-${process.pid}-${Date.now()}.db`;
7
- const SCHEMA_PATH = join(import.meta.dir, "..", "..", "..", "openltm-core", "src", "schema.sql");
12
+ type Tool = { name: string; execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }> };
13
+ type Handler = (event: unknown) => Promise<unknown> | unknown;
8
14
 
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>> = {};
15
+ function createStubPi() {
16
+ const tools = new Map<string, Tool>();
17
+ const handlers = new Map<string, Handler>();
18
18
  return {
19
19
  tools,
20
20
  handlers,
21
- registerTool(def: typeof tools[0]) { tools.push(def); },
22
- on(event: string, handler: (...args: unknown[]) => unknown) {
23
- if (!handlers[event]) handlers[event] = [];
24
- handlers[event]!.push(handler);
25
- },
21
+ registerTool(def: Tool) { tools.set(def.name, def); },
22
+ on(event: string, handler: Handler) { handlers.set(event, handler); },
26
23
  };
27
24
  }
28
25
 
29
- beforeAll(async () => {
30
- const { runPendingMigrations, _setDbForTesting } = await import("@rohirik/openltm-core");
31
- const db = new Database(dbPath, { create: true });
32
- db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
33
- db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
34
- await runPendingMigrations(db);
35
- _setDbForTesting(db);
36
- }, 30_000);
37
-
38
- afterAll(() => {
39
- try { unlinkSync(dbPath); } catch {}
40
- try { unlinkSync(`${dbPath}-shm`); } catch {}
41
- try { unlinkSync(`${dbPath}-wal`); } catch {}
42
- });
43
-
44
- describe("Pi LTM extension — registerTools()", () => {
45
- it("registers exactly 3 tools", async () => {
46
- const { registerTools } = await import("../tools.js");
47
- const pi = createMockPi();
48
- registerTools(pi);
49
- expect(pi.tools).toHaveLength(3);
50
- });
26
+ async function until(cond: () => boolean, ms = 20_000): Promise<void> {
27
+ const t0 = Date.now();
28
+ while (!cond()) {
29
+ if (Date.now() - t0 > ms) throw new Error("timed out waiting for the MCP bridge");
30
+ await Bun.sleep(50);
31
+ }
32
+ }
51
33
 
52
- it("registers ltm_recall, ltm_learn, ltm_forget", async () => {
53
- const { registerTools } = await import("../tools.js");
54
- const pi = createMockPi();
55
- registerTools(pi);
56
- const names = pi.tools.map(t => t.name);
57
- expect(names).toContain("ltm_recall");
58
- expect(names).toContain("ltm_learn");
59
- expect(names).toContain("ltm_forget");
34
+ const text = (r: { content: Array<{ text: string }> }) => r.content.map((c) => c.text).join("\n");
35
+
36
+ describe("Pi extension (real entry point over the MCP bridge)", () => {
37
+ const root = mkdtempSync(join(tmpdir(), "ltm-pi-"));
38
+ // Under Bun (this runner) node:sqlite is unavailable, so the legacy-name check
39
+ // cannot run and keeps the raw folder name; use one that is already normalized.
40
+ // Mixed-case unification is exercised under real Node in scripts/qa/adapters-smoke.ts.
41
+ const cwd = join(root, "code", "pi-demo");
42
+ const saved = { LTM_DB_PATH: process.env.LTM_DB_PATH, CLAUDE_PLUGIN_DATA: process.env.CLAUDE_PLUGIN_DATA };
43
+ const pi = createStubPi();
44
+
45
+ beforeAll(async () => {
46
+ mkdirSync(join(cwd, ".git"), { recursive: true });
47
+ process.env.LTM_DB_PATH = join(root, "openltm.db");
48
+ delete process.env.CLAUDE_PLUGIN_DATA;
49
+ const { default: ltmExtension } = await import("../index.js");
50
+ ltmExtension(pi);
51
+ await until(() => pi.tools.has("learn") && pi.tools.has("context_add"));
52
+ }, 30_000);
53
+
54
+ afterAll(() => {
55
+ for (const [k, v] of Object.entries(saved)) v === undefined ? delete process.env[k] : (process.env[k] = v);
56
+ rmSync(root, { recursive: true, force: true });
60
57
  });
61
58
 
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();
59
+ it("registers every MCP tool and hooks before_agent_start + session_compact", () => {
60
+ for (const name of ["recall", "get", "learn", "context", "context_items", "context_add", "proposals"]) {
61
+ expect(pi.tools.has(name)).toBe(true);
69
62
  }
63
+ expect(pi.handlers.has("before_agent_start")).toBe(true);
64
+ expect(pi.handlers.has("session_compact")).toBe(true);
70
65
  });
71
- });
72
-
73
- describe("Pi LTM extension — registerHooks()", () => {
74
- it("registers before_agent_start and session_compact hooks", async () => {
75
- const { registerHooks } = await import("../hooks.js");
76
- const pi = createMockPi();
77
- registerHooks(pi);
78
- expect(pi.handlers["before_agent_start"]?.length).toBeGreaterThan(0);
79
- expect(pi.handlers["session_compact"]?.length).toBeGreaterThan(0);
80
- });
81
-
82
- it("before_agent_start returns systemPrompt with Prior Knowledge when memories exist", async () => {
83
- const { learn } = await import("@rohirik/openltm-core");
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
- });
91
-
92
- const { registerHooks } = await import("../hooks.js");
93
- const pi = createMockPi();
94
- registerHooks(pi);
95
-
96
- const result = await pi.handlers["before_agent_start"]![0]!({
97
- cwd: "/tmp/pi-test-proj",
98
- systemPrompt: "",
99
- }) as { systemPrompt?: string } | undefined;
100
-
101
- expect(result?.systemPrompt).toContain("Prior Knowledge");
102
- });
103
-
104
- it("before_agent_start returns undefined when no memories for project", async () => {
105
- const { registerHooks } = await import("../hooks.js");
106
- const pi = createMockPi();
107
- registerHooks(pi);
108
-
109
- const result = await pi.handlers["before_agent_start"]![0]!({
110
- cwd: "/tmp/empty-pi-project-xyz-no-memories",
111
- systemPrompt: "",
112
- });
113
-
114
- expect(result).toBeUndefined();
115
- });
116
- });
117
66
 
118
- describe("ltm_learn / ltm_recall roundtrip via Pi tools", () => {
119
- it("stores and retrieves a memory", async () => {
120
- const { registerTools } = await import("../tools.js");
121
- const pi = createMockPi();
122
- registerTools(pi);
123
-
124
- const learnTool = pi.tools.find(t => t.name === "ltm_learn")!;
125
- const recallTool = pi.tools.find(t => t.name === "ltm_recall")!;
126
-
127
- const learnResult = await learnTool.execute("call-1", {
128
- content: "Pi roundtrip test — always verify types",
129
- category: "gotcha",
130
- importance: 4,
131
- }) as { content: Array<{ type: string; text: string }> };
132
-
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");
136
-
137
- const recallResult = await recallTool.execute("call-2", {
138
- query: "Pi roundtrip verify types",
139
- }) as { content: Array<{ type: string; text: string }> };
140
-
141
- const memories = JSON.parse(recallResult.content[0]!.text) as Array<{ id: number }>;
142
- expect(memories.some(m => m.id === learned.id)).toBe(true);
143
- });
67
+ it("learn → before_agent_start injects it for the project resolved from cwd", async () => {
68
+ const learned = JSON.parse(text(await pi.tools.get("learn")!.execute("t1", {
69
+ content: "Pi demo: deploys go through the blue/green switch, never in place", category: "workflow", importance: 3, project: "pi-demo",
70
+ }))) as { id: number };
71
+ const res = (await pi.handlers.get("before_agent_start")!({ cwd, systemPrompt: "BASE" })) as { systemPrompt: string };
72
+ expect(res.systemPrompt.startsWith("BASE\n\n## Prior Knowledge (LTM)")).toBe(true);
73
+ expect(res.systemPrompt).toContain(`Project (pi-demo):`);
74
+ expect(res.systemPrompt).toContain(`[${learned.id}] Pi demo: deploys go through the blue/green switch`);
75
+ }, 30_000);
76
+
77
+ it("session_compact records the summary as project progress via context_add", async () => {
78
+ const summary = "Compacted session: wired the blue/green deploy switch and documented the rollback steps for the demo service.";
79
+ await pi.handlers.get("session_compact")!({ cwd, summary });
80
+ const items = text(await pi.tools.get("context_items")!.execute("t2", { project: "pi-demo", type: "progress" }));
81
+ expect(items).toContain("Compacted: Compacted session: wired the blue/green deploy switch");
82
+ }, 30_000);
83
+
84
+ it("ignores short compaction summaries", async () => {
85
+ await pi.handlers.get("session_compact")!({ cwd, summary: "too short" });
86
+ const items = text(await pi.tools.get("context_items")!.execute("t3", { project: "pi-demo", type: "progress" }));
87
+ expect(items).not.toContain("too short");
88
+ }, 30_000);
144
89
  });
package/src/index.ts CHANGED
@@ -8,10 +8,12 @@
8
8
  * Pattern adapted from context-mode's Pi adapter (MIT).
9
9
  */
10
10
  import { spawn, execSync } from "node:child_process";
11
- import { join } from "node:path";
11
+ import { dirname, join } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  import { existsSync, readdirSync } from "node:fs";
14
14
  import { findCoreCli } from "./find-core.js";
15
+ // Relative import so esbuild inlines the Node-safe resolver into dist (core itself is external).
16
+ import { dataDirFor, getClaudePluginDataDir, getLegacyClaudeDir, legacyLastSegment, loadProjectRegistry, resolveProjectName, sharedDbPath } from "../../openltm-core/src/project.js";
15
17
 
16
18
  // ── Fork-bomb prevention ──────────────────────────────────────────────────────
17
19
 
@@ -42,25 +44,55 @@ function findBun(): string | null {
42
44
  return null;
43
45
  }
44
46
 
45
- function findMcpServer(): { script: string; args: 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)) {
47
+ /** Compare dotted versions numerically ("2.17.0" > "2.9.3"). */
48
+ function compareVersions(a: string, b: string): number {
49
+ const pa = a.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
50
+ const pb = b.split(/[.-]/).map((x) => parseInt(x, 10) || 0);
51
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
52
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
53
+ if (d !== 0) return d;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ interface McpServerLocation {
59
+ script: string;
60
+ args: string[];
61
+ /** DB the server must open — passed to it as LTM_DB_PATH so naming and storage agree. */
62
+ dbPath: string | null;
63
+ }
64
+
65
+ export function findMcpServer(): McpServerLocation | null {
66
+ const explicitDb = process.env["LTM_DB_PATH"] || null;
67
+ // 1. Claude Code plugin cache — newest version first. Share Claude Code's
68
+ // database (its plugin data dir) so both agents see the same memories.
69
+ const claudeDir = process.env["CLAUDE_CONFIG_DIR"] || getLegacyClaudeDir();
70
+ const plugins = join(claudeDir, "plugins");
71
+ for (const [marketplace, plugin, dataDir] of [
72
+ ["OpenLtm", "openltm", getClaudePluginDataDir(claudeDir)],
73
+ ["ltm", "ltm", join(plugins, "data", "ltm-ltm")],
74
+ ] as const) {
75
+ const cacheBase = join(plugins, "cache", marketplace, plugin);
76
+ if (!existsSync(cacheBase)) continue;
49
77
  try {
50
- const versions = readdirSync(cacheBase)
51
- .filter((v) => /^\d/.test(v))
52
- .sort()
53
- .reverse();
78
+ const versions = readdirSync(cacheBase).filter((v) => /^\d/.test(v)).sort(compareVersions).reverse();
54
79
  for (const v of versions) {
55
80
  const script = join(cacheBase, v, "src", "mcp-server.ts");
56
- if (existsSync(script)) return { script, args: [] };
81
+ if (existsSync(script)) {
82
+ return { script, args: [], dbPath: explicitDb ?? join(dataDir, "openltm.db") };
83
+ }
57
84
  }
58
85
  } catch {
59
86
  // continue to next strategy
60
87
  }
61
88
  }
62
89
  // 2. openltm-core package — run the packaged CLI entrypoint with mcp-serve
63
- return findCoreCli(import.meta.url);
90
+ const core = findCoreCli(import.meta.url);
91
+ if (!core) return null;
92
+ const dbPath = explicitDb
93
+ ?? (process.env["CLAUDE_PLUGIN_DATA"] ? join(process.env["CLAUDE_PLUGIN_DATA"]!, "openltm.db") : null)
94
+ ?? sharedDbPath(join(dirname(core.script), "..", "..", "..", "..", "data", "openltm.db"));
95
+ return { ...core, dbPath };
64
96
  }
65
97
 
66
98
  // ── Minimal MCP stdio client ──────────────────────────────────────────────────
@@ -82,12 +114,13 @@ class LtmMcpClient {
82
114
  private readonly runtime: string,
83
115
  private readonly script: string,
84
116
  private readonly args: string[] = [],
117
+ private readonly extraEnv: Record<string, string> = {},
85
118
  ) {}
86
119
 
87
120
  start(): void {
88
121
  if (this.child) return;
89
122
  const depth = parseInt(process.env[BRIDGE_DEPTH_ENV] ?? "0", 10);
90
- const env = { ...process.env, [BRIDGE_DEPTH_ENV]: String(depth + 1) };
123
+ const env = { ...process.env, ...this.extraEnv, [BRIDGE_DEPTH_ENV]: String(depth + 1) };
91
124
 
92
125
  this.child = spawn(this.runtime, [this.script, ...this.args], { stdio: ["pipe", "pipe", "pipe"], env });
93
126
 
@@ -204,6 +237,26 @@ function formatContextPayload(project: string, raw: string): string {
204
237
  }
205
238
  }
206
239
 
240
+ /**
241
+ * Shared resolver (registry → repo root → cwd basename), memoised per cwd. Pi used
242
+ * the raw cwd basename before unified identity; that name is kept while it is
243
+ * the only one with rows, so no memory is orphaned.
244
+ */
245
+ const projectCache = new Map<string, string>();
246
+ function projectOf(cwd: string, dbPath: string | null): string {
247
+ let name = projectCache.get(cwd);
248
+ if (name === undefined) {
249
+ name = resolveProjectName(cwd, {
250
+ registry: dbPath ? loadProjectRegistry(join(dataDirFor(dbPath), "projects", "registry.json")) : null,
251
+ legacyName: legacyLastSegment(cwd),
252
+ legacyScope: "all",
253
+ dbPath,
254
+ });
255
+ projectCache.set(cwd, name);
256
+ }
257
+ return name;
258
+ }
259
+
207
260
  export default function ltmExtension(pi: unknown): void {
208
261
  const p = pi as {
209
262
  registerTool: (def: {
@@ -221,7 +274,7 @@ export default function ltmExtension(pi: unknown): void {
221
274
  const server = findMcpServer();
222
275
  if (!bun || !server) return; // degrade gracefully — no bun or server found
223
276
 
224
- const client = new LtmMcpClient(bun, server.script, server.args);
277
+ const client = new LtmMcpClient(bun, server.script, server.args, server.dbPath ? { LTM_DB_PATH: server.dbPath } : {});
225
278
  client.start();
226
279
 
227
280
  // Bootstrap runs async — tools are registered once handshake completes.
@@ -252,7 +305,7 @@ export default function ltmExtension(pi: unknown): void {
252
305
  const ev = event as { cwd?: string; systemPrompt?: string } | null;
253
306
  try {
254
307
  const cwd = String(ev?.cwd ?? process.cwd());
255
- const project = cwd.replace(/\/$/, "").split("/").pop() ?? "";
308
+ const project = projectOf(cwd, server.dbPath);
256
309
  const toolName = toolNames.has("context") ? "context" : (toolNames.has("recall") ? "recall" : "");
257
310
  if (!toolName) return;
258
311
  const text = toolName === "context"
@@ -268,4 +321,23 @@ export default function ltmExtension(pi: unknown): void {
268
321
  // non-fatal
269
322
  }
270
323
  });
324
+
325
+ // Record the compaction summary as a progress item for the project. Raw
326
+ // summaries are too noisy to store as memories.
327
+ p.on("session_compact", async (event: unknown) => {
328
+ await ready;
329
+ const ev = event as { cwd?: string; summary?: string } | null;
330
+ try {
331
+ const summary = String(ev?.summary ?? "").replace(/\s+/g, " ").trim();
332
+ if (summary.length <= 50 || !toolNames.has("context_add")) return;
333
+ const today = new Date().toISOString().split("T")[0];
334
+ await client.callTool("context_add", {
335
+ type: "progress",
336
+ content: `✓ [${today}] Compacted: ${summary.slice(0, 300)}`,
337
+ project: projectOf(String(ev?.cwd ?? process.cwd()), server.dbPath),
338
+ });
339
+ } catch {
340
+ // non-fatal
341
+ }
342
+ });
271
343
  }
package/src/hooks.ts DELETED
@@ -1,45 +0,0 @@
1
- import { buildPrefillContext, deriveProjectFromCwd, learn, PREFILL_DEFAULTS } from "@rohirik/openltm-core";
2
-
3
- type PiAny = any;
4
-
5
- function projectFromCwd(cwd: string): string {
6
- return deriveProjectFromCwd(cwd);
7
- }
8
-
9
- export function registerHooks(pi: PiAny): void {
10
- // Inject relevant memories into the system prompt before each agent turn
11
- pi.on("before_agent_start", async (event: PiAny) => {
12
- try {
13
- const cwd = String(event?.cwd ?? process.cwd());
14
- const project = projectFromCwd(cwd);
15
- const block = buildPrefillContext({ project, ...PREFILL_DEFAULTS });
16
- if (!block) return;
17
- const existing = String(event?.systemPrompt ?? "");
18
- const parts = existing ? [existing, block] : [block];
19
- return { systemPrompt: parts.join("\n\n") };
20
- } catch {
21
- // Non-fatal — session continues without LTM context
22
- }
23
- });
24
-
25
- // Learn from session summary after compact
26
- pi.on("session_compact", (event: PiAny) => {
27
- try {
28
- const cwd = String(event?.cwd ?? process.cwd());
29
- const project = projectFromCwd(cwd);
30
- const summary = String(event?.summary ?? "");
31
- if (summary.trim().length > 50) {
32
- learn({
33
- content: summary.slice(0, 500),
34
- category: "pattern",
35
- importance: 2,
36
- project_scope: project,
37
- actor: "pi:compact",
38
- skipExport: true,
39
- });
40
- }
41
- } catch {
42
- // Non-fatal
43
- }
44
- });
45
- }
package/src/tools.ts DELETED
@@ -1,97 +0,0 @@
1
- import { recall, learn, forget } from "@rohirik/openltm-core";
2
-
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"],
24
- },
25
- },
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
- });
40
-
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"],
54
- },
55
- importance: { type: "number", description: "Importance 1-5 (default 3)", minimum: 1, maximum: 5 },
56
- project: { type: "string", description: "Scope to a specific project" },
57
- },
58
- required: ["content"],
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
- });
76
-
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" },
86
- },
87
- required: ["id"],
88
- },
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
- });
97
- }