@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
@@ -0,0 +1,179 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ import type { LLMClient } from "../trainer/llmExtractor.js";
5
+ import type { MemoryHelperLLM } from "../preflight/detectIntents.js";
6
+ import type { CompileMemoryIntentsResult } from "../preflight/detectIntents.js";
7
+ import {
8
+ COMPILE_MEMORY_INTENTS_PARAMETERS,
9
+ MEMORY_HELPER_TOOL_NAME,
10
+ } from "../preflight/detectIntents.js";
11
+
12
+ export interface OllamaConfig {
13
+ baseUrl: string;
14
+ model: string;
15
+ }
16
+
17
+ export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
18
+ baseUrl: "http://localhost:11434",
19
+ model: "qwen3:8b",
20
+ };
21
+
22
+ interface OllamaChatMessage {
23
+ role: "system" | "user" | "assistant";
24
+ content: string;
25
+ }
26
+
27
+ interface OllamaChatRequest {
28
+ model: string;
29
+ messages: OllamaChatMessage[];
30
+ stream: false;
31
+ tools?: OllamaTool[];
32
+ options?: { num_predict?: number };
33
+ }
34
+
35
+ interface OllamaTool {
36
+ type: "function";
37
+ function: {
38
+ name: string;
39
+ description: string;
40
+ parameters: Record<string, unknown>;
41
+ };
42
+ }
43
+
44
+ interface OllamaChatResponse {
45
+ message?: {
46
+ role?: string;
47
+ content?: string;
48
+ tool_calls?: Array<{
49
+ function?: {
50
+ name?: string;
51
+ arguments?: Record<string, unknown>;
52
+ };
53
+ }>;
54
+ };
55
+ error?: string;
56
+ }
57
+
58
+ async function ollamaRequest<T>(
59
+ baseUrl: string,
60
+ path: string,
61
+ body: unknown,
62
+ ): Promise<T> {
63
+ const url = new URL(path, baseUrl);
64
+ const mod = url.protocol === "https:" ? https : http;
65
+ const payload = JSON.stringify(body);
66
+
67
+ return new Promise((resolve, reject) => {
68
+ const req = mod.request(
69
+ url,
70
+ {
71
+ method: "POST",
72
+ headers: {
73
+ "Content-Type": "application/json",
74
+ "Content-Length": Buffer.byteLength(payload),
75
+ },
76
+ timeout: 60_000,
77
+ },
78
+ (res) => {
79
+ const chunks: Buffer[] = [];
80
+ res.on("data", (c) => chunks.push(c));
81
+ res.on("end", () => {
82
+ const text = Buffer.concat(chunks).toString("utf8");
83
+ try {
84
+ resolve(JSON.parse(text) as T);
85
+ } catch {
86
+ reject(new Error(`Ollama: invalid JSON response: ${text.slice(0, 200)}`));
87
+ }
88
+ });
89
+ },
90
+ );
91
+ req.on("error", (err) => reject(new Error(`Ollama: ${err.message}`)));
92
+ req.on("timeout", () => {
93
+ req.destroy();
94
+ reject(new Error("Ollama: request timeout"));
95
+ });
96
+ req.write(payload);
97
+ req.end();
98
+ });
99
+ }
100
+
101
+ export async function ollamaHealthCheck(baseUrl: string): Promise<boolean> {
102
+ try {
103
+ const url = new URL("/api/tags", baseUrl);
104
+ const mod = url.protocol === "https:" ? https : http;
105
+ return new Promise((resolve) => {
106
+ const req = mod.get(url, { timeout: 3_000 }, (res) => {
107
+ res.resume();
108
+ resolve(res.statusCode === 200);
109
+ });
110
+ req.on("error", () => resolve(false));
111
+ req.on("timeout", () => { req.destroy(); resolve(false); });
112
+ });
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ export function createOllamaLLMClient(cfg: OllamaConfig): LLMClient {
119
+ return {
120
+ async complete(prompt: string): Promise<string> {
121
+ const body: OllamaChatRequest = {
122
+ model: cfg.model,
123
+ messages: [{ role: "user", content: prompt }],
124
+ stream: false,
125
+ options: { num_predict: 8192 },
126
+ };
127
+ const resp = await ollamaRequest<OllamaChatResponse>(cfg.baseUrl, "/api/chat", body);
128
+ if (resp.error) throw new Error(`Ollama: ${resp.error}`);
129
+ const text = resp.message?.content?.trim();
130
+ if (!text) throw new Error("Ollama: empty response");
131
+ return text;
132
+ },
133
+ };
134
+ }
135
+
136
+ export function createOllamaMemoryHelper(cfg: OllamaConfig): MemoryHelperLLM {
137
+ return {
138
+ async compileIntents(text: string): Promise<CompileMemoryIntentsResult> {
139
+ const body: OllamaChatRequest = {
140
+ model: cfg.model,
141
+ messages: [
142
+ {
143
+ role: "user",
144
+ content: `Analyze whether the user message requires recalling private episodic memory.\n\n<message>\n${text}\n</message>`,
145
+ },
146
+ ],
147
+ stream: false,
148
+ tools: [
149
+ {
150
+ type: "function",
151
+ function: {
152
+ name: MEMORY_HELPER_TOOL_NAME,
153
+ description: "Decide whether to recall private episodic memory and compile structured query intents.",
154
+ parameters: COMPILE_MEMORY_INTENTS_PARAMETERS as unknown as Record<string, unknown>,
155
+ },
156
+ },
157
+ ],
158
+ options: { num_predict: 2048 },
159
+ };
160
+ const resp = await ollamaRequest<OllamaChatResponse>(cfg.baseUrl, "/api/chat", body);
161
+ if (resp.error) throw new Error(`Ollama: ${resp.error}`);
162
+
163
+ const toolCall = resp.message?.tool_calls?.[0]?.function;
164
+ if (toolCall?.name === MEMORY_HELPER_TOOL_NAME && toolCall.arguments) {
165
+ return toolCall.arguments as unknown as CompileMemoryIntentsResult;
166
+ }
167
+
168
+ const raw = resp.message?.content?.trim();
169
+ if (!raw) return { should_recall: false, intents: [] };
170
+
171
+ try {
172
+ const cleaned = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
173
+ return JSON.parse(cleaned) as CompileMemoryIntentsResult;
174
+ } catch {
175
+ return { should_recall: false, intents: [] };
176
+ }
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,155 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ import type { LLMClient } from "../trainer/llmExtractor.js";
5
+ import type { MemoryHelperLLM } from "../preflight/detectIntents.js";
6
+ import type { CompileMemoryIntentsResult } from "../preflight/detectIntents.js";
7
+ import {
8
+ COMPILE_MEMORY_INTENTS_PARAMETERS,
9
+ MEMORY_HELPER_TOOL_NAME,
10
+ } from "../preflight/detectIntents.js";
11
+
12
+ export interface OpenAICompatConfig {
13
+ baseUrl: string;
14
+ model: string;
15
+ apiKey?: string;
16
+ }
17
+
18
+ interface ChatMessage {
19
+ role: "system" | "user" | "assistant";
20
+ content: string;
21
+ }
22
+
23
+ interface ChatCompletionRequest {
24
+ model: string;
25
+ messages: ChatMessage[];
26
+ max_tokens?: number;
27
+ tools?: Array<{
28
+ type: "function";
29
+ function: { name: string; description: string; parameters: Record<string, unknown> };
30
+ }>;
31
+ tool_choice?: { type: "function"; function: { name: string } };
32
+ }
33
+
34
+ interface ChatCompletionResponse {
35
+ choices?: Array<{
36
+ message?: {
37
+ content?: string | null;
38
+ tool_calls?: Array<{
39
+ function?: { name?: string; arguments?: string };
40
+ }>;
41
+ };
42
+ }>;
43
+ error?: { message?: string };
44
+ }
45
+
46
+ async function postJSON<T>(url: URL, body: unknown, apiKey?: string): Promise<T> {
47
+ const mod = url.protocol === "https:" ? https : http;
48
+ const payload = JSON.stringify(body);
49
+
50
+ return new Promise((resolve, reject) => {
51
+ const headers: Record<string, string> = {
52
+ "Content-Type": "application/json",
53
+ "Content-Length": String(Buffer.byteLength(payload)),
54
+ };
55
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
56
+
57
+ const req = mod.request(url, { method: "POST", headers, timeout: 120_000 }, (res) => {
58
+ const chunks: Buffer[] = [];
59
+ res.on("data", (c) => chunks.push(c));
60
+ res.on("end", () => {
61
+ const text = Buffer.concat(chunks).toString("utf8");
62
+ try { resolve(JSON.parse(text) as T); }
63
+ catch { reject(new Error(`OpenAI-compat: invalid JSON: ${text.slice(0, 200)}`)); }
64
+ });
65
+ });
66
+ req.on("error", (err) => reject(new Error(`OpenAI-compat: ${err.message}`)));
67
+ req.on("timeout", () => { req.destroy(); reject(new Error("OpenAI-compat: timeout")); });
68
+ req.write(payload);
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ export async function openaiCompatHealthCheck(baseUrl: string): Promise<boolean> {
74
+ try {
75
+ const url = new URL("/v1/models", baseUrl);
76
+ const mod = url.protocol === "https:" ? https : http;
77
+ return new Promise((resolve) => {
78
+ const req = mod.get(url, { timeout: 3_000 }, (res) => {
79
+ res.resume();
80
+ resolve(res.statusCode === 200);
81
+ });
82
+ req.on("error", () => resolve(false));
83
+ req.on("timeout", () => { req.destroy(); resolve(false); });
84
+ });
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export function createOpenAICompatLLMClient(cfg: OpenAICompatConfig): LLMClient {
91
+ return {
92
+ async complete(prompt: string): Promise<string> {
93
+ const url = new URL("/v1/chat/completions", cfg.baseUrl);
94
+ const body: ChatCompletionRequest = {
95
+ model: cfg.model,
96
+ messages: [{ role: "user", content: prompt }],
97
+ max_tokens: 8192,
98
+ };
99
+ const resp = await postJSON<ChatCompletionResponse>(url, body, cfg.apiKey);
100
+ if (resp.error?.message) throw new Error(`OpenAI-compat: ${resp.error.message}`);
101
+ const text = resp.choices?.[0]?.message?.content?.trim();
102
+ if (!text) throw new Error("OpenAI-compat: empty response");
103
+ return text;
104
+ },
105
+ };
106
+ }
107
+
108
+ export function createOpenAICompatMemoryHelper(cfg: OpenAICompatConfig): MemoryHelperLLM {
109
+ return {
110
+ async compileIntents(text: string): Promise<CompileMemoryIntentsResult> {
111
+ const url = new URL("/v1/chat/completions", cfg.baseUrl);
112
+ const body: ChatCompletionRequest = {
113
+ model: cfg.model,
114
+ messages: [
115
+ {
116
+ role: "user",
117
+ content: `Analyze whether the user message requires recalling private episodic memory.\n\n<message>\n${text}\n</message>`,
118
+ },
119
+ ],
120
+ max_tokens: 2048,
121
+ tools: [
122
+ {
123
+ type: "function",
124
+ function: {
125
+ name: MEMORY_HELPER_TOOL_NAME,
126
+ description: "Decide whether to recall private episodic memory and compile structured query intents.",
127
+ parameters: COMPILE_MEMORY_INTENTS_PARAMETERS as unknown as Record<string, unknown>,
128
+ },
129
+ },
130
+ ],
131
+ tool_choice: { type: "function", function: { name: MEMORY_HELPER_TOOL_NAME } },
132
+ };
133
+ const resp = await postJSON<ChatCompletionResponse>(url, body, cfg.apiKey);
134
+ if (resp.error?.message) throw new Error(`OpenAI-compat: ${resp.error.message}`);
135
+
136
+ const msg = resp.choices?.[0]?.message;
137
+ const toolCall = msg?.tool_calls?.[0]?.function;
138
+ if (toolCall?.name === MEMORY_HELPER_TOOL_NAME && toolCall.arguments) {
139
+ try {
140
+ return JSON.parse(toolCall.arguments) as CompileMemoryIntentsResult;
141
+ } catch { /* fall through */ }
142
+ }
143
+
144
+ const raw = msg?.content?.trim();
145
+ if (!raw) return { should_recall: false, intents: [] };
146
+
147
+ try {
148
+ const cleaned = raw.replace(/^```(?:json)?\s*/m, "").replace(/\s*```\s*$/m, "").trim();
149
+ return JSON.parse(cleaned) as CompileMemoryIntentsResult;
150
+ } catch {
151
+ return { should_recall: false, intents: [] };
152
+ }
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,72 @@
1
+ import { LRUCache } from "lru-cache";
2
+ import type { QueryIntent } from "../types.js";
3
+ import type { RankedResult } from "../fallback/llmRerank.js";
4
+ import type { SessionSearchHit } from "../fallback/sessionSearch.js";
5
+
6
+ const INTENT_TTL_MS = 15 * 60_000;
7
+ const RERANK_TTL_MS = 15 * 60_000;
8
+ const NEGATIVE_TTL_MS = 60_000;
9
+
10
+ /**
11
+ * In-process LRU + TTL caches for pi-memory.
12
+ *
13
+ * - intentCache : helper-LLM compile_memory_intents results, keyed by
14
+ * normalised query text. TTL 15 min, LRU 128 entries.
15
+ * - rerankCache : LLM rerank results, keyed by query + hit fingerprint.
16
+ * TTL 15 min, LRU 256 entries.
17
+ * - negativeCache: queries that recently returned no usable context.
18
+ * TTL 60 s, max 512 entries.
19
+ *
20
+ * All three caches must be invalidated together (`invalidateMemoryCaches()`)
21
+ * after bundle reload so stale negative entries never block fresh graph data.
22
+ */
23
+ export const intentCache = new LRUCache<string, QueryIntent[]>({
24
+ max: 128,
25
+ ttl: INTENT_TTL_MS,
26
+ });
27
+
28
+ export const rerankCache = new LRUCache<string, RankedResult[]>({
29
+ max: 256,
30
+ ttl: RERANK_TTL_MS,
31
+ });
32
+
33
+ export const negativeCache = new LRUCache<string, true>({
34
+ max: 512,
35
+ ttl: NEGATIVE_TTL_MS,
36
+ });
37
+
38
+ function normalizeQuery(query: string): string {
39
+ return query.trim();
40
+ }
41
+
42
+ export function cacheKeyForIntents(query: string): string {
43
+ return normalizeQuery(query);
44
+ }
45
+
46
+ /**
47
+ * Rerank cache key combines the query with a fingerprint of the hit set so
48
+ * different FTS results for the same query get their own entry.
49
+ */
50
+ export function cacheKeyForRerank(query: string, hits: SessionSearchHit[]): string {
51
+ const hitIds = hits.map((h) => `${h.session_id}:${h.msg_index}`).join("|");
52
+ return `${normalizeQuery(query)}|${hitIds}`;
53
+ }
54
+
55
+ export function isNegativeCached(query: string): boolean {
56
+ return negativeCache.has(cacheKeyForIntents(query));
57
+ }
58
+
59
+ export function setNegativeCache(query: string): void {
60
+ negativeCache.set(cacheKeyForIntents(query), true);
61
+ }
62
+
63
+ export function deleteNegativeCache(query: string): void {
64
+ negativeCache.delete(cacheKeyForIntents(query));
65
+ }
66
+
67
+ /** Clear all three caches. Call after bundle reload or session shutdown. */
68
+ export function invalidateMemoryCaches(): void {
69
+ intentCache.clear();
70
+ rerankCache.clear();
71
+ negativeCache.clear();
72
+ }
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
 
5
5
  import { createStandaloneLLMClient } from "./adapters/piComplete.js";
6
6
  import { installBundle } from "./bundle/install.js";
7
- import { defaultMemoryConfig } from "./config.js";
7
+ import type { MemoryConfig } from "./config.js";
8
+ import { loadMemoryConfig } from "./settings.js";
8
9
  import { openSessionIndex } from "./fallback/sessionIndex.js";
9
10
  import { SidecarClient } from "./sidecar/client.js";
10
11
  import { MemoryService } from "./service.js";
@@ -13,7 +14,7 @@ import { createLLMFactExtractor } from "./trainer/llmExtractor.js";
13
14
  import { createTrainScheduler } from "./trainer/scheduler.js";
14
15
  import type { QueryIntent } from "./types.js";
15
16
 
16
- async function tryReloadSidecar(cfg: ReturnType<typeof defaultMemoryConfig>): Promise<void> {
17
+ async function tryReloadSidecar(cfg: MemoryConfig): Promise<void> {
17
18
  try {
18
19
  fs.accessSync(cfg.socketPath, fs.constants.F_OK);
19
20
  } catch {
@@ -34,7 +35,7 @@ async function main(): Promise<void> {
34
35
  process.exit(0);
35
36
  }
36
37
 
37
- const cfg = defaultMemoryConfig();
38
+ const cfg = loadMemoryConfig();
38
39
  const service = new MemoryService(cfg);
39
40
 
40
41
  if (cmd === "health") {
@@ -1,5 +1,6 @@
1
1
  import type { LLMClient } from "../trainer/llmExtractor.js";
2
2
  import type { SessionSearchHit } from "./sessionSearch.js";
3
+ import { cacheKeyForRerank, rerankCache } from "../cache/memoryCaches.js";
3
4
 
4
5
  export interface RerankOptions {
5
6
  client: LLMClient;
@@ -79,11 +80,17 @@ export async function rerankWithLLM(
79
80
  const maxCandidates = opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES;
80
81
  const truncated = hits.slice(0, maxCandidates);
81
82
 
83
+ const cacheKey = cacheKeyForRerank(query, truncated);
84
+ const cached = rerankCache.get(cacheKey);
85
+ if (cached) return cached;
86
+
82
87
  const prompt = buildRerankPrompt(query, truncated);
83
88
 
84
89
  try {
85
90
  const response = await opts.client.complete(prompt);
86
- return parseRerankResponse(response, truncated.length);
91
+ const results = parseRerankResponse(response, truncated.length);
92
+ if (results) rerankCache.set(cacheKey, results);
93
+ return results;
87
94
  } catch {
88
95
  return null;
89
96
  }
@@ -146,62 +146,100 @@ export function openSessionIndex(dbPath: string, injectedDb?: SqliteDatabase): S
146
146
  return count;
147
147
  }
148
148
 
149
+ async function collectFiles(dir: string): Promise<string[]> {
150
+ let names: string[];
151
+ try {
152
+ names = await fs.readdir(dir);
153
+ } catch {
154
+ return [];
155
+ }
156
+ const files: string[] = [];
157
+ for (const name of names) {
158
+ const full = path.join(dir, name);
159
+ let st;
160
+ try { st = await fs.stat(full); } catch { continue; }
161
+ if (st.isDirectory()) {
162
+ files.push(...await collectFiles(full));
163
+ } else if (st.isFile() && (name.endsWith(".json") || name.endsWith(".jsonl"))) {
164
+ files.push(full);
165
+ }
166
+ }
167
+ return files;
168
+ }
169
+
170
+ function parseJsonlMessages(raw: string, filePath: string): {
171
+ id: string; title: string; createdAt: string;
172
+ messages: Array<{ role: string; content: string; index: number }>;
173
+ } | null {
174
+ const lines = raw.split("\n").filter((l) => l.trim());
175
+ if (lines.length === 0) return null;
176
+ let id = path.basename(filePath, path.extname(filePath));
177
+ let title = "";
178
+ let createdAt = "";
179
+ const messages: Array<{ role: string; content: string; index: number }> = [];
180
+ let idx = 0;
181
+ for (const line of lines) {
182
+ let obj: Record<string, unknown>;
183
+ try { obj = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
184
+ if (obj.type === "session") {
185
+ id = (obj.id as string) ?? id;
186
+ title = (obj.title as string) ?? "";
187
+ createdAt = (obj.timestamp as string) ?? "";
188
+ continue;
189
+ }
190
+ if (obj.type === "message") {
191
+ const msg = (obj as { message?: PiSessionMessage }).message;
192
+ if (!msg?.role || !msg.content) continue;
193
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
194
+ const text = messageText(msg.content);
195
+ if (text.trim()) {
196
+ messages.push({ role: msg.role, content: text, index: idx++ });
197
+ }
198
+ }
199
+ }
200
+ if (messages.length === 0) return null;
201
+ return { id, title, createdAt, messages };
202
+ }
203
+
149
204
  async function loadSessionFiles(sessionsDir: string, modifiedAfter?: Date | null): Promise<Array<{
150
205
  id: string;
151
206
  title: string;
152
207
  createdAt: string;
153
208
  messages: Array<{ role: string; content: string; index: number }>;
154
209
  }>> {
155
- let entries: string[];
156
- try {
157
- entries = await fs.readdir(sessionsDir);
158
- } catch {
159
- return [];
160
- }
161
-
210
+ const filePaths = await collectFiles(sessionsDir);
162
211
  const results: Array<{
163
- id: string;
164
- title: string;
165
- createdAt: string;
212
+ id: string; title: string; createdAt: string;
166
213
  messages: Array<{ role: string; content: string; index: number }>;
167
214
  }> = [];
168
215
 
169
- for (const name of entries) {
170
- if (!name.endsWith(".json")) continue;
171
- const filePath = path.join(sessionsDir, name);
216
+ for (const filePath of filePaths) {
172
217
  let st;
173
- try {
174
- st = await fs.stat(filePath);
175
- } catch {
176
- continue;
177
- }
218
+ try { st = await fs.stat(filePath); } catch { continue; }
178
219
  if (!st.isFile()) continue;
179
220
  if (modifiedAfter && st.mtime <= modifiedAfter) continue;
180
221
 
181
- let session: PiSessionFile;
182
- try {
183
- const raw = await fs.readFile(filePath, "utf8");
184
- session = JSON.parse(raw) as PiSessionFile;
185
- } catch {
186
- continue;
187
- }
222
+ let raw: string;
223
+ try { raw = await fs.readFile(filePath, "utf8"); } catch { continue; }
188
224
 
189
- const sessionId = session.id ?? path.basename(name, ".json");
190
- const messages: Array<{ role: string; content: string; index: number }> = [];
191
- for (let i = 0; i < (session.messages?.length ?? 0); i++) {
192
- const msg = session.messages![i]!;
193
- const text = messageText(msg.content);
194
- if (text.trim()) {
195
- messages.push({ role: msg.role ?? "unknown", content: text, index: i });
225
+ if (filePath.endsWith(".jsonl")) {
226
+ const parsed = parseJsonlMessages(raw, filePath);
227
+ if (parsed) results.push(parsed);
228
+ } else {
229
+ let session: PiSessionFile;
230
+ try { session = JSON.parse(raw) as PiSessionFile; } catch { continue; }
231
+ const sessionId = session.id ?? path.basename(filePath, ".json");
232
+ const messages: Array<{ role: string; content: string; index: number }> = [];
233
+ for (let i = 0; i < (session.messages?.length ?? 0); i++) {
234
+ const msg = session.messages![i]!;
235
+ const text = messageText(msg.content);
236
+ if (text.trim()) {
237
+ messages.push({ role: msg.role ?? "unknown", content: text, index: i });
238
+ }
239
+ }
240
+ if (messages.length > 0) {
241
+ results.push({ id: sessionId, title: session.title ?? "", createdAt: session.created_at ?? "", messages });
196
242
  }
197
- }
198
- if (messages.length > 0) {
199
- results.push({
200
- id: sessionId,
201
- title: session.title ?? "",
202
- createdAt: session.created_at ?? "",
203
- messages,
204
- });
205
243
  }
206
244
  }
207
245
  return results;