@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 +94 -0
- package/commands.ts +178 -0
- package/embedding.ts +46 -0
- package/index.ts +150 -0
- package/package.json +55 -0
- package/search.ts +134 -0
- package/skills/memory/SKILL.md +151 -0
- package/storage.ts +716 -0
- package/tools.ts +354 -0
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
|
+
}
|