agent-optic 0.2.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/LICENSE +21 -0
- package/README.md +337 -0
- package/examples/commit-tracker.ts +389 -0
- package/examples/cost-per-feature.ts +182 -0
- package/examples/match-git-commits.ts +171 -0
- package/examples/model-costs.ts +131 -0
- package/examples/pipe-match.ts +177 -0
- package/examples/prompt-history.ts +119 -0
- package/examples/session-digest.ts +89 -0
- package/examples/timesheet.ts +127 -0
- package/examples/work-patterns.ts +124 -0
- package/package.json +41 -0
- package/src/agent-optic.ts +325 -0
- package/src/aggregations/daily.ts +90 -0
- package/src/aggregations/project.ts +71 -0
- package/src/aggregations/time.ts +44 -0
- package/src/aggregations/tools.ts +60 -0
- package/src/claude-optic.ts +7 -0
- package/src/cli/index.ts +407 -0
- package/src/index.ts +69 -0
- package/src/parsers/content-blocks.ts +58 -0
- package/src/parsers/session-detail.ts +323 -0
- package/src/parsers/tool-categories.ts +86 -0
- package/src/pricing.ts +62 -0
- package/src/privacy/config.ts +67 -0
- package/src/privacy/redact.ts +99 -0
- package/src/readers/codex-rollout-reader.ts +145 -0
- package/src/readers/history-reader.ts +205 -0
- package/src/readers/plan-reader.ts +60 -0
- package/src/readers/project-reader.ts +101 -0
- package/src/readers/session-reader.ts +280 -0
- package/src/readers/skill-reader.ts +28 -0
- package/src/readers/stats-reader.ts +12 -0
- package/src/readers/task-reader.ts +117 -0
- package/src/types/aggregations.ts +47 -0
- package/src/types/plan.ts +6 -0
- package/src/types/privacy.ts +18 -0
- package/src/types/project.ts +13 -0
- package/src/types/provider.ts +9 -0
- package/src/types/session.ts +56 -0
- package/src/types/stats.ts +15 -0
- package/src/types/task.ts +16 -0
- package/src/types/transcript.ts +36 -0
- package/src/utils/dates.ts +40 -0
- package/src/utils/jsonl.ts +83 -0
- package/src/utils/paths.ts +57 -0
- package/src/utils/providers.ts +30 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
interface CodexSessionHeader {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
gitBranch?: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
modelProvider?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const rolloutIndexCache = new Map<string, Promise<Map<string, string>>>();
|
|
11
|
+
const headerCache = new Map<string, Promise<CodexSessionHeader>>();
|
|
12
|
+
|
|
13
|
+
function parseRolloutFilename(
|
|
14
|
+
filename: string,
|
|
15
|
+
): { date: string; sessionId: string } | null {
|
|
16
|
+
const m = filename.match(
|
|
17
|
+
/^rollout-(\d{4}-\d{2}-\d{2})T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/,
|
|
18
|
+
);
|
|
19
|
+
return m ? { date: m[1], sessionId: m[2] } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function buildRolloutIndex(sessionsDir: string): Promise<Map<string, string>> {
|
|
23
|
+
const index = new Map<string, string>();
|
|
24
|
+
const glob = new Bun.Glob("**/*.jsonl");
|
|
25
|
+
for await (const path of glob.scan({
|
|
26
|
+
cwd: sessionsDir,
|
|
27
|
+
absolute: false,
|
|
28
|
+
})) {
|
|
29
|
+
const filename = path.split("/").pop()!;
|
|
30
|
+
const parsed = parseRolloutFilename(filename);
|
|
31
|
+
if (parsed) index.set(parsed.sessionId, join(sessionsDir, path));
|
|
32
|
+
}
|
|
33
|
+
return index;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getRolloutIndex(sessionsDir: string): Promise<Map<string, string>> {
|
|
37
|
+
let promise = rolloutIndexCache.get(sessionsDir);
|
|
38
|
+
if (!promise) {
|
|
39
|
+
promise = buildRolloutIndex(sessionsDir);
|
|
40
|
+
rolloutIndexCache.set(sessionsDir, promise);
|
|
41
|
+
}
|
|
42
|
+
return promise;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function findRolloutFile(
|
|
46
|
+
sessionsDir: string,
|
|
47
|
+
sessionId: string,
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
const index = await getRolloutIndex(sessionsDir);
|
|
50
|
+
const cached = index.get(sessionId);
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
|
|
53
|
+
// Fallback for newly created files before cache refresh.
|
|
54
|
+
const glob = new Bun.Glob(`**/*-${sessionId}.jsonl`);
|
|
55
|
+
for await (const path of glob.scan({
|
|
56
|
+
cwd: sessionsDir,
|
|
57
|
+
absolute: false,
|
|
58
|
+
})) {
|
|
59
|
+
const fullPath = join(sessionsDir, path);
|
|
60
|
+
index.set(sessionId, fullPath);
|
|
61
|
+
return fullPath;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readCodexSessionHeader(
|
|
67
|
+
sessionsDir: string,
|
|
68
|
+
sessionId: string,
|
|
69
|
+
): Promise<CodexSessionHeader> {
|
|
70
|
+
const key = `${sessionsDir}:${sessionId}`;
|
|
71
|
+
let promise = headerCache.get(key);
|
|
72
|
+
if (!promise) {
|
|
73
|
+
promise = (async () => {
|
|
74
|
+
const rolloutPath = await findRolloutFile(sessionsDir, sessionId);
|
|
75
|
+
if (!rolloutPath) return {};
|
|
76
|
+
|
|
77
|
+
const file = Bun.file(rolloutPath);
|
|
78
|
+
if (!(await file.exists())) return {};
|
|
79
|
+
|
|
80
|
+
let cwd: string | undefined;
|
|
81
|
+
let gitBranch: string | undefined;
|
|
82
|
+
let model: string | undefined;
|
|
83
|
+
let modelProvider: string | undefined;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const text = await file.text();
|
|
87
|
+
for (const line of text.split("\n")) {
|
|
88
|
+
if (!line.trim()) continue;
|
|
89
|
+
let entry: any;
|
|
90
|
+
try {
|
|
91
|
+
entry = JSON.parse(line);
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (entry.type === "session_meta") {
|
|
97
|
+
cwd = entry.payload?.cwd;
|
|
98
|
+
gitBranch = entry.payload?.git?.branch;
|
|
99
|
+
modelProvider = entry.payload?.model_provider;
|
|
100
|
+
} else if (entry.type === "turn_context") {
|
|
101
|
+
model = entry.payload?.model;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cwd && model) break;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { cwd, gitBranch, model, modelProvider };
|
|
111
|
+
})();
|
|
112
|
+
headerCache.set(key, promise);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return promise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseCodexMessageText(content: unknown): string {
|
|
119
|
+
if (!Array.isArray(content)) return "";
|
|
120
|
+
return content
|
|
121
|
+
.filter(
|
|
122
|
+
(el): el is { type: string; text: string } =>
|
|
123
|
+
!!el &&
|
|
124
|
+
typeof el === "object" &&
|
|
125
|
+
typeof (el as any).type === "string" &&
|
|
126
|
+
typeof (el as any).text === "string" &&
|
|
127
|
+
((el as any).type === "input_text" ||
|
|
128
|
+
(el as any).type === "output_text" ||
|
|
129
|
+
(el as any).type === "text"),
|
|
130
|
+
)
|
|
131
|
+
.map((el) => el.text)
|
|
132
|
+
.join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseCodexToolArguments(
|
|
136
|
+
argumentsText: unknown,
|
|
137
|
+
): Record<string, unknown> | undefined {
|
|
138
|
+
if (typeof argumentsText !== "string" || !argumentsText.trim()) return undefined;
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(argumentsText);
|
|
141
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import type { PrivacyConfig } from "../types/privacy.js";
|
|
3
|
+
import type { Provider } from "../types/provider.js";
|
|
4
|
+
import type { SessionInfo } from "../types/session.js";
|
|
5
|
+
import { toLocalDate } from "../utils/dates.js";
|
|
6
|
+
import { projectName } from "../utils/paths.js";
|
|
7
|
+
import { canonicalProvider } from "../utils/providers.js";
|
|
8
|
+
import { isProjectExcluded, redactString } from "../privacy/redact.js";
|
|
9
|
+
import { readCodexSessionHeader } from "./codex-rollout-reader.js";
|
|
10
|
+
|
|
11
|
+
interface ClaudeHistoryEntry {
|
|
12
|
+
display: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
project: string;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
pastedContents?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CodexHistoryEntry {
|
|
20
|
+
session_id: string;
|
|
21
|
+
ts: number;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read history.jsonl and group entries into SessionInfo objects.
|
|
27
|
+
* This is the fast path — no session file reads, just history.jsonl.
|
|
28
|
+
*/
|
|
29
|
+
export async function readHistory(
|
|
30
|
+
historyFile: string,
|
|
31
|
+
from: string,
|
|
32
|
+
to: string,
|
|
33
|
+
privacy: PrivacyConfig,
|
|
34
|
+
options?: {
|
|
35
|
+
provider?: Provider;
|
|
36
|
+
sessionsDir?: string;
|
|
37
|
+
},
|
|
38
|
+
): Promise<SessionInfo[]> {
|
|
39
|
+
const provider = canonicalProvider(options?.provider ?? "claude");
|
|
40
|
+
if (provider === "codex") {
|
|
41
|
+
return readCodexHistory(
|
|
42
|
+
historyFile,
|
|
43
|
+
from,
|
|
44
|
+
to,
|
|
45
|
+
privacy,
|
|
46
|
+
options?.sessionsDir ?? join(dirname(historyFile), "sessions"),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return readClaudeHistory(historyFile, from, to, privacy);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readClaudeHistory(
|
|
53
|
+
historyFile: string,
|
|
54
|
+
from: string,
|
|
55
|
+
to: string,
|
|
56
|
+
privacy: PrivacyConfig,
|
|
57
|
+
): Promise<SessionInfo[]> {
|
|
58
|
+
const file = Bun.file(historyFile);
|
|
59
|
+
if (!(await file.exists())) return [];
|
|
60
|
+
|
|
61
|
+
const text = await file.text();
|
|
62
|
+
const entries: ClaudeHistoryEntry[] = [];
|
|
63
|
+
|
|
64
|
+
for (const line of text.split("\n")) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
try {
|
|
67
|
+
const entry = JSON.parse(line) as ClaudeHistoryEntry;
|
|
68
|
+
const entryDate = toLocalDate(entry.timestamp);
|
|
69
|
+
|
|
70
|
+
// Early exit: skip entries outside date range
|
|
71
|
+
if (entryDate < from || entryDate > to) continue;
|
|
72
|
+
|
|
73
|
+
// Privacy: skip excluded projects
|
|
74
|
+
if (isProjectExcluded(entry.project, privacy)) continue;
|
|
75
|
+
|
|
76
|
+
entries.push(entry);
|
|
77
|
+
} catch {
|
|
78
|
+
// skip malformed
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Group by sessionId
|
|
83
|
+
const sessionMap = new Map<
|
|
84
|
+
string,
|
|
85
|
+
{ project: string; prompts: string[]; timestamps: number[] }
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const existing = sessionMap.get(entry.sessionId);
|
|
90
|
+
const display = privacy.redactPrompts
|
|
91
|
+
? "[redacted]"
|
|
92
|
+
: privacy.redactPatterns.length > 0
|
|
93
|
+
? redactString(entry.display, privacy)
|
|
94
|
+
: entry.display;
|
|
95
|
+
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.prompts.push(display);
|
|
98
|
+
existing.timestamps.push(entry.timestamp);
|
|
99
|
+
} else {
|
|
100
|
+
sessionMap.set(entry.sessionId, {
|
|
101
|
+
project: entry.project,
|
|
102
|
+
prompts: [display],
|
|
103
|
+
timestamps: [entry.timestamp],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sessions: SessionInfo[] = [];
|
|
109
|
+
for (const [sessionId, data] of sessionMap) {
|
|
110
|
+
sessions.push({
|
|
111
|
+
sessionId,
|
|
112
|
+
project: data.project,
|
|
113
|
+
projectName: projectName(data.project),
|
|
114
|
+
prompts: data.prompts,
|
|
115
|
+
promptTimestamps: data.timestamps,
|
|
116
|
+
timeRange: {
|
|
117
|
+
start: Math.min(...data.timestamps),
|
|
118
|
+
end: Math.max(...data.timestamps),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sessions.sort((a, b) => a.timeRange.start - b.timeRange.start);
|
|
124
|
+
return sessions;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readCodexHistory(
|
|
128
|
+
historyFile: string,
|
|
129
|
+
from: string,
|
|
130
|
+
to: string,
|
|
131
|
+
privacy: PrivacyConfig,
|
|
132
|
+
sessionsDir: string,
|
|
133
|
+
): Promise<SessionInfo[]> {
|
|
134
|
+
const file = Bun.file(historyFile);
|
|
135
|
+
if (!(await file.exists())) return [];
|
|
136
|
+
|
|
137
|
+
const text = await file.text();
|
|
138
|
+
const entries: CodexHistoryEntry[] = [];
|
|
139
|
+
|
|
140
|
+
for (const line of text.split("\n")) {
|
|
141
|
+
if (!line.trim()) continue;
|
|
142
|
+
try {
|
|
143
|
+
const entry = JSON.parse(line) as CodexHistoryEntry;
|
|
144
|
+
if (
|
|
145
|
+
typeof entry.session_id !== "string" ||
|
|
146
|
+
typeof entry.ts !== "number" ||
|
|
147
|
+
typeof entry.text !== "string"
|
|
148
|
+
) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const timestampMs = entry.ts * 1000;
|
|
152
|
+
const entryDate = toLocalDate(timestampMs);
|
|
153
|
+
if (entryDate < from || entryDate > to) continue;
|
|
154
|
+
entries.push(entry);
|
|
155
|
+
} catch {
|
|
156
|
+
// skip malformed
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sessionMap = new Map<string, { prompts: string[]; timestamps: number[] }>();
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const existing = sessionMap.get(entry.session_id);
|
|
163
|
+
const prompt = privacy.redactPrompts
|
|
164
|
+
? "[redacted]"
|
|
165
|
+
: privacy.redactPatterns.length > 0
|
|
166
|
+
? redactString(entry.text, privacy)
|
|
167
|
+
: entry.text;
|
|
168
|
+
|
|
169
|
+
const timestampMs = entry.ts * 1000;
|
|
170
|
+
if (existing) {
|
|
171
|
+
existing.prompts.push(prompt);
|
|
172
|
+
existing.timestamps.push(timestampMs);
|
|
173
|
+
} else {
|
|
174
|
+
sessionMap.set(entry.session_id, {
|
|
175
|
+
prompts: [prompt],
|
|
176
|
+
timestamps: [timestampMs],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const sessions = await Promise.all(
|
|
182
|
+
[...sessionMap.entries()].map(async ([sessionId, data]): Promise<SessionInfo | null> => {
|
|
183
|
+
const header = await readCodexSessionHeader(sessionsDir, sessionId);
|
|
184
|
+
const project = header.cwd ?? `(unknown)/${sessionId}`;
|
|
185
|
+
|
|
186
|
+
if (isProjectExcluded(project, privacy)) return null;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
sessionId,
|
|
190
|
+
project,
|
|
191
|
+
projectName: projectName(project),
|
|
192
|
+
prompts: data.prompts,
|
|
193
|
+
promptTimestamps: data.timestamps,
|
|
194
|
+
timeRange: {
|
|
195
|
+
start: Math.min(...data.timestamps),
|
|
196
|
+
end: Math.max(...data.timestamps),
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
return sessions
|
|
203
|
+
.filter((session): session is SessionInfo => !!session)
|
|
204
|
+
.sort((a, b) => a.timeRange.start - b.timeRange.start);
|
|
205
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { PlanInfo } from "../types/plan.js";
|
|
4
|
+
|
|
5
|
+
function isInDateRange(fileDate: Date, from: string, to: string): boolean {
|
|
6
|
+
const year = fileDate.getFullYear();
|
|
7
|
+
const month = String(fileDate.getMonth() + 1).padStart(2, "0");
|
|
8
|
+
const day = String(fileDate.getDate()).padStart(2, "0");
|
|
9
|
+
const dateStr = `${year}-${month}-${day}`;
|
|
10
|
+
return dateStr >= from && dateStr <= to;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read plans from provider/plans/*.md */
|
|
14
|
+
export async function readPlans(
|
|
15
|
+
plansDir: string,
|
|
16
|
+
from: string,
|
|
17
|
+
to: string,
|
|
18
|
+
includeContent: boolean = false,
|
|
19
|
+
): Promise<PlanInfo[]> {
|
|
20
|
+
const plans: PlanInfo[] = [];
|
|
21
|
+
|
|
22
|
+
let files: string[];
|
|
23
|
+
try {
|
|
24
|
+
files = await readdir(plansDir);
|
|
25
|
+
} catch {
|
|
26
|
+
return plans;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
if (!file.endsWith(".md")) continue;
|
|
31
|
+
const filePath = join(plansDir, file);
|
|
32
|
+
|
|
33
|
+
const fileStat = await stat(filePath).catch(() => null);
|
|
34
|
+
if (!fileStat || !isInDateRange(fileStat.mtime, from, to)) continue;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const content = await Bun.file(filePath).text();
|
|
38
|
+
const lines = content.split("\n");
|
|
39
|
+
const title =
|
|
40
|
+
lines.find((l) => l.startsWith("# "))?.replace("# ", "") ||
|
|
41
|
+
file.replace(".md", "");
|
|
42
|
+
const snippet = lines
|
|
43
|
+
.filter((l) => l.trim() && !l.startsWith("#"))
|
|
44
|
+
.slice(0, 3)
|
|
45
|
+
.join(" ")
|
|
46
|
+
.slice(0, 300);
|
|
47
|
+
|
|
48
|
+
plans.push({
|
|
49
|
+
filename: file,
|
|
50
|
+
title,
|
|
51
|
+
snippet,
|
|
52
|
+
content: includeContent ? content : undefined,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
// skip
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return plans;
|
|
60
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ProjectInfo, ProjectMemory } from "../types/project.js";
|
|
4
|
+
import { decodeProjectPath } from "../utils/paths.js";
|
|
5
|
+
import type { PrivacyConfig } from "../types/privacy.js";
|
|
6
|
+
import { isProjectExcluded } from "../privacy/redact.js";
|
|
7
|
+
|
|
8
|
+
/** List all projects from provider/projects/ */
|
|
9
|
+
export async function readProjects(
|
|
10
|
+
projectsDir: string,
|
|
11
|
+
privacy: PrivacyConfig,
|
|
12
|
+
): Promise<ProjectInfo[]> {
|
|
13
|
+
const projects: ProjectInfo[] = [];
|
|
14
|
+
|
|
15
|
+
let entries: string[];
|
|
16
|
+
try {
|
|
17
|
+
entries = await readdir(projectsDir);
|
|
18
|
+
} catch {
|
|
19
|
+
return projects;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const encodedPath of entries) {
|
|
23
|
+
if (encodedPath.startsWith(".")) continue;
|
|
24
|
+
|
|
25
|
+
const decodedPath = decodeProjectPath(encodedPath);
|
|
26
|
+
|
|
27
|
+
if (isProjectExcluded(decodedPath, privacy)) continue;
|
|
28
|
+
|
|
29
|
+
const projectDir = join(projectsDir, encodedPath);
|
|
30
|
+
|
|
31
|
+
// Count session files
|
|
32
|
+
let sessionCount = 0;
|
|
33
|
+
try {
|
|
34
|
+
const files = await readdir(projectDir);
|
|
35
|
+
sessionCount = files.filter((f) => f.endsWith(".jsonl")).length;
|
|
36
|
+
} catch {
|
|
37
|
+
// skip unreadable dirs
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check for MEMORY.md
|
|
42
|
+
const memoryPath = join(projectDir, "memory", "MEMORY.md");
|
|
43
|
+
const hasMemory = await Bun.file(memoryPath).exists();
|
|
44
|
+
|
|
45
|
+
const name = decodedPath.split("/").pop() || decodedPath;
|
|
46
|
+
|
|
47
|
+
projects.push({
|
|
48
|
+
encodedPath,
|
|
49
|
+
decodedPath,
|
|
50
|
+
name,
|
|
51
|
+
sessionCount,
|
|
52
|
+
hasMemory,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return projects;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Read MEMORY.md for a project. */
|
|
60
|
+
export async function readProjectMemory(
|
|
61
|
+
projectPath: string,
|
|
62
|
+
projectsDir: string,
|
|
63
|
+
): Promise<ProjectMemory | null> {
|
|
64
|
+
const encoded = projectPath.replace(/\//g, "-");
|
|
65
|
+
const memoryPath = join(projectsDir, encoded, "memory", "MEMORY.md");
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const file = Bun.file(memoryPath);
|
|
69
|
+
if (!(await file.exists())) return null;
|
|
70
|
+
const content = await file.text();
|
|
71
|
+
if (!content.trim()) return null;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
projectPath,
|
|
75
|
+
projectName: projectPath.split("/").pop() || projectPath,
|
|
76
|
+
content: content.slice(0, 2000),
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Read MEMORY.md for multiple projects. Returns Map<projectName, content>. */
|
|
84
|
+
export async function readProjectMemories(
|
|
85
|
+
projectPaths: string[],
|
|
86
|
+
projectsDir: string,
|
|
87
|
+
): Promise<Map<string, string>> {
|
|
88
|
+
const memory = new Map<string, string>();
|
|
89
|
+
const unique = [...new Set(projectPaths)];
|
|
90
|
+
|
|
91
|
+
await Promise.all(
|
|
92
|
+
unique.map(async (projectPath) => {
|
|
93
|
+
const result = await readProjectMemory(projectPath, projectsDir);
|
|
94
|
+
if (result) {
|
|
95
|
+
memory.set(result.projectName, result.content);
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return memory;
|
|
101
|
+
}
|