@pi-unipi/memory 0.1.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,94 @@
1
+ # @unipi/memory
2
+
3
+ Persistent cross-session memory with vector search for Pi coding agent.
4
+
5
+ ## Features
6
+
7
+ - **Two-tier storage:** SQLite + sqlite-vec for vector search, markdown files for human-readable memory
8
+ - **Project-scoped + global memory:** Each project gets its own DB, global memories accessible cross-project
9
+ - **Hybrid search:** Vector similarity + fuzzy text matching for best recall
10
+ - **Session injection:** Agent sees memory titles at session start
11
+ - **Auto-consolidation:** Memories extracted during compaction
12
+ - **Update-first:** Prevents memory duplication
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ # All-in-one (includes memory)
18
+ pi install npm:unipi
19
+
20
+ # Standalone
21
+ pi install npm:@unipi/memory
22
+ ```
23
+
24
+ ## Tools
25
+
26
+ | Tool | Description |
27
+ |------|-------------|
28
+ | `memory_store` | Store/update memory (project scope) |
29
+ | `memory_search` | Search memories (project scope) |
30
+ | `memory_delete` | Delete memory by ID or title |
31
+ | `memory_list` | List all project memories |
32
+ | `global_memory_store` | Store/update memory (global scope) |
33
+ | `global_memory_search` | Search global memories |
34
+ | `global_memory_list` | List all global memories |
35
+
36
+ ## Commands
37
+
38
+ | Command | Description |
39
+ |---------|-------------|
40
+ | `/unipi:memory-process <text>` | Analyze text and store extracted memories |
41
+ | `/unipi:memory-search <term>` | Search project memories |
42
+ | `/unipi:memory-consolidate` | Consolidate session into memory |
43
+ | `/unipi:memory-forget <title>` | Delete a memory by title |
44
+ | `/unipi:global-memory-process <text>` | Analyze text and store to global |
45
+ | `/unipi:global-memory-search <term>` | Search global memories |
46
+ | `/unipi:global-memory-list` | List all global memories |
47
+
48
+ ## Memory File Format
49
+
50
+ Memories are stored as markdown with YAML frontmatter:
51
+
52
+ ```markdown
53
+ ---
54
+ title: auth_jwt_prefer_refresh_tokens
55
+ tags: [auth, jwt, preferences]
56
+ project: my-app
57
+ created: 2026-04-26T10:00:00Z
58
+ updated: 2026-04-26T15:30:00Z
59
+ type: preference
60
+ ---
61
+
62
+ # Auth: Prefer Refresh Tokens
63
+
64
+ User prefers short-lived access tokens (15min) with long-lived refresh tokens (30d).
65
+ Always implement token rotation on refresh.
66
+ ```
67
+
68
+ ## Naming Convention
69
+
70
+ **Format:** `<most_important>_<less_important>_<lesser>`
71
+
72
+ Examples:
73
+ - `auth_jwt_prefer_refresh_tokens`
74
+ - `db_postgres_use_connection_pooling`
75
+ - `style_typescript_strict_mode_always`
76
+
77
+ ## Storage Layout
78
+
79
+ ```
80
+ ~/.unipi/memory/
81
+ ├── global/
82
+ │ ├── memory.db # Global vector DB
83
+ │ └── *.md # Global memory files
84
+ └── <project_name>/
85
+ ├── memory.db # Project vector DB
86
+ └── *.md # Project memory files
87
+ ```
88
+
89
+ ## Dependencies
90
+
91
+ - `better-sqlite3` - SQLite database
92
+ - `sqlite-vec` - Vector search extension
93
+ - `js-yaml` - YAML frontmatter parsing
94
+ - `@unipi/core` - Shared utilities
package/commands.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @unipi/memory — Command registration
3
+ *
4
+ * User-facing commands for memory management.
5
+ * All storage is project-scoped. "Global" commands search across all projects.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { MemoryStorage, searchAllProjects, listAllProjects } from "./storage.js";
10
+
11
+ /**
12
+ * Register memory commands.
13
+ */
14
+ export function registerMemoryCommands(
15
+ pi: ExtensionAPI,
16
+ getStorage: () => MemoryStorage
17
+ ): void {
18
+ // --- /unipi:memory-process ---
19
+ pi.registerCommand("unipi:memory-process", {
20
+ description: "Analyze text and store extracted memories",
21
+ handler: async (args, ctx) => {
22
+ if (!args.trim()) {
23
+ ctx.ui.notify("Usage: /unipi:memory-process <text to analyze>", "info");
24
+ return;
25
+ }
26
+
27
+ ctx.ui.notify(
28
+ "Analyzing text for memories... Use memory_store tool to save.",
29
+ "info"
30
+ );
31
+
32
+ // Inject instruction for agent to process
33
+ pi.sendUserMessage(
34
+ `Analyze the following text and extract any memory-worthy items (user preferences, project decisions, code patterns, conversation summaries). For each item found, use the memory_store tool to save it.\n\nText to analyze:\n${args}`,
35
+ { deliverAs: "followUp" }
36
+ );
37
+ },
38
+ });
39
+
40
+ // --- /unipi:memory-search ---
41
+ pi.registerCommand("unipi:memory-search", {
42
+ description: "Search project memories",
43
+ handler: async (args, ctx) => {
44
+ if (!args.trim()) {
45
+ ctx.ui.notify("Usage: /unipi:memory-search <search term>", "info");
46
+ return;
47
+ }
48
+
49
+ const storage = getStorage();
50
+ const results = storage.search(args.trim());
51
+
52
+ if (results.length === 0) {
53
+ ctx.ui.notify(`No memories found for: "${args}"`, "info");
54
+ return;
55
+ }
56
+
57
+ const output = results
58
+ .map(
59
+ (r, i) =>
60
+ `${i + 1}. ${r.record.title} (${r.record.type})\n ${r.snippet}`
61
+ )
62
+ .join("\n\n");
63
+
64
+ ctx.ui.notify(
65
+ `Found ${results.length} memories:\n\n${output}`,
66
+ "info"
67
+ );
68
+ },
69
+ });
70
+
71
+ // --- /unipi:memory-consolidate ---
72
+ pi.registerCommand("unipi:memory-consolidate", {
73
+ description: "Consolidate current session into memory",
74
+ handler: async (args, ctx) => {
75
+ ctx.ui.notify(
76
+ "Consolidating session into memory... Use memory_store tool to save insights.",
77
+ "info"
78
+ );
79
+
80
+ // Inject instruction for agent to consolidate
81
+ pi.sendUserMessage(
82
+ `Review the current session and identify any memory-worthy items:
83
+ - User preferences discovered
84
+ - Project decisions made
85
+ - Code patterns learned
86
+ - Important context to remember
87
+
88
+ For each item, use the memory_store tool to save it with an appropriate title and type.`,
89
+ { deliverAs: "followUp" }
90
+ );
91
+ },
92
+ });
93
+
94
+ // --- /unipi:memory-forget ---
95
+ pi.registerCommand("unipi:memory-forget", {
96
+ description: "Delete a memory by title",
97
+ handler: async (args, ctx) => {
98
+ if (!args.trim()) {
99
+ ctx.ui.notify("Usage: /unipi:memory-forget <memory title>", "info");
100
+ return;
101
+ }
102
+
103
+ const storage = getStorage();
104
+ const deleted = storage.deleteByTitle(args.trim());
105
+
106
+ ctx.ui.notify(
107
+ deleted
108
+ ? `Deleted memory: ${args}`
109
+ : `Memory not found: ${args}`,
110
+ "info"
111
+ );
112
+ },
113
+ });
114
+
115
+ // --- /unipi:global-memory-search ---
116
+ pi.registerCommand("unipi:global-memory-search", {
117
+ description: "Search memories across all projects",
118
+ handler: async (args, ctx) => {
119
+ if (!args.trim()) {
120
+ ctx.ui.notify("Usage: /unipi:global-memory-search <search term>", "info");
121
+ return;
122
+ }
123
+
124
+ const results = searchAllProjects(args.trim());
125
+
126
+ if (results.length === 0) {
127
+ ctx.ui.notify(`No memories found across projects for: "${args}"`, "info");
128
+ return;
129
+ }
130
+
131
+ const output = results
132
+ .map(
133
+ (r, i) =>
134
+ `${i + 1}. [${r.record.project}] ${r.record.title} (${r.record.type})\n ${r.snippet}`
135
+ )
136
+ .join("\n\n");
137
+
138
+ ctx.ui.notify(
139
+ `Found ${results.length} memories across projects:\n\n${output}`,
140
+ "info"
141
+ );
142
+ },
143
+ });
144
+
145
+ // --- /unipi:global-memory-list ---
146
+ pi.registerCommand("unipi:global-memory-list", {
147
+ description: "List all memories across all projects",
148
+ handler: async (args, ctx) => {
149
+ const memories = listAllProjects();
150
+
151
+ if (memories.length === 0) {
152
+ ctx.ui.notify("No memories stored in any project.", "info");
153
+ return;
154
+ }
155
+
156
+ // Group by project
157
+ const grouped = new Map<string, typeof memories>();
158
+ for (const m of memories) {
159
+ const list = grouped.get(m.project) || [];
160
+ list.push(m);
161
+ grouped.set(m.project, list);
162
+ }
163
+
164
+ let output = "";
165
+ for (const [project, projectMemories] of grouped) {
166
+ output += `\n${project} (${projectMemories.length}):\n`;
167
+ for (const m of projectMemories) {
168
+ output += ` - ${m.title} (${m.type})\n`;
169
+ }
170
+ }
171
+
172
+ ctx.ui.notify(
173
+ `All memories across ${grouped.size} projects (${memories.length} total):${output}`,
174
+ "info"
175
+ );
176
+ },
177
+ });
178
+ }
package/embedding.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @unipi/memory — Embedding generation
3
+ *
4
+ * Placeholder for future embedding support.
5
+ * Currently uses fuzzy text search only.
6
+ */
7
+
8
+ /**
9
+ * Generate an embedding for the given text.
10
+ * Returns null (fuzzy-only mode).
11
+ *
12
+ * Future: Use LLM or local model for embeddings.
13
+ */
14
+ export async function generateEmbedding(
15
+ _text: string,
16
+ _ai?: any
17
+ ): Promise<Float32Array | null> {
18
+ // Fuzzy-only mode for now
19
+ return null;
20
+ }
21
+
22
+ /**
23
+ * Convert Float32Array to Buffer for SQLite storage.
24
+ */
25
+ export function vectorToBuffer(vec: Float32Array): Buffer {
26
+ return Buffer.from(vec.buffer);
27
+ }
28
+
29
+ /**
30
+ * Convert Buffer from SQLite to Float32Array.
31
+ */
32
+ export function bufferToVector(buf: Buffer): Float32Array {
33
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
34
+ }
35
+
36
+ /**
37
+ * Check if embeddings are available (sqlite-vec loaded).
38
+ */
39
+ export function hasEmbeddings(db: any): boolean {
40
+ try {
41
+ db.prepare("SELECT * FROM memories_vec LIMIT 1").get();
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
package/index.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @unipi/memory — Extension entry
3
+ *
4
+ * Persistent cross-session memory with vector search.
5
+ * All storage is project-scoped. "Global" tools search across all projects.
6
+ * Injects memory titles at session start.
7
+ * Auto-consolidates on compaction.
8
+ */
9
+
10
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
+ import {
12
+ UNIPI_EVENTS,
13
+ MODULES,
14
+ emitEvent,
15
+ getPackageVersion,
16
+ } from "@unipi/core";
17
+ import {
18
+ MemoryStorage,
19
+ getProjectName,
20
+ searchAllProjects,
21
+ listAllProjects,
22
+ } from "./storage.js";
23
+ import { registerMemoryTools, MEMORY_TOOLS } from "./tools.js";
24
+ import { registerMemoryCommands } from "./commands.js";
25
+
26
+ /** Package version */
27
+ const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
28
+
29
+ /** Storage instance for current project */
30
+ let projectStorage: MemoryStorage | null = null;
31
+
32
+ /**
33
+ * Get storage for the current project.
34
+ */
35
+ function getStorage(): MemoryStorage {
36
+ if (!projectStorage) {
37
+ // Fallback: create new instance (shouldn't happen after session_start)
38
+ return new MemoryStorage("unknown");
39
+ }
40
+ return projectStorage;
41
+ }
42
+
43
+ export default function (pi: ExtensionAPI) {
44
+ // Register skills directory
45
+ const skillsDir = new URL("./skills", import.meta.url).pathname;
46
+ pi.on("resources_discover", async (_event, _ctx) => {
47
+ return {
48
+ skillPaths: [skillsDir],
49
+ };
50
+ });
51
+
52
+ // Register tools and commands
53
+ registerMemoryTools(pi, getStorage);
54
+ registerMemoryCommands(pi, getStorage);
55
+
56
+ // Session lifecycle
57
+ pi.on("session_start", async (_event, ctx) => {
58
+ // Initialize project storage
59
+ const projectName = getProjectName(ctx.cwd);
60
+ projectStorage = new MemoryStorage(projectName);
61
+ projectStorage.init();
62
+
63
+ // Announce module
64
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
65
+ name: MODULES.MEMORY,
66
+ version: VERSION,
67
+ commands: [
68
+ "unipi:memory-process",
69
+ "unipi:memory-search",
70
+ "unipi:memory-consolidate",
71
+ "unipi:memory-forget",
72
+ "unipi:global-memory-search",
73
+ "unipi:global-memory-list",
74
+ ],
75
+ tools: [
76
+ MEMORY_TOOLS.STORE,
77
+ MEMORY_TOOLS.SEARCH,
78
+ MEMORY_TOOLS.DELETE,
79
+ MEMORY_TOOLS.LIST,
80
+ MEMORY_TOOLS.GLOBAL_SEARCH,
81
+ MEMORY_TOOLS.GLOBAL_LIST,
82
+ ],
83
+ });
84
+
85
+ // Show memory status in UI
86
+ if (ctx.hasUI) {
87
+ const projectCount = projectStorage.listAll().length;
88
+ const allMemories = listAllProjects();
89
+ const projectCountAll = allMemories.length;
90
+ ctx.ui.setStatus(
91
+ "unipi-memory",
92
+ `🧠 memory ${projectCount}p/${projectCountAll}all`
93
+ );
94
+ }
95
+ });
96
+
97
+ // Inject memory titles at session start
98
+ pi.on("before_agent_start", async (event, ctx) => {
99
+ if (!projectStorage) return;
100
+
101
+ const projectName = getProjectName(ctx.cwd);
102
+ const projectMemories = projectStorage.listAll();
103
+
104
+ if (projectMemories.length === 0) {
105
+ return; // No memories to inject
106
+ }
107
+
108
+ let injection = "\n\n<memory>\n";
109
+ injection += `Available memories for project "${projectName}":\n\n`;
110
+
111
+ // Project memories
112
+ for (const m of projectMemories) {
113
+ injection += `- ${m.title}\n`;
114
+ }
115
+
116
+ injection += "\nUse memory_search to retrieve full content. Use memory_store to save new memories.\n";
117
+ injection += "Use global_memory_search to search across ALL projects.\n";
118
+ injection += "</memory>";
119
+
120
+ return {
121
+ systemPrompt: event.systemPrompt + injection,
122
+ };
123
+ });
124
+
125
+ // Auto-consolidation on compaction
126
+ pi.on("session_before_compact", async (event, ctx) => {
127
+ const { preparation } = event;
128
+
129
+ // Extract summary text
130
+ const summary = preparation.previousSummary || "";
131
+
132
+ if (!summary || summary.length < 100) {
133
+ // Summary too short to extract memories from
134
+ return;
135
+ }
136
+
137
+ // For now, just log that consolidation would happen
138
+ // Future: Use LLM to extract memories
139
+ console.log("[unipi/memory] Auto-consolidation triggered, summary length:", summary.length);
140
+
141
+ // Don't modify the compaction summary - return unchanged
142
+ return {};
143
+ });
144
+
145
+ // Cleanup on shutdown
146
+ pi.on("session_shutdown", async () => {
147
+ projectStorage?.close();
148
+ projectStorage = null;
149
+ });
150
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@pi-unipi/memory",
3
+ "version": "0.1.0",
4
+ "description": "Persistent cross-session memory with vector search for Pi coding agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/memory"
12
+ },
13
+ "homepage": "https://github.com/Neuron-Mr-White/unipi#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/Neuron-Mr-White/unipi/issues"
16
+ },
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi-extension",
20
+ "pi-coding-agent",
21
+ "unipi",
22
+ "memory",
23
+ "vector-search",
24
+ "sqlite-vec"
25
+ ],
26
+ "pi": {
27
+ "extensions": ["index.ts"],
28
+ "skills": ["skills"]
29
+ },
30
+ "files": [
31
+ "index.ts",
32
+ "storage.ts",
33
+ "search.ts",
34
+ "embedding.ts",
35
+ "tools.ts",
36
+ "commands.ts",
37
+ "skills/**/*",
38
+ "README.md"
39
+ ],
40
+ "dependencies": {
41
+ "better-sqlite3": "^12.9.0",
42
+ "sqlite-vec": "^0.1.9",
43
+ "js-yaml": "^4.1.0",
44
+ "@unipi/core": "*"
45
+ },
46
+ "peerDependencies": {
47
+ "@mariozechner/pi-coding-agent": "*",
48
+ "@sinclair/typebox": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@types/better-sqlite3": "^7.6.0",
52
+ "@types/js-yaml": "^4.0.0",
53
+ "@types/node": "^25.6.0"
54
+ }
55
+ }
package/search.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @unipi/memory — Hybrid search algorithm
3
+ *
4
+ * Combines vector similarity search with fuzzy text matching
5
+ * for best recall across semantic and exact matches.
6
+ */
7
+
8
+ import type { MemoryStorage, MemoryRecord, SearchResult } from "./storage.js";
9
+
10
+ /**
11
+ * Perform hybrid search combining vector + fuzzy.
12
+ */
13
+ export function hybridSearch(
14
+ storage: MemoryStorage,
15
+ query: string,
16
+ limit = 10,
17
+ embedding?: Float32Array | null
18
+ ): SearchResult[] {
19
+ // Delegate to storage's search method which already implements hybrid
20
+ return storage.search(query, limit, embedding);
21
+ }
22
+
23
+ /**
24
+ * Calculate fuzzy match score between text and query.
25
+ * Returns 0-1 score (1 = perfect match).
26
+ */
27
+ export function fuzzyMatch(text: string, query: string): number {
28
+ const lowerText = text.toLowerCase();
29
+ const lowerQuery = query.toLowerCase();
30
+
31
+ // Exact match
32
+ if (lowerText === lowerQuery) return 1.0;
33
+
34
+ // Starts with
35
+ if (lowerText.startsWith(lowerQuery)) return 0.9;
36
+
37
+ // Contains
38
+ if (lowerText.includes(lowerQuery)) return 0.7;
39
+
40
+ // Word boundary match
41
+ const words = lowerQuery.split(/\s+/);
42
+ let matchedWords = 0;
43
+ for (const word of words) {
44
+ if (lowerText.includes(word)) {
45
+ matchedWords++;
46
+ }
47
+ }
48
+ if (matchedWords > 0) {
49
+ return 0.3 + (matchedWords / words.length) * 0.4;
50
+ }
51
+
52
+ // Subsequence match
53
+ let textIdx = 0;
54
+ let queryIdx = 0;
55
+ let subsequenceMatches = 0;
56
+
57
+ while (textIdx < lowerText.length && queryIdx < lowerQuery.length) {
58
+ if (lowerText[textIdx] === lowerQuery[queryIdx]) {
59
+ subsequenceMatches++;
60
+ queryIdx++;
61
+ }
62
+ textIdx++;
63
+ }
64
+
65
+ if (queryIdx === lowerQuery.length) {
66
+ // All query chars found in order
67
+ return 0.2 + (subsequenceMatches / lowerQuery.length) * 0.2;
68
+ }
69
+
70
+ return 0;
71
+ }
72
+
73
+ /**
74
+ * Extract a snippet around the query match.
75
+ */
76
+ export function extractSnippet(
77
+ content: string,
78
+ query: string,
79
+ chars = 150
80
+ ): string {
81
+ const lowerContent = content.toLowerCase();
82
+ const lowerQuery = query.toLowerCase();
83
+
84
+ // Find best match position
85
+ let bestIdx = -1;
86
+ let bestScore = 0;
87
+
88
+ const words = lowerQuery.split(/\s+/);
89
+ for (const word of words) {
90
+ const idx = lowerContent.indexOf(word);
91
+ if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
92
+ bestIdx = idx;
93
+ bestScore = 0.8;
94
+ }
95
+ }
96
+
97
+ if (bestIdx === -1) {
98
+ // No match, return beginning
99
+ return content.slice(0, chars) + (content.length > chars ? "..." : "");
100
+ }
101
+
102
+ const start = Math.max(0, bestIdx - chars / 3);
103
+ const end = Math.min(content.length, bestIdx + chars * 2 / 3);
104
+ let snippet = content.slice(start, end);
105
+
106
+ if (start > 0) snippet = "..." + snippet;
107
+ if (end < content.length) snippet = snippet + "...";
108
+
109
+ return snippet;
110
+ }
111
+
112
+ /**
113
+ * Merge and deduplicate search results from multiple sources.
114
+ */
115
+ export function mergeResults(
116
+ ...resultSets: SearchResult[][]
117
+ ): SearchResult[] {
118
+ const merged = new Map<string, SearchResult>();
119
+
120
+ for (const results of resultSets) {
121
+ for (const result of results) {
122
+ const existing = merged.get(result.record.id);
123
+ if (existing) {
124
+ // Boost score if found in multiple sources
125
+ existing.score = Math.min(existing.score + result.score * 0.2, 1);
126
+ } else {
127
+ merged.set(result.record.id, { ...result });
128
+ }
129
+ }
130
+ }
131
+
132
+ return Array.from(merged.values())
133
+ .sort((a, b) => b.score - a.score);
134
+ }