@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 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
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "dist",
6
+ "typeRoots": ["./types", "../../node_modules/@types"]
7
+ },
8
+ "include": ["src/**/*", "types/**/*"],
9
+ "exclude": ["node_modules", "dist"]
10
+ }
@@ -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
+ }