@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 +4 -2
- package/dist/index.js +8 -8
- package/package.json +2 -2
- package/src/__tests__/find-mcp-server.test.ts +69 -0
- package/src/__tests__/plugin.test.ts +73 -128
- package/src/index.ts +86 -14
- package/src/hooks.ts +0 -45
- package/src/tools.ts +0 -97
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
|
-
|
|
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
|
|
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
|
|
2
|
-
`);this.buffer=
|
|
3
|
-
`;this.child?.stdin?.write(
|
|
4
|
-
`);if(
|
|
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
|
|
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
|
-
${
|
|
9
|
-
`;if(!
|
|
8
|
+
${x}
|
|
9
|
+
`;if(!P.trim())return;let R=String(i?.systemPrompt??"");return{systemPrompt:R?`${R}
|
|
10
10
|
|
|
11
|
-
${
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
10
|
-
const tools
|
|
11
|
-
|
|
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:
|
|
22
|
-
on(event: string, handler:
|
|
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
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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("
|
|
63
|
-
const
|
|
64
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|