@rohirik/pi-ltm 2.0.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 +38 -0
- package/package.json +37 -0
- package/src/__tests__/plugin.test.ts +131 -0
- package/src/hooks.ts +52 -0
- package/src/index.ts +8 -0
- package/src/tools.ts +76 -0
- package/tsconfig.json +10 -0
- package/types/pi-ai.d.ts +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @rohirik/pi-ltm
|
|
2
|
+
|
|
3
|
+
Long-Term Memory (LTM) native extension for [Pi](https://pi.ai) coding agent — shares the same memory database as Claude Code and OpenCode.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@rohirik/pi-ltm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add to `pi.toml`:
|
|
12
|
+
|
|
13
|
+
```toml
|
|
14
|
+
[[extensions]]
|
|
15
|
+
package = "@rohirik/pi-ltm"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## What it does
|
|
19
|
+
|
|
20
|
+
- **Session start** (`session:start`): injects a `## Prior Knowledge` block into the system prompt
|
|
21
|
+
- **Compaction** (`compact`): saves session summary to LTM before conversation compaction
|
|
22
|
+
- **3 tools**: `ltm_recall`, `ltm_learn`, `ltm_forget`
|
|
23
|
+
|
|
24
|
+
## Shared memory
|
|
25
|
+
|
|
26
|
+
All three tools read from and write to the same database:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
~/.claude/plugins/data/ltm-ltm/ltm.db
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A memory learned in Pi appears in the next Claude Code session, and vice versa.
|
|
33
|
+
|
|
34
|
+
## Custom DB path
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export LTM_DB_PATH="/custom/path/ltm.db"
|
|
38
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rohirik/pi-ltm",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "LTM (Long-Term Memory) native extension for Pi — share memories with Claude Code and OpenCode",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "bunx tsc --noEmit",
|
|
12
|
+
"test": "bun test"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/RohiRIK/claude-ltm-plugin"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["pi", "pi-coding-agent", "ltm", "long-term-memory", "extension"],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@rohirik/ltm-core": "^2.0.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": ">=1.0.0",
|
|
28
|
+
"bun-types": ">=1.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@earendil-works/pi-ai": { "optional": true },
|
|
32
|
+
"bun-types": { "optional": true }
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { readFileSync, unlinkSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import type { PiExtensionAPI, PiToolDefinition } from "@earendil-works/pi-ai";
|
|
6
|
+
|
|
7
|
+
const dbPath = `/tmp/test-pi-ltm-${process.pid}-${Date.now()}.db`;
|
|
8
|
+
const SCHEMA_PATH = join(import.meta.dir, "..", "..", "..", "ltm-core", "src", "schema.sql");
|
|
9
|
+
|
|
10
|
+
// Mock Pi API
|
|
11
|
+
function createMockPi(): PiExtensionAPI & { tools: PiToolDefinition[]; handlers: Record<string, Function[]> } {
|
|
12
|
+
const tools: PiToolDefinition[] = [];
|
|
13
|
+
const handlers: Record<string, Function[]> = {};
|
|
14
|
+
return {
|
|
15
|
+
tools,
|
|
16
|
+
handlers,
|
|
17
|
+
registerTool(def) { tools.push(def); },
|
|
18
|
+
on(event, handler) {
|
|
19
|
+
if (!handlers[event]) handlers[event] = [];
|
|
20
|
+
handlers[event]!.push(handler as Function);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
const { runPendingMigrations, _setDbForTesting } = await import("@rohirik/ltm-core");
|
|
27
|
+
const db = new Database(dbPath, { create: true });
|
|
28
|
+
db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
|
|
29
|
+
db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
|
|
30
|
+
await runPendingMigrations(db);
|
|
31
|
+
_setDbForTesting(db);
|
|
32
|
+
}, 30_000);
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
try { unlinkSync(dbPath); } catch {}
|
|
36
|
+
try { unlinkSync(`${dbPath}-shm`); } catch {}
|
|
37
|
+
try { unlinkSync(`${dbPath}-wal`); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Pi LTM extension — registerTools()", () => {
|
|
41
|
+
it("registers exactly 3 tools", async () => {
|
|
42
|
+
const { registerTools } = await import("../tools.js");
|
|
43
|
+
const pi = createMockPi();
|
|
44
|
+
registerTools(pi);
|
|
45
|
+
expect(pi.tools).toHaveLength(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("registers ltm_recall, ltm_learn, ltm_forget", async () => {
|
|
49
|
+
const { registerTools } = await import("../tools.js");
|
|
50
|
+
const pi = createMockPi();
|
|
51
|
+
registerTools(pi);
|
|
52
|
+
const names = pi.tools.map(t => t.name);
|
|
53
|
+
expect(names).toContain("ltm_recall");
|
|
54
|
+
expect(names).toContain("ltm_learn");
|
|
55
|
+
expect(names).toContain("ltm_forget");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("Pi LTM extension — registerHooks()", () => {
|
|
60
|
+
it("registers session:start and compact hooks", async () => {
|
|
61
|
+
const { registerHooks } = await import("../hooks.js");
|
|
62
|
+
const pi = createMockPi();
|
|
63
|
+
registerHooks(pi);
|
|
64
|
+
expect(pi.handlers["session:start"]?.length).toBeGreaterThan(0);
|
|
65
|
+
expect(pi.handlers["compact"]?.length).toBeGreaterThan(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("session:start injects Prior Knowledge block when memories exist", async () => {
|
|
69
|
+
const { learn } = await import("@rohirik/ltm-core");
|
|
70
|
+
learn({ content: "Pi test memory — use strict types", category: "pattern", importance: 3, project_scope: "pi-test-proj", skipExport: true });
|
|
71
|
+
|
|
72
|
+
const { registerHooks } = await import("../hooks.js");
|
|
73
|
+
const pi = createMockPi();
|
|
74
|
+
registerHooks(pi);
|
|
75
|
+
|
|
76
|
+
const appended: string[] = [];
|
|
77
|
+
const ctx = {
|
|
78
|
+
cwd: "/tmp/pi-test-proj",
|
|
79
|
+
sessionID: "pi-test-session-1",
|
|
80
|
+
appendToSystemPrompt: (block: string) => { appended.push(block); },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await pi.handlers["session:start"]![0]!(ctx);
|
|
84
|
+
|
|
85
|
+
expect(appended.length).toBeGreaterThan(0);
|
|
86
|
+
expect(appended.some(b => b.includes("Prior Knowledge"))).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("session:start does not inject when no memories for project", async () => {
|
|
90
|
+
const { registerHooks } = await import("../hooks.js");
|
|
91
|
+
const pi = createMockPi();
|
|
92
|
+
registerHooks(pi);
|
|
93
|
+
|
|
94
|
+
const appended: string[] = [];
|
|
95
|
+
const ctx = {
|
|
96
|
+
cwd: "/tmp/empty-pi-project",
|
|
97
|
+
sessionID: "pi-test-session-2",
|
|
98
|
+
appendToSystemPrompt: (block: string) => { appended.push(block); },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
await pi.handlers["session:start"]![0]!(ctx);
|
|
102
|
+
|
|
103
|
+
expect(appended.length).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("ltm_learn / ltm_recall roundtrip via Pi tools", () => {
|
|
108
|
+
it("stores and retrieves a memory", async () => {
|
|
109
|
+
const { registerTools } = await import("../tools.js");
|
|
110
|
+
const pi = createMockPi();
|
|
111
|
+
registerTools(pi);
|
|
112
|
+
|
|
113
|
+
const learnTool = pi.tools.find(t => t.name === "ltm_learn")!;
|
|
114
|
+
const recallTool = pi.tools.find(t => t.name === "ltm_recall")!;
|
|
115
|
+
|
|
116
|
+
const result = await learnTool.handler({
|
|
117
|
+
content: "Pi roundtrip test — always verify types",
|
|
118
|
+
category: "gotcha",
|
|
119
|
+
importance: 4,
|
|
120
|
+
}) as { id: number; action: string };
|
|
121
|
+
|
|
122
|
+
expect(result.id).toBeGreaterThan(0);
|
|
123
|
+
expect(result.action).toBe("created");
|
|
124
|
+
|
|
125
|
+
const memories = await recallTool.handler({
|
|
126
|
+
query: "Pi roundtrip verify types",
|
|
127
|
+
}) as Array<{ id: number; content: string }>;
|
|
128
|
+
|
|
129
|
+
expect(memories.some(m => m.id === result.id)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PiExtensionAPI } from "@earendil-works/pi-ai";
|
|
2
|
+
import { recall, learn } from "@rohirik/ltm-core";
|
|
3
|
+
|
|
4
|
+
const MAX_CONTEXT_MEMORIES = 10;
|
|
5
|
+
|
|
6
|
+
function formatContextBlock(memories: Array<{ id: number; content: string; category: string }>): string {
|
|
7
|
+
const lines = memories.map(m => `- [${m.id}] (${m.category}) ${m.content}`);
|
|
8
|
+
return "## Prior Knowledge (LTM)\n\n" + lines.join("\n") + "\n";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function projectFromCwd(cwd: string): string {
|
|
12
|
+
return cwd.replace(/\/$/, "").split("/").pop() ?? "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerHooks(pi: PiExtensionAPI): void {
|
|
16
|
+
pi.on("session:start", async (ctx) => {
|
|
17
|
+
try {
|
|
18
|
+
const project = projectFromCwd(ctx.cwd);
|
|
19
|
+
const memories = await recall({
|
|
20
|
+
project,
|
|
21
|
+
limit: MAX_CONTEXT_MEMORIES,
|
|
22
|
+
sort_by: "relevance",
|
|
23
|
+
});
|
|
24
|
+
if (memories.length > 0) {
|
|
25
|
+
ctx.appendToSystemPrompt(formatContextBlock(
|
|
26
|
+
memories.map(m => ({ id: m.id, content: m.content, category: m.category })),
|
|
27
|
+
));
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Non-fatal — session continues without LTM context
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.on("compact", async (ctx) => {
|
|
35
|
+
try {
|
|
36
|
+
const project = projectFromCwd(ctx.cwd);
|
|
37
|
+
const summary = ctx.getSessionSummary();
|
|
38
|
+
if (summary.trim().length > 50) {
|
|
39
|
+
learn({
|
|
40
|
+
content: summary.slice(0, 500),
|
|
41
|
+
category: "pattern",
|
|
42
|
+
importance: 2,
|
|
43
|
+
project_scope: project,
|
|
44
|
+
actor: "pi:compact",
|
|
45
|
+
skipExport: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Non-fatal
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PiExtensionAPI } from "@earendil-works/pi-ai";
|
|
2
|
+
import { registerTools } from "./tools.js";
|
|
3
|
+
import { registerHooks } from "./hooks.js";
|
|
4
|
+
|
|
5
|
+
export default function ltmExtension(pi: PiExtensionAPI): void {
|
|
6
|
+
registerTools(pi);
|
|
7
|
+
registerHooks(pi);
|
|
8
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { PiExtensionAPI, PiToolDefinition } from "@earendil-works/pi-ai";
|
|
2
|
+
import { recall, learn, forget } from "@rohirik/ltm-core";
|
|
3
|
+
|
|
4
|
+
export function registerTools(pi: PiExtensionAPI): void {
|
|
5
|
+
const tools: PiToolDefinition[] = [
|
|
6
|
+
{
|
|
7
|
+
name: "ltm_recall",
|
|
8
|
+
description: "Search long-term memories by query, category, or project scope. Call before starting any non-trivial task.",
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
query: { type: "string", description: "Full-text search query" },
|
|
13
|
+
project: { type: "string", description: "Filter by project scope" },
|
|
14
|
+
limit: { type: "number", description: "Max results (default 10)", minimum: 1, maximum: 50 },
|
|
15
|
+
category: { type: "string", enum: ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"] },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
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
|
+
},
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
name: "ltm_learn",
|
|
31
|
+
description: "Store or reinforce a memory. Call after discovering a non-obvious pattern, gotcha, or architectural decision.",
|
|
32
|
+
parameters: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
content: { type: "string", description: "The insight, pattern, or decision to store" },
|
|
36
|
+
category: { type: "string", enum: ["preference", "architecture", "gotcha", "pattern", "workflow", "constraint"] },
|
|
37
|
+
importance: { type: "number", description: "Importance 1-5 (default 3)", minimum: 1, maximum: 5 },
|
|
38
|
+
project: { type: "string", description: "Scope to a specific project" },
|
|
39
|
+
},
|
|
40
|
+
required: ["content"],
|
|
41
|
+
},
|
|
42
|
+
async handler({ content, category, importance, project }) {
|
|
43
|
+
const result = learn({
|
|
44
|
+
content: content as string,
|
|
45
|
+
category: category as string | undefined,
|
|
46
|
+
importance: importance as number | undefined,
|
|
47
|
+
project_scope: project as string | undefined,
|
|
48
|
+
actor: "pi:ltm_learn",
|
|
49
|
+
skipExport: true,
|
|
50
|
+
});
|
|
51
|
+
return { id: result.id, action: result.action, category: result.category };
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
name: "ltm_forget",
|
|
57
|
+
description: "Delete a memory by ID. Call when a memory is wrong or outdated.",
|
|
58
|
+
parameters: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
id: { type: "number", description: "Memory ID to delete" },
|
|
62
|
+
reason: { type: "string", description: "Why this memory is being removed" },
|
|
63
|
+
},
|
|
64
|
+
required: ["id"],
|
|
65
|
+
},
|
|
66
|
+
async handler({ id, reason }) {
|
|
67
|
+
forget({ id: id as number, reason: reason as string | undefined, actor: "pi:ltm_forget" });
|
|
68
|
+
return { ok: true, id };
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const tool of tools) {
|
|
74
|
+
pi.registerTool(tool);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/tsconfig.json
ADDED
package/types/pi-ai.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
}
|