agent-optic 0.2.0 → 0.3.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/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // Main factory
2
- export { createHistory, createClaudeHistory } from "./agent-optic.js";
3
- export type { History, HistoryConfig, ClaudeHistory, ClaudeHistoryConfig } from "./agent-optic.js";
2
+ export { createHistory } from "./agent-optic.js";
3
+ export type { History, HistoryConfig } from "./agent-optic.js";
4
4
 
5
- // Provider types and utils
5
+ // Provider types
6
6
  export type { Provider } from "./types/provider.js";
7
7
  export { SUPPORTED_PROVIDERS } from "./types/provider.js";
8
8
 
9
- // Types
9
+ // Domain types
10
10
  export type {
11
11
  HistoryEntry,
12
12
  SessionInfo,
@@ -15,17 +15,11 @@ export type {
15
15
  ToolCategory,
16
16
  ToolCallSummary,
17
17
  } from "./types/session.js";
18
-
19
18
  export type { ContentBlock, TranscriptEntry } from "./types/transcript.js";
20
-
21
19
  export type { TaskInfo, TodoItem } from "./types/task.js";
22
-
23
20
  export type { PlanInfo } from "./types/plan.js";
24
-
25
21
  export type { ProjectInfo, ProjectMemory } from "./types/project.js";
26
-
27
22
  export type { StatsCache } from "./types/stats.js";
28
-
29
23
  export type {
30
24
  DailySummary,
31
25
  ProjectSummary,
@@ -33,37 +27,15 @@ export type {
33
27
  DateFilter,
34
28
  SessionListFilter,
35
29
  } from "./types/aggregations.js";
36
-
37
30
  export type { PrivacyConfig, PrivacyProfile } from "./types/privacy.js";
38
31
 
39
- // Privacy profiles (runtime values, not just types)
32
+ // Privacy
40
33
  export { PRIVACY_PROFILES, resolvePrivacyConfig } from "./privacy/config.js";
41
34
 
42
- // Utilities (for advanced users)
43
- export {
44
- encodeProjectPath,
45
- decodeProjectPath,
46
- projectName,
47
- providerPaths,
48
- claudePaths,
49
- } from "./utils/paths.js";
50
- export {
51
- DEFAULT_PROVIDER,
52
- defaultProviderDir,
53
- providerHomeDirName,
54
- isProvider,
55
- } from "./utils/providers.js";
56
- export { toLocalDate, today, formatTime, resolveDateRange } from "./utils/dates.js";
57
- export { parseJsonl, streamJsonl, peekJsonl, readJsonl } from "./utils/jsonl.js";
58
-
59
- // Parsers (for advanced users building custom pipelines)
60
- export { parseSessionDetail, parseSessions } from "./parsers/session-detail.js";
61
- export { categorizeToolName, toolDisplayName } from "./parsers/tool-categories.js";
62
- export { extractText, extractToolCalls, extractFilePaths, countThinkingBlocks } from "./parsers/content-blocks.js";
35
+ // Small public utilities
36
+ export { projectName } from "./utils/paths.js";
37
+ export { toLocalDate, today } from "./utils/dates.js";
63
38
 
64
39
  // Pricing
65
40
  export type { ModelPricing } from "./pricing.js";
66
41
  export { MODEL_PRICING, getModelPricing, estimateCost, setPricing } from "./pricing.js";
67
-
68
- // Readers (for advanced users)
69
- export { readProjectMemories } from "./readers/project-reader.js";
@@ -13,6 +13,7 @@ import {
13
13
  parseCodexMessageText,
14
14
  parseCodexToolArguments,
15
15
  } from "../readers/codex-rollout-reader.js";
16
+ import { parsePiSessionDetail } from "../readers/pi-session-reader.js";
16
17
 
17
18
  /**
18
19
  * Parse a full session JSONL file into a SessionDetail.
@@ -25,6 +26,9 @@ export async function parseSessionDetail(
25
26
  privacy: PrivacyConfig,
26
27
  ): Promise<SessionDetail> {
27
28
  const normalized = canonicalProvider(provider);
29
+ if (normalized === "pi") {
30
+ return parsePiSessionDetail(session, paths.sessionsDir, privacy);
31
+ }
28
32
  if (normalized === "codex") {
29
33
  return parseCodexSessionDetail(session, paths.sessionsDir, privacy);
30
34
  }
@@ -111,8 +115,13 @@ async function parseClaudeSessionDetail(
111
115
  detail.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0;
112
116
  }
113
117
 
114
- // Count messages
115
- if (role === "user" || role === "assistant") {
118
+ // Count messages (skip meta-only, synthetic errors, tool result carriers)
119
+ if (
120
+ (role === "user" || role === "assistant") &&
121
+ !filtered.isMeta &&
122
+ filtered.message?.model !== "<synthetic>" &&
123
+ entry.toolUseResult === undefined
124
+ ) {
116
125
  detail.messageCount++;
117
126
  }
118
127
 
@@ -292,10 +301,6 @@ function extractCodexToolTarget(
292
301
  }
293
302
  }
294
303
 
295
- if (name === "exec_command" && typeof input.cmd === "string") {
296
- return input.cmd.split(" ")[0];
297
- }
298
-
299
304
  return undefined;
300
305
  }
301
306
 
@@ -33,6 +33,14 @@ const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
33
33
  AskUserQuestion: "task",
34
34
  Skill: "task",
35
35
 
36
+ // Pi tools (lowercase variants)
37
+ bash: "shell",
38
+ read: "file_read",
39
+ write: "file_write",
40
+ edit: "file_write",
41
+ glob: "file_read",
42
+ grep: "file_read",
43
+
36
44
  // Codex tools
37
45
  exec_command: "shell",
38
46
  shell_command: "shell",
@@ -7,6 +7,7 @@ import { projectName } from "../utils/paths.js";
7
7
  import { canonicalProvider } from "../utils/providers.js";
8
8
  import { isProjectExcluded, redactString } from "../privacy/redact.js";
9
9
  import { readCodexSessionHeader } from "./codex-rollout-reader.js";
10
+ import { readPiHistory } from "./pi-session-reader.js";
10
11
 
11
12
  interface ClaudeHistoryEntry {
12
13
  display: string;
@@ -37,6 +38,12 @@ export async function readHistory(
37
38
  },
38
39
  ): Promise<SessionInfo[]> {
39
40
  const provider = canonicalProvider(options?.provider ?? "claude");
41
+ if (provider === "pi") {
42
+ return readPiHistory(
43
+ options?.sessionsDir ?? join(dirname(historyFile), "sessions"),
44
+ from, to, privacy,
45
+ );
46
+ }
40
47
  if (provider === "codex") {
41
48
  return readCodexHistory(
42
49
  historyFile,
@@ -0,0 +1,466 @@
1
+ import { join } from "node:path";
2
+ import type { PrivacyConfig } from "../types/privacy.js";
3
+ import type { SessionDetail, SessionInfo, SessionMeta, ToolCallSummary } from "../types/session.js";
4
+ import type { ContentBlock, TranscriptEntry } from "../types/transcript.js";
5
+ import { projectName } from "../utils/paths.js";
6
+ import { isProjectExcluded, redactString, filterTranscriptEntry } from "../privacy/redact.js";
7
+ import { extractText, extractFilePaths, countThinkingBlocks } from "../parsers/content-blocks.js";
8
+ import { categorizeToolName, toolDisplayName } from "../parsers/tool-categories.js";
9
+
10
+ // Pi filenames: {ISO-timestamp}_{uuid}.jsonl
11
+ // e.g. 2026-02-05T20-05-58-927Z_05f61a6d-20f8-4c57-917b-df7906fe952f.jsonl
12
+ function parsePiFilename(
13
+ filename: string,
14
+ ): { date: string; sessionId: string; timestamp: string } | null {
15
+ const m = filename.match(
16
+ /^(\d{4}-\d{2}-\d{2})T[\d-]+Z_([0-9a-f-]{36})\.jsonl$/,
17
+ );
18
+ return m ? { date: m[1], sessionId: m[2], timestamp: m[1] } : null;
19
+ }
20
+
21
+ const piIndexCache = new Map<string, Promise<Map<string, string>>>();
22
+
23
+ async function buildPiIndex(sessionsDir: string): Promise<Map<string, string>> {
24
+ const index = new Map<string, string>();
25
+ const glob = new Bun.Glob("**/*.jsonl");
26
+ for await (const path of glob.scan({ cwd: sessionsDir, absolute: false })) {
27
+ const filename = path.split("/").pop()!;
28
+ const parsed = parsePiFilename(filename);
29
+ if (parsed) index.set(parsed.sessionId, join(sessionsDir, path));
30
+ }
31
+ return index;
32
+ }
33
+
34
+ async function getPiIndex(sessionsDir: string): Promise<Map<string, string>> {
35
+ let promise = piIndexCache.get(sessionsDir);
36
+ if (!promise) {
37
+ promise = buildPiIndex(sessionsDir);
38
+ piIndexCache.set(sessionsDir, promise);
39
+ }
40
+ return promise;
41
+ }
42
+
43
+ /** Find a Pi session file by session ID. */
44
+ async function findPiSessionFile(
45
+ sessionsDir: string,
46
+ sessionId: string,
47
+ ): Promise<string | null> {
48
+ const index = await getPiIndex(sessionsDir);
49
+ const cached = index.get(sessionId);
50
+ if (cached) return cached;
51
+
52
+ // Fallback for newly created files
53
+ const glob = new Bun.Glob(`**/*_${sessionId}.jsonl`);
54
+ for await (const path of glob.scan({ cwd: sessionsDir, absolute: false })) {
55
+ const fullPath = join(sessionsDir, path);
56
+ index.set(sessionId, fullPath);
57
+ return fullPath;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /** Read all Pi sessions by scanning directory tree (no history.jsonl). */
63
+ export async function readPiHistory(
64
+ sessionsDir: string,
65
+ from: string,
66
+ to: string,
67
+ privacy: PrivacyConfig,
68
+ ): Promise<SessionInfo[]> {
69
+ const sessions: SessionInfo[] = [];
70
+ const glob = new Bun.Glob("**/*.jsonl");
71
+
72
+ for await (const path of glob.scan({ cwd: sessionsDir, absolute: false })) {
73
+ const filename = path.split("/").pop()!;
74
+ const parsed = parsePiFilename(filename);
75
+ if (!parsed) continue;
76
+
77
+ // Filter by date from filename before reading file
78
+ if (parsed.date < from || parsed.date > to) continue;
79
+
80
+ const fullPath = join(sessionsDir, path);
81
+ const file = Bun.file(fullPath);
82
+ if (!(await file.exists())) continue;
83
+
84
+ let cwd: string | undefined;
85
+ let firstPrompt: string | undefined;
86
+ let sessionTimestamp: number | undefined;
87
+
88
+ try {
89
+ const text = await file.text();
90
+ for (const line of text.split("\n")) {
91
+ if (!line.trim()) continue;
92
+ let entry: any;
93
+ try {
94
+ entry = JSON.parse(line);
95
+ } catch {
96
+ continue;
97
+ }
98
+
99
+ if (entry.type === "session") {
100
+ cwd = entry.cwd;
101
+ sessionTimestamp = new Date(entry.timestamp).getTime();
102
+ }
103
+
104
+ if (
105
+ entry.type === "message" &&
106
+ entry.message?.role === "user" &&
107
+ !firstPrompt
108
+ ) {
109
+ const content = entry.message.content;
110
+ if (typeof content === "string") {
111
+ firstPrompt = content;
112
+ } else if (Array.isArray(content)) {
113
+ const textBlock = content.find(
114
+ (b: any) => b.type === "text" && typeof b.text === "string",
115
+ );
116
+ if (textBlock) firstPrompt = textBlock.text;
117
+ }
118
+ }
119
+
120
+ if (cwd && firstPrompt) break;
121
+ }
122
+ } catch {
123
+ continue;
124
+ }
125
+
126
+ if (!cwd) continue;
127
+ if (isProjectExcluded(cwd, privacy)) continue;
128
+
129
+ const ts = sessionTimestamp ?? new Date(parsed.date).getTime();
130
+ const prompt = firstPrompt
131
+ ? privacy.redactPrompts
132
+ ? "[redacted]"
133
+ : privacy.redactPatterns.length > 0
134
+ ? redactString(firstPrompt, privacy)
135
+ : firstPrompt
136
+ : "(no prompt)";
137
+
138
+ sessions.push({
139
+ sessionId: parsed.sessionId,
140
+ project: cwd,
141
+ projectName: projectName(cwd),
142
+ prompts: [prompt],
143
+ promptTimestamps: [ts],
144
+ timeRange: { start: ts, end: ts },
145
+ });
146
+ }
147
+
148
+ sessions.sort((a, b) => a.timeRange.start - b.timeRange.start);
149
+ return sessions;
150
+ }
151
+
152
+ /** Peek Pi session metadata (model, tokens, cost). */
153
+ export async function peekPiSession(
154
+ session: SessionInfo,
155
+ sessionsDir: string,
156
+ ): Promise<SessionMeta> {
157
+ const meta: SessionMeta = {
158
+ ...session,
159
+ totalInputTokens: 0,
160
+ totalOutputTokens: 0,
161
+ cacheCreationInputTokens: 0,
162
+ cacheReadInputTokens: 0,
163
+ messageCount: 0,
164
+ };
165
+
166
+ const filePath = await findPiSessionFile(sessionsDir, session.sessionId);
167
+ if (!filePath) return meta;
168
+
169
+ const file = Bun.file(filePath);
170
+ if (!(await file.exists())) return meta;
171
+
172
+ let totalCost = 0;
173
+
174
+ try {
175
+ const text = await file.text();
176
+ for (const line of text.split("\n")) {
177
+ if (!line.trim()) continue;
178
+ let entry: any;
179
+ try {
180
+ entry = JSON.parse(line);
181
+ } catch {
182
+ continue;
183
+ }
184
+
185
+ if (entry.type === "model_change") {
186
+ if (!meta.model && typeof entry.modelId === "string") {
187
+ meta.model = entry.modelId;
188
+ }
189
+ }
190
+
191
+ if (entry.type === "message" && entry.message) {
192
+ const msg = entry.message;
193
+
194
+ if (msg.role === "user" || msg.role === "assistant") {
195
+ meta.messageCount++;
196
+ }
197
+
198
+ if (msg.usage && typeof msg.usage === "object") {
199
+ meta.totalInputTokens += Number(msg.usage.input ?? 0);
200
+ meta.totalOutputTokens += Number(msg.usage.output ?? 0);
201
+ meta.cacheReadInputTokens += Number(msg.usage.cacheRead ?? 0);
202
+ meta.cacheCreationInputTokens += Number(msg.usage.cacheWrite ?? 0);
203
+ }
204
+
205
+ if (msg.usage?.cost && typeof msg.usage.cost.total === "number") {
206
+ totalCost += msg.usage.cost.total;
207
+ }
208
+ }
209
+ }
210
+ } catch {
211
+ // file unreadable
212
+ }
213
+
214
+ if (totalCost > 0) meta.totalCost = totalCost;
215
+ return meta;
216
+ }
217
+
218
+ /** Parse full Pi session detail. */
219
+ export async function parsePiSessionDetail(
220
+ session: SessionInfo,
221
+ sessionsDir: string,
222
+ privacy: PrivacyConfig,
223
+ ): Promise<SessionDetail> {
224
+ const detail: SessionDetail = {
225
+ ...session,
226
+ totalInputTokens: 0,
227
+ totalOutputTokens: 0,
228
+ cacheCreationInputTokens: 0,
229
+ cacheReadInputTokens: 0,
230
+ messageCount: 0,
231
+ assistantSummaries: [],
232
+ toolCalls: [],
233
+ filesReferenced: [],
234
+ planReferenced: false,
235
+ thinkingBlockCount: 0,
236
+ hasSidechains: false,
237
+ };
238
+
239
+ const filePath = await findPiSessionFile(sessionsDir, session.sessionId);
240
+ if (!filePath) return detail;
241
+
242
+ const file = Bun.file(filePath);
243
+ if (!(await file.exists())) return detail;
244
+
245
+ const toolCallSet = new Map<string, ToolCallSummary>();
246
+ const fileSet = new Set<string>();
247
+ let model: string | undefined;
248
+ let totalCost = 0;
249
+
250
+ try {
251
+ const text = await file.text();
252
+ for (const line of text.split("\n")) {
253
+ if (!line.trim()) continue;
254
+ let entry: any;
255
+ try {
256
+ entry = JSON.parse(line);
257
+ } catch {
258
+ continue;
259
+ }
260
+
261
+ if (entry.type === "model_change") {
262
+ if (!model && typeof entry.modelId === "string") {
263
+ model = entry.modelId;
264
+ }
265
+ }
266
+
267
+ if (entry.type !== "message" || !entry.message) continue;
268
+ const msg = entry.message;
269
+
270
+ if (msg.role === "user" || msg.role === "assistant") {
271
+ detail.messageCount++;
272
+ }
273
+
274
+ if (msg.usage && typeof msg.usage === "object") {
275
+ detail.totalInputTokens += Number(msg.usage.input ?? 0);
276
+ detail.totalOutputTokens += Number(msg.usage.output ?? 0);
277
+ detail.cacheReadInputTokens += Number(msg.usage.cacheRead ?? 0);
278
+ detail.cacheCreationInputTokens += Number(msg.usage.cacheWrite ?? 0);
279
+ }
280
+
281
+ if (msg.usage?.cost && typeof msg.usage.cost.total === "number") {
282
+ totalCost += msg.usage.cost.total;
283
+ }
284
+
285
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
286
+ // Map Pi content blocks to our ContentBlock format for extraction
287
+ const blocks: ContentBlock[] = [];
288
+ for (const block of msg.content) {
289
+ if (block.type === "text" && typeof block.text === "string") {
290
+ blocks.push({ type: "text", text: block.text });
291
+ } else if (block.type === "thinking" && typeof block.thinking === "string") {
292
+ blocks.push({ type: "thinking", thinking: block.thinking });
293
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
294
+ const input = block.arguments && typeof block.arguments === "object"
295
+ ? block.arguments
296
+ : undefined;
297
+ blocks.push({ type: "tool_use", name: block.name, input });
298
+
299
+ const displayName = toolDisplayName(block.name, input);
300
+ const target = extractPiToolTarget(block.name, input);
301
+ toolCallSet.set(displayName, {
302
+ name: block.name,
303
+ displayName,
304
+ category: categorizeToolName(block.name),
305
+ target,
306
+ });
307
+
308
+ const fp = extractPiFilePath(input);
309
+ if (fp) fileSet.add(fp);
310
+ }
311
+ }
312
+
313
+ const textContent = extractText(blocks);
314
+ if (textContent && textContent.length > 20) {
315
+ const redacted =
316
+ privacy.redactPatterns.length > 0 || privacy.redactHomeDir
317
+ ? redactString(textContent, privacy)
318
+ : textContent;
319
+ detail.assistantSummaries.push(
320
+ redacted.slice(0, 200) + (redacted.length > 200 ? "..." : ""),
321
+ );
322
+ }
323
+
324
+ for (const fp of extractFilePaths(blocks)) {
325
+ fileSet.add(fp);
326
+ }
327
+
328
+ detail.thinkingBlockCount += countThinkingBlocks(blocks);
329
+ }
330
+ }
331
+ } catch {
332
+ // file unreadable
333
+ }
334
+
335
+ detail.toolCalls = [...toolCallSet.values()];
336
+ detail.filesReferenced = [...fileSet];
337
+ detail.model = model;
338
+ if (totalCost > 0) detail.totalCost = totalCost;
339
+ detail.assistantSummaries = detail.assistantSummaries.slice(0, 10);
340
+ return detail;
341
+ }
342
+
343
+ /** Stream Pi transcript entries with privacy filtering. */
344
+ export async function* streamPiTranscript(
345
+ sessionId: string,
346
+ sessionsDir: string,
347
+ privacy: PrivacyConfig,
348
+ ): AsyncGenerator<TranscriptEntry> {
349
+ const filePath = await findPiSessionFile(sessionsDir, sessionId);
350
+ if (!filePath) return;
351
+
352
+ const file = Bun.file(filePath);
353
+ if (!(await file.exists())) return;
354
+
355
+ let currentModel: string | undefined;
356
+
357
+ const text = await file.text();
358
+ for (const line of text.split("\n")) {
359
+ if (!line.trim()) continue;
360
+ let raw: any;
361
+ try {
362
+ raw = JSON.parse(line);
363
+ } catch {
364
+ continue;
365
+ }
366
+
367
+ if (raw.type === "model_change" && typeof raw.modelId === "string") {
368
+ currentModel = raw.modelId;
369
+ continue;
370
+ }
371
+
372
+ // Skip non-message events
373
+ if (raw.type !== "message" || !raw.message) continue;
374
+
375
+ const msg = raw.message;
376
+ let mapped: TranscriptEntry | null = null;
377
+
378
+ if (msg.role === "user") {
379
+ const content = Array.isArray(msg.content)
380
+ ? msg.content
381
+ .filter((b: any) => b.type === "text" && typeof b.text === "string")
382
+ .map((b: any) => b.text)
383
+ .join("\n")
384
+ : typeof msg.content === "string"
385
+ ? msg.content
386
+ : "";
387
+ mapped = {
388
+ timestamp: raw.timestamp,
389
+ message: { role: "user", content },
390
+ };
391
+ } else if (msg.role === "assistant" && Array.isArray(msg.content)) {
392
+ const blocks: ContentBlock[] = [];
393
+ for (const block of msg.content) {
394
+ if (block.type === "text" && typeof block.text === "string") {
395
+ blocks.push({ type: "text", text: block.text });
396
+ } else if (block.type === "thinking" && typeof block.thinking === "string") {
397
+ // Strip signature
398
+ blocks.push({ type: "thinking", thinking: block.thinking });
399
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
400
+ const input = block.arguments && typeof block.arguments === "object"
401
+ ? block.arguments
402
+ : undefined;
403
+ blocks.push({ type: "tool_use", name: block.name, id: block.id, input });
404
+ }
405
+ }
406
+ mapped = {
407
+ timestamp: raw.timestamp,
408
+ message: {
409
+ role: "assistant",
410
+ model: currentModel ?? msg.model,
411
+ content: blocks,
412
+ usage: msg.usage
413
+ ? {
414
+ input_tokens: msg.usage.input,
415
+ output_tokens: msg.usage.output,
416
+ cache_read_input_tokens: msg.usage.cacheRead,
417
+ cache_creation_input_tokens: msg.usage.cacheWrite,
418
+ }
419
+ : undefined,
420
+ },
421
+ };
422
+ } else if (msg.role === "toolResult") {
423
+ const output = Array.isArray(msg.content)
424
+ ? msg.content
425
+ .filter((b: any) => b.type === "text" && typeof b.text === "string")
426
+ .map((b: any) => b.text)
427
+ .join("\n")
428
+ : typeof msg.content === "string"
429
+ ? msg.content
430
+ : undefined;
431
+ mapped = {
432
+ timestamp: raw.timestamp,
433
+ toolUseResult: output,
434
+ };
435
+ }
436
+
437
+ if (!mapped) continue;
438
+ const filtered = filterTranscriptEntry(mapped, privacy);
439
+ if (filtered) yield filtered;
440
+ }
441
+ }
442
+
443
+ function extractPiFilePath(input: Record<string, unknown> | undefined): string | undefined {
444
+ if (!input) return undefined;
445
+ for (const key of ["file_path", "path", "target_file", "notebook_path"]) {
446
+ const value = input[key];
447
+ if (typeof value === "string" && value.length > 0) return value;
448
+ }
449
+ return undefined;
450
+ }
451
+
452
+ function extractPiToolTarget(
453
+ name: string,
454
+ input: Record<string, unknown> | undefined,
455
+ ): string | undefined {
456
+ const filePath = extractPiFilePath(input);
457
+ if (filePath) return filePath;
458
+ if (!input) return undefined;
459
+ for (const key of ["command", "pattern", "query"]) {
460
+ const value = input[key];
461
+ if (typeof value === "string" && value.length > 0) {
462
+ return key === "command" ? value.split(" ")[0] : value;
463
+ }
464
+ }
465
+ return undefined;
466
+ }
@@ -1,7 +1,8 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ProjectInfo, ProjectMemory } from "../types/project.js";
4
- import { decodeProjectPath } from "../utils/paths.js";
4
+ import type { Provider } from "../types/provider.js";
5
+ import { decodeProjectPath, decodePiProjectPath } from "../utils/paths.js";
5
6
  import type { PrivacyConfig } from "../types/privacy.js";
6
7
  import { isProjectExcluded } from "../privacy/redact.js";
7
8
 
@@ -9,6 +10,7 @@ import { isProjectExcluded } from "../privacy/redact.js";
9
10
  export async function readProjects(
10
11
  projectsDir: string,
11
12
  privacy: PrivacyConfig,
13
+ provider?: Provider,
12
14
  ): Promise<ProjectInfo[]> {
13
15
  const projects: ProjectInfo[] = [];
14
16
 
@@ -22,7 +24,10 @@ export async function readProjects(
22
24
  for (const encodedPath of entries) {
23
25
  if (encodedPath.startsWith(".")) continue;
24
26
 
25
- const decodedPath = decodeProjectPath(encodedPath);
27
+ const isPiDir = provider === "pi" && encodedPath.startsWith("--") && encodedPath.endsWith("--");
28
+ const decodedPath = isPiDir
29
+ ? decodePiProjectPath(encodedPath)
30
+ : decodeProjectPath(encodedPath);
26
31
 
27
32
  if (isProjectExcluded(decodedPath, privacy)) continue;
28
33
 
@@ -60,8 +65,11 @@ export async function readProjects(
60
65
  export async function readProjectMemory(
61
66
  projectPath: string,
62
67
  projectsDir: string,
68
+ provider?: Provider,
63
69
  ): Promise<ProjectMemory | null> {
64
- const encoded = projectPath.replace(/\//g, "-");
70
+ const encoded = provider === "pi"
71
+ ? "--" + projectPath.slice(1).replace(/\//g, "-") + "--"
72
+ : projectPath.replace(/\//g, "-");
65
73
  const memoryPath = join(projectsDir, encoded, "memory", "MEMORY.md");
66
74
 
67
75
  try {
@@ -84,13 +92,14 @@ export async function readProjectMemory(
84
92
  export async function readProjectMemories(
85
93
  projectPaths: string[],
86
94
  projectsDir: string,
95
+ provider?: Provider,
87
96
  ): Promise<Map<string, string>> {
88
97
  const memory = new Map<string, string>();
89
98
  const unique = [...new Set(projectPaths)];
90
99
 
91
100
  await Promise.all(
92
101
  unique.map(async (projectPath) => {
93
- const result = await readProjectMemory(projectPath, projectsDir);
102
+ const result = await readProjectMemory(projectPath, projectsDir, provider);
94
103
  if (result) {
95
104
  memory.set(result.projectName, result.content);
96
105
  }