@chendpoc/pi-memory 0.1.0 → 0.1.12

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.
Files changed (67) hide show
  1. package/README.md +156 -111
  2. package/dist/adapters/ollamaClient.d.ts +11 -0
  3. package/dist/adapters/ollamaClient.d.ts.map +1 -0
  4. package/dist/adapters/ollamaClient.js +122 -0
  5. package/dist/adapters/ollamaClient.js.map +1 -0
  6. package/dist/adapters/openaiCompatClient.d.ts +11 -0
  7. package/dist/adapters/openaiCompatClient.d.ts.map +1 -0
  8. package/dist/adapters/openaiCompatClient.js +118 -0
  9. package/dist/adapters/openaiCompatClient.js.map +1 -0
  10. package/dist/cli.js +2 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/fallback/sessionIndex.d.ts.map +1 -1
  13. package/dist/fallback/sessionIndex.js +90 -25
  14. package/dist/fallback/sessionIndex.js.map +1 -1
  15. package/dist/fallback/sessionSearch.d.ts +1 -1
  16. package/dist/fallback/sessionSearch.d.ts.map +1 -1
  17. package/dist/fallback/sessionSearch.js +101 -28
  18. package/dist/fallback/sessionSearch.js.map +1 -1
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/local/graphQuery.d.ts +21 -0
  24. package/dist/local/graphQuery.d.ts.map +1 -0
  25. package/dist/local/graphQuery.js +170 -0
  26. package/dist/local/graphQuery.js.map +1 -0
  27. package/dist/paths.js +1 -1
  28. package/dist/paths.js.map +1 -1
  29. package/dist/pi-extension.d.ts.map +1 -1
  30. package/dist/pi-extension.js +57 -17
  31. package/dist/pi-extension.js.map +1 -1
  32. package/dist/service.d.ts +10 -10
  33. package/dist/service.d.ts.map +1 -1
  34. package/dist/service.js +72 -30
  35. package/dist/service.js.map +1 -1
  36. package/dist/settings.d.ts +38 -0
  37. package/dist/settings.d.ts.map +1 -0
  38. package/dist/settings.js +68 -0
  39. package/dist/settings.js.map +1 -0
  40. package/dist/sidecar/process.d.ts.map +1 -1
  41. package/dist/sidecar/process.js +16 -4
  42. package/dist/sidecar/process.js.map +1 -1
  43. package/dist/trainer/sessionLoader.d.ts +2 -2
  44. package/dist/trainer/sessionLoader.d.ts.map +1 -1
  45. package/dist/trainer/sessionLoader.js +115 -39
  46. package/dist/trainer/sessionLoader.js.map +1 -1
  47. package/package.json +8 -4
  48. package/src/adapters/ollamaClient.ts +179 -0
  49. package/src/adapters/openaiCompatClient.ts +155 -0
  50. package/src/cache/memoryCaches.ts +72 -0
  51. package/src/cli.ts +4 -3
  52. package/src/fallback/llmRerank.ts +8 -1
  53. package/src/fallback/sessionIndex.ts +78 -40
  54. package/src/fallback/sessionSearch.ts +107 -27
  55. package/src/index.ts +28 -0
  56. package/src/local/graphQuery.ts +252 -0
  57. package/src/paths.ts +1 -1
  58. package/src/pi-extension.ts +164 -36
  59. package/src/preflight/detectIntents.ts +6 -0
  60. package/src/preflight/hook.ts +68 -5
  61. package/src/preflight/render.ts +28 -3
  62. package/src/service.ts +133 -29
  63. package/src/settings.ts +126 -0
  64. package/src/sidecar/process.ts +19 -4
  65. package/src/tools/memoryRecall.ts +33 -9
  66. package/src/trainer/scheduler.ts +3 -0
  67. package/src/trainer/sessionLoader.ts +128 -42
@@ -16,10 +16,6 @@ export interface SessionSearchHit {
16
16
  let cachedIndex: SessionIndex | null = null;
17
17
  let cachedDbPath: string | null = null;
18
18
 
19
- /**
20
- * Get or open the FTS5 session index. Returns null if better-sqlite3
21
- * is unavailable or the DB file doesn't exist.
22
- */
23
19
  function getSessionIndex(dbPath: string): SessionIndex | null {
24
20
  if (cachedIndex && cachedDbPath === dbPath) return cachedIndex;
25
21
  if (!fsSync.existsSync(dbPath)) return null;
@@ -47,8 +43,29 @@ interface PiSessionFile {
47
43
 
48
44
  const SNIPPET_MAX = 240;
49
45
 
46
+ async function collectFiles(dir: string): Promise<string[]> {
47
+ let names: string[];
48
+ try {
49
+ names = await fs.readdir(dir);
50
+ } catch {
51
+ return [];
52
+ }
53
+ const files: string[] = [];
54
+ for (const name of names) {
55
+ const full = path.join(dir, name);
56
+ let st;
57
+ try { st = await fs.stat(full); } catch { continue; }
58
+ if (st.isDirectory()) {
59
+ files.push(...await collectFiles(full));
60
+ } else if (st.isFile() && (name.endsWith(".json") || name.endsWith(".jsonl"))) {
61
+ files.push(full);
62
+ }
63
+ }
64
+ return files;
65
+ }
66
+
50
67
  /**
51
- * Keyword search over Pi-style session JSON files (one directory level).
68
+ * Keyword search over Pi-style session files (JSON + JSONL, recursive subdirectories).
52
69
  * Uses FTS5 index when available, falls back to file scan.
53
70
  * All whitespace-separated terms must match (case-insensitive AND).
54
71
  */
@@ -72,17 +89,10 @@ export async function sessionKeywordSearch(
72
89
  const terms = splitTerms(q);
73
90
  if (terms.length === 0) return [];
74
91
 
75
- let entries: string[];
76
- try {
77
- entries = await fs.readdir(sessionsDir);
78
- } catch {
79
- return [];
80
- }
81
-
92
+ const filePaths = await collectFiles(sessionsDir);
82
93
  const hits: SessionSearchHit[] = [];
83
- for (const name of entries) {
84
- if (!name.endsWith(".json")) continue;
85
- const filePath = path.join(sessionsDir, name);
94
+
95
+ for (const filePath of filePaths) {
86
96
  let st;
87
97
  try {
88
98
  st = await fs.stat(filePath);
@@ -91,34 +101,104 @@ export async function sessionKeywordSearch(
91
101
  }
92
102
  if (!st.isFile()) continue;
93
103
 
94
- let session: PiSessionFile;
104
+ let raw: string;
105
+ try {
106
+ raw = await fs.readFile(filePath, "utf8");
107
+ } catch {
108
+ continue;
109
+ }
110
+
111
+ if (filePath.endsWith(".jsonl")) {
112
+ scanJsonlFile(raw, filePath, terms, hits, limit);
113
+ } else {
114
+ scanJsonFile(raw, filePath, terms, hits, limit);
115
+ }
116
+
117
+ if (hits.length >= limit) return hits;
118
+ }
119
+ return hits;
120
+ }
121
+
122
+ function scanJsonFile(
123
+ raw: string,
124
+ filePath: string,
125
+ terms: string[],
126
+ hits: SessionSearchHit[],
127
+ limit: number,
128
+ ): void {
129
+ let session: PiSessionFile;
130
+ try {
131
+ session = JSON.parse(raw) as PiSessionFile;
132
+ } catch {
133
+ return;
134
+ }
135
+
136
+ const sessionId = session.id ?? path.basename(filePath, ".json");
137
+ const title = session.title ?? "";
138
+ const createdAt = session.created_at ?? "";
139
+
140
+ for (let i = 0; i < (session.messages?.length ?? 0); i++) {
141
+ const msg = session.messages![i]!;
142
+ const text = messageText(msg.content);
143
+ if (!text || !allTermsMatch(text, terms)) continue;
144
+ hits.push({
145
+ session_id: sessionId,
146
+ session_title: title,
147
+ role: msg.role ?? "unknown",
148
+ snippet: makeSnippet(text, terms[0]!),
149
+ msg_index: i,
150
+ created_at: createdAt,
151
+ });
152
+ if (hits.length >= limit) return;
153
+ }
154
+ }
155
+
156
+ function scanJsonlFile(
157
+ raw: string,
158
+ filePath: string,
159
+ terms: string[],
160
+ hits: SessionSearchHit[],
161
+ limit: number,
162
+ ): void {
163
+ const lines = raw.split("\n").filter((l) => l.trim());
164
+ let sessionId = path.basename(filePath, ".jsonl");
165
+ let title = "";
166
+ let createdAt = "";
167
+ let msgIndex = 0;
168
+
169
+ for (const line of lines) {
170
+ let obj: Record<string, unknown>;
95
171
  try {
96
- const raw = await fs.readFile(filePath, "utf8");
97
- session = JSON.parse(raw) as PiSessionFile;
172
+ obj = JSON.parse(line) as Record<string, unknown>;
98
173
  } catch {
99
174
  continue;
100
175
  }
101
176
 
102
- const sessionId = session.id ?? path.basename(name, ".json");
103
- const title = session.title ?? "";
104
- const createdAt = session.created_at ?? "";
177
+ if (obj.type === "session") {
178
+ sessionId = (obj.id as string) ?? sessionId;
179
+ title = (obj.title as string) ?? "";
180
+ createdAt = (obj.timestamp as string) ?? "";
181
+ continue;
182
+ }
105
183
 
106
- for (let i = 0; i < (session.messages?.length ?? 0); i++) {
107
- const msg = session.messages![i]!;
184
+ if (obj.type === "message") {
185
+ const msg = (obj as { message?: PiSessionMessage }).message;
186
+ if (!msg?.role || !msg.content) continue;
187
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
108
188
  const text = messageText(msg.content);
109
189
  if (!text || !allTermsMatch(text, terms)) continue;
110
190
  hits.push({
111
191
  session_id: sessionId,
112
192
  session_title: title,
113
- role: msg.role ?? "unknown",
193
+ role: msg.role,
114
194
  snippet: makeSnippet(text, terms[0]!),
115
- msg_index: i,
195
+ msg_index: msgIndex,
116
196
  created_at: createdAt,
117
197
  });
118
- if (hits.length >= limit) return hits;
198
+ msgIndex++;
199
+ if (hits.length >= limit) return;
119
200
  }
120
201
  }
121
- return hits;
122
202
  }
123
203
 
124
204
  function splitTerms(query: string): string[] {
package/src/index.ts CHANGED
@@ -25,6 +25,15 @@ export {
25
25
  type MemoryProvider,
26
26
  } from "./config.js";
27
27
 
28
+ export {
29
+ defaultMemoryConfigPath,
30
+ loadMemoryConfig,
31
+ loadMemorySettings,
32
+ resolveHelperModelSpec,
33
+ type LoadedMemorySettings,
34
+ type MemorySettingsFile,
35
+ } from "./settings.js";
36
+
28
37
  export {
29
38
  defaultBundleRoot,
30
39
  defaultPiHome,
@@ -88,10 +97,12 @@ export {
88
97
 
89
98
  export {
90
99
  PRIVATE_MEMORY_BODY_BYTE_CAP,
100
+ SEMANTIC_FALLBACK_CANDIDATES,
91
101
  renderFallbackPrivateMemory,
92
102
  renderPrivateMemoryContext,
93
103
  sanitizeUserBlock,
94
104
  truncatePrivateMemoryBody,
105
+ type FallbackRenderOptions,
95
106
  type PreflightQueryResult,
96
107
  } from "./preflight/render.js";
97
108
 
@@ -206,6 +217,23 @@ export {
206
217
 
207
218
  export { defaultSessionDbPath } from "./fallback/sessionSearch.js";
208
219
 
220
+ export { LocalGraphQuerier } from "./local/graphQuery.js";
221
+
222
+ export {
223
+ createOllamaLLMClient,
224
+ createOllamaMemoryHelper,
225
+ ollamaHealthCheck,
226
+ DEFAULT_OLLAMA_CONFIG,
227
+ type OllamaConfig,
228
+ } from "./adapters/ollamaClient.js";
229
+
230
+ export {
231
+ createOpenAICompatLLMClient,
232
+ createOpenAICompatMemoryHelper,
233
+ openaiCompatHealthCheck,
234
+ type OpenAICompatConfig,
235
+ } from "./adapters/openaiCompatClient.js";
236
+
209
237
  export {
210
238
  rerankWithLLM,
211
239
  type RerankOptions,
@@ -0,0 +1,252 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type {
5
+ ErrorClass,
6
+ MemoryBlock,
7
+ MemoryCandidateGroup,
8
+ QueryCandidate,
9
+ QueryIntent,
10
+ ResponseEnvelope,
11
+ } from "../types.js";
12
+
13
+ interface BundleEntity {
14
+ entity_id: string;
15
+ label: string;
16
+ type: string;
17
+ aliases: string[];
18
+ mention_count: number;
19
+ distinct_session_count: number;
20
+ }
21
+
22
+ interface BundleEdge {
23
+ head_entity_id: string;
24
+ relation: string;
25
+ tail_entity_id: string;
26
+ supporting_event_ids: string[];
27
+ evidence: string;
28
+ }
29
+
30
+ interface BundleEvent {
31
+ event_id: string;
32
+ description: string;
33
+ session_id: string;
34
+ timestamp: string;
35
+ }
36
+
37
+ interface GraphData {
38
+ entities: BundleEntity[];
39
+ edges: BundleEdge[];
40
+ events: BundleEvent[];
41
+ }
42
+
43
+ export class LocalGraphQuerier {
44
+ private entities: BundleEntity[] = [];
45
+ private edges: BundleEdge[] = [];
46
+ private events: BundleEvent[] = [];
47
+ private entityById = new Map<string, BundleEntity>();
48
+ private entityByLabel = new Map<string, BundleEntity>();
49
+ private loaded = false;
50
+ private graphMtime = 0;
51
+
52
+ constructor(private readonly bundleRoot: string) {}
53
+
54
+ load(): boolean {
55
+ const graphPath = path.join(this.bundleRoot, "current", "graph.json");
56
+ try {
57
+ const raw = fs.readFileSync(graphPath, "utf8");
58
+ const data = JSON.parse(raw) as GraphData;
59
+ this.entities = data.entities ?? [];
60
+ this.edges = data.edges ?? [];
61
+ this.events = data.events ?? [];
62
+
63
+ this.entityById.clear();
64
+ this.entityByLabel.clear();
65
+ for (const e of this.entities) {
66
+ this.entityById.set(e.entity_id, e);
67
+ this.entityByLabel.set(e.label.toLowerCase(), e);
68
+ for (const alias of e.aliases) {
69
+ this.entityByLabel.set(alias.toLowerCase(), e);
70
+ }
71
+ }
72
+ this.graphMtime = fs.statSync(graphPath).mtimeMs;
73
+ this.loaded = true;
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ isLoaded(): boolean {
81
+ return this.loaded;
82
+ }
83
+
84
+ /**
85
+ * Returns true when the on-disk graph.json has changed since the last load().
86
+ * Always returns false when not yet loaded.
87
+ */
88
+ isStale(): boolean {
89
+ if (!this.loaded) return false;
90
+ const graphPath = path.join(this.bundleRoot, "current", "graph.json");
91
+ try {
92
+ return fs.statSync(graphPath).mtimeMs !== this.graphMtime;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Re-loads from disk if isStale(). Returns true when a reload actually happened.
100
+ */
101
+ reloadIfStale(): boolean {
102
+ if (!this.isStale()) return false;
103
+ return this.load();
104
+ }
105
+
106
+ private findEntity(mention: string): BundleEntity | null {
107
+ const key = mention.toLowerCase().trim();
108
+ return this.entityByLabel.get(key) ?? null;
109
+ }
110
+
111
+ private findEntities(mentions: string[]): BundleEntity[] {
112
+ const found: BundleEntity[] = [];
113
+ for (const m of mentions) {
114
+ const e = this.findEntity(m);
115
+ if (e) found.push(e);
116
+ }
117
+ return found;
118
+ }
119
+
120
+ query(intent: QueryIntent): {
121
+ env: ResponseEnvelope | null;
122
+ errorClass: ErrorClass;
123
+ } {
124
+ if (!this.loaded) {
125
+ return { env: null, errorClass: "unavailable" };
126
+ }
127
+
128
+ const anchors = this.findEntities(intent.anchor_mentions);
129
+ if (anchors.length === 0) {
130
+ return {
131
+ env: this.makeEnvelope(intent, [], [], "no matching entities found"),
132
+ errorClass: "ok",
133
+ };
134
+ }
135
+
136
+ const limit = intent.result_limit && intent.result_limit > 0 ? intent.result_limit : 10;
137
+ const candidates: QueryCandidate[] = [];
138
+ const groups: MemoryCandidateGroup[] = [];
139
+
140
+ for (const anchor of anchors) {
141
+ const relatedEdges = this.edges.filter((e) => {
142
+ const matchesHead = e.head_entity_id === anchor.entity_id;
143
+ const matchesTail = e.tail_entity_id === anchor.entity_id;
144
+ if (!matchesHead && !matchesTail) return false;
145
+
146
+ if (intent.relation_constraints?.length) {
147
+ return intent.relation_constraints.some((rc) => {
148
+ const clean = rc.replace(/\^-1$/, "").replace(/^\^/, "");
149
+ return e.relation === clean;
150
+ });
151
+ }
152
+ return true;
153
+ });
154
+
155
+ for (const edge of relatedEdges) {
156
+ if (candidates.length >= limit) break;
157
+ const isHead = edge.head_entity_id === anchor.entity_id;
158
+ const otherId = isHead ? edge.tail_entity_id : edge.head_entity_id;
159
+ const other = this.entityById.get(otherId);
160
+ if (!other) continue;
161
+
162
+ if (intent.candidate_type) {
163
+ if (other.type.toLowerCase() !== intent.candidate_type.toLowerCase()) continue;
164
+ }
165
+
166
+ const score = other.mention_count + (other.distinct_session_count * 2);
167
+
168
+ candidates.push({
169
+ value: other.label,
170
+ score,
171
+ evidence: edge.evidence.slice(0, 200),
172
+ supporting_event_ids: edge.supporting_event_ids,
173
+ entity_id: other.entity_id,
174
+ scope: `via_${anchor.label}`,
175
+ support_count: other.mention_count,
176
+ distinct_session_count: other.distinct_session_count,
177
+ observed_path: [{
178
+ from_entity_id: anchor.entity_id,
179
+ from_label: anchor.label,
180
+ relation: edge.relation,
181
+ direction: isHead ? "forward" : "inverse",
182
+ to_entity_id: other.entity_id,
183
+ to_label: other.label,
184
+ supporting_event_ids: edge.supporting_event_ids,
185
+ }],
186
+ path_collision_count: 0,
187
+ });
188
+
189
+ groups.push({
190
+ value: other.label,
191
+ score,
192
+ evidence: edge.evidence.slice(0, 200),
193
+ support_count: other.mention_count,
194
+ supporting_event_ids: edge.supporting_event_ids,
195
+ entity_ids: [other.entity_id],
196
+ scopes: [`via_${anchor.label}`],
197
+ via_relations: [edge.relation],
198
+ via_anchor_entity_ids: [anchor.entity_id],
199
+ observed_path: [{
200
+ from_entity_id: anchor.entity_id,
201
+ from_label: anchor.label,
202
+ relation: edge.relation,
203
+ direction: isHead ? "forward" : "inverse",
204
+ to_entity_id: other.entity_id,
205
+ to_label: other.label,
206
+ supporting_event_ids: edge.supporting_event_ids,
207
+ }],
208
+ path_collision_count: 0,
209
+ });
210
+ }
211
+ }
212
+
213
+ candidates.sort((a, b) => b.score - a.score);
214
+ groups.sort((a, b) => b.score - a.score);
215
+
216
+ return {
217
+ env: this.makeEnvelope(intent, candidates.slice(0, limit), groups.slice(0, limit)),
218
+ errorClass: "ok",
219
+ };
220
+ }
221
+
222
+ private makeEnvelope(
223
+ intent: QueryIntent,
224
+ candidates: QueryCandidate[],
225
+ groups: MemoryCandidateGroup[],
226
+ noDataReason?: string,
227
+ ): ResponseEnvelope {
228
+ const manifestPath = path.join(this.bundleRoot, "current", "manifest.json");
229
+ let bundleVersion: string | undefined;
230
+ try {
231
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { bundle_version?: string };
232
+ bundleVersion = m.bundle_version;
233
+ } catch { /* ok */ }
234
+
235
+ const memoryBlock: MemoryBlock = {
236
+ groups,
237
+ notes: [],
238
+ ...(noDataReason ? { no_data_reason: noDataReason } : {}),
239
+ };
240
+
241
+ return {
242
+ protocol_version: 1,
243
+ bundle_version: bundleVersion,
244
+ request_id: `local-${Date.now()}`,
245
+ candidates,
246
+ memory_block: memoryBlock,
247
+ warnings: [],
248
+ reason: candidates.length > 0 ? "ok" : "no_data",
249
+ latency_ms: 0,
250
+ };
251
+ }
252
+ }
package/src/paths.ts CHANGED
@@ -24,5 +24,5 @@ export function defaultSocketPath(): string {
24
24
  }
25
25
 
26
26
  export function defaultSessionsDir(): string {
27
- return path.join(defaultPiHome(), "sessions");
27
+ return path.join(defaultPiHome(), "agent", "sessions");
28
28
  }