om-memory-system 3.5.0 → 3.6.0-next.38
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 +24 -16
- package/dist/adapters/claude-code/hook-client.d.ts +73 -0
- package/dist/adapters/claude-code/hook-client.js +285 -0
- package/dist/adapters/claude-code/hook-command.d.ts +6 -0
- package/dist/adapters/claude-code/hook-command.js +25 -0
- package/dist/cli/index.js +19 -3
- package/dist/cli/memory-command.d.ts +24 -0
- package/dist/cli/memory-command.js +180 -0
- package/dist/core/retrieval.d.ts +21 -0
- package/dist/core/retrieval.js +35 -0
- package/dist/importer/backfill-controls.d.ts +10 -0
- package/dist/importer/backfill-controls.js +40 -11
- package/dist/importer/backfill-model.d.ts +1 -1
- package/dist/importer/backfill-model.js +3 -0
- package/dist/importer/claude-conversation.d.ts +74 -0
- package/dist/importer/claude-conversation.js +216 -0
- package/dist/importer/claude-hook-api.d.ts +50 -0
- package/dist/importer/claude-hook-api.js +441 -0
- package/dist/importer/claude-import.d.ts +15 -0
- package/dist/importer/claude-import.js +62 -0
- package/dist/importer/claude-reader.d.ts +99 -0
- package/dist/importer/claude-reader.js +292 -0
- package/dist/importer/external-backfill-models.js +2 -2
- package/dist/importer/import-args.d.ts +1 -1
- package/dist/importer/import-args.js +11 -4
- package/dist/importer/import-readiness.d.ts +13 -1
- package/dist/importer/import-readiness.js +19 -0
- package/dist/importer/import-runs.js +1 -1
- package/dist/importer/import-sessions.d.ts +4 -1
- package/dist/importer/import-sessions.js +14 -2
- package/dist/importer/import-sources.d.ts +8 -2
- package/dist/importer/import-sources.js +21 -4
- package/dist/importer/map-suggestions.d.ts +1 -0
- package/dist/importer/map-suggestions.js +6 -1
- package/dist/importer/run-import.js +14 -10
- package/dist/importer/web-import-jobs.js +10 -3
- package/dist/index.js +9 -23
- package/dist/services/ai/live-model-choice.d.ts +5 -0
- package/dist/services/ai/live-model-choice.js +7 -0
- package/dist/services/backfill-state.d.ts +1 -1
- package/dist/services/capture-retry-queue.js +3 -2
- package/dist/services/claude-capture-cursor.d.ts +10 -0
- package/dist/services/claude-capture-cursor.js +44 -0
- package/dist/services/settings-snapshot.d.ts +1 -0
- package/dist/services/settings-snapshot.js +3 -1
- package/dist/services/web-server.d.ts +2 -0
- package/dist/services/web-server.js +61 -28
- package/dist/types/host-label.d.ts +3 -0
- package/dist/types/host-label.js +9 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/web/assets/index-BFiVixOK.js +124 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-DIy--9g_.js +0 -124
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { CONFIG, refreshConfigIfChanged } from "../config.js";
|
|
4
|
+
import { captureConversation } from "../core/capture.js";
|
|
5
|
+
import { classifyCaptureFailure, quickRetryDelayMs } from "../core/capture-retry-policy.js";
|
|
6
|
+
import { buildRecentMemoriesSection, buildRetrievalSection, formatMemoriesForCompaction, stripRetrievalSections, wrapRetrievalSection, } from "../core/retrieval.js";
|
|
7
|
+
import { resolveClaudeCodeLiveModel } from "../services/ai/live-model-choice.js";
|
|
8
|
+
import { captureFailureOf, drainCaptureRetries, queueFailedCapture, registerCaptureRetryDrain, startCaptureRetryDrain, } from "../services/capture-retry-drain.js";
|
|
9
|
+
import { readClaudeCaptureCursor, writeClaudeCaptureCursor, } from "../services/claude-capture-cursor.js";
|
|
10
|
+
import { memoryClient } from "../services/client.js";
|
|
11
|
+
import { log } from "../services/logger.js";
|
|
12
|
+
import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
|
|
13
|
+
import { getTags } from "../services/tags.js";
|
|
14
|
+
import { extractClaudeWindowsAfter, parseClaudeTranscript, } from "./claude-conversation.js";
|
|
15
|
+
// The web app's side of the Claude Code hooks (design decisions 4, 6, 7):
|
|
16
|
+
// retrieval for SessionStart and UserPromptSubmit, a one-worker capture queue
|
|
17
|
+
// for Stop, profile learning, and the once-per-process backfill start. Logs
|
|
18
|
+
// carry event names, session ids, counts, sizes, and codes; never text.
|
|
19
|
+
const HOST = "claude-code";
|
|
20
|
+
/** A hook request the handler cannot use. `status` is the HTTP status. */
|
|
21
|
+
export class ClaudeHookRequestError extends Error {
|
|
22
|
+
status = 400;
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ClaudeHookRequestError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function objectBody(body) {
|
|
29
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
30
|
+
throw new ClaudeHookRequestError("A JSON object is required");
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
}
|
|
34
|
+
function sessionAndCwd(body) {
|
|
35
|
+
const { session_id: sessionId, cwd } = body;
|
|
36
|
+
if (typeof sessionId !== "string" || !sessionId.trim()) {
|
|
37
|
+
throw new ClaudeHookRequestError("session_id is required");
|
|
38
|
+
}
|
|
39
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) {
|
|
40
|
+
throw new ClaudeHookRequestError("cwd must be an absolute path");
|
|
41
|
+
}
|
|
42
|
+
return { sessionId, cwd };
|
|
43
|
+
}
|
|
44
|
+
function optionalString(body, key) {
|
|
45
|
+
const value = body[key];
|
|
46
|
+
if (value === undefined || value === null)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (typeof value !== "string")
|
|
49
|
+
throw new ClaudeHookRequestError(`${key} must be a string`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function parseRetrieveRequest(body) {
|
|
53
|
+
const input = objectBody(body);
|
|
54
|
+
if (input.event !== "session-start" && input.event !== "user-prompt-submit") {
|
|
55
|
+
throw new ClaudeHookRequestError("event must be session-start or user-prompt-submit");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
event: input.event,
|
|
59
|
+
...sessionAndCwd(input),
|
|
60
|
+
source: optionalString(input, "source"),
|
|
61
|
+
prompt: optionalString(input, "prompt"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function parseCaptureRequest(body) {
|
|
65
|
+
const input = objectBody(body);
|
|
66
|
+
const transcriptPath = input.transcript_path;
|
|
67
|
+
if (typeof transcriptPath !== "string" ||
|
|
68
|
+
!isAbsolute(transcriptPath) ||
|
|
69
|
+
!transcriptPath.endsWith(".jsonl")) {
|
|
70
|
+
throw new ClaudeHookRequestError("transcript_path must be an absolute .jsonl path");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...sessionAndCwd(input),
|
|
74
|
+
transcriptPath,
|
|
75
|
+
lastAssistantMessage: optionalString(input, "last_assistant_message"),
|
|
76
|
+
stopHookActive: input.stop_hook_active === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function errorCode(error) {
|
|
80
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
|
|
81
|
+
return error.code;
|
|
82
|
+
}
|
|
83
|
+
return error instanceof Error ? error.name : "unknown";
|
|
84
|
+
}
|
|
85
|
+
// --- Retrieval ---
|
|
86
|
+
let backfillRequested = false;
|
|
87
|
+
/** The first session start in this process starts the Claude Code backfill, once. */
|
|
88
|
+
function maybeStartBackfill(deps) {
|
|
89
|
+
if (backfillRequested || !deps.startBackfill)
|
|
90
|
+
return;
|
|
91
|
+
if (!CONFIG.autoBackfill || process.env.OMMS_DISABLE_AUTO_BACKFILL === "1")
|
|
92
|
+
return;
|
|
93
|
+
backfillRequested = true;
|
|
94
|
+
void deps
|
|
95
|
+
.startBackfill()
|
|
96
|
+
.catch((error) => log("Claude Code backfill start failed", { code: errorCode(error) }));
|
|
97
|
+
}
|
|
98
|
+
async function retrievalSection(request) {
|
|
99
|
+
if (request.event === "user-prompt-submit") {
|
|
100
|
+
const prompt = stripPrivateContent(request.prompt ?? "").trim();
|
|
101
|
+
if (!CONFIG.chatMessage.enabled || !prompt || isFullyPrivate(request.prompt ?? "")) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return buildRetrievalSection(prompt, request.cwd, request.sessionId);
|
|
105
|
+
}
|
|
106
|
+
const tags = getTags(request.cwd);
|
|
107
|
+
if (request.source === "compact" || request.source === "resume") {
|
|
108
|
+
if (!CONFIG.compaction.enabled)
|
|
109
|
+
return null;
|
|
110
|
+
const result = await memoryClient.searchMemoriesBySessionID(request.sessionId, tags.project.tag, CONFIG.compaction.memoryLimit);
|
|
111
|
+
const own = result.success
|
|
112
|
+
? result.results.filter((memory) => memory.metadata?.host === HOST)
|
|
113
|
+
: [];
|
|
114
|
+
return own.length > 0 ? formatMemoriesForCompaction(own) : null;
|
|
115
|
+
}
|
|
116
|
+
if (!CONFIG.chatMessage.enabled)
|
|
117
|
+
return null;
|
|
118
|
+
return buildRecentMemoriesSection({
|
|
119
|
+
projectTag: tags.project.tag,
|
|
120
|
+
userEmail: tags.user.userEmail,
|
|
121
|
+
sessionId: request.sessionId,
|
|
122
|
+
maxMemories: CONFIG.chatMessage.maxMemories,
|
|
123
|
+
excludeCurrentSession: CONFIG.chatMessage.excludeCurrentSession,
|
|
124
|
+
maxAgeDays: CONFIG.chatMessage.maxAgeDays,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `POST /api/claude/retrieve`: recent project memories for a fresh session,
|
|
129
|
+
* the session's own memories after compaction or resume, or the retrieval
|
|
130
|
+
* section for a prompt. Every text is wrapped in the retrieval tag; an empty
|
|
131
|
+
* string means nothing to add.
|
|
132
|
+
*/
|
|
133
|
+
export async function handleClaudeRetrieve(body, deps = {}) {
|
|
134
|
+
const request = parseRetrieveRequest(body);
|
|
135
|
+
refreshConfigIfChanged(request.cwd);
|
|
136
|
+
if (request.event === "session-start")
|
|
137
|
+
maybeStartBackfill(deps);
|
|
138
|
+
try {
|
|
139
|
+
const section = await retrievalSection(request);
|
|
140
|
+
return { additionalContext: section ? wrapRetrievalSection(section) : "" };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
log("Claude Code retrieval failed", {
|
|
144
|
+
event: request.event,
|
|
145
|
+
sessionID: request.sessionId,
|
|
146
|
+
code: errorCode(error),
|
|
147
|
+
});
|
|
148
|
+
return { additionalContext: "" };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// --- Capture ---
|
|
152
|
+
let captureChain = Promise.resolve();
|
|
153
|
+
let captureOffReported = false;
|
|
154
|
+
/** Log once per process that capture is off and which settings are missing. */
|
|
155
|
+
function reportCaptureOff(issues) {
|
|
156
|
+
if (captureOffReported)
|
|
157
|
+
return;
|
|
158
|
+
captureOffReported = true;
|
|
159
|
+
log("Claude Code capture is off", { issues });
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `POST /api/claude/capture`: check the request, queue the turn on the
|
|
163
|
+
* one-worker queue, and answer at once. Throws `ClaudeHookRequestError` for a
|
|
164
|
+
* request that cannot be used.
|
|
165
|
+
*/
|
|
166
|
+
export function handleClaudeCapture(body, deps = {}) {
|
|
167
|
+
const request = parseCaptureRequest(body);
|
|
168
|
+
captureChain = captureChain
|
|
169
|
+
.then(() => captureTurn(request, deps))
|
|
170
|
+
.catch((error) => log("Claude Code capture failed", { sessionID: request.sessionId, code: errorCode(error) }));
|
|
171
|
+
return { queued: true };
|
|
172
|
+
}
|
|
173
|
+
/** Resolves when every queued capture has finished. */
|
|
174
|
+
export function whenClaudeCaptureIdle() {
|
|
175
|
+
return captureChain;
|
|
176
|
+
}
|
|
177
|
+
function skipped(request, code, data = {}) {
|
|
178
|
+
log("Claude Code capture skipped", { sessionID: request.sessionId, code, ...data });
|
|
179
|
+
}
|
|
180
|
+
async function readTranscript(request) {
|
|
181
|
+
let text;
|
|
182
|
+
try {
|
|
183
|
+
text = await readFile(request.transcriptPath, "utf8");
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const code = errorCode(error);
|
|
187
|
+
skipped(request, code === "ENOENT" ? "transcript-missing" : "transcript-unreadable", {
|
|
188
|
+
error: code,
|
|
189
|
+
});
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const parsed = parseClaudeTranscript(text);
|
|
193
|
+
if (parsed.entries.length === 0) {
|
|
194
|
+
skipped(request, "transcript-unreadable", {
|
|
195
|
+
bytes: Buffer.byteLength(text),
|
|
196
|
+
unreadableLines: parsed.unreadableLines,
|
|
197
|
+
unknownTypes: parsed.unknownTypes,
|
|
198
|
+
});
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
return parsed.entries;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The windows after the cursor. The transcript file can lag the hook, so the
|
|
205
|
+
* hook's final text is added as a last assistant reply when the transcript
|
|
206
|
+
* does not already end with it. It carries no entry id, so `sourceEntryIds`
|
|
207
|
+
* stay the reader's ids.
|
|
208
|
+
*/
|
|
209
|
+
export function claudeWindowsToCapture(entries, cursor, finalText) {
|
|
210
|
+
const tail = finalText?.trim();
|
|
211
|
+
if (!tail)
|
|
212
|
+
return extractClaudeWindowsAfter(entries, cursor);
|
|
213
|
+
const hookReply = {
|
|
214
|
+
type: "assistant",
|
|
215
|
+
message: { role: "assistant", content: [{ type: "text", text: tail }] },
|
|
216
|
+
};
|
|
217
|
+
const windows = extractClaudeWindowsAfter([...entries, hookReply], cursor);
|
|
218
|
+
const last = windows.at(-1);
|
|
219
|
+
const replies = last?.textResponses ?? [];
|
|
220
|
+
if (replies.length > 1 && replies[replies.length - 2].trim().endsWith(tail))
|
|
221
|
+
replies.pop();
|
|
222
|
+
return windows;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The project a session belongs to: the first prompt's directory, the same rule
|
|
226
|
+
* the history import uses. Live capture must file a turn under the project the
|
|
227
|
+
* import would choose, or the import cannot see that the turn is already saved.
|
|
228
|
+
*/
|
|
229
|
+
function sessionDirectory(entries, fallback) {
|
|
230
|
+
const first = entries.find((entry) => entry.type === "user" && entry.isSidechain !== true);
|
|
231
|
+
return typeof first?.cwd === "string" && first.cwd ? first.cwd : fallback;
|
|
232
|
+
}
|
|
233
|
+
/** A cleaned work unit, or null for a prompt with nothing left to capture. */
|
|
234
|
+
function workUnitOf(request, window, directory) {
|
|
235
|
+
const prompt = stripRetrievalSections(window.userPrompt);
|
|
236
|
+
if (!prompt || isFullyPrivate(prompt))
|
|
237
|
+
return null;
|
|
238
|
+
return {
|
|
239
|
+
host: HOST,
|
|
240
|
+
hostSessionId: request.sessionId,
|
|
241
|
+
sourceType: "live-capture",
|
|
242
|
+
projectDirectory: directory,
|
|
243
|
+
userPrompt: stripPrivateContent(prompt),
|
|
244
|
+
promptId: window.userEntryId,
|
|
245
|
+
textResponses: window.textResponses.map(stripPrivateContent),
|
|
246
|
+
toolCalls: window.toolCalls.map((call) => ({
|
|
247
|
+
name: call.name,
|
|
248
|
+
input: stripPrivateContent(call.input),
|
|
249
|
+
})),
|
|
250
|
+
sourceEntryIds: window.sourceEntryIds,
|
|
251
|
+
...(window.sourceTimestamp !== undefined ? { sourceTimestamp: window.sourceTimestamp } : {}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** Try the turn up to `autoCaptureMaxRetries` times, as the other hosts do. */
|
|
255
|
+
async function captureWithQuickRetries(unit, provider, sleep) {
|
|
256
|
+
const maxRetries = Math.max(1, CONFIG.autoCaptureMaxRetries ?? 3);
|
|
257
|
+
for (let attempt = 1;; attempt++) {
|
|
258
|
+
try {
|
|
259
|
+
return await captureConversation(unit, provider);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (attempt >= maxRetries)
|
|
263
|
+
throw error;
|
|
264
|
+
await sleep(quickRetryDelayMs(attempt));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function externalModels() {
|
|
269
|
+
const { selectImportModel } = await import("./model-selection.js");
|
|
270
|
+
return selectImportModel({});
|
|
271
|
+
}
|
|
272
|
+
async function captureTurn(request, deps) {
|
|
273
|
+
const started = Date.now();
|
|
274
|
+
refreshConfigIfChanged(request.cwd);
|
|
275
|
+
if (!CONFIG.autoCaptureEnabled)
|
|
276
|
+
return;
|
|
277
|
+
const live = resolveClaudeCodeLiveModel(CONFIG);
|
|
278
|
+
if (!live.ready)
|
|
279
|
+
return reportCaptureOff(live.issues);
|
|
280
|
+
if (memoryClient.getEmbeddingInitError?.())
|
|
281
|
+
return skipped(request, "not-ready");
|
|
282
|
+
const entries = await readTranscript(request);
|
|
283
|
+
if (!entries)
|
|
284
|
+
return;
|
|
285
|
+
const cursor = await readClaudeCaptureCursor(request.sessionId, CONFIG);
|
|
286
|
+
const windows = claudeWindowsToCapture(entries, cursor, request.lastAssistantMessage);
|
|
287
|
+
if (windows.length === 0) {
|
|
288
|
+
// A repeated Stop is the case the flag exists for, so record it even with nothing to capture.
|
|
289
|
+
if (request.stopHookActive)
|
|
290
|
+
log("Claude Code capture idle", { sessionID: request.sessionId, stopHookActive: true });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const directory = sessionDirectory(entries, request.cwd);
|
|
294
|
+
let provider;
|
|
295
|
+
try {
|
|
296
|
+
provider = deps.captureProvider?.() ?? (await externalModels()).capture;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
return skipped(request, "model-unavailable", { error: errorCode(error) });
|
|
300
|
+
}
|
|
301
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
302
|
+
const counts = { captured: 0, skipped: 0, failed: 0, private: 0, queued: 0 };
|
|
303
|
+
let blocked = null;
|
|
304
|
+
for (const window of windows) {
|
|
305
|
+
const unit = workUnitOf(request, window, directory);
|
|
306
|
+
if (!unit) {
|
|
307
|
+
counts.private++;
|
|
308
|
+
}
|
|
309
|
+
else if (blocked) {
|
|
310
|
+
// The model is out of reach: queue the rest of the turns without calling it.
|
|
311
|
+
if (await queueFailedCapture(unit, blocked, CONFIG))
|
|
312
|
+
counts.queued++;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
try {
|
|
316
|
+
const result = await captureWithQuickRetries(unit, provider, sleep);
|
|
317
|
+
counts[result.status]++;
|
|
318
|
+
await recordPrompt(request, unit, result.status === "captured" ? result.memoryId : null);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
counts.failed++;
|
|
322
|
+
if (await queueFailedCapture(unit, error, CONFIG))
|
|
323
|
+
counts.queued++;
|
|
324
|
+
if (classifyCaptureFailure(captureFailureOf(error)) === "retryable")
|
|
325
|
+
blocked = error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await writeClaudeCaptureCursor(request.sessionId, window.userEntryId, CONFIG);
|
|
329
|
+
}
|
|
330
|
+
log("Claude Code capture finished", {
|
|
331
|
+
sessionID: request.sessionId,
|
|
332
|
+
windows: windows.length,
|
|
333
|
+
...counts,
|
|
334
|
+
stopHookActive: request.stopHookActive,
|
|
335
|
+
elapsedMs: Date.now() - started,
|
|
336
|
+
});
|
|
337
|
+
if (counts.captured > 0) {
|
|
338
|
+
// The model answers again, so earlier failed turns may go through now.
|
|
339
|
+
startCaptureRetryDrain(HOST);
|
|
340
|
+
}
|
|
341
|
+
if (counts.captured + counts.skipped > 0)
|
|
342
|
+
await learnProfile(request.cwd, deps);
|
|
343
|
+
}
|
|
344
|
+
// --- Profile learning ---
|
|
345
|
+
async function recordPrompt(request, unit, memoryId) {
|
|
346
|
+
try {
|
|
347
|
+
const { userPromptManager } = await import("../services/user-prompt/user-prompt-manager.js");
|
|
348
|
+
const id = await userPromptManager.savePrompt(request.sessionId, unit.promptId, request.cwd, unit.userPrompt);
|
|
349
|
+
await userPromptManager.markAsCaptured(id);
|
|
350
|
+
if (memoryId)
|
|
351
|
+
await userPromptManager.linkMemoryToPrompt(id, memoryId);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
log("Claude Code prompt record failed", {
|
|
355
|
+
sessionID: request.sessionId,
|
|
356
|
+
code: errorCode(error),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
let learning = false;
|
|
361
|
+
/**
|
|
362
|
+
* Build or update the user profile from the recorded prompts once there are
|
|
363
|
+
* `userProfileAnalysisInterval` of them. A failure is logged and never
|
|
364
|
+
* blocks capture; the prompts stay for the next pass.
|
|
365
|
+
*/
|
|
366
|
+
async function learnProfile(directory, deps) {
|
|
367
|
+
const interval = CONFIG.userProfileAnalysisInterval ?? 0;
|
|
368
|
+
if (!(interval > 0) || learning)
|
|
369
|
+
return;
|
|
370
|
+
learning = true;
|
|
371
|
+
try {
|
|
372
|
+
const [{ userPromptManager }, { userProfileManager }, { analyzeProfile }] = await Promise.all([
|
|
373
|
+
import("../services/user-prompt/user-prompt-manager.js"),
|
|
374
|
+
import("../services/user-profile/user-profile-manager.js"),
|
|
375
|
+
import("../core/profile-analysis.js"),
|
|
376
|
+
]);
|
|
377
|
+
if ((await userPromptManager.countUnanalyzedForUserLearning()) < interval)
|
|
378
|
+
return;
|
|
379
|
+
const user = getTags(directory).user;
|
|
380
|
+
if (!user.userEmail) {
|
|
381
|
+
log("Claude Code profile learning skipped", { code: "no-user-email" });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const batch = await userPromptManager.getPromptsForUserLearning(interval);
|
|
385
|
+
if (batch.length === 0)
|
|
386
|
+
return;
|
|
387
|
+
const model = deps.profileModel?.() ?? (await externalModels()).profile;
|
|
388
|
+
const existing = await userProfileManager.getActiveProfile(user.userEmail);
|
|
389
|
+
const context = batch.map((prompt, index) => `${index + 1}. ${prompt.content}`).join("\n");
|
|
390
|
+
const analysis = await analyzeProfile(model, context, existing ? { id: existing.id, profileData: existing.profileData } : null);
|
|
391
|
+
if (existing) {
|
|
392
|
+
const updated = analysis.merged &&
|
|
393
|
+
(await userProfileManager.updateProfile(existing.id, analysis.merged, batch.length, `Claude Code analysis of ${batch.length} prompts`));
|
|
394
|
+
if (!updated)
|
|
395
|
+
throw new Error("Profile update conflict");
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
await userProfileManager.createProfile(user.userEmail, user.displayName || user.userEmail, user.userName || user.userEmail, user.userEmail, analysis.raw, batch.length);
|
|
399
|
+
}
|
|
400
|
+
await userPromptManager.markMultipleAsUserLearningCaptured(batch.map((prompt) => prompt.id));
|
|
401
|
+
log("Claude Code profile learning finished", {
|
|
402
|
+
prompts: batch.length,
|
|
403
|
+
hadExisting: Boolean(existing),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
log("Claude Code profile learning failed", { code: errorCode(error) });
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
learning = false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// --- Web app start ---
|
|
414
|
+
/**
|
|
415
|
+
* Register the Claude Code retry drain in this web app and start one retry
|
|
416
|
+
* pass. The drain uses the external API. When capture is off, the first
|
|
417
|
+
* capture request logs the missing settings, so a web app that never serves
|
|
418
|
+
* Claude Code does not log them.
|
|
419
|
+
*/
|
|
420
|
+
export function startClaudeCodeWorker() {
|
|
421
|
+
registerCaptureRetryDrain(HOST, async () => {
|
|
422
|
+
let provider = null;
|
|
423
|
+
if (resolveClaudeCodeLiveModel(CONFIG).ready) {
|
|
424
|
+
provider = await externalModels()
|
|
425
|
+
.then((models) => models.capture)
|
|
426
|
+
.catch(() => null);
|
|
427
|
+
}
|
|
428
|
+
return drainCaptureRetries({
|
|
429
|
+
host: HOST,
|
|
430
|
+
// Never called while isReady is false.
|
|
431
|
+
provider: provider ?? {
|
|
432
|
+
summarize: () => Promise.reject(new Error("Claude Code capture model unavailable")),
|
|
433
|
+
},
|
|
434
|
+
config: CONFIG,
|
|
435
|
+
isReady: () => provider !== null && !memoryClient.getEmbeddingInitError?.(),
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
if (resolveClaudeCodeLiveModel(CONFIG).ready && CONFIG.autoCaptureEnabled) {
|
|
439
|
+
startCaptureRetryDrain(HOST);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ImportFilters, type ImporterDeps, type ImportReport } from "./importer.js";
|
|
2
|
+
import { type ClaudeHistoryDeps } from "./claude-reader.js";
|
|
3
|
+
export interface ClaudeImportReport extends ImportReport {
|
|
4
|
+
/** Transcript lines that were not JSON entries. */
|
|
5
|
+
unreadableLines: number;
|
|
6
|
+
/** Entries of a type the reader does not know. */
|
|
7
|
+
unknownTypes: number;
|
|
8
|
+
}
|
|
9
|
+
export type ClaudeImportDeps = Pick<ImporterDeps, "provider" | "ledger" | "onProgress" | "signal" | "profile"> & ClaudeHistoryDeps;
|
|
10
|
+
/**
|
|
11
|
+
* Import Claude Code history through the shared capture, ledger, and profile
|
|
12
|
+
* steps, with host `claude-code`. Mirrors `importPiHistory`: the same filters,
|
|
13
|
+
* report, and dry-run rules.
|
|
14
|
+
*/
|
|
15
|
+
export declare function importClaudeHistory(deps: ClaudeImportDeps, filters: ImportFilters): Promise<ClaudeImportReport>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { importHistorySource, } from "./importer.js";
|
|
2
|
+
import { openClaudeHistorySource } from "./claude-reader.js";
|
|
3
|
+
/**
|
|
4
|
+
* Import Claude Code history through the shared capture, ledger, and profile
|
|
5
|
+
* steps, with host `claude-code`. Mirrors `importPiHistory`: the same filters,
|
|
6
|
+
* report, and dry-run rules.
|
|
7
|
+
*/
|
|
8
|
+
export async function importClaudeHistory(deps, filters) {
|
|
9
|
+
const source = await openClaudeHistorySource({
|
|
10
|
+
...(filters.root !== undefined ? { root: filters.root } : {}),
|
|
11
|
+
...(filters.since !== undefined ? { since: filters.since } : {}),
|
|
12
|
+
...(filters.until !== undefined ? { until: filters.until } : {}),
|
|
13
|
+
...(filters.cutoff !== undefined ? { cutoff: filters.cutoff } : {}),
|
|
14
|
+
...(filters.session ? { session: filters.session } : {}),
|
|
15
|
+
...(filters.selectionKeys ? { selectionKeys: filters.selectionKeys } : {}),
|
|
16
|
+
...(filters.maxSessions !== undefined ? { maxSessions: filters.maxSessions } : {}),
|
|
17
|
+
...(filters.pathMaps ? { pathMaps: filters.pathMaps } : {}),
|
|
18
|
+
...(filters.scope === "current-project" ? { project: filters.currentDirectory } : {}),
|
|
19
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
20
|
+
}, deps.loadSession ? { loadSession: deps.loadSession } : {});
|
|
21
|
+
const report = {
|
|
22
|
+
dryRun: Boolean(filters.dryRun),
|
|
23
|
+
root: filters.root ?? "",
|
|
24
|
+
sessionsDiscovered: source.sessionsDiscovered,
|
|
25
|
+
sessionsLoaded: source.sessionsLoaded,
|
|
26
|
+
sessionsFilteredOut: source.sessionsFilteredOut,
|
|
27
|
+
sessionsUnrecognized: source.unrecognized.length,
|
|
28
|
+
unresolvableSessions: source.unresolvableSessions,
|
|
29
|
+
// Shared with the source, so a session that fails in `open()` is reported too.
|
|
30
|
+
loadErrors: source.loadErrors,
|
|
31
|
+
unitsTotal: 0,
|
|
32
|
+
unitsImported: 0,
|
|
33
|
+
unitsWouldImport: 0,
|
|
34
|
+
unitsSkipped: 0,
|
|
35
|
+
unitsFailed: 0,
|
|
36
|
+
unitsAlreadyHandled: 0,
|
|
37
|
+
skipReasons: {},
|
|
38
|
+
projects: source.projects,
|
|
39
|
+
units: [],
|
|
40
|
+
unitsHeldBack: source.unitsHeldBack,
|
|
41
|
+
unitsUntimed: source.unitsUntimed,
|
|
42
|
+
unreadableLines: source.unreadableLines,
|
|
43
|
+
unknownTypes: source.unknownTypes,
|
|
44
|
+
};
|
|
45
|
+
if (filters.skipMemories) {
|
|
46
|
+
report.unitsTotal = source.unitsKept;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
await importHistorySource(source, "claude-code", deps, filters, report);
|
|
50
|
+
}
|
|
51
|
+
if (deps.profile && !deps.signal?.aborted) {
|
|
52
|
+
const { importProfileFromHistory } = await import("./profile-import.js");
|
|
53
|
+
report.profile = await importProfileFromHistory(source.open(), {
|
|
54
|
+
host: "claude-code",
|
|
55
|
+
signal: deps.signal,
|
|
56
|
+
dryRun: Boolean(filters.dryRun),
|
|
57
|
+
model: deps.profile.model,
|
|
58
|
+
batchSize: deps.profile.batchSize,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return report;
|
|
62
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type ClaudeConversationWindow } from "./claude-conversation.js";
|
|
2
|
+
import type { UnrecognizedFile } from "./discovery.js";
|
|
3
|
+
import { type ImportPathMap, type ImportProjectReport, type LazyImportSource } from "./importer.js";
|
|
4
|
+
/**
|
|
5
|
+
* Discovery and loading of Claude Code transcripts for the history import.
|
|
6
|
+
* Read-only: transcripts are never written.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code keeps one folder per project under `~/.claude/projects/` and one
|
|
9
|
+
* `<session-id>.jsonl` file per session in it. Subagent transcripts sit one
|
|
10
|
+
* level lower (`<session-id>/subagents/*.jsonl`) and are not sessions, so the
|
|
11
|
+
* walk reads only the root and its direct sub-folders. The root may also be a
|
|
12
|
+
* single project folder or one `.jsonl` file. Symlinks are never followed.
|
|
13
|
+
*
|
|
14
|
+
* Discovery reads each file only up to its first user entry, which carries
|
|
15
|
+
* the working directory and date; units are parsed later, one session at a
|
|
16
|
+
* time.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CLAUDE_HEADER_READ_LIMIT: number;
|
|
19
|
+
export declare function defaultClaudeProjectsRoot(): string;
|
|
20
|
+
export interface DiscoveredClaudeSession {
|
|
21
|
+
file: string;
|
|
22
|
+
/** Path relative to the root, with `/` separators; the file name for a file root. */
|
|
23
|
+
key: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
/** Working directory of the first user entry. */
|
|
26
|
+
cwd: string | null;
|
|
27
|
+
/** First user entry ISO timestamp converted to epoch ms, when present. */
|
|
28
|
+
timestamp: number | null;
|
|
29
|
+
}
|
|
30
|
+
export interface ClaudeDiscoveryResult {
|
|
31
|
+
sessions: DiscoveredClaudeSession[];
|
|
32
|
+
unrecognized: UnrecognizedFile[];
|
|
33
|
+
}
|
|
34
|
+
export interface ClaudeDiscoveryOptions {
|
|
35
|
+
root?: string;
|
|
36
|
+
maxSessions?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface LoadedClaudeSession {
|
|
39
|
+
sessionId: string;
|
|
40
|
+
sourceFile: string;
|
|
41
|
+
cwd: string | null;
|
|
42
|
+
windows: ClaudeConversationWindow[];
|
|
43
|
+
unreadableLines: number;
|
|
44
|
+
unknownTypes: number;
|
|
45
|
+
}
|
|
46
|
+
/** Read one file's first user entry, or say why the file is not a usable session. */
|
|
47
|
+
export declare function readClaudeSessionHead(file: string, key: string): DiscoveredClaudeSession | UnrecognizedFile;
|
|
48
|
+
export declare function discoverClaudeSessions(options?: ClaudeDiscoveryOptions): ClaudeDiscoveryResult;
|
|
49
|
+
/** Parse one transcript into capture windows. Throws when the file cannot be read. */
|
|
50
|
+
export declare function loadClaudeSession(file: string): LoadedClaudeSession;
|
|
51
|
+
export interface ClaudeHistoryOptions {
|
|
52
|
+
root?: string;
|
|
53
|
+
/** Inclusive bounds on the prompt timestamp (epoch ms). */
|
|
54
|
+
since?: number;
|
|
55
|
+
until?: number;
|
|
56
|
+
/** Web selection: prompts after this epoch ms are held back for a later run. */
|
|
57
|
+
cutoff?: number;
|
|
58
|
+
/** One session, by session ID or file path. */
|
|
59
|
+
session?: string;
|
|
60
|
+
/** Web selection: only these discovery keys. */
|
|
61
|
+
selectionKeys?: string[];
|
|
62
|
+
maxSessions?: number;
|
|
63
|
+
pathMaps?: ImportPathMap[];
|
|
64
|
+
/** Keep only sessions of this project directory (current-project scope). */
|
|
65
|
+
project?: string;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
}
|
|
68
|
+
export interface ClaudeHistoryDeps {
|
|
69
|
+
loadSession?: (file: string) => LoadedClaudeSession;
|
|
70
|
+
}
|
|
71
|
+
/** A lazy import source plus the discovery figures the import report needs. */
|
|
72
|
+
export interface ClaudeHistorySource extends LazyImportSource {
|
|
73
|
+
sessionsDiscovered: number;
|
|
74
|
+
sessionsLoaded: number;
|
|
75
|
+
sessionsFilteredOut: number;
|
|
76
|
+
unrecognized: UnrecognizedFile[];
|
|
77
|
+
unresolvableSessions: Array<{
|
|
78
|
+
file: string;
|
|
79
|
+
cwd: string | null;
|
|
80
|
+
}>;
|
|
81
|
+
loadErrors: Array<{
|
|
82
|
+
file: string;
|
|
83
|
+
error: string;
|
|
84
|
+
}>;
|
|
85
|
+
projects: ImportProjectReport[];
|
|
86
|
+
/** Units kept after the date and cutoff filters. */
|
|
87
|
+
unitsKept: number;
|
|
88
|
+
unitsHeldBack: number;
|
|
89
|
+
unitsUntimed: number;
|
|
90
|
+
unreadableLines: number;
|
|
91
|
+
unknownTypes: number;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Discover Claude Code sessions and return them as a `LazyImportSource`. A
|
|
95
|
+
* counting pass loads each selected session once for the totals and the
|
|
96
|
+
* per-project report; `open()` loads them again one at a time, so a large
|
|
97
|
+
* history never holds every turn at once.
|
|
98
|
+
*/
|
|
99
|
+
export declare function openClaudeHistorySource(options?: ClaudeHistoryOptions, deps?: ClaudeHistoryDeps): Promise<ClaudeHistorySource>;
|