@edihasaj/recall 0.5.2
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 +409 -0
- package/dist/chunk-4CV4JOE5.js +27 -0
- package/dist/chunk-4CV4JOE5.js.map +1 -0
- package/dist/chunk-A5UIRZU6.js +469 -0
- package/dist/chunk-A5UIRZU6.js.map +1 -0
- package/dist/chunk-AYHFPCGY.js +964 -0
- package/dist/chunk-AYHFPCGY.js.map +1 -0
- package/dist/chunk-DNFKAHS6.js +204 -0
- package/dist/chunk-DNFKAHS6.js.map +1 -0
- package/dist/chunk-GC5XMBG4.js +551 -0
- package/dist/chunk-GC5XMBG4.js.map +1 -0
- package/dist/chunk-IILLSHLM.js +3021 -0
- package/dist/chunk-IILLSHLM.js.map +1 -0
- package/dist/chunk-LVQW6WHK.js +146 -0
- package/dist/chunk-LVQW6WHK.js.map +1 -0
- package/dist/chunk-LZ6PMQRX.js +955 -0
- package/dist/chunk-LZ6PMQRX.js.map +1 -0
- package/dist/chunk-PC43MBX5.js +2960 -0
- package/dist/chunk-PC43MBX5.js.map +1 -0
- package/dist/chunk-VEPXEHRZ.js +1763 -0
- package/dist/chunk-VEPXEHRZ.js.map +1 -0
- package/dist/cleanup-TVOX2S2S.js +28 -0
- package/dist/cleanup-TVOX2S2S.js.map +1 -0
- package/dist/cli.js +3425 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon.js +1298 -0
- package/dist/daemon.js.map +1 -0
- package/dist/dispatcher-UGMU6THT.js +15 -0
- package/dist/dispatcher-UGMU6THT.js.map +1 -0
- package/dist/keychain-5QG52ANO.js +22 -0
- package/dist/keychain-5QG52ANO.js.map +1 -0
- package/dist/mcp.js +21 -0
- package/dist/mcp.js.map +1 -0
- package/dist/quality-Z7LPMMBC.js +17 -0
- package/dist/quality-Z7LPMMBC.js.map +1 -0
- package/dist/sync-server.js +225 -0
- package/dist/sync-server.js.map +1 -0
- package/dist/tasks-UOLSPXJQ.js +61 -0
- package/dist/tasks-UOLSPXJQ.js.map +1 -0
- package/dist/usage-CY3V72YN.js +101 -0
- package/dist/usage-CY3V72YN.js.map +1 -0
- package/drizzle/0000_initial_create.sql +240 -0
- package/drizzle/0001_rich_liz_osborn.sql +21 -0
- package/drizzle/0002_unknown_spot.sql +18 -0
- package/drizzle/0003_red_wendigo.sql +19 -0
- package/drizzle/0004_early_carlie_cooper.sql +1 -0
- package/drizzle/0005_simple_emma_frost.sql +96 -0
- package/drizzle/0006_keen_mongoose.sql +2 -0
- package/drizzle/0007_flawless_maximus.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +1630 -0
- package/drizzle/meta/0001_snapshot.json +1773 -0
- package/drizzle/meta/0002_snapshot.json +1891 -0
- package/drizzle/meta/0003_snapshot.json +2014 -0
- package/drizzle/meta/0004_snapshot.json +2022 -0
- package/drizzle/meta/0005_snapshot.json +2064 -0
- package/drizzle/meta/0006_snapshot.json +2078 -0
- package/drizzle/meta/0007_snapshot.json +2183 -0
- package/drizzle/meta/_journal.json +62 -0
- package/package.json +64 -0
- package/scripts/recall-claude +7 -0
- package/scripts/recall-codex +7 -0
- package/scripts/recall-session +71 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
dispatchCodexNotify,
|
|
4
|
+
ensureDailyBackup,
|
|
5
|
+
executePromptHook,
|
|
6
|
+
executeSessionEndHook,
|
|
7
|
+
executeSessionStartHook,
|
|
8
|
+
executeToolHook,
|
|
9
|
+
formatInjectionContext,
|
|
10
|
+
formatMaintenanceBacklogContext,
|
|
11
|
+
formatPendingConfirmationsContext,
|
|
12
|
+
getHookCallStats,
|
|
13
|
+
listBackups,
|
|
14
|
+
parseInteger,
|
|
15
|
+
parseRecentToolCallsOption,
|
|
16
|
+
readClaudeCodePromptInputFromStdin,
|
|
17
|
+
readClaudeCodeSessionEndInputFromStdin,
|
|
18
|
+
readClaudeCodeSessionStartInputFromStdin,
|
|
19
|
+
readClaudeCodeToolInputFromStdin,
|
|
20
|
+
readCodexPromptInputFromStdin,
|
|
21
|
+
readCodexSessionEndInputFromStdin,
|
|
22
|
+
readCodexSessionStartInputFromStdin,
|
|
23
|
+
readCodexToolInputFromStdin,
|
|
24
|
+
restoreBackup
|
|
25
|
+
} from "./chunk-AYHFPCGY.js";
|
|
26
|
+
import {
|
|
27
|
+
RECALL_DB_USER_VERSION,
|
|
28
|
+
compileContext,
|
|
29
|
+
compileContextHybrid,
|
|
30
|
+
computeMetrics,
|
|
31
|
+
createActivityEvent,
|
|
32
|
+
createPolicy,
|
|
33
|
+
deletePolicy,
|
|
34
|
+
endEvalSession,
|
|
35
|
+
evaluatePolicy,
|
|
36
|
+
exportClaude,
|
|
37
|
+
exportCodex,
|
|
38
|
+
exportMarkdown,
|
|
39
|
+
formatMetricsReport,
|
|
40
|
+
formatPruneReport,
|
|
41
|
+
formatRetrievalEvalReport,
|
|
42
|
+
getDbPath,
|
|
43
|
+
getDbUserVersion,
|
|
44
|
+
getSignalStats,
|
|
45
|
+
inferRepoSlugFromPath,
|
|
46
|
+
initDb,
|
|
47
|
+
listActivityEvents,
|
|
48
|
+
listActivitySessions,
|
|
49
|
+
listHistorySnippets,
|
|
50
|
+
listPendingApprovals,
|
|
51
|
+
listPolicies,
|
|
52
|
+
loadRetrievalEvalFile,
|
|
53
|
+
pruneMemories,
|
|
54
|
+
recordSignal,
|
|
55
|
+
requestApproval,
|
|
56
|
+
resetDb,
|
|
57
|
+
resolveApproval,
|
|
58
|
+
runRetrievalEval,
|
|
59
|
+
scanAndStore,
|
|
60
|
+
searchHistorySnippets,
|
|
61
|
+
startEvalSession,
|
|
62
|
+
togglePolicy,
|
|
63
|
+
writeRepoContextArtifact
|
|
64
|
+
} from "./chunk-PC43MBX5.js";
|
|
65
|
+
import {
|
|
66
|
+
autoResolveContradictions,
|
|
67
|
+
computeAllHealthScores,
|
|
68
|
+
computeHealthScore,
|
|
69
|
+
detectContradictions,
|
|
70
|
+
formatHealthReport,
|
|
71
|
+
getRepoQualityProfile,
|
|
72
|
+
inferScope,
|
|
73
|
+
listContradictions,
|
|
74
|
+
processCorrection,
|
|
75
|
+
processReviewFeedback,
|
|
76
|
+
resolveContradiction
|
|
77
|
+
} from "./chunk-VEPXEHRZ.js";
|
|
78
|
+
import {
|
|
79
|
+
bootstrapEmbeddings,
|
|
80
|
+
confirmMemory,
|
|
81
|
+
ensureEmbeddingProviderReady,
|
|
82
|
+
formatAuditTrail,
|
|
83
|
+
getAuditTrail,
|
|
84
|
+
getEmbeddingModelInfo,
|
|
85
|
+
getMemory,
|
|
86
|
+
getRecentAudit,
|
|
87
|
+
hybridSearch,
|
|
88
|
+
listMemories,
|
|
89
|
+
listRepos,
|
|
90
|
+
loadEmbeddingConfigFromEnv,
|
|
91
|
+
queryMemories,
|
|
92
|
+
queueMemoryEmbeddingSync,
|
|
93
|
+
rebuildEmbeddingIndex,
|
|
94
|
+
rejectMemory,
|
|
95
|
+
rollbackMemory,
|
|
96
|
+
verifyEmbeddings
|
|
97
|
+
} from "./chunk-IILLSHLM.js";
|
|
98
|
+
import {
|
|
99
|
+
memories,
|
|
100
|
+
syncState
|
|
101
|
+
} from "./chunk-A5UIRZU6.js";
|
|
102
|
+
import {
|
|
103
|
+
init_keychain,
|
|
104
|
+
keychain_exports
|
|
105
|
+
} from "./chunk-DNFKAHS6.js";
|
|
106
|
+
import {
|
|
107
|
+
__toCommonJS
|
|
108
|
+
} from "./chunk-4CV4JOE5.js";
|
|
109
|
+
|
|
110
|
+
// src/cli.ts
|
|
111
|
+
import { Command } from "commander";
|
|
112
|
+
import { resolve as resolve6 } from "path";
|
|
113
|
+
import { writeFileSync as writeFileSync5, readFileSync as readFileSync5 } from "fs";
|
|
114
|
+
import { join as join7 } from "path";
|
|
115
|
+
|
|
116
|
+
// src/sync/client.ts
|
|
117
|
+
import { eq, gt } from "drizzle-orm";
|
|
118
|
+
import { randomUUID } from "crypto";
|
|
119
|
+
function getSyncState(db) {
|
|
120
|
+
const row = db.select().from(syncState).where(eq(syncState.id, "local")).get();
|
|
121
|
+
if (row) return row;
|
|
122
|
+
db.insert(syncState).values({
|
|
123
|
+
id: "local",
|
|
124
|
+
remote_url: null,
|
|
125
|
+
team_id: null,
|
|
126
|
+
last_push_at: null,
|
|
127
|
+
last_pull_at: null,
|
|
128
|
+
last_push_version: 0,
|
|
129
|
+
last_pull_version: 0
|
|
130
|
+
}).run();
|
|
131
|
+
return db.select().from(syncState).where(eq(syncState.id, "local")).get();
|
|
132
|
+
}
|
|
133
|
+
function updateSyncState(db, updates) {
|
|
134
|
+
db.update(syncState).set(updates).where(eq(syncState.id, "local")).run();
|
|
135
|
+
}
|
|
136
|
+
async function pushMemories(db, config) {
|
|
137
|
+
const state = getSyncState(db);
|
|
138
|
+
const localMemories = db.select().from(memories).where(gt(memories.sync_version, state.last_push_version)).all();
|
|
139
|
+
if (localMemories.length === 0) {
|
|
140
|
+
return { pushed: 0, version: state.last_push_version };
|
|
141
|
+
}
|
|
142
|
+
const payload = localMemories.map((m) => ({
|
|
143
|
+
...m,
|
|
144
|
+
origin_id: m.id,
|
|
145
|
+
evidence: typeof m.evidence === "string" ? JSON.parse(m.evidence) : m.evidence
|
|
146
|
+
}));
|
|
147
|
+
const resp = await fetch(`${config.remote_url}/api/push`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
Authorization: `Bearer ${config.api_key}`
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
team_id: config.team_id,
|
|
155
|
+
memories: payload
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
if (!resp.ok) {
|
|
159
|
+
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
160
|
+
throw new Error(`Push failed: ${err.error ?? resp.statusText}`);
|
|
161
|
+
}
|
|
162
|
+
const result = await resp.json();
|
|
163
|
+
updateSyncState(db, {
|
|
164
|
+
remote_url: config.remote_url,
|
|
165
|
+
team_id: config.team_id,
|
|
166
|
+
last_push_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
167
|
+
last_push_version: Math.max(
|
|
168
|
+
...localMemories.map((m) => m.sync_version)
|
|
169
|
+
)
|
|
170
|
+
});
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
async function pullMemories(db, config) {
|
|
174
|
+
const state = getSyncState(db);
|
|
175
|
+
const resp = await fetch(`${config.remote_url}/api/pull`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
Authorization: `Bearer ${config.api_key}`
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
team_id: config.team_id,
|
|
183
|
+
since_version: state.last_pull_version
|
|
184
|
+
})
|
|
185
|
+
});
|
|
186
|
+
if (!resp.ok) {
|
|
187
|
+
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
188
|
+
throw new Error(`Pull failed: ${err.error ?? resp.statusText}`);
|
|
189
|
+
}
|
|
190
|
+
const data = await resp.json();
|
|
191
|
+
let pulled = 0;
|
|
192
|
+
let conflicts = 0;
|
|
193
|
+
for (const remote of data.memories) {
|
|
194
|
+
const localMem = db.select().from(memories).where(eq(memories.id, remote.origin_id)).get();
|
|
195
|
+
if (localMem) {
|
|
196
|
+
if (remote.updated_at > localMem.updated_at) {
|
|
197
|
+
db.update(memories).set({
|
|
198
|
+
text: remote.text,
|
|
199
|
+
status: remote.status,
|
|
200
|
+
confidence: remote.confidence,
|
|
201
|
+
evidence: remote.evidence,
|
|
202
|
+
updated_at: remote.updated_at,
|
|
203
|
+
team_id: config.team_id
|
|
204
|
+
}).where(eq(memories.id, localMem.id)).run();
|
|
205
|
+
queueMemoryEmbeddingSync(db, localMem.id);
|
|
206
|
+
pulled++;
|
|
207
|
+
conflicts++;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
const memoryId = remote.origin_id ?? randomUUID();
|
|
211
|
+
db.insert(memories).values({
|
|
212
|
+
id: memoryId,
|
|
213
|
+
type: remote.type,
|
|
214
|
+
text: remote.text,
|
|
215
|
+
scope: remote.scope,
|
|
216
|
+
path_scope: remote.path_scope,
|
|
217
|
+
repo: remote.repo,
|
|
218
|
+
status: remote.status,
|
|
219
|
+
confidence: remote.confidence,
|
|
220
|
+
source: remote.source,
|
|
221
|
+
evidence: remote.evidence,
|
|
222
|
+
supersedes: remote.supersedes,
|
|
223
|
+
created_at: remote.created_at,
|
|
224
|
+
updated_at: remote.updated_at,
|
|
225
|
+
team_id: config.team_id,
|
|
226
|
+
sync_version: 0
|
|
227
|
+
}).run();
|
|
228
|
+
queueMemoryEmbeddingSync(db, memoryId);
|
|
229
|
+
pulled++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
updateSyncState(db, {
|
|
233
|
+
last_pull_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
234
|
+
last_pull_version: data.version
|
|
235
|
+
});
|
|
236
|
+
return { pulled, conflicts };
|
|
237
|
+
}
|
|
238
|
+
async function sync(db, config) {
|
|
239
|
+
const errors = [];
|
|
240
|
+
let pushed = 0;
|
|
241
|
+
let pulled = 0;
|
|
242
|
+
let conflicts = 0;
|
|
243
|
+
try {
|
|
244
|
+
const pushResult = await pushMemories(db, config);
|
|
245
|
+
pushed = pushResult.pushed;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
errors.push(`push: ${err.message}`);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const pullResult = await pullMemories(db, config);
|
|
251
|
+
pulled = pullResult.pulled;
|
|
252
|
+
conflicts = pullResult.conflicts;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
errors.push(`pull: ${err.message}`);
|
|
255
|
+
}
|
|
256
|
+
return { pushed, pulled, conflicts, errors };
|
|
257
|
+
}
|
|
258
|
+
async function createTeam(config, name) {
|
|
259
|
+
const resp = await fetch(`${config.remote_url}/api/team`, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: {
|
|
262
|
+
"Content-Type": "application/json",
|
|
263
|
+
Authorization: `Bearer ${config.api_key}`
|
|
264
|
+
},
|
|
265
|
+
body: JSON.stringify({ name })
|
|
266
|
+
});
|
|
267
|
+
if (!resp.ok) throw new Error(`Failed to create team: ${resp.statusText}`);
|
|
268
|
+
const data = await resp.json();
|
|
269
|
+
return data.team_id;
|
|
270
|
+
}
|
|
271
|
+
async function joinTeam(config, teamId) {
|
|
272
|
+
const resp = await fetch(`${config.remote_url}/api/team/${teamId}/join`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
Authorization: `Bearer ${config.api_key}`
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
if (!resp.ok) throw new Error(`Failed to join team: ${resp.statusText}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/setup/local.ts
|
|
283
|
+
import { existsSync as existsSync3 } from "fs";
|
|
284
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
285
|
+
import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
|
|
286
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
287
|
+
|
|
288
|
+
// src/agents/claude-code.ts
|
|
289
|
+
import {
|
|
290
|
+
existsSync,
|
|
291
|
+
mkdirSync,
|
|
292
|
+
readFileSync,
|
|
293
|
+
renameSync,
|
|
294
|
+
writeFileSync
|
|
295
|
+
} from "fs";
|
|
296
|
+
import { dirname, join, resolve } from "path";
|
|
297
|
+
import { fileURLToPath } from "url";
|
|
298
|
+
|
|
299
|
+
// src/agents/utils.ts
|
|
300
|
+
import { execFileSync } from "child_process";
|
|
301
|
+
import { homedir } from "os";
|
|
302
|
+
function resolveUserHomeDir() {
|
|
303
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
304
|
+
}
|
|
305
|
+
function hasCommand(name) {
|
|
306
|
+
try {
|
|
307
|
+
execFileSync("which", [name], { stdio: "ignore" });
|
|
308
|
+
return true;
|
|
309
|
+
} catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/agents/claude-code.ts
|
|
315
|
+
var CLAUDE_CONFIG_RELATIVE_PATH = [".claude", "settings.json"];
|
|
316
|
+
var MANAGED_TAG = "recall:managed:claude-code";
|
|
317
|
+
var SESSION_START_MATCHER = "startup|resume|clear|compact";
|
|
318
|
+
var SESSION_END_MATCHER = "clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other";
|
|
319
|
+
var configPath = () => join(resolveUserHomeDir(), ...CLAUDE_CONFIG_RELATIVE_PATH);
|
|
320
|
+
function installClaudeCodeHooks(options = {}) {
|
|
321
|
+
const targetPath = options.configPath ?? configPath();
|
|
322
|
+
const current = readSettingsFile(targetPath);
|
|
323
|
+
const managedGroups = buildManagedGroups({
|
|
324
|
+
cliPath: options.cliPath,
|
|
325
|
+
nodePath: options.nodePath,
|
|
326
|
+
profile: options.profile,
|
|
327
|
+
promptInjection: options.promptInjection
|
|
328
|
+
});
|
|
329
|
+
const next = cloneSettings(current.settings);
|
|
330
|
+
const hooks = ensureHooksObject(next);
|
|
331
|
+
let changed = false;
|
|
332
|
+
for (const [eventName, groups] of Object.entries(managedGroups)) {
|
|
333
|
+
const existing = hooks[eventName] ?? [];
|
|
334
|
+
const preserved = existing.filter((group) => !isManagedGroup(group));
|
|
335
|
+
const merged = [...preserved, ...groups];
|
|
336
|
+
if (!sameJson(existing, merged)) {
|
|
337
|
+
hooks[eventName] = merged;
|
|
338
|
+
changed = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!changed) {
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
changed: false,
|
|
345
|
+
config_path: targetPath,
|
|
346
|
+
message: "Claude Code hooks already installed"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
writeSettingsFile(targetPath, current.raw, next);
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
changed: true,
|
|
353
|
+
config_path: targetPath,
|
|
354
|
+
message: "Installed Claude Code Recall hooks"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function uninstallClaudeCodeHooks(options = {}) {
|
|
358
|
+
const targetPath = options.configPath ?? configPath();
|
|
359
|
+
if (!existsSync(targetPath)) {
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
changed: false,
|
|
363
|
+
config_path: targetPath,
|
|
364
|
+
message: "Claude Code settings file not found"
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const current = readSettingsFile(targetPath);
|
|
368
|
+
const next = cloneSettings(current.settings);
|
|
369
|
+
const hooks = next.hooks;
|
|
370
|
+
if (!hooks || typeof hooks !== "object") {
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
changed: false,
|
|
374
|
+
config_path: targetPath,
|
|
375
|
+
message: "No Claude Code hooks configured"
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
let changed = false;
|
|
379
|
+
for (const eventName of Object.keys(hooks)) {
|
|
380
|
+
const existing = hooks[eventName] ?? [];
|
|
381
|
+
const preserved = existing.filter((group) => !isManagedGroup(group));
|
|
382
|
+
if (!sameJson(existing, preserved)) {
|
|
383
|
+
changed = true;
|
|
384
|
+
if (preserved.length > 0) {
|
|
385
|
+
hooks[eventName] = preserved;
|
|
386
|
+
} else {
|
|
387
|
+
delete hooks[eventName];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (Object.keys(hooks).length === 0) {
|
|
392
|
+
delete next.hooks;
|
|
393
|
+
}
|
|
394
|
+
if (!changed) {
|
|
395
|
+
return {
|
|
396
|
+
ok: true,
|
|
397
|
+
changed: false,
|
|
398
|
+
config_path: targetPath,
|
|
399
|
+
message: "No Recall-managed Claude Code hooks found"
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
writeSettingsFile(targetPath, current.raw, next);
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
changed: true,
|
|
406
|
+
config_path: targetPath,
|
|
407
|
+
message: "Removed Claude Code Recall hooks"
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function buildManagedGroups(options) {
|
|
411
|
+
const installedEvents = new Set(options.profile ?? []);
|
|
412
|
+
const commandPrefix = resolveHookCommandPrefix(options);
|
|
413
|
+
const groups = {};
|
|
414
|
+
groups.SessionStart = [
|
|
415
|
+
{
|
|
416
|
+
matcher: SESSION_START_MATCHER,
|
|
417
|
+
hooks: [commandHook(`${commandPrefix} hook session-start --agent claude-code --claude-code-stdin`, "session-start")]
|
|
418
|
+
}
|
|
419
|
+
];
|
|
420
|
+
if (installedEvents.size === 0 || installedEvents.has("prompt_submitted")) {
|
|
421
|
+
const envPrefix = options.promptInjection === false ? "RECALL_HOOK_INJECT_PROMPT=false " : "";
|
|
422
|
+
groups.UserPromptSubmit = [
|
|
423
|
+
{
|
|
424
|
+
hooks: [commandHook(`${envPrefix}${commandPrefix} hook prompt --agent claude-code --claude-code-stdin`, "prompt")]
|
|
425
|
+
}
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
if (installedEvents.size === 0 || installedEvents.has("tool_invoked")) {
|
|
429
|
+
groups.PostToolUse = [
|
|
430
|
+
{
|
|
431
|
+
matcher: "Edit|Write|Bash",
|
|
432
|
+
hooks: [commandHook(`${commandPrefix} hook tool --agent claude-code --claude-code-stdin`, "tool")]
|
|
433
|
+
}
|
|
434
|
+
];
|
|
435
|
+
}
|
|
436
|
+
if (installedEvents.size === 0 || installedEvents.has("session_ended")) {
|
|
437
|
+
groups.SessionEnd = [
|
|
438
|
+
{
|
|
439
|
+
matcher: SESSION_END_MATCHER,
|
|
440
|
+
hooks: [commandHook(`${commandPrefix} hook session-end --agent claude-code --claude-code-stdin`, "session-end")]
|
|
441
|
+
}
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
return groups;
|
|
445
|
+
}
|
|
446
|
+
function commandHook(command, tag) {
|
|
447
|
+
return {
|
|
448
|
+
type: "command",
|
|
449
|
+
command: `${command} # ${MANAGED_TAG}:${tag}`
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function resolveHookCommandPrefix(options) {
|
|
453
|
+
const nodePath = options.nodePath ?? process.env.RECALL_NODE_PATH ?? process.execPath;
|
|
454
|
+
const cliPath = options.cliPath ?? resolveCliPath();
|
|
455
|
+
return `${shellQuote(nodePath)} ${shellQuote(cliPath)}`;
|
|
456
|
+
}
|
|
457
|
+
function resolveCliPath() {
|
|
458
|
+
const fromEnv = process.env.RECALL_CLI_PATH;
|
|
459
|
+
if (fromEnv && existsSync(fromEnv)) {
|
|
460
|
+
return resolve(fromEnv);
|
|
461
|
+
}
|
|
462
|
+
const fromArgv = process.argv[1];
|
|
463
|
+
if (fromArgv && /(?:^|\/)cli\.[cm]?js$/.test(fromArgv) && existsSync(fromArgv)) {
|
|
464
|
+
return resolve(fromArgv);
|
|
465
|
+
}
|
|
466
|
+
const sibling = resolve(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
467
|
+
if (existsSync(sibling)) {
|
|
468
|
+
return sibling;
|
|
469
|
+
}
|
|
470
|
+
const distCli = resolve(process.cwd(), "dist", "cli.js");
|
|
471
|
+
if (existsSync(distCli)) {
|
|
472
|
+
return distCli;
|
|
473
|
+
}
|
|
474
|
+
throw new Error("Unable to resolve Recall CLI path for Claude Code hooks");
|
|
475
|
+
}
|
|
476
|
+
function readSettingsFile(configPath3) {
|
|
477
|
+
if (!existsSync(configPath3)) {
|
|
478
|
+
return { raw: null, settings: {} };
|
|
479
|
+
}
|
|
480
|
+
const raw = readFileSync(configPath3, "utf-8");
|
|
481
|
+
const parsed = JSON.parse(raw);
|
|
482
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
483
|
+
throw new Error(`Invalid Claude Code settings at ${configPath3}`);
|
|
484
|
+
}
|
|
485
|
+
return { raw, settings: parsed };
|
|
486
|
+
}
|
|
487
|
+
function writeSettingsFile(configPath3, previousRaw, settings) {
|
|
488
|
+
const parentDir = dirname(configPath3);
|
|
489
|
+
mkdirSync(parentDir, { recursive: true });
|
|
490
|
+
if (previousRaw != null) {
|
|
491
|
+
const backupPath = `${configPath3}.recall.bak.${Date.now()}`;
|
|
492
|
+
writeFileSync(backupPath, previousRaw);
|
|
493
|
+
}
|
|
494
|
+
const tmpPath = `${configPath3}.tmp.${process.pid}`;
|
|
495
|
+
writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}
|
|
496
|
+
`);
|
|
497
|
+
renameSync(tmpPath, configPath3);
|
|
498
|
+
}
|
|
499
|
+
function ensureHooksObject(settings) {
|
|
500
|
+
if (!settings.hooks) {
|
|
501
|
+
settings.hooks = {};
|
|
502
|
+
}
|
|
503
|
+
if (typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
|
|
504
|
+
throw new Error("Claude Code hooks config must be an object");
|
|
505
|
+
}
|
|
506
|
+
return settings.hooks;
|
|
507
|
+
}
|
|
508
|
+
function isManagedGroup(group) {
|
|
509
|
+
return (group.hooks ?? []).some((hook) => typeof hook.command === "string" && hook.command.includes(MANAGED_TAG));
|
|
510
|
+
}
|
|
511
|
+
function cloneSettings(settings) {
|
|
512
|
+
return JSON.parse(JSON.stringify(settings));
|
|
513
|
+
}
|
|
514
|
+
function sameJson(left, right) {
|
|
515
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
516
|
+
}
|
|
517
|
+
function shellQuote(value) {
|
|
518
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/agents/codex.ts
|
|
522
|
+
import {
|
|
523
|
+
existsSync as existsSync2,
|
|
524
|
+
mkdirSync as mkdirSync2,
|
|
525
|
+
readFileSync as readFileSync2,
|
|
526
|
+
renameSync as renameSync2,
|
|
527
|
+
writeFileSync as writeFileSync2
|
|
528
|
+
} from "fs";
|
|
529
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
530
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
531
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
532
|
+
var CODEX_CONFIG_RELATIVE_PATH = [".codex", "config.toml"];
|
|
533
|
+
var CODEX_HOOKS_RELATIVE_PATH = [".codex", "hooks.json"];
|
|
534
|
+
var MANAGED_START = "# recall:managed:codex:start";
|
|
535
|
+
var MANAGED_END = "# recall:managed:codex:end";
|
|
536
|
+
var MANAGED_FEATURE_FLAG = "# recall:managed:codex:feature";
|
|
537
|
+
var MANAGED_HOOK_TAG = "recall:managed:codex";
|
|
538
|
+
var DEFAULT_MIN_CODEX_HOOKS_VERSION = "0.115.0";
|
|
539
|
+
var configPath2 = () => join2(resolveUserHomeDir(), ...CODEX_CONFIG_RELATIVE_PATH);
|
|
540
|
+
var hooksJsonPath = () => join2(resolveUserHomeDir(), ...CODEX_HOOKS_RELATIVE_PATH);
|
|
541
|
+
function installCodexNotifyBridge(options = {}) {
|
|
542
|
+
const targetPath = options.configPath ?? configPath2();
|
|
543
|
+
const existing = existsSync2(targetPath) ? readFileSync2(targetPath, "utf-8") : "";
|
|
544
|
+
const stripped = stripManagedBlock(existing);
|
|
545
|
+
if (hasUnmanagedNotify(stripped)) {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
changed: false,
|
|
549
|
+
config_path: targetPath,
|
|
550
|
+
message: "Codex notify is already configured outside Recall-managed block"
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const managedBlock = buildManagedNotifyBlock(options);
|
|
554
|
+
const next = appendManagedBlock(stripped, managedBlock);
|
|
555
|
+
if (next === existing) {
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
changed: false,
|
|
559
|
+
config_path: targetPath,
|
|
560
|
+
message: "Codex notify bridge already installed"
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
writeConfigFile(targetPath, existing || null, next);
|
|
564
|
+
return {
|
|
565
|
+
ok: true,
|
|
566
|
+
changed: true,
|
|
567
|
+
config_path: targetPath,
|
|
568
|
+
message: "Installed Codex Recall notify bridge"
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function uninstallCodexNotifyBridge(options = {}) {
|
|
572
|
+
const targetPath = options.configPath ?? configPath2();
|
|
573
|
+
if (!existsSync2(targetPath)) {
|
|
574
|
+
return {
|
|
575
|
+
ok: true,
|
|
576
|
+
changed: false,
|
|
577
|
+
config_path: targetPath,
|
|
578
|
+
message: "Codex config.toml not found"
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const existing = readFileSync2(targetPath, "utf-8");
|
|
582
|
+
const next = stripManagedBlock(existing);
|
|
583
|
+
if (next === existing) {
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
changed: false,
|
|
587
|
+
config_path: targetPath,
|
|
588
|
+
message: "No Recall-managed Codex notify bridge found"
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
writeConfigFile(targetPath, existing, next);
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
changed: true,
|
|
595
|
+
config_path: targetPath,
|
|
596
|
+
message: "Removed Codex Recall notify bridge"
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function detectCodexCapability(options = {}) {
|
|
600
|
+
const required = options.minCodexHooksVersion ?? process.env.RECALL_CODEX_HOOKS_MIN_VERSION ?? DEFAULT_MIN_CODEX_HOOKS_VERSION;
|
|
601
|
+
const detected = probeCodexVersion();
|
|
602
|
+
if (!detected) {
|
|
603
|
+
return {
|
|
604
|
+
hooks_json: false,
|
|
605
|
+
detected_version: null,
|
|
606
|
+
required_version: required,
|
|
607
|
+
reason: "codex CLI not found on PATH \u2014 cannot verify hook support"
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (compareSemver(detected, required) < 0) {
|
|
611
|
+
return {
|
|
612
|
+
hooks_json: false,
|
|
613
|
+
detected_version: detected,
|
|
614
|
+
required_version: required,
|
|
615
|
+
reason: `codex ${detected} < ${required} (hooks.json unsupported)`
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
hooks_json: true,
|
|
620
|
+
detected_version: detected,
|
|
621
|
+
required_version: required
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function installCodexHooks(options = {}) {
|
|
625
|
+
const targetConfig = options.configPath ?? configPath2();
|
|
626
|
+
const targetHooks = options.hooksPath ?? hooksJsonPath();
|
|
627
|
+
const capability = options.forceNotifyBridge ? { hooks_json: false, detected_version: null, required_version: "n/a", reason: "forced notify bridge" } : options.forceHooks ? { hooks_json: true, detected_version: null, required_version: "n/a" } : detectCodexCapability(options);
|
|
628
|
+
if (!capability.hooks_json) {
|
|
629
|
+
const bridge = installCodexNotifyBridge(options);
|
|
630
|
+
const reason = capability.reason ?? "codex hooks unsupported";
|
|
631
|
+
return {
|
|
632
|
+
ok: bridge.ok,
|
|
633
|
+
changed: bridge.changed,
|
|
634
|
+
config_path: bridge.config_path,
|
|
635
|
+
message: `${bridge.message} (fell back to notify bridge: ${reason})`
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
uninstallCodexNotifyBridge({ configPath: targetConfig });
|
|
639
|
+
const flagResult = ensureCodexHooksFeatureFlag(targetConfig);
|
|
640
|
+
const hooksResult = writeCodexHooksJson(targetHooks, options);
|
|
641
|
+
const changed = flagResult.changed || hooksResult.changed;
|
|
642
|
+
const ok2 = flagResult.ok && hooksResult.ok;
|
|
643
|
+
const versionNote = capability.detected_version ? ` (codex ${capability.detected_version})` : "";
|
|
644
|
+
const messages = [flagResult.message, hooksResult.message].filter(Boolean).join("; ");
|
|
645
|
+
return {
|
|
646
|
+
ok: ok2,
|
|
647
|
+
changed,
|
|
648
|
+
config_path: targetHooks,
|
|
649
|
+
message: (messages || (changed ? "Installed Codex hooks.json" : "Codex hooks already installed")) + versionNote
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function probeCodexVersion() {
|
|
653
|
+
if (!hasCommand("codex")) return null;
|
|
654
|
+
try {
|
|
655
|
+
const raw = execFileSync2("codex", ["--version"], {
|
|
656
|
+
encoding: "utf-8",
|
|
657
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
658
|
+
timeout: 5e3
|
|
659
|
+
});
|
|
660
|
+
return extractSemverFromVersionString(raw);
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function extractSemverFromVersionString(raw) {
|
|
666
|
+
const match = raw.match(/(\d+)\.(\d+)\.(\d+)(?:[-+][A-Za-z0-9.-]+)?/);
|
|
667
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
|
668
|
+
}
|
|
669
|
+
function compareSemver(a, b) {
|
|
670
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
671
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
672
|
+
for (let i = 0; i < 3; i++) {
|
|
673
|
+
const av = pa[i] ?? 0;
|
|
674
|
+
const bv = pb[i] ?? 0;
|
|
675
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
676
|
+
}
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
function uninstallCodexHooks(options = {}) {
|
|
680
|
+
const targetConfig = options.configPath ?? configPath2();
|
|
681
|
+
const targetHooks = options.hooksPath ?? hooksJsonPath();
|
|
682
|
+
const flagResult = removeCodexHooksFeatureFlag(targetConfig);
|
|
683
|
+
const hooksResult = removeCodexHooksJson(targetHooks);
|
|
684
|
+
const legacyResult = uninstallCodexNotifyBridge({ configPath: targetConfig });
|
|
685
|
+
const changed = flagResult.changed || hooksResult.changed || legacyResult.changed;
|
|
686
|
+
const ok2 = flagResult.ok && hooksResult.ok && legacyResult.ok;
|
|
687
|
+
return {
|
|
688
|
+
ok: ok2,
|
|
689
|
+
changed,
|
|
690
|
+
config_path: targetHooks,
|
|
691
|
+
message: changed ? "Removed Codex Recall hooks" : "No Recall-managed Codex hooks found"
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
function writeCodexHooksJson(targetPath, options) {
|
|
695
|
+
const existing = readCodexHooksJson(targetPath);
|
|
696
|
+
const next = cloneCodexHooks(existing.parsed);
|
|
697
|
+
const hooks = ensureCodexHooksObject(next);
|
|
698
|
+
const managed = buildCodexManagedGroups(options);
|
|
699
|
+
let changed = false;
|
|
700
|
+
for (const [eventName, groups] of Object.entries(managed)) {
|
|
701
|
+
const current = hooks[eventName] ?? [];
|
|
702
|
+
const preserved = current.filter((group) => !isCodexManagedGroup(group));
|
|
703
|
+
const merged = [...preserved, ...groups];
|
|
704
|
+
if (JSON.stringify(current) !== JSON.stringify(merged)) {
|
|
705
|
+
hooks[eventName] = merged;
|
|
706
|
+
changed = true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (!changed) {
|
|
710
|
+
return {
|
|
711
|
+
ok: true,
|
|
712
|
+
changed: false,
|
|
713
|
+
config_path: targetPath,
|
|
714
|
+
message: ""
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
writeJsonFile(targetPath, existing.raw, next);
|
|
718
|
+
return {
|
|
719
|
+
ok: true,
|
|
720
|
+
changed: true,
|
|
721
|
+
config_path: targetPath,
|
|
722
|
+
message: "wrote hooks.json"
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
function removeCodexHooksJson(targetPath) {
|
|
726
|
+
if (!existsSync2(targetPath)) {
|
|
727
|
+
return { ok: true, changed: false, config_path: targetPath, message: "" };
|
|
728
|
+
}
|
|
729
|
+
const existing = readCodexHooksJson(targetPath);
|
|
730
|
+
const next = cloneCodexHooks(existing.parsed);
|
|
731
|
+
const hooks = next.hooks;
|
|
732
|
+
if (!hooks || typeof hooks !== "object") {
|
|
733
|
+
return { ok: true, changed: false, config_path: targetPath, message: "" };
|
|
734
|
+
}
|
|
735
|
+
let changed = false;
|
|
736
|
+
for (const eventName of Object.keys(hooks)) {
|
|
737
|
+
const current = hooks[eventName] ?? [];
|
|
738
|
+
const preserved = current.filter((group) => !isCodexManagedGroup(group));
|
|
739
|
+
if (preserved.length !== current.length) {
|
|
740
|
+
changed = true;
|
|
741
|
+
if (preserved.length > 0) {
|
|
742
|
+
hooks[eventName] = preserved;
|
|
743
|
+
} else {
|
|
744
|
+
delete hooks[eventName];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (Object.keys(hooks).length === 0) delete next.hooks;
|
|
749
|
+
if (!changed) {
|
|
750
|
+
return { ok: true, changed: false, config_path: targetPath, message: "" };
|
|
751
|
+
}
|
|
752
|
+
writeJsonFile(targetPath, existing.raw, next);
|
|
753
|
+
return { ok: true, changed: true, config_path: targetPath, message: "cleaned hooks.json" };
|
|
754
|
+
}
|
|
755
|
+
function buildCodexManagedGroups(options) {
|
|
756
|
+
const installedEvents = new Set(options.profile ?? []);
|
|
757
|
+
const commandPrefix = resolveHookCommandPrefix2(options);
|
|
758
|
+
const groups = {};
|
|
759
|
+
groups.SessionStart = [
|
|
760
|
+
{
|
|
761
|
+
matcher: "startup|resume",
|
|
762
|
+
hooks: [commandHook2(`${commandPrefix} hook session-start --agent codex --codex-stdin`, "session-start")]
|
|
763
|
+
}
|
|
764
|
+
];
|
|
765
|
+
if (installedEvents.size === 0 || installedEvents.has("prompt_submitted")) {
|
|
766
|
+
const envPrefix = options.promptInjection === false ? "RECALL_HOOK_INJECT_PROMPT=false " : "";
|
|
767
|
+
groups.UserPromptSubmit = [
|
|
768
|
+
{
|
|
769
|
+
hooks: [commandHook2(`${envPrefix}${commandPrefix} hook prompt --agent codex --codex-stdin`, "prompt")]
|
|
770
|
+
}
|
|
771
|
+
];
|
|
772
|
+
}
|
|
773
|
+
if (installedEvents.size === 0 || installedEvents.has("tool_invoked")) {
|
|
774
|
+
groups.PostToolUse = [
|
|
775
|
+
{
|
|
776
|
+
matcher: "Bash",
|
|
777
|
+
hooks: [commandHook2(`${commandPrefix} hook tool --agent codex --codex-stdin`, "tool")]
|
|
778
|
+
}
|
|
779
|
+
];
|
|
780
|
+
}
|
|
781
|
+
return groups;
|
|
782
|
+
}
|
|
783
|
+
function commandHook2(command, tag) {
|
|
784
|
+
return {
|
|
785
|
+
type: "command",
|
|
786
|
+
command: `${command} # ${MANAGED_HOOK_TAG}:${tag}`
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
function resolveHookCommandPrefix2(options) {
|
|
790
|
+
const nodePath = options.nodePath ?? process.env.RECALL_NODE_PATH ?? process.execPath;
|
|
791
|
+
const cliPath = options.cliPath ?? resolveCliPath2();
|
|
792
|
+
return `${shellQuote2(nodePath)} ${shellQuote2(cliPath)}`;
|
|
793
|
+
}
|
|
794
|
+
function shellQuote2(value) {
|
|
795
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
796
|
+
}
|
|
797
|
+
function readCodexHooksJson(targetPath) {
|
|
798
|
+
if (!existsSync2(targetPath)) return { raw: null, parsed: {} };
|
|
799
|
+
const raw = readFileSync2(targetPath, "utf-8");
|
|
800
|
+
let parsed;
|
|
801
|
+
try {
|
|
802
|
+
parsed = JSON.parse(raw);
|
|
803
|
+
} catch {
|
|
804
|
+
throw new Error(`Invalid Codex hooks.json at ${targetPath}`);
|
|
805
|
+
}
|
|
806
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
807
|
+
throw new Error(`Codex hooks.json must be an object at ${targetPath}`);
|
|
808
|
+
}
|
|
809
|
+
return { raw, parsed };
|
|
810
|
+
}
|
|
811
|
+
function writeJsonFile(targetPath, previousRaw, value) {
|
|
812
|
+
const parentDir = dirname2(targetPath);
|
|
813
|
+
mkdirSync2(parentDir, { recursive: true });
|
|
814
|
+
if (previousRaw != null) {
|
|
815
|
+
writeFileSync2(`${targetPath}.recall.bak.${Date.now()}`, previousRaw);
|
|
816
|
+
}
|
|
817
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}`;
|
|
818
|
+
writeFileSync2(tmpPath, `${JSON.stringify(value, null, 2)}
|
|
819
|
+
`);
|
|
820
|
+
renameSync2(tmpPath, targetPath);
|
|
821
|
+
}
|
|
822
|
+
function ensureCodexHooksObject(file) {
|
|
823
|
+
if (!file.hooks) file.hooks = {};
|
|
824
|
+
if (typeof file.hooks !== "object" || Array.isArray(file.hooks)) {
|
|
825
|
+
throw new Error("Codex hooks.json hooks must be an object");
|
|
826
|
+
}
|
|
827
|
+
return file.hooks;
|
|
828
|
+
}
|
|
829
|
+
function isCodexManagedGroup(group) {
|
|
830
|
+
return (group.hooks ?? []).some(
|
|
831
|
+
(hook) => typeof hook.command === "string" && hook.command.includes(MANAGED_HOOK_TAG)
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
function cloneCodexHooks(file) {
|
|
835
|
+
return JSON.parse(JSON.stringify(file));
|
|
836
|
+
}
|
|
837
|
+
function ensureCodexHooksFeatureFlag(targetConfigPath) {
|
|
838
|
+
const existing = existsSync2(targetConfigPath) ? readFileSync2(targetConfigPath, "utf-8") : "";
|
|
839
|
+
if (/^\s*codex_hooks\s*=\s*true\b/m.test(existing)) {
|
|
840
|
+
return { ok: true, changed: false, config_path: targetConfigPath, message: "" };
|
|
841
|
+
}
|
|
842
|
+
const featureHeader = /^\[features\]\s*$/m;
|
|
843
|
+
let next;
|
|
844
|
+
if (featureHeader.test(existing)) {
|
|
845
|
+
next = existing.replace(
|
|
846
|
+
featureHeader,
|
|
847
|
+
`[features]
|
|
848
|
+
codex_hooks = true ${MANAGED_FEATURE_FLAG}`
|
|
849
|
+
);
|
|
850
|
+
} else {
|
|
851
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
852
|
+
next = `${existing}${separator}
|
|
853
|
+
[features]
|
|
854
|
+
codex_hooks = true ${MANAGED_FEATURE_FLAG}
|
|
855
|
+
`;
|
|
856
|
+
}
|
|
857
|
+
writeConfigFile(targetConfigPath, existing || null, next);
|
|
858
|
+
return { ok: true, changed: true, config_path: targetConfigPath, message: "enabled codex_hooks feature flag" };
|
|
859
|
+
}
|
|
860
|
+
function removeCodexHooksFeatureFlag(targetConfigPath) {
|
|
861
|
+
if (!existsSync2(targetConfigPath)) {
|
|
862
|
+
return { ok: true, changed: false, config_path: targetConfigPath, message: "" };
|
|
863
|
+
}
|
|
864
|
+
const existing = readFileSync2(targetConfigPath, "utf-8");
|
|
865
|
+
const managedLine = new RegExp(
|
|
866
|
+
`^\\s*codex_hooks\\s*=\\s*true\\s*${escapeRegExp(MANAGED_FEATURE_FLAG)}\\s*$\\n?`,
|
|
867
|
+
"m"
|
|
868
|
+
);
|
|
869
|
+
if (!managedLine.test(existing)) {
|
|
870
|
+
return { ok: true, changed: false, config_path: targetConfigPath, message: "" };
|
|
871
|
+
}
|
|
872
|
+
const next = existing.replace(managedLine, "");
|
|
873
|
+
writeConfigFile(targetConfigPath, existing, next);
|
|
874
|
+
return { ok: true, changed: true, config_path: targetConfigPath, message: "removed codex_hooks flag" };
|
|
875
|
+
}
|
|
876
|
+
function buildManagedNotifyBlock(options) {
|
|
877
|
+
const command = [
|
|
878
|
+
options.nodePath ?? process.env.RECALL_NODE_PATH ?? process.execPath,
|
|
879
|
+
options.cliPath ?? resolveCliPath2(),
|
|
880
|
+
"hook",
|
|
881
|
+
"codex-notify"
|
|
882
|
+
];
|
|
883
|
+
return `${MANAGED_START}
|
|
884
|
+
notify = ${renderTomlStringArray(command)}
|
|
885
|
+
${MANAGED_END}
|
|
886
|
+
`;
|
|
887
|
+
}
|
|
888
|
+
function resolveCliPath2() {
|
|
889
|
+
const fromEnv = process.env.RECALL_CLI_PATH;
|
|
890
|
+
if (fromEnv && existsSync2(fromEnv)) {
|
|
891
|
+
return resolve2(fromEnv);
|
|
892
|
+
}
|
|
893
|
+
const fromArgv = process.argv[1];
|
|
894
|
+
if (fromArgv && /(?:^|\/)cli\.[cm]?js$/.test(fromArgv) && existsSync2(fromArgv)) {
|
|
895
|
+
return resolve2(fromArgv);
|
|
896
|
+
}
|
|
897
|
+
const sibling = resolve2(dirname2(fileURLToPath2(import.meta.url)), "..", "cli.js");
|
|
898
|
+
if (existsSync2(sibling)) {
|
|
899
|
+
return sibling;
|
|
900
|
+
}
|
|
901
|
+
const distCli = resolve2(process.cwd(), "dist", "cli.js");
|
|
902
|
+
if (existsSync2(distCli)) {
|
|
903
|
+
return distCli;
|
|
904
|
+
}
|
|
905
|
+
throw new Error("Unable to resolve Recall CLI path for Codex notify bridge");
|
|
906
|
+
}
|
|
907
|
+
function stripManagedBlock(content) {
|
|
908
|
+
if (!content.includes(MANAGED_START)) {
|
|
909
|
+
return content;
|
|
910
|
+
}
|
|
911
|
+
return content.replace(new RegExp(`${escapeRegExp(MANAGED_START)}[\\s\\S]*?${escapeRegExp(MANAGED_END)}\\n?`, "g"), "").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "").replace(/\s+$/, "");
|
|
912
|
+
}
|
|
913
|
+
function appendManagedBlock(content, block) {
|
|
914
|
+
const trimmed = content.trim();
|
|
915
|
+
if (trimmed.length === 0) {
|
|
916
|
+
return block;
|
|
917
|
+
}
|
|
918
|
+
return `${trimmed}
|
|
919
|
+
|
|
920
|
+
${block}`;
|
|
921
|
+
}
|
|
922
|
+
function hasUnmanagedNotify(content) {
|
|
923
|
+
return /^\s*notify\s*=.*$/m.test(content);
|
|
924
|
+
}
|
|
925
|
+
function renderTomlStringArray(values) {
|
|
926
|
+
return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
|
927
|
+
}
|
|
928
|
+
function writeConfigFile(configPathValue, previousRaw, nextRaw) {
|
|
929
|
+
const parentDir = dirname2(configPathValue);
|
|
930
|
+
mkdirSync2(parentDir, { recursive: true });
|
|
931
|
+
if (previousRaw != null) {
|
|
932
|
+
writeFileSync2(`${configPathValue}.recall.bak.${Date.now()}`, previousRaw);
|
|
933
|
+
}
|
|
934
|
+
const tmpPath = `${configPathValue}.tmp.${process.pid}`;
|
|
935
|
+
writeFileSync2(tmpPath, normalizeTrailingNewline(nextRaw));
|
|
936
|
+
renameSync2(tmpPath, configPathValue);
|
|
937
|
+
}
|
|
938
|
+
function normalizeTrailingNewline(content) {
|
|
939
|
+
return content.trim().length === 0 ? "" : `${content.trimEnd()}
|
|
940
|
+
`;
|
|
941
|
+
}
|
|
942
|
+
function escapeRegExp(value) {
|
|
943
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/setup/local.ts
|
|
947
|
+
function resolveRuntimePaths(appPath) {
|
|
948
|
+
if (appPath || process.platform === "darwin" && existsSync3("/Applications/Recall.app")) {
|
|
949
|
+
const resolvedAppPath = appPath ?? "/Applications/Recall.app";
|
|
950
|
+
const runtimeRoot = join3(resolvedAppPath, "Contents", "Resources", "Runtime");
|
|
951
|
+
return {
|
|
952
|
+
appPath: resolvedAppPath,
|
|
953
|
+
runtimeNodePath: join3(runtimeRoot, "bin", "node"),
|
|
954
|
+
runtimeCliPath: join3(runtimeRoot, "dist", "cli.js"),
|
|
955
|
+
runtimeMcpPath: join3(runtimeRoot, "dist", "mcp.js")
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const distDir = dirname3(fileURLToPath3(import.meta.url));
|
|
959
|
+
return {
|
|
960
|
+
appPath: distDir,
|
|
961
|
+
runtimeNodePath: process.execPath,
|
|
962
|
+
runtimeCliPath: join3(distDir, "cli.js"),
|
|
963
|
+
runtimeMcpPath: join3(distDir, "mcp.js")
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
function runLocalSetup(opts = {}) {
|
|
967
|
+
const targetCodex = opts.codex ?? true;
|
|
968
|
+
const targetClaude = opts.claude ?? true;
|
|
969
|
+
const result = runRecallSetup({
|
|
970
|
+
appPath: opts.appPath,
|
|
971
|
+
agent: [
|
|
972
|
+
...targetCodex ? ["codex"] : [],
|
|
973
|
+
...targetClaude ? ["claude-code"] : []
|
|
974
|
+
],
|
|
975
|
+
hooksOnly: false,
|
|
976
|
+
mcpOnly: false,
|
|
977
|
+
scope: "global",
|
|
978
|
+
promptInjection: opts.promptInjection
|
|
979
|
+
});
|
|
980
|
+
const codex = result.agents.find((agent) => agent.agent === "codex");
|
|
981
|
+
const claude = result.agents.find((agent) => agent.agent === "claude-code");
|
|
982
|
+
return {
|
|
983
|
+
appPath: result.appPath,
|
|
984
|
+
runtimeNodePath: result.runtimeNodePath,
|
|
985
|
+
runtimeCliPath: result.runtimeCliPath,
|
|
986
|
+
runtimeMcpPath: result.runtimeMcpPath,
|
|
987
|
+
codex: codex?.mcp ?? skipped("skipped"),
|
|
988
|
+
claude: claude?.mcp ?? skipped("skipped"),
|
|
989
|
+
codex_hooks: codex?.hooks ?? skipped("skipped"),
|
|
990
|
+
claude_hooks: claude?.hooks ?? skipped("skipped")
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function runRecallSetup(opts = {}) {
|
|
994
|
+
const scope = opts.scope ?? "global";
|
|
995
|
+
const dryRun = opts.dryRun ?? false;
|
|
996
|
+
const hooksOnly = opts.hooksOnly ?? false;
|
|
997
|
+
const mcpOnly = opts.mcpOnly ?? false;
|
|
998
|
+
const uninstallHooks = opts.uninstallHooks ?? false;
|
|
999
|
+
const runner = opts.runner ?? defaultRunner;
|
|
1000
|
+
const paths = resolveRuntimePaths(opts.appPath);
|
|
1001
|
+
if (!existsSync3(paths.runtimeNodePath)) {
|
|
1002
|
+
throw new Error(`Node runtime not found at ${paths.runtimeNodePath}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (!existsSync3(paths.runtimeCliPath)) {
|
|
1005
|
+
throw new Error(`Recall CLI entry not found at ${paths.runtimeCliPath}`);
|
|
1006
|
+
}
|
|
1007
|
+
if (!existsSync3(paths.runtimeMcpPath)) {
|
|
1008
|
+
throw new Error(`Recall MCP entry not found at ${paths.runtimeMcpPath}`);
|
|
1009
|
+
}
|
|
1010
|
+
const targetAgents = resolveTargetAgents(opts.agent);
|
|
1011
|
+
const cwd = resolve3(opts.cwd ?? process.cwd());
|
|
1012
|
+
const agents = targetAgents.map(
|
|
1013
|
+
(agent) => setupAgent(agent, {
|
|
1014
|
+
cwd,
|
|
1015
|
+
dryRun,
|
|
1016
|
+
hooksOnly,
|
|
1017
|
+
mcpOnly,
|
|
1018
|
+
paths,
|
|
1019
|
+
runner,
|
|
1020
|
+
scope,
|
|
1021
|
+
uninstallHooks,
|
|
1022
|
+
promptInjection: opts.promptInjection
|
|
1023
|
+
})
|
|
1024
|
+
);
|
|
1025
|
+
return {
|
|
1026
|
+
...paths,
|
|
1027
|
+
scope,
|
|
1028
|
+
dry_run: dryRun,
|
|
1029
|
+
hooks_only: hooksOnly,
|
|
1030
|
+
mcp_only: mcpOnly,
|
|
1031
|
+
uninstall_hooks: uninstallHooks,
|
|
1032
|
+
agents
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function setupAgent(agent, options) {
|
|
1036
|
+
const detected = detectAgent(agent);
|
|
1037
|
+
const hookConfigPath = resolveHookConfigPath(agent, options.scope, options.cwd);
|
|
1038
|
+
const mcp = options.hooksOnly ? skipped("hooks-only") : configureMcp(agent, options);
|
|
1039
|
+
const hooks = options.mcpOnly ? skipped("mcp-only") : configureHooks(agent, {
|
|
1040
|
+
configPath: hookConfigPath,
|
|
1041
|
+
dryRun: options.dryRun,
|
|
1042
|
+
paths: options.paths,
|
|
1043
|
+
uninstallHooks: options.uninstallHooks,
|
|
1044
|
+
promptInjection: options.promptInjection
|
|
1045
|
+
});
|
|
1046
|
+
return {
|
|
1047
|
+
agent,
|
|
1048
|
+
detected,
|
|
1049
|
+
mcp,
|
|
1050
|
+
hooks,
|
|
1051
|
+
hook_config_path: options.mcpOnly ? null : hookConfigPath
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
function configureMcp(agent, options) {
|
|
1055
|
+
if (agent === "codex") {
|
|
1056
|
+
if (!hasCommand("codex")) return skipped("codex not found on PATH");
|
|
1057
|
+
if (options.scope === "project") {
|
|
1058
|
+
return skipped("project-scoped Codex MCP not supported by Codex CLI");
|
|
1059
|
+
}
|
|
1060
|
+
if (options.dryRun) {
|
|
1061
|
+
return ok("would configure global Codex MCP server");
|
|
1062
|
+
}
|
|
1063
|
+
tryRun(options.runner, "codex", ["mcp", "remove", "recall"]);
|
|
1064
|
+
options.runner("codex", ["mcp", "add", "recall", "--", options.paths.runtimeNodePath, options.paths.runtimeMcpPath]);
|
|
1065
|
+
return ok("configured global Codex MCP server");
|
|
1066
|
+
}
|
|
1067
|
+
if (!hasCommand("claude")) return skipped("claude not found on PATH");
|
|
1068
|
+
const claudeScope = options.scope === "project" ? "project" : "user";
|
|
1069
|
+
if (options.dryRun) {
|
|
1070
|
+
return ok(`would configure ${claudeScope} Claude MCP server`);
|
|
1071
|
+
}
|
|
1072
|
+
tryRun(options.runner, "claude", ["mcp", "remove", "recall", "-s", claudeScope]);
|
|
1073
|
+
options.runner("claude", ["mcp", "add", "-s", claudeScope, "recall", options.paths.runtimeNodePath, options.paths.runtimeMcpPath]);
|
|
1074
|
+
return ok(`configured ${claudeScope} Claude MCP server`);
|
|
1075
|
+
}
|
|
1076
|
+
function configureHooks(agent, options) {
|
|
1077
|
+
if (options.dryRun) {
|
|
1078
|
+
return ok(
|
|
1079
|
+
options.uninstallHooks ? `would remove hooks from ${options.configPath}` : `would install hooks into ${options.configPath}${options.promptInjection === false ? " (prompt injection opt-out)" : ""}`
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
const codexHooksPath = agent === "codex" ? join3(dirname3(options.configPath), "hooks.json") : void 0;
|
|
1083
|
+
const result = agent === "claude-code" ? options.uninstallHooks ? uninstallClaudeCodeHooks({ configPath: options.configPath }) : installClaudeCodeHooks({
|
|
1084
|
+
configPath: options.configPath,
|
|
1085
|
+
cliPath: options.paths.runtimeCliPath,
|
|
1086
|
+
nodePath: options.paths.runtimeNodePath,
|
|
1087
|
+
promptInjection: options.promptInjection
|
|
1088
|
+
}) : options.uninstallHooks ? uninstallCodexHooks({ configPath: options.configPath, hooksPath: codexHooksPath }) : installCodexHooks({
|
|
1089
|
+
configPath: options.configPath,
|
|
1090
|
+
hooksPath: codexHooksPath,
|
|
1091
|
+
cliPath: options.paths.runtimeCliPath,
|
|
1092
|
+
nodePath: options.paths.runtimeNodePath,
|
|
1093
|
+
promptInjection: options.promptInjection
|
|
1094
|
+
});
|
|
1095
|
+
return {
|
|
1096
|
+
enabled: true,
|
|
1097
|
+
ok: result.ok,
|
|
1098
|
+
message: result.message
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function resolveTargetAgents(target) {
|
|
1102
|
+
if (target && target.length > 0) {
|
|
1103
|
+
return [...new Set(target)];
|
|
1104
|
+
}
|
|
1105
|
+
const detected = [];
|
|
1106
|
+
if (detectAgent("codex")) detected.push("codex");
|
|
1107
|
+
if (detectAgent("claude-code")) detected.push("claude-code");
|
|
1108
|
+
return detected;
|
|
1109
|
+
}
|
|
1110
|
+
function detectAgent(agent) {
|
|
1111
|
+
if (agent === "codex") {
|
|
1112
|
+
return hasCommand("codex") || existsSync3(join3(resolveUserHomeDir(), ".codex", "config.toml"));
|
|
1113
|
+
}
|
|
1114
|
+
return hasCommand("claude") || existsSync3(join3(resolveUserHomeDir(), ".claude", "settings.json"));
|
|
1115
|
+
}
|
|
1116
|
+
function resolveHookConfigPath(agent, scope, cwd) {
|
|
1117
|
+
if (scope === "project") {
|
|
1118
|
+
return agent === "codex" ? join3(cwd, ".codex", "config.toml") : join3(cwd, ".claude", "settings.json");
|
|
1119
|
+
}
|
|
1120
|
+
return agent === "codex" ? join3(resolveUserHomeDir(), ".codex", "config.toml") : join3(resolveUserHomeDir(), ".claude", "settings.json");
|
|
1121
|
+
}
|
|
1122
|
+
function defaultRunner(command, args) {
|
|
1123
|
+
execFileSync3(command, args, stdioOpts());
|
|
1124
|
+
}
|
|
1125
|
+
function tryRun(runner, command, args) {
|
|
1126
|
+
try {
|
|
1127
|
+
runner(command, args);
|
|
1128
|
+
} catch {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function stdioOpts() {
|
|
1133
|
+
return {
|
|
1134
|
+
stdio: "ignore"
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
function ok(message) {
|
|
1138
|
+
return { enabled: true, ok: true, message };
|
|
1139
|
+
}
|
|
1140
|
+
function skipped(message) {
|
|
1141
|
+
return { enabled: false, ok: false, message };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/cli.ts
|
|
1145
|
+
import { createRequire } from "module";
|
|
1146
|
+
import { pathToFileURL } from "url";
|
|
1147
|
+
|
|
1148
|
+
// src/doctor/report.ts
|
|
1149
|
+
import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
|
|
1150
|
+
import { join as join6 } from "path";
|
|
1151
|
+
import Database from "better-sqlite3";
|
|
1152
|
+
|
|
1153
|
+
// src/daemon/launchd.ts
|
|
1154
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
1155
|
+
import { homedir as homedir2 } from "os";
|
|
1156
|
+
import { dirname as dirname4, join as join4, resolve as resolve4 } from "path";
|
|
1157
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
1158
|
+
var DEFAULT_LABEL = "com.recall.daemon";
|
|
1159
|
+
function installLaunchAgent(opts = {}) {
|
|
1160
|
+
assertDarwin();
|
|
1161
|
+
const cfg = resolveConfig(opts);
|
|
1162
|
+
mkdirSync3(dirname4(cfg.plistPath), { recursive: true });
|
|
1163
|
+
mkdirSync3(dirname4(cfg.stdoutPath), { recursive: true });
|
|
1164
|
+
writeFileSync3(cfg.plistPath, renderPlist(cfg));
|
|
1165
|
+
tryRun2("launchctl", ["bootout", domainTarget(), cfg.plistPath]);
|
|
1166
|
+
execFileSync4("launchctl", ["bootstrap", domainTarget(), cfg.plistPath], stdioOpts2());
|
|
1167
|
+
execFileSync4("launchctl", ["enable", `${domainTarget()}/${cfg.label}`], stdioOpts2());
|
|
1168
|
+
execFileSync4("launchctl", ["kickstart", "-k", `${domainTarget()}/${cfg.label}`], stdioOpts2());
|
|
1169
|
+
return getLaunchAgentStatus(cfg.label);
|
|
1170
|
+
}
|
|
1171
|
+
function uninstallLaunchAgent(label = DEFAULT_LABEL) {
|
|
1172
|
+
assertDarwin();
|
|
1173
|
+
const cfg = resolveConfig({ label });
|
|
1174
|
+
tryRun2("launchctl", ["bootout", domainTarget(), cfg.plistPath]);
|
|
1175
|
+
tryRun2("launchctl", ["disable", `${domainTarget()}/${cfg.label}`]);
|
|
1176
|
+
rmSync(cfg.plistPath, { force: true });
|
|
1177
|
+
return getLaunchAgentStatus(cfg.label);
|
|
1178
|
+
}
|
|
1179
|
+
function startLaunchAgent(label = DEFAULT_LABEL) {
|
|
1180
|
+
assertDarwin();
|
|
1181
|
+
const cfg = resolveConfig({ label });
|
|
1182
|
+
if (!exists(cfg.plistPath)) {
|
|
1183
|
+
throw new Error(`LaunchAgent not installed: ${cfg.plistPath}`);
|
|
1184
|
+
}
|
|
1185
|
+
execFileSync4("launchctl", ["enable", `${domainTarget()}/${cfg.label}`], stdioOpts2());
|
|
1186
|
+
tryRun2("launchctl", ["bootstrap", domainTarget(), cfg.plistPath]);
|
|
1187
|
+
execFileSync4("launchctl", ["kickstart", "-k", `${domainTarget()}/${cfg.label}`], stdioOpts2());
|
|
1188
|
+
return getLaunchAgentStatus(cfg.label);
|
|
1189
|
+
}
|
|
1190
|
+
function stopLaunchAgent(label = DEFAULT_LABEL) {
|
|
1191
|
+
assertDarwin();
|
|
1192
|
+
const cfg = resolveConfig({ label });
|
|
1193
|
+
if (exists(cfg.plistPath)) {
|
|
1194
|
+
tryRun2("launchctl", ["bootout", domainTarget(), cfg.plistPath]);
|
|
1195
|
+
}
|
|
1196
|
+
return getLaunchAgentStatus(cfg.label);
|
|
1197
|
+
}
|
|
1198
|
+
function getLaunchAgentStatus(label = DEFAULT_LABEL) {
|
|
1199
|
+
assertDarwin();
|
|
1200
|
+
const cfg = resolveConfig({ label });
|
|
1201
|
+
const installed = exists(cfg.plistPath);
|
|
1202
|
+
const output = tryOutput("launchctl", ["print", `${domainTarget()}/${cfg.label}`]);
|
|
1203
|
+
return {
|
|
1204
|
+
label: cfg.label,
|
|
1205
|
+
plistPath: cfg.plistPath,
|
|
1206
|
+
installed,
|
|
1207
|
+
loaded: output.ok,
|
|
1208
|
+
state: output.ok ? extractState(output.output) : void 0
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function getLaunchAgentInfo(label = DEFAULT_LABEL) {
|
|
1212
|
+
assertDarwin();
|
|
1213
|
+
const cfg = resolveConfig({ label });
|
|
1214
|
+
const status = getLaunchAgentStatus(label);
|
|
1215
|
+
const installed = readInstalledConfig(cfg.plistPath);
|
|
1216
|
+
const lines = [
|
|
1217
|
+
`Label: ${status.label}`,
|
|
1218
|
+
`Plist: ${cfg.plistPath}`,
|
|
1219
|
+
`Installed: ${status.installed ? "yes" : "no"}`,
|
|
1220
|
+
`Loaded: ${status.loaded ? "yes" : "no"}`
|
|
1221
|
+
];
|
|
1222
|
+
if (status.state) {
|
|
1223
|
+
lines.push(`State: ${status.state}`);
|
|
1224
|
+
}
|
|
1225
|
+
lines.push(`Port: ${installed?.port ?? cfg.port}`);
|
|
1226
|
+
lines.push(`Data dir: ${installed?.dataDir ?? cfg.dataDir}`);
|
|
1227
|
+
if (installed?.repoRoots ?? cfg.repoRoots) {
|
|
1228
|
+
lines.push(`Repos: ${installed?.repoRoots ?? cfg.repoRoots}`);
|
|
1229
|
+
}
|
|
1230
|
+
if (installed?.embeddingProvider ?? cfg.embeddingProvider) {
|
|
1231
|
+
lines.push(`EmbedProv: ${installed?.embeddingProvider ?? cfg.embeddingProvider}`);
|
|
1232
|
+
}
|
|
1233
|
+
if (installed?.embeddingDims ?? cfg.embeddingDims) {
|
|
1234
|
+
lines.push(`EmbedDims: ${installed?.embeddingDims ?? cfg.embeddingDims}`);
|
|
1235
|
+
}
|
|
1236
|
+
if (installed?.embeddingsDisabled ?? cfg.embeddingsDisabled) {
|
|
1237
|
+
lines.push(`EmbedOff: ${installed?.embeddingsDisabled ?? cfg.embeddingsDisabled}`);
|
|
1238
|
+
}
|
|
1239
|
+
lines.push(`Node: ${installed?.nodePath ?? cfg.nodePath}`);
|
|
1240
|
+
lines.push(`Script: ${installed?.daemonScript ?? cfg.daemonScript}`);
|
|
1241
|
+
lines.push(`Maintain: ${installed?.maintenanceIntervalSeconds ?? cfg.maintenanceIntervalSeconds}s`);
|
|
1242
|
+
lines.push(`Stdout: ${installed?.stdoutPath ?? cfg.stdoutPath}`);
|
|
1243
|
+
lines.push(`Stderr: ${installed?.stderrPath ?? cfg.stderrPath}`);
|
|
1244
|
+
return lines.join("\n");
|
|
1245
|
+
}
|
|
1246
|
+
function resolveConfig(opts) {
|
|
1247
|
+
const label = opts.label ?? DEFAULT_LABEL;
|
|
1248
|
+
const home = homedir2();
|
|
1249
|
+
const port = opts.port ?? parseInt(process.env.RECALL_PORT ?? "7890", 10);
|
|
1250
|
+
const maintenanceIntervalSeconds = opts.maintenanceIntervalSeconds ?? parseInt(process.env.RECALL_MAINTENANCE_INTERVAL_SECONDS ?? "300", 10);
|
|
1251
|
+
const dataDir = resolve4(
|
|
1252
|
+
opts.dataDir ?? process.env.RECALL_DATA_DIR ?? join4(home, ".recall")
|
|
1253
|
+
);
|
|
1254
|
+
const nodePath = resolve4(opts.nodePath ?? process.execPath);
|
|
1255
|
+
const daemonScript = resolve4(
|
|
1256
|
+
opts.daemonScript ?? join4(process.cwd(), "dist", "daemon.js")
|
|
1257
|
+
);
|
|
1258
|
+
const plistPath = join4(home, "Library", "LaunchAgents", `${label}.plist`);
|
|
1259
|
+
const logDir = join4(dataDir, "logs");
|
|
1260
|
+
return {
|
|
1261
|
+
label,
|
|
1262
|
+
port,
|
|
1263
|
+
maintenanceIntervalSeconds,
|
|
1264
|
+
dataDir,
|
|
1265
|
+
nodePath,
|
|
1266
|
+
daemonScript,
|
|
1267
|
+
plistPath,
|
|
1268
|
+
stdoutPath: join4(logDir, "daemon.stdout.log"),
|
|
1269
|
+
stderrPath: join4(logDir, "daemon.stderr.log"),
|
|
1270
|
+
repoRoots: opts.repoRoots ?? process.env.RECALL_REPO_ROOTS,
|
|
1271
|
+
embeddingProvider: opts.embeddingProvider ?? process.env.RECALL_EMBEDDING_PROVIDER,
|
|
1272
|
+
embeddingDims: opts.embeddingDims ?? process.env.RECALL_EMBEDDING_DIMS,
|
|
1273
|
+
embeddingsDisabled: opts.embeddingsDisabled ?? process.env.RECALL_EMBEDDINGS_DISABLED
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
function renderPlist(cfg) {
|
|
1277
|
+
const env = {
|
|
1278
|
+
RECALL_PORT: String(cfg.port),
|
|
1279
|
+
RECALL_MAINTENANCE_INTERVAL_SECONDS: String(cfg.maintenanceIntervalSeconds),
|
|
1280
|
+
RECALL_DATA_DIR: cfg.dataDir,
|
|
1281
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
1282
|
+
...cfg.repoRoots ? { RECALL_REPO_ROOTS: cfg.repoRoots } : {},
|
|
1283
|
+
...cfg.embeddingProvider ? { RECALL_EMBEDDING_PROVIDER: cfg.embeddingProvider } : {},
|
|
1284
|
+
...cfg.embeddingDims ? { RECALL_EMBEDDING_DIMS: cfg.embeddingDims } : {},
|
|
1285
|
+
...cfg.embeddingsDisabled ? { RECALL_EMBEDDINGS_DISABLED: cfg.embeddingsDisabled } : {}
|
|
1286
|
+
};
|
|
1287
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1288
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1289
|
+
<plist version="1.0">
|
|
1290
|
+
<dict>
|
|
1291
|
+
<key>Label</key>
|
|
1292
|
+
<string>${escapeXml(cfg.label)}</string>
|
|
1293
|
+
<key>ProgramArguments</key>
|
|
1294
|
+
<array>
|
|
1295
|
+
<string>${escapeXml(cfg.nodePath)}</string>
|
|
1296
|
+
<string>${escapeXml(cfg.daemonScript)}</string>
|
|
1297
|
+
</array>
|
|
1298
|
+
<key>EnvironmentVariables</key>
|
|
1299
|
+
<dict>
|
|
1300
|
+
<key>PATH</key>
|
|
1301
|
+
<string>${escapeXml(env.PATH)}</string>
|
|
1302
|
+
<key>RECALL_PORT</key>
|
|
1303
|
+
<string>${escapeXml(env.RECALL_PORT)}</string>
|
|
1304
|
+
<key>RECALL_MAINTENANCE_INTERVAL_SECONDS</key>
|
|
1305
|
+
<string>${escapeXml(env.RECALL_MAINTENANCE_INTERVAL_SECONDS)}</string>
|
|
1306
|
+
<key>RECALL_DATA_DIR</key>
|
|
1307
|
+
<string>${escapeXml(env.RECALL_DATA_DIR)}</string>
|
|
1308
|
+
${env.RECALL_REPO_ROOTS ? ` <key>RECALL_REPO_ROOTS</key>
|
|
1309
|
+
<string>${escapeXml(env.RECALL_REPO_ROOTS)}</string>
|
|
1310
|
+
` : ""}${env.RECALL_EMBEDDING_PROVIDER ? ` <key>RECALL_EMBEDDING_PROVIDER</key>
|
|
1311
|
+
<string>${escapeXml(env.RECALL_EMBEDDING_PROVIDER)}</string>
|
|
1312
|
+
` : ""}${env.RECALL_EMBEDDING_DIMS ? ` <key>RECALL_EMBEDDING_DIMS</key>
|
|
1313
|
+
<string>${escapeXml(env.RECALL_EMBEDDING_DIMS)}</string>
|
|
1314
|
+
` : ""}${env.RECALL_EMBEDDINGS_DISABLED ? ` <key>RECALL_EMBEDDINGS_DISABLED</key>
|
|
1315
|
+
<string>${escapeXml(env.RECALL_EMBEDDINGS_DISABLED)}</string>
|
|
1316
|
+
` : ""} </dict>
|
|
1317
|
+
<key>RunAtLoad</key>
|
|
1318
|
+
<true/>
|
|
1319
|
+
<key>KeepAlive</key>
|
|
1320
|
+
<true/>
|
|
1321
|
+
<key>WorkingDirectory</key>
|
|
1322
|
+
<string>${escapeXml(process.cwd())}</string>
|
|
1323
|
+
<key>StandardOutPath</key>
|
|
1324
|
+
<string>${escapeXml(cfg.stdoutPath)}</string>
|
|
1325
|
+
<key>StandardErrorPath</key>
|
|
1326
|
+
<string>${escapeXml(cfg.stderrPath)}</string>
|
|
1327
|
+
</dict>
|
|
1328
|
+
</plist>
|
|
1329
|
+
`;
|
|
1330
|
+
}
|
|
1331
|
+
function domainTarget() {
|
|
1332
|
+
const uid = process.getuid?.();
|
|
1333
|
+
if (uid == null) {
|
|
1334
|
+
throw new Error("Could not determine current macOS user id");
|
|
1335
|
+
}
|
|
1336
|
+
return `gui/${uid}`;
|
|
1337
|
+
}
|
|
1338
|
+
function extractState(output) {
|
|
1339
|
+
const line = output.split("\n").find((item) => item.trim().startsWith("state = "));
|
|
1340
|
+
if (!line) return void 0;
|
|
1341
|
+
return line.split("=").slice(1).join("=").trim();
|
|
1342
|
+
}
|
|
1343
|
+
function tryRun2(cmd, args) {
|
|
1344
|
+
try {
|
|
1345
|
+
execFileSync4(cmd, args, stdioOpts2());
|
|
1346
|
+
} catch {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function tryOutput(cmd, args) {
|
|
1351
|
+
try {
|
|
1352
|
+
const output = execFileSync4(cmd, args, {
|
|
1353
|
+
encoding: "utf8",
|
|
1354
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1355
|
+
});
|
|
1356
|
+
return { ok: true, output };
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
return {
|
|
1359
|
+
ok: false,
|
|
1360
|
+
output: String(error?.stdout ?? error?.stderr ?? error?.message ?? "")
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function readInstalledConfig(plistPath) {
|
|
1365
|
+
if (!exists(plistPath)) return null;
|
|
1366
|
+
try {
|
|
1367
|
+
const raw = execFileSync4("plutil", ["-convert", "json", "-o", "-", plistPath], {
|
|
1368
|
+
encoding: "utf8",
|
|
1369
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1370
|
+
});
|
|
1371
|
+
const parsed = JSON.parse(raw);
|
|
1372
|
+
return {
|
|
1373
|
+
nodePath: parsed?.ProgramArguments?.[0],
|
|
1374
|
+
daemonScript: parsed?.ProgramArguments?.[1],
|
|
1375
|
+
port: parsed?.EnvironmentVariables?.RECALL_PORT,
|
|
1376
|
+
maintenanceIntervalSeconds: parsed?.EnvironmentVariables?.RECALL_MAINTENANCE_INTERVAL_SECONDS,
|
|
1377
|
+
dataDir: parsed?.EnvironmentVariables?.RECALL_DATA_DIR,
|
|
1378
|
+
repoRoots: parsed?.EnvironmentVariables?.RECALL_REPO_ROOTS,
|
|
1379
|
+
embeddingProvider: parsed?.EnvironmentVariables?.RECALL_EMBEDDING_PROVIDER,
|
|
1380
|
+
embeddingDims: parsed?.EnvironmentVariables?.RECALL_EMBEDDING_DIMS,
|
|
1381
|
+
embeddingsDisabled: parsed?.EnvironmentVariables?.RECALL_EMBEDDINGS_DISABLED,
|
|
1382
|
+
stdoutPath: parsed?.StandardOutPath,
|
|
1383
|
+
stderrPath: parsed?.StandardErrorPath
|
|
1384
|
+
};
|
|
1385
|
+
} catch {
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function stdioOpts2() {
|
|
1390
|
+
return {
|
|
1391
|
+
stdio: "pipe"
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
function exists(path) {
|
|
1395
|
+
return existsSync4(path);
|
|
1396
|
+
}
|
|
1397
|
+
function escapeXml(value) {
|
|
1398
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1399
|
+
}
|
|
1400
|
+
function assertDarwin() {
|
|
1401
|
+
if (process.platform !== "darwin") {
|
|
1402
|
+
throw new Error("launchd daemon install is only supported on macOS");
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/daemon/systemd.ts
|
|
1407
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
1408
|
+
import { homedir as homedir3 } from "os";
|
|
1409
|
+
import { dirname as dirname5, join as join5, resolve as resolve5 } from "path";
|
|
1410
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
1411
|
+
import { execFileSync as execFileSync5 } from "child_process";
|
|
1412
|
+
var DEFAULT_LABEL2 = "recall-daemon";
|
|
1413
|
+
function installSystemdUnit(opts = {}) {
|
|
1414
|
+
assertLinux();
|
|
1415
|
+
const cfg = resolveConfig2(opts);
|
|
1416
|
+
mkdirSync4(dirname5(cfg.unitPath), { recursive: true });
|
|
1417
|
+
mkdirSync4(cfg.logDir, { recursive: true });
|
|
1418
|
+
writeFileSync4(cfg.unitPath, renderUnit(cfg));
|
|
1419
|
+
systemctl(["--user", "daemon-reload"]);
|
|
1420
|
+
systemctl(["--user", "enable", "--now", `${cfg.label}.service`]);
|
|
1421
|
+
return getSystemdStatus(cfg.label);
|
|
1422
|
+
}
|
|
1423
|
+
function uninstallSystemdUnit(label = DEFAULT_LABEL2) {
|
|
1424
|
+
assertLinux();
|
|
1425
|
+
const cfg = resolveConfig2({ label });
|
|
1426
|
+
trySystemctl(["--user", "disable", "--now", `${cfg.label}.service`]);
|
|
1427
|
+
rmSync2(cfg.unitPath, { force: true });
|
|
1428
|
+
trySystemctl(["--user", "daemon-reload"]);
|
|
1429
|
+
return getSystemdStatus(cfg.label);
|
|
1430
|
+
}
|
|
1431
|
+
function startSystemdUnit(label = DEFAULT_LABEL2) {
|
|
1432
|
+
assertLinux();
|
|
1433
|
+
const cfg = resolveConfig2({ label });
|
|
1434
|
+
if (!existsSync5(cfg.unitPath)) {
|
|
1435
|
+
throw new Error(`systemd unit not installed: ${cfg.unitPath}`);
|
|
1436
|
+
}
|
|
1437
|
+
systemctl(["--user", "start", `${cfg.label}.service`]);
|
|
1438
|
+
return getSystemdStatus(cfg.label);
|
|
1439
|
+
}
|
|
1440
|
+
function stopSystemdUnit(label = DEFAULT_LABEL2) {
|
|
1441
|
+
assertLinux();
|
|
1442
|
+
const cfg = resolveConfig2({ label });
|
|
1443
|
+
if (existsSync5(cfg.unitPath)) {
|
|
1444
|
+
trySystemctl(["--user", "stop", `${cfg.label}.service`]);
|
|
1445
|
+
}
|
|
1446
|
+
return getSystemdStatus(cfg.label);
|
|
1447
|
+
}
|
|
1448
|
+
function getSystemdStatus(label = DEFAULT_LABEL2) {
|
|
1449
|
+
assertLinux();
|
|
1450
|
+
const cfg = resolveConfig2({ label });
|
|
1451
|
+
const installed = existsSync5(cfg.unitPath);
|
|
1452
|
+
const active = trySystemctlOutput(["--user", "is-active", `${cfg.label}.service`]);
|
|
1453
|
+
const loaded = active.ok && active.output.trim() === "active";
|
|
1454
|
+
const state = active.output.trim() || void 0;
|
|
1455
|
+
return {
|
|
1456
|
+
label: cfg.label,
|
|
1457
|
+
unitPath: cfg.unitPath,
|
|
1458
|
+
installed,
|
|
1459
|
+
loaded,
|
|
1460
|
+
state
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
function getSystemdInfo(label = DEFAULT_LABEL2) {
|
|
1464
|
+
assertLinux();
|
|
1465
|
+
const cfg = resolveConfig2({ label });
|
|
1466
|
+
const status = getSystemdStatus(label);
|
|
1467
|
+
const installed = readInstalledConfig2(cfg.unitPath);
|
|
1468
|
+
const lines = [
|
|
1469
|
+
`Label: ${status.label}`,
|
|
1470
|
+
`Unit: ${cfg.unitPath}`,
|
|
1471
|
+
`Installed: ${status.installed ? "yes" : "no"}`,
|
|
1472
|
+
`Loaded: ${status.loaded ? "yes" : "no"}`
|
|
1473
|
+
];
|
|
1474
|
+
if (status.state) lines.push(`State: ${status.state}`);
|
|
1475
|
+
lines.push(`Port: ${installed?.port ?? cfg.port}`);
|
|
1476
|
+
lines.push(`Data dir: ${installed?.dataDir ?? cfg.dataDir}`);
|
|
1477
|
+
if (installed?.repoRoots ?? cfg.repoRoots) {
|
|
1478
|
+
lines.push(`Repos: ${installed?.repoRoots ?? cfg.repoRoots}`);
|
|
1479
|
+
}
|
|
1480
|
+
if (installed?.embeddingProvider ?? cfg.embeddingProvider) {
|
|
1481
|
+
lines.push(`EmbedProv: ${installed?.embeddingProvider ?? cfg.embeddingProvider}`);
|
|
1482
|
+
}
|
|
1483
|
+
if (installed?.embeddingDims ?? cfg.embeddingDims) {
|
|
1484
|
+
lines.push(`EmbedDims: ${installed?.embeddingDims ?? cfg.embeddingDims}`);
|
|
1485
|
+
}
|
|
1486
|
+
if (installed?.embeddingsDisabled ?? cfg.embeddingsDisabled) {
|
|
1487
|
+
lines.push(`EmbedOff: ${installed?.embeddingsDisabled ?? cfg.embeddingsDisabled}`);
|
|
1488
|
+
}
|
|
1489
|
+
lines.push(`Node: ${installed?.nodePath ?? cfg.nodePath}`);
|
|
1490
|
+
lines.push(`Script: ${installed?.daemonScript ?? cfg.daemonScript}`);
|
|
1491
|
+
lines.push(`Maintain: ${installed?.maintenanceIntervalSeconds ?? cfg.maintenanceIntervalSeconds}s`);
|
|
1492
|
+
lines.push(`Logs: journalctl --user -u ${cfg.label}.service`);
|
|
1493
|
+
return lines.join("\n");
|
|
1494
|
+
}
|
|
1495
|
+
function resolveConfig2(opts) {
|
|
1496
|
+
const label = opts.label ?? DEFAULT_LABEL2;
|
|
1497
|
+
const home = homedir3();
|
|
1498
|
+
const port = opts.port ?? parseInt(process.env.RECALL_PORT ?? "7890", 10);
|
|
1499
|
+
const maintenanceIntervalSeconds = opts.maintenanceIntervalSeconds ?? parseInt(process.env.RECALL_MAINTENANCE_INTERVAL_SECONDS ?? "300", 10);
|
|
1500
|
+
const dataDir = resolve5(
|
|
1501
|
+
opts.dataDir ?? process.env.RECALL_DATA_DIR ?? join5(home, ".recall")
|
|
1502
|
+
);
|
|
1503
|
+
const nodePath = resolve5(opts.nodePath ?? process.execPath);
|
|
1504
|
+
const daemonScript = resolve5(
|
|
1505
|
+
opts.daemonScript ?? defaultDaemonScript()
|
|
1506
|
+
);
|
|
1507
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? join5(home, ".config");
|
|
1508
|
+
const unitPath = join5(configHome, "systemd", "user", `${label}.service`);
|
|
1509
|
+
const logDir = join5(dataDir, "logs");
|
|
1510
|
+
return {
|
|
1511
|
+
label,
|
|
1512
|
+
port,
|
|
1513
|
+
maintenanceIntervalSeconds,
|
|
1514
|
+
dataDir,
|
|
1515
|
+
nodePath,
|
|
1516
|
+
daemonScript,
|
|
1517
|
+
unitPath,
|
|
1518
|
+
logDir,
|
|
1519
|
+
repoRoots: opts.repoRoots ?? process.env.RECALL_REPO_ROOTS,
|
|
1520
|
+
embeddingProvider: opts.embeddingProvider ?? process.env.RECALL_EMBEDDING_PROVIDER,
|
|
1521
|
+
embeddingDims: opts.embeddingDims ?? process.env.RECALL_EMBEDDING_DIMS,
|
|
1522
|
+
embeddingsDisabled: opts.embeddingsDisabled ?? process.env.RECALL_EMBEDDINGS_DISABLED
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
function defaultDaemonScript() {
|
|
1526
|
+
const here = dirname5(fileURLToPath4(import.meta.url));
|
|
1527
|
+
return join5(here, "daemon.js");
|
|
1528
|
+
}
|
|
1529
|
+
function renderUnit(cfg) {
|
|
1530
|
+
const envLines = [
|
|
1531
|
+
`Environment=PATH=${process.env.PATH ?? "/usr/bin:/bin:/usr/local/bin"}`,
|
|
1532
|
+
`Environment=RECALL_PORT=${cfg.port}`,
|
|
1533
|
+
`Environment=RECALL_MAINTENANCE_INTERVAL_SECONDS=${cfg.maintenanceIntervalSeconds}`,
|
|
1534
|
+
`Environment=RECALL_DATA_DIR=${cfg.dataDir}`
|
|
1535
|
+
];
|
|
1536
|
+
if (cfg.repoRoots) envLines.push(`Environment=RECALL_REPO_ROOTS=${cfg.repoRoots}`);
|
|
1537
|
+
if (cfg.embeddingProvider) envLines.push(`Environment=RECALL_EMBEDDING_PROVIDER=${cfg.embeddingProvider}`);
|
|
1538
|
+
if (cfg.embeddingDims) envLines.push(`Environment=RECALL_EMBEDDING_DIMS=${cfg.embeddingDims}`);
|
|
1539
|
+
if (cfg.embeddingsDisabled) envLines.push(`Environment=RECALL_EMBEDDINGS_DISABLED=${cfg.embeddingsDisabled}`);
|
|
1540
|
+
return `[Unit]
|
|
1541
|
+
Description=Recall local daemon
|
|
1542
|
+
After=network.target
|
|
1543
|
+
|
|
1544
|
+
[Service]
|
|
1545
|
+
Type=simple
|
|
1546
|
+
ExecStart=${cfg.nodePath} ${cfg.daemonScript}
|
|
1547
|
+
Restart=always
|
|
1548
|
+
RestartSec=2
|
|
1549
|
+
${envLines.join("\n")}
|
|
1550
|
+
|
|
1551
|
+
[Install]
|
|
1552
|
+
WantedBy=default.target
|
|
1553
|
+
`;
|
|
1554
|
+
}
|
|
1555
|
+
function readInstalledConfig2(unitPath) {
|
|
1556
|
+
if (!existsSync5(unitPath)) return null;
|
|
1557
|
+
try {
|
|
1558
|
+
const raw = readFileSync3(unitPath, "utf8");
|
|
1559
|
+
const exec = raw.match(/^ExecStart=(.+)$/m)?.[1]?.trim().split(/\s+/);
|
|
1560
|
+
const env = {};
|
|
1561
|
+
for (const m of raw.matchAll(/^Environment=([^=]+)=(.+)$/gm)) {
|
|
1562
|
+
env[m[1]] = m[2];
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
nodePath: exec?.[0],
|
|
1566
|
+
daemonScript: exec?.[1],
|
|
1567
|
+
port: env.RECALL_PORT,
|
|
1568
|
+
maintenanceIntervalSeconds: env.RECALL_MAINTENANCE_INTERVAL_SECONDS,
|
|
1569
|
+
dataDir: env.RECALL_DATA_DIR,
|
|
1570
|
+
repoRoots: env.RECALL_REPO_ROOTS,
|
|
1571
|
+
embeddingProvider: env.RECALL_EMBEDDING_PROVIDER,
|
|
1572
|
+
embeddingDims: env.RECALL_EMBEDDING_DIMS,
|
|
1573
|
+
embeddingsDisabled: env.RECALL_EMBEDDINGS_DISABLED
|
|
1574
|
+
};
|
|
1575
|
+
} catch {
|
|
1576
|
+
return null;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
function systemctl(args) {
|
|
1580
|
+
execFileSync5("systemctl", args, { stdio: "pipe" });
|
|
1581
|
+
}
|
|
1582
|
+
function trySystemctl(args) {
|
|
1583
|
+
try {
|
|
1584
|
+
execFileSync5("systemctl", args, { stdio: "pipe" });
|
|
1585
|
+
} catch {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function trySystemctlOutput(args) {
|
|
1590
|
+
try {
|
|
1591
|
+
const output = execFileSync5("systemctl", args, {
|
|
1592
|
+
encoding: "utf8",
|
|
1593
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1594
|
+
});
|
|
1595
|
+
return { ok: true, output };
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
return {
|
|
1598
|
+
ok: false,
|
|
1599
|
+
output: String(error?.stdout ?? error?.stderr ?? error?.message ?? "")
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
function assertLinux() {
|
|
1604
|
+
if (process.platform !== "linux") {
|
|
1605
|
+
throw new Error("systemd daemon install is only supported on Linux");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/doctor/report.ts
|
|
1610
|
+
function getDoctorReport() {
|
|
1611
|
+
const dbPath = getDbPath();
|
|
1612
|
+
const launchd = process.platform === "darwin" ? (() => {
|
|
1613
|
+
try {
|
|
1614
|
+
const status = getLaunchAgentStatus();
|
|
1615
|
+
return {
|
|
1616
|
+
installed: status.installed,
|
|
1617
|
+
loaded: status.loaded,
|
|
1618
|
+
state: status.state
|
|
1619
|
+
};
|
|
1620
|
+
} catch {
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
})() : null;
|
|
1624
|
+
const systemd = process.platform === "linux" ? (() => {
|
|
1625
|
+
try {
|
|
1626
|
+
const status = getSystemdStatus();
|
|
1627
|
+
return {
|
|
1628
|
+
installed: status.installed,
|
|
1629
|
+
loaded: status.loaded,
|
|
1630
|
+
state: status.state
|
|
1631
|
+
};
|
|
1632
|
+
} catch {
|
|
1633
|
+
return null;
|
|
1634
|
+
}
|
|
1635
|
+
})() : null;
|
|
1636
|
+
const agents = inspectAgentInstalls();
|
|
1637
|
+
return {
|
|
1638
|
+
db_path: dbPath,
|
|
1639
|
+
db_user_version: getDbUserVersion(dbPath),
|
|
1640
|
+
db_target_version: RECALL_DB_USER_VERSION,
|
|
1641
|
+
embeddings: getEmbeddingModelInfo(),
|
|
1642
|
+
launchd,
|
|
1643
|
+
systemd,
|
|
1644
|
+
agents,
|
|
1645
|
+
upgrade: computeUpgradeSignal(agents),
|
|
1646
|
+
cleanup: readCleanupHealth(dbPath),
|
|
1647
|
+
dispatcher: readDispatcherHealth(dbPath)
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
function readDispatcherHealth(dbPath) {
|
|
1651
|
+
if (!existsSync6(dbPath)) return null;
|
|
1652
|
+
let sqlite = null;
|
|
1653
|
+
try {
|
|
1654
|
+
sqlite = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
1655
|
+
const tables = sqlite.prepare(
|
|
1656
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('memory_maintenance_tasks','llm_usage')"
|
|
1657
|
+
).all();
|
|
1658
|
+
const names = new Set(tables.map((t) => t.name));
|
|
1659
|
+
let providers = [];
|
|
1660
|
+
try {
|
|
1661
|
+
const { hasProviderConfigured } = (init_keychain(), __toCommonJS(keychain_exports));
|
|
1662
|
+
providers = ["anthropic", "azure-openai", "openai"].filter(
|
|
1663
|
+
(p) => hasProviderConfigured(p)
|
|
1664
|
+
);
|
|
1665
|
+
} catch {
|
|
1666
|
+
providers = [];
|
|
1667
|
+
}
|
|
1668
|
+
const pending = {};
|
|
1669
|
+
if (names.has("memory_maintenance_tasks")) {
|
|
1670
|
+
const rows = sqlite.prepare(
|
|
1671
|
+
"SELECT kind, COUNT(*) AS n FROM memory_maintenance_tasks WHERE status='pending' GROUP BY kind"
|
|
1672
|
+
).all();
|
|
1673
|
+
for (const r of rows) pending[r.kind] = r.n;
|
|
1674
|
+
}
|
|
1675
|
+
let lastAt = null;
|
|
1676
|
+
let lastOk = null;
|
|
1677
|
+
if (names.has("llm_usage")) {
|
|
1678
|
+
const row = sqlite.prepare(
|
|
1679
|
+
"SELECT created_at, ok FROM llm_usage ORDER BY created_at DESC LIMIT 1"
|
|
1680
|
+
).get();
|
|
1681
|
+
if (row) {
|
|
1682
|
+
lastAt = row.created_at;
|
|
1683
|
+
lastOk = row.ok ? "ok" : "error";
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
providers_configured: providers,
|
|
1688
|
+
pending_tasks: pending,
|
|
1689
|
+
last_dispatch_at: lastAt,
|
|
1690
|
+
last_dispatch_outcome: lastOk
|
|
1691
|
+
};
|
|
1692
|
+
} catch {
|
|
1693
|
+
return null;
|
|
1694
|
+
} finally {
|
|
1695
|
+
sqlite?.close();
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
function readCleanupHealth(dbPath) {
|
|
1699
|
+
if (!existsSync6(dbPath)) return null;
|
|
1700
|
+
let sqlite = null;
|
|
1701
|
+
try {
|
|
1702
|
+
sqlite = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
1703
|
+
const tables = sqlite.prepare(
|
|
1704
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('maintenance_cleanup_log','memories','memory_injections')"
|
|
1705
|
+
).all();
|
|
1706
|
+
const names = new Set(tables.map((t) => t.name));
|
|
1707
|
+
if (!names.has("maintenance_cleanup_log")) return null;
|
|
1708
|
+
const totals = sqlite.prepare(
|
|
1709
|
+
"SELECT COUNT(DISTINCT run_id) AS runs, MAX(created_at) AS last_at FROM maintenance_cleanup_log"
|
|
1710
|
+
).get();
|
|
1711
|
+
let lastRunId = null;
|
|
1712
|
+
const actions = {};
|
|
1713
|
+
if (totals.last_at) {
|
|
1714
|
+
const lastRow = sqlite.prepare(
|
|
1715
|
+
"SELECT run_id FROM maintenance_cleanup_log WHERE created_at = ? LIMIT 1"
|
|
1716
|
+
).get(totals.last_at);
|
|
1717
|
+
lastRunId = lastRow?.run_id ?? null;
|
|
1718
|
+
if (lastRunId) {
|
|
1719
|
+
const rows = sqlite.prepare(
|
|
1720
|
+
"SELECT action, COUNT(*) as c FROM maintenance_cleanup_log WHERE run_id = ? GROUP BY action"
|
|
1721
|
+
).all(lastRunId);
|
|
1722
|
+
for (const r of rows) actions[r.action] = r.c;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
let pending = 0;
|
|
1726
|
+
if (names.has("memories")) {
|
|
1727
|
+
const row = sqlite.prepare(
|
|
1728
|
+
"SELECT COUNT(*) AS n FROM memories WHERE status='candidate' AND source='user_correction'"
|
|
1729
|
+
).get();
|
|
1730
|
+
pending = row.n;
|
|
1731
|
+
}
|
|
1732
|
+
let followedRate = null;
|
|
1733
|
+
let resolvedInjections = 0;
|
|
1734
|
+
if (names.has("memory_injections")) {
|
|
1735
|
+
const since = new Date(Date.now() - 14 * 864e5).toISOString();
|
|
1736
|
+
const rows = sqlite.prepare(
|
|
1737
|
+
"SELECT outcome, COUNT(*) as c FROM memory_injections WHERE injected_at >= ? GROUP BY outcome"
|
|
1738
|
+
).all(since);
|
|
1739
|
+
let followed = 0;
|
|
1740
|
+
for (const r of rows) {
|
|
1741
|
+
if (!r.outcome) continue;
|
|
1742
|
+
resolvedInjections += r.c;
|
|
1743
|
+
if (r.outcome === "followed") followed += r.c;
|
|
1744
|
+
}
|
|
1745
|
+
followedRate = resolvedInjections > 0 ? followed / resolvedInjections : null;
|
|
1746
|
+
}
|
|
1747
|
+
return {
|
|
1748
|
+
last_run_id: lastRunId,
|
|
1749
|
+
last_run_at: totals.last_at,
|
|
1750
|
+
last_run_actions: actions,
|
|
1751
|
+
total_runs: totals.runs,
|
|
1752
|
+
pending_candidate_corrections: pending,
|
|
1753
|
+
followed_rate_resolved: followedRate,
|
|
1754
|
+
resolved_injections: resolvedInjections
|
|
1755
|
+
};
|
|
1756
|
+
} catch {
|
|
1757
|
+
return null;
|
|
1758
|
+
} finally {
|
|
1759
|
+
sqlite?.close();
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
function computeUpgradeSignal(agents) {
|
|
1763
|
+
const reasons = [];
|
|
1764
|
+
for (const agent of agents) {
|
|
1765
|
+
if (!agent.detected) continue;
|
|
1766
|
+
if (agent.legacy_notify_bridge) {
|
|
1767
|
+
reasons.push(
|
|
1768
|
+
`${agent.agent}: legacy notify bridge detected \u2014 upgrade to hooks.json for per-turn memory injection`
|
|
1769
|
+
);
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
if (agent.mcp && !agent.hooks) {
|
|
1773
|
+
reasons.push(
|
|
1774
|
+
`${agent.agent}: MCP configured but lifecycle hooks missing \u2014 memory injection depends on the model calling query`
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return { available: reasons.length > 0, reasons };
|
|
1779
|
+
}
|
|
1780
|
+
function inspectAgentInstalls(homeDir) {
|
|
1781
|
+
const home = homeDir ?? resolveUserHomeDir();
|
|
1782
|
+
return [
|
|
1783
|
+
inspectClaudeCodeInstall(home),
|
|
1784
|
+
inspectCodexInstall(home)
|
|
1785
|
+
];
|
|
1786
|
+
}
|
|
1787
|
+
function inspectClaudeCodeInstall(home) {
|
|
1788
|
+
const configPath3 = join6(home, ".claude", "settings.json");
|
|
1789
|
+
const detected = existsSync6(configPath3) || hasCommand("claude");
|
|
1790
|
+
const notes = [];
|
|
1791
|
+
let mcp = false;
|
|
1792
|
+
let hooks = false;
|
|
1793
|
+
if (existsSync6(configPath3)) {
|
|
1794
|
+
try {
|
|
1795
|
+
const parsed = JSON.parse(readFileSync4(configPath3, "utf-8"));
|
|
1796
|
+
mcp = Boolean(parsed?.mcpServers?.recall);
|
|
1797
|
+
const hookGroups = parsed?.hooks ?? {};
|
|
1798
|
+
hooks = Object.values(hookGroups).some(
|
|
1799
|
+
(groups) => Array.isArray(groups) && groups.some(
|
|
1800
|
+
(group) => isHookGroupManagedBy(group, "recall:managed:claude-code")
|
|
1801
|
+
)
|
|
1802
|
+
);
|
|
1803
|
+
if (!mcp) notes.push("MCP server 'recall' not registered in mcpServers");
|
|
1804
|
+
if (!hooks) notes.push("No Recall-managed hooks found in settings.json");
|
|
1805
|
+
} catch (err) {
|
|
1806
|
+
notes.push(`Could not parse ${configPath3}: ${err.message}`);
|
|
1807
|
+
}
|
|
1808
|
+
} else if (detected) {
|
|
1809
|
+
notes.push("Claude CLI detected but settings.json missing");
|
|
1810
|
+
}
|
|
1811
|
+
return {
|
|
1812
|
+
agent: "claude-code",
|
|
1813
|
+
detected,
|
|
1814
|
+
mcp,
|
|
1815
|
+
hooks,
|
|
1816
|
+
config_path: configPath3,
|
|
1817
|
+
notes
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
function inspectCodexInstall(home) {
|
|
1821
|
+
const configPath3 = join6(home, ".codex", "config.toml");
|
|
1822
|
+
const hooksPath = join6(home, ".codex", "hooks.json");
|
|
1823
|
+
const detected = existsSync6(configPath3) || hasCommand("codex");
|
|
1824
|
+
const notes = [];
|
|
1825
|
+
let mcp = false;
|
|
1826
|
+
let hooks = false;
|
|
1827
|
+
let legacy_notify_bridge = false;
|
|
1828
|
+
if (existsSync6(configPath3)) {
|
|
1829
|
+
const raw = readFileSync4(configPath3, "utf-8");
|
|
1830
|
+
mcp = /\[mcp_servers\.recall\]/.test(raw);
|
|
1831
|
+
const featureFlagSet = /^\s*codex_hooks\s*=\s*true\b/m.test(raw);
|
|
1832
|
+
const managedHooksJson = existsSync6(hooksPath) && readFileSync4(hooksPath, "utf-8").includes("recall:managed:codex");
|
|
1833
|
+
hooks = featureFlagSet && managedHooksJson;
|
|
1834
|
+
legacy_notify_bridge = raw.includes("# recall:managed:codex:start") && raw.includes("codex-notify");
|
|
1835
|
+
if (!mcp) notes.push("MCP block [mcp_servers.recall] not in config.toml");
|
|
1836
|
+
if (legacy_notify_bridge) {
|
|
1837
|
+
notes.push(
|
|
1838
|
+
"Legacy notify bridge present \u2014 install the new hooks.json path to enable per-turn memory injection"
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
if (!featureFlagSet) notes.push("codex_hooks = true missing from [features]");
|
|
1842
|
+
if (!managedHooksJson) notes.push("No Recall-managed entries in ~/.codex/hooks.json");
|
|
1843
|
+
} else if (detected) {
|
|
1844
|
+
notes.push("Codex CLI detected but config.toml missing");
|
|
1845
|
+
}
|
|
1846
|
+
return {
|
|
1847
|
+
agent: "codex",
|
|
1848
|
+
detected,
|
|
1849
|
+
mcp,
|
|
1850
|
+
hooks,
|
|
1851
|
+
legacy_notify_bridge,
|
|
1852
|
+
config_path: configPath3,
|
|
1853
|
+
hook_path: hooksPath,
|
|
1854
|
+
notes
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
function isHookGroupManagedBy(group, tag) {
|
|
1858
|
+
if (!group || typeof group !== "object") return false;
|
|
1859
|
+
const hooks = group.hooks;
|
|
1860
|
+
if (!Array.isArray(hooks)) return false;
|
|
1861
|
+
return hooks.some(
|
|
1862
|
+
(hook) => hook && typeof hook === "object" && typeof hook.command === "string" && hook.command.includes(tag)
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
function formatDoctorReport(report) {
|
|
1866
|
+
const lines = [
|
|
1867
|
+
"# Recall Doctor",
|
|
1868
|
+
"",
|
|
1869
|
+
`DB: ${report.db_path}`,
|
|
1870
|
+
`DB ver: ${report.db_user_version}/${report.db_target_version}`
|
|
1871
|
+
];
|
|
1872
|
+
if (report.embeddings) {
|
|
1873
|
+
lines.push(`Embed: ${report.embeddings.provider}`);
|
|
1874
|
+
lines.push(`Model: ${report.embeddings.model}`);
|
|
1875
|
+
lines.push(`Dims: index=${report.embeddings.index_dimensions} canonical=${report.embeddings.canonical_dimensions}`);
|
|
1876
|
+
lines.push(`Cache: ${report.embeddings.size_label} @ ${report.embeddings.cache_path}`);
|
|
1877
|
+
} else {
|
|
1878
|
+
lines.push("Embed: disabled");
|
|
1879
|
+
}
|
|
1880
|
+
if (report.launchd) {
|
|
1881
|
+
lines.push(`Launchd: ${report.launchd.installed ? "installed" : "missing"} / ${report.launchd.loaded ? "loaded" : "not loaded"}${report.launchd.state ? ` (${report.launchd.state})` : ""}`);
|
|
1882
|
+
}
|
|
1883
|
+
if (report.systemd) {
|
|
1884
|
+
lines.push(`Systemd: ${report.systemd.installed ? "installed" : "missing"} / ${report.systemd.loaded ? "loaded" : "not loaded"}${report.systemd.state ? ` (${report.systemd.state})` : ""}`);
|
|
1885
|
+
}
|
|
1886
|
+
lines.push("", "## Agents");
|
|
1887
|
+
for (const agent of report.agents) {
|
|
1888
|
+
const label = agent.agent.padEnd(12);
|
|
1889
|
+
if (!agent.detected) {
|
|
1890
|
+
lines.push(`${label} not detected`);
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
const mcp = agent.mcp ? "ok" : "MISSING";
|
|
1894
|
+
const hooks = agent.hooks ? "ok" : "MISSING";
|
|
1895
|
+
const legacy = agent.legacy_notify_bridge ? " (legacy notify bridge)" : "";
|
|
1896
|
+
lines.push(`${label} mcp:${mcp} hooks:${hooks}${legacy}`);
|
|
1897
|
+
for (const note of agent.notes) {
|
|
1898
|
+
lines.push(` - ${note}`);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (report.cleanup) {
|
|
1902
|
+
lines.push("", "## Cleanup");
|
|
1903
|
+
if (report.cleanup.last_run_at) {
|
|
1904
|
+
const actions = Object.entries(report.cleanup.last_run_actions).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
1905
|
+
lines.push(`Last run: ${report.cleanup.last_run_id?.slice(0, 8)} at ${report.cleanup.last_run_at.slice(0, 19)}`);
|
|
1906
|
+
lines.push(`Actions: ${actions || "(none)"}`);
|
|
1907
|
+
} else {
|
|
1908
|
+
lines.push("Last run: never");
|
|
1909
|
+
}
|
|
1910
|
+
lines.push(`Total runs: ${report.cleanup.total_runs}`);
|
|
1911
|
+
lines.push(`Pending correction candidates: ${report.cleanup.pending_candidate_corrections}`);
|
|
1912
|
+
if (report.cleanup.followed_rate_resolved != null) {
|
|
1913
|
+
const pct = (report.cleanup.followed_rate_resolved * 100).toFixed(1);
|
|
1914
|
+
lines.push(`Followed rate (last 14d, of ${report.cleanup.resolved_injections} resolved): ${pct}%`);
|
|
1915
|
+
} else {
|
|
1916
|
+
lines.push(`Followed rate (last 14d): n/a (no resolved injections)`);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
if (report.dispatcher) {
|
|
1920
|
+
lines.push("", "## Dispatcher (LLM refinement)");
|
|
1921
|
+
const provs = report.dispatcher.providers_configured;
|
|
1922
|
+
lines.push(`Providers: ${provs.length === 0 ? "none configured (LLM tier dormant)" : provs.join(", ")}`);
|
|
1923
|
+
const pendingEntries = Object.entries(report.dispatcher.pending_tasks);
|
|
1924
|
+
if (pendingEntries.length === 0) {
|
|
1925
|
+
lines.push("Pending tasks: 0");
|
|
1926
|
+
} else {
|
|
1927
|
+
const total = pendingEntries.reduce((s, [, n]) => s + n, 0);
|
|
1928
|
+
lines.push(`Pending tasks: ${total} (${pendingEntries.map(([k, n]) => `${k}=${n}`).join(", ")})`);
|
|
1929
|
+
}
|
|
1930
|
+
if (report.dispatcher.last_dispatch_at) {
|
|
1931
|
+
lines.push(`Last dispatch: ${report.dispatcher.last_dispatch_at.slice(0, 19)} (${report.dispatcher.last_dispatch_outcome ?? "unknown"})`);
|
|
1932
|
+
} else {
|
|
1933
|
+
lines.push("Last dispatch: never");
|
|
1934
|
+
}
|
|
1935
|
+
if (provs.length === 0 && pendingEntries.length > 0) {
|
|
1936
|
+
lines.push("Tasks are queued but no provider is configured. Run `recall maintenance dispatch --preview` to inspect prompts, or `recall maintenance credentials set <provider> <key>` to enable.");
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
if (report.upgrade.available) {
|
|
1940
|
+
lines.push("", "## Upgrade available");
|
|
1941
|
+
for (const reason of report.upgrade.reasons) {
|
|
1942
|
+
lines.push(`- ${reason}`);
|
|
1943
|
+
}
|
|
1944
|
+
lines.push("Run `recall doctor --fix` or `recall setup --yes` to apply.");
|
|
1945
|
+
}
|
|
1946
|
+
return lines.join("\n");
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// src/daemon/service.ts
|
|
1950
|
+
var isLinux = process.platform === "linux";
|
|
1951
|
+
var isDarwin = process.platform === "darwin";
|
|
1952
|
+
function unsupported() {
|
|
1953
|
+
throw new Error(
|
|
1954
|
+
`Recall daemon service is only supported on macOS (launchd) and Linux (systemd). Current platform: ${process.platform}`
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
function installService(opts = {}) {
|
|
1958
|
+
if (isDarwin) return installLaunchAgent(opts);
|
|
1959
|
+
if (isLinux) return installSystemdUnit(opts);
|
|
1960
|
+
unsupported();
|
|
1961
|
+
}
|
|
1962
|
+
function uninstallService(label) {
|
|
1963
|
+
if (isDarwin) return uninstallLaunchAgent(label);
|
|
1964
|
+
if (isLinux) return uninstallSystemdUnit(label);
|
|
1965
|
+
unsupported();
|
|
1966
|
+
}
|
|
1967
|
+
function startService(label) {
|
|
1968
|
+
if (isDarwin) return startLaunchAgent(label);
|
|
1969
|
+
if (isLinux) return startSystemdUnit(label);
|
|
1970
|
+
unsupported();
|
|
1971
|
+
}
|
|
1972
|
+
function stopService(label) {
|
|
1973
|
+
if (isDarwin) return stopLaunchAgent(label);
|
|
1974
|
+
if (isLinux) return stopSystemdUnit(label);
|
|
1975
|
+
unsupported();
|
|
1976
|
+
}
|
|
1977
|
+
function getServiceInfo(label) {
|
|
1978
|
+
if (isDarwin) return getLaunchAgentInfo(label);
|
|
1979
|
+
if (isLinux) return getSystemdInfo(label);
|
|
1980
|
+
unsupported();
|
|
1981
|
+
}
|
|
1982
|
+
function defaultServiceLabel() {
|
|
1983
|
+
return isLinux ? "recall-daemon" : "com.recall.daemon";
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// src/cli.ts
|
|
1987
|
+
var require2 = createRequire(import.meta.url);
|
|
1988
|
+
var pkg = require2("../package.json");
|
|
1989
|
+
var program = new Command();
|
|
1990
|
+
program.name("recall").description("Cross-tool coding memory and instruction compiler").version(pkg.version);
|
|
1991
|
+
program.command("init").description("Initialize Recall database").action(() => {
|
|
1992
|
+
initDb();
|
|
1993
|
+
console.log("Recall initialized. Database ready.");
|
|
1994
|
+
});
|
|
1995
|
+
program.command("doctor").description("Show local Recall runtime, DB, embedding, and agent-install health").option("--json", "Emit raw JSON report").option("--fix", "Install missing hooks/MCP for detected agents").action((opts) => {
|
|
1996
|
+
const report = getDoctorReport();
|
|
1997
|
+
if (opts.fix) {
|
|
1998
|
+
const detectedAgents = report.agents.filter((a) => a.detected && (!a.mcp || !a.hooks)).map((a) => a.agent);
|
|
1999
|
+
if (detectedAgents.length === 0) {
|
|
2000
|
+
if (!opts.json) console.log("Nothing to fix \u2014 all detected agents are wired.");
|
|
2001
|
+
} else {
|
|
2002
|
+
const fixResult = runRecallSetup({
|
|
2003
|
+
agent: detectedAgents
|
|
2004
|
+
});
|
|
2005
|
+
if (!opts.json) {
|
|
2006
|
+
console.log(`Applied fix for: ${detectedAgents.join(", ")}`);
|
|
2007
|
+
for (const agent of fixResult.agents) {
|
|
2008
|
+
console.log(`${formatAgentName(agent.agent)} MCP: ${formatSetupStep(agent.mcp)}`);
|
|
2009
|
+
console.log(`${formatAgentName(agent.agent)} hooks: ${formatSetupStep(agent.hooks)}`);
|
|
2010
|
+
}
|
|
2011
|
+
console.log("");
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
const finalReport = opts.fix ? getDoctorReport() : report;
|
|
2016
|
+
if (opts.json) {
|
|
2017
|
+
console.log(JSON.stringify(finalReport, null, 2));
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
console.log(formatDoctorReport(finalReport));
|
|
2021
|
+
});
|
|
2022
|
+
var dbCmd = program.command("db").description("Manage the local Recall database");
|
|
2023
|
+
dbCmd.command("reset").description("Reset the local Recall database and reinitialize the clean schema").option("--yes", "Confirm destructive reset").option("--yes-i-know", "Confirm destructive reset").option("--purge-models", "Also remove the local embedding model cache").action((opts) => {
|
|
2024
|
+
if (!opts.yes && !opts.yesIKnow) {
|
|
2025
|
+
console.error("Refusing to reset without --yes or --yes-i-know.");
|
|
2026
|
+
process.exit(1);
|
|
2027
|
+
}
|
|
2028
|
+
const dbPath = getDbPath();
|
|
2029
|
+
resetDb(dbPath, { purgeModels: opts.purgeModels });
|
|
2030
|
+
initDb(dbPath);
|
|
2031
|
+
console.log(`Reset ${dbPath}`);
|
|
2032
|
+
if (opts.purgeModels) {
|
|
2033
|
+
console.log("Purged local embedding model cache.");
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
dbCmd.command("backup").description("Create a dated snapshot of the local database (idempotent per day)").option("--retention <n>", "Number of snapshots to retain", (v) => Number.parseInt(v, 10)).action((opts) => {
|
|
2037
|
+
initDb();
|
|
2038
|
+
const result = ensureDailyBackup({
|
|
2039
|
+
retention: Number.isFinite(opts.retention) ? opts.retention : void 0
|
|
2040
|
+
});
|
|
2041
|
+
if (result.created) console.log(`Created ${result.created}`);
|
|
2042
|
+
else console.log("Today's backup already exists.");
|
|
2043
|
+
console.log(`Retained: ${result.retained.length}`);
|
|
2044
|
+
for (const p of result.retained) console.log(` ${p}`);
|
|
2045
|
+
if (result.removed.length) {
|
|
2046
|
+
console.log(`Removed: ${result.removed.length}`);
|
|
2047
|
+
for (const p of result.removed) console.log(` ${p}`);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
dbCmd.command("backups").description("List available database snapshots").action(() => {
|
|
2051
|
+
const backups = listBackups();
|
|
2052
|
+
if (backups.length === 0) {
|
|
2053
|
+
console.log("No backups yet.");
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
for (const b of backups) {
|
|
2057
|
+
const mb = (b.size_bytes / 1024 / 1024).toFixed(2);
|
|
2058
|
+
console.log(`${b.date} ${mb} MB ${b.path}`);
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
dbCmd.command("restore <date>").description("Restore the local database from a dated snapshot (YYYY-MM-DD)").option("--yes", "Confirm overwrite").action((date, opts) => {
|
|
2062
|
+
if (!opts.yes) {
|
|
2063
|
+
console.error("Refusing to restore without --yes.");
|
|
2064
|
+
process.exit(1);
|
|
2065
|
+
}
|
|
2066
|
+
const result = restoreBackup(date);
|
|
2067
|
+
if (!result.restored) {
|
|
2068
|
+
console.error(`No backup found at ${result.from}`);
|
|
2069
|
+
process.exit(1);
|
|
2070
|
+
}
|
|
2071
|
+
console.log(`Restored ${result.from} -> ${result.to}`);
|
|
2072
|
+
});
|
|
2073
|
+
var setupCmd = program.command("setup").description("Setup Recall for local use");
|
|
2074
|
+
setupCmd.option("--app-path <path>", "Override Recall.app path", "/Applications/Recall.app").option("--hooks-only", "Install hooks only").option("--mcp-only", "Install MCP wiring only").option("--agent <agent>", "Restrict setup to a single agent (repeatable)", collectAgents, []).option("--uninstall-hooks", "Remove Recall-managed hooks while leaving MCP configured").option("--dry-run", "Show planned setup changes without writing").option("--scope <scope>", "Hook config scope: global or project", "global").option("--no-prompt-injection", "Opt out of per-prompt memory injection (writes RECALL_HOOK_INJECT_PROMPT=false inline into the agent hook command)").option("--yes", "Skip confirmation prompt").action(async (opts) => {
|
|
2075
|
+
if (!opts.yes && !opts.dryRun) {
|
|
2076
|
+
const confirmed = await confirmSetupWrite(opts.scope);
|
|
2077
|
+
if (!confirmed) {
|
|
2078
|
+
console.error("Aborted setup.");
|
|
2079
|
+
process.exit(1);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const result = runRecallSetup({
|
|
2083
|
+
appPath: opts.appPath,
|
|
2084
|
+
agent: opts.agent.length > 0 ? opts.agent : void 0,
|
|
2085
|
+
dryRun: opts.dryRun,
|
|
2086
|
+
hooksOnly: opts.hooksOnly,
|
|
2087
|
+
mcpOnly: opts.mcpOnly,
|
|
2088
|
+
scope: opts.scope,
|
|
2089
|
+
uninstallHooks: opts.uninstallHooks,
|
|
2090
|
+
promptInjection: opts.promptInjection
|
|
2091
|
+
});
|
|
2092
|
+
console.log(`Recall app: ${result.appPath}`);
|
|
2093
|
+
console.log(`Bundled node: ${result.runtimeNodePath}`);
|
|
2094
|
+
console.log(`Bundled CLI: ${result.runtimeCliPath}`);
|
|
2095
|
+
console.log(`Bundled MCP: ${result.runtimeMcpPath}`);
|
|
2096
|
+
console.log(`Scope: ${result.scope}${result.dry_run ? " (dry-run)" : ""}`);
|
|
2097
|
+
console.log("");
|
|
2098
|
+
if (result.agents.length === 0) {
|
|
2099
|
+
console.log("No installed agents detected.");
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
for (const agent of result.agents) {
|
|
2103
|
+
console.log(`${formatAgentName(agent.agent)}:`);
|
|
2104
|
+
console.log(` detected: ${agent.detected ? "yes" : "no"}`);
|
|
2105
|
+
console.log(` mcp: ${formatSetupStep(agent.mcp)}`);
|
|
2106
|
+
console.log(` hooks: ${formatSetupStep(agent.hooks)}`);
|
|
2107
|
+
if (agent.hook_config_path) {
|
|
2108
|
+
console.log(` config: ${agent.hook_config_path}`);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
});
|
|
2112
|
+
setupCmd.command("local").description("Configure local agent MCP + hooks against the installed Recall.app").option("--app-path <path>", "Override Recall.app path", "/Applications/Recall.app").option("--codex-only", "Configure only Codex").option("--claude-only", "Configure only Claude").option("--no-prompt-injection", "Opt out of per-prompt memory injection (writes RECALL_HOOK_INJECT_PROMPT=false inline into the agent hook command)").action((opts) => {
|
|
2113
|
+
const result = runLocalSetup({
|
|
2114
|
+
appPath: opts.appPath,
|
|
2115
|
+
codex: opts.claudeOnly ? false : true,
|
|
2116
|
+
claude: opts.codexOnly ? false : true,
|
|
2117
|
+
promptInjection: opts.promptInjection
|
|
2118
|
+
});
|
|
2119
|
+
console.log(`Recall app: ${result.appPath}`);
|
|
2120
|
+
console.log(`Bundled node: ${result.runtimeNodePath}`);
|
|
2121
|
+
console.log(`Bundled CLI: ${result.runtimeCliPath}`);
|
|
2122
|
+
console.log(`Bundled MCP: ${result.runtimeMcpPath}`);
|
|
2123
|
+
console.log("");
|
|
2124
|
+
console.log(`Codex MCP: ${formatSetupStep(result.codex)}`);
|
|
2125
|
+
console.log(`Codex hooks: ${formatSetupStep(result.codex_hooks)}`);
|
|
2126
|
+
console.log(`Claude MCP: ${formatSetupStep(result.claude)}`);
|
|
2127
|
+
console.log(`Claude hooks: ${formatSetupStep(result.claude_hooks)}`);
|
|
2128
|
+
});
|
|
2129
|
+
var hookCmd = program.command("hook").description("Run lifecycle hook handlers for agent integrations");
|
|
2130
|
+
function safeHookAction(event, action) {
|
|
2131
|
+
return async (...args) => {
|
|
2132
|
+
try {
|
|
2133
|
+
return await action(...args);
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
let logPath = "~/.recall/logs/hook-errors.log";
|
|
2136
|
+
try {
|
|
2137
|
+
const { appendFileSync, mkdirSync: mkdirSync5 } = await import("fs");
|
|
2138
|
+
const { homedir: homedir4 } = await import("os");
|
|
2139
|
+
const dir = join7(homedir4(), ".recall", "logs");
|
|
2140
|
+
mkdirSync5(dir, { recursive: true });
|
|
2141
|
+
logPath = join7(dir, "hook-errors.log");
|
|
2142
|
+
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
2143
|
+
appendFileSync(
|
|
2144
|
+
logPath,
|
|
2145
|
+
`${(/* @__PURE__ */ new Date()).toISOString()} ${event} ${message}
|
|
2146
|
+
`
|
|
2147
|
+
);
|
|
2148
|
+
} catch {
|
|
2149
|
+
}
|
|
2150
|
+
process.stderr.write(
|
|
2151
|
+
`Recall: ${event} hook hit a snag \u2014 see ${logPath}
|
|
2152
|
+
`
|
|
2153
|
+
);
|
|
2154
|
+
process.exit(0);
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
hookCmd.command("prompt").description("Record a submitted prompt").option("--text <text>", "Prompt text").option("--repo <repo>", "Repository slug").option("--repo-path <path>", "Repository path").option("--session <id>", "Session ID").option("--path <path>", "File path context").option("--agent <agent>", "Agent name").option("--prev-assistant <text>", "Previous assistant turn").option("--recent-tools <json>", "Recent tool calls as a JSON array").option("--claude-code-stdin", "Read Claude Code hook JSON from stdin").option("--codex-stdin", "Read Codex hook JSON from stdin").action(safeHookAction("prompt", async (opts) => {
|
|
2159
|
+
const stdinAgent = opts.claudeCodeStdin ? "claude-code" : opts.codexStdin ? "codex" : null;
|
|
2160
|
+
const input = stdinAgent === "claude-code" ? await readClaudeCodePromptInputFromStdin() : stdinAgent === "codex" ? await readCodexPromptInputFromStdin() : {
|
|
2161
|
+
text: opts.text,
|
|
2162
|
+
repo: opts.repo,
|
|
2163
|
+
repo_path: opts.repoPath,
|
|
2164
|
+
session_id: opts.session,
|
|
2165
|
+
path: opts.path,
|
|
2166
|
+
agent: opts.agent,
|
|
2167
|
+
prev_assistant_turn: opts.prevAssistant,
|
|
2168
|
+
recent_tool_calls: parseRecentToolCallsOption(opts.recentTools)
|
|
2169
|
+
};
|
|
2170
|
+
const result = await executePromptHook(input);
|
|
2171
|
+
if (stdinAgent && result.injection) {
|
|
2172
|
+
const output = {
|
|
2173
|
+
hookSpecificOutput: {
|
|
2174
|
+
hookEventName: "UserPromptSubmit",
|
|
2175
|
+
additionalContext: formatInjectionContext(result.injection)
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
process.stdout.write(`${JSON.stringify(output)}
|
|
2179
|
+
`);
|
|
2180
|
+
}
|
|
2181
|
+
}));
|
|
2182
|
+
hookCmd.command("tool").description("Record a completed tool invocation").option("--name <name>", "Tool name").option("--exit <code>", "Tool exit code").option("--repo <repo>", "Repository slug").option("--repo-path <path>", "Repository path").option("--session <id>", "Session ID").option("--path <path>", "File path context").option("--agent <agent>", "Agent name").option("--input-summary <text>", "Tool input summary").option("--claude-code-stdin", "Read Claude Code hook JSON from stdin").option("--codex-stdin", "Read Codex hook JSON from stdin").action(safeHookAction("tool", async (opts) => {
|
|
2183
|
+
const input = opts.claudeCodeStdin ? await readClaudeCodeToolInputFromStdin() : opts.codexStdin ? await readCodexToolInputFromStdin() : {
|
|
2184
|
+
name: opts.name,
|
|
2185
|
+
exit_code: parseInteger(opts.exit, "exit"),
|
|
2186
|
+
repo: opts.repo,
|
|
2187
|
+
repo_path: opts.repoPath,
|
|
2188
|
+
session_id: opts.session,
|
|
2189
|
+
path: opts.path,
|
|
2190
|
+
agent: opts.agent,
|
|
2191
|
+
input_summary: opts.inputSummary
|
|
2192
|
+
};
|
|
2193
|
+
await executeToolHook(input);
|
|
2194
|
+
}));
|
|
2195
|
+
hookCmd.command("session-start").description("Record session start").option("--session <id>", "Session ID").option("--agent <agent>", "Agent name").option("--repo <repo>", "Repository slug").option("--repo-path <path>", "Repository path").option("--path <path>", "File path context").option("--claude-code-stdin", "Read Claude Code hook JSON from stdin").option("--codex-stdin", "Read Codex hook JSON from stdin").action(safeHookAction("session-start", async (opts) => {
|
|
2196
|
+
const stdinAgent = opts.claudeCodeStdin ? "claude-code" : opts.codexStdin ? "codex" : null;
|
|
2197
|
+
const input = stdinAgent === "claude-code" ? await readClaudeCodeSessionStartInputFromStdin() : stdinAgent === "codex" ? await readCodexSessionStartInputFromStdin() : {
|
|
2198
|
+
session_id: opts.session,
|
|
2199
|
+
agent: opts.agent,
|
|
2200
|
+
repo: opts.repo,
|
|
2201
|
+
repo_path: opts.repoPath,
|
|
2202
|
+
path: opts.path
|
|
2203
|
+
};
|
|
2204
|
+
const result = await executeSessionStartHook(input);
|
|
2205
|
+
if (stdinAgent) {
|
|
2206
|
+
const parts = [];
|
|
2207
|
+
if (result.injection) parts.push(formatInjectionContext(result.injection));
|
|
2208
|
+
if (result.maintenance_backlog) {
|
|
2209
|
+
parts.push(formatMaintenanceBacklogContext(result.maintenance_backlog));
|
|
2210
|
+
}
|
|
2211
|
+
if (result.pending_confirmations) {
|
|
2212
|
+
parts.push(formatPendingConfirmationsContext(result.pending_confirmations));
|
|
2213
|
+
}
|
|
2214
|
+
if (parts.length > 0) {
|
|
2215
|
+
const output = {
|
|
2216
|
+
hookSpecificOutput: {
|
|
2217
|
+
hookEventName: "SessionStart",
|
|
2218
|
+
additionalContext: parts.join("\n\n")
|
|
2219
|
+
}
|
|
2220
|
+
};
|
|
2221
|
+
process.stdout.write(`${JSON.stringify(output)}
|
|
2222
|
+
`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}));
|
|
2226
|
+
hookCmd.command("session-end").description("Record session end").option("--session <id>", "Session ID").option("--repo <repo>", "Repository slug").option("--repo-path <path>", "Repository path").option("--path <path>", "File path context").option("--agent <agent>", "Agent name").option("--turn-count <count>", "Turn count").option("--claude-code-stdin", "Read Claude Code hook JSON from stdin").option("--codex-stdin", "Read Codex hook JSON from stdin").action(safeHookAction("session-end", async (opts) => {
|
|
2227
|
+
const input = opts.claudeCodeStdin ? await readClaudeCodeSessionEndInputFromStdin() : opts.codexStdin ? await readCodexSessionEndInputFromStdin() : {
|
|
2228
|
+
session_id: opts.session,
|
|
2229
|
+
repo: opts.repo,
|
|
2230
|
+
repo_path: opts.repoPath,
|
|
2231
|
+
path: opts.path,
|
|
2232
|
+
agent: opts.agent,
|
|
2233
|
+
turn_count: opts.turnCount ? parseInteger(opts.turnCount, "turn-count") : void 0
|
|
2234
|
+
};
|
|
2235
|
+
await executeSessionEndHook(input);
|
|
2236
|
+
}));
|
|
2237
|
+
hookCmd.command("codex-notify").description("Bridge a Codex notify payload into Recall hook handlers").argument("[payload]", "Codex notify payload JSON").action(safeHookAction("codex-notify", async (payload) => {
|
|
2238
|
+
await dispatchCodexNotify(payload);
|
|
2239
|
+
}));
|
|
2240
|
+
hookCmd.command("stats").description("Inspect local hook call telemetry").option("--agent <agent>", "Filter by agent").option("--event <event>", "Filter by event").option("--limit <n>", "Limit rows").option("--json", "Emit raw JSON").action((opts) => {
|
|
2241
|
+
const db = initDb();
|
|
2242
|
+
const stats = getHookCallStats(db, {
|
|
2243
|
+
agent: opts.agent,
|
|
2244
|
+
event: opts.event,
|
|
2245
|
+
limit: opts.limit ? parseInt(opts.limit, 10) : void 0
|
|
2246
|
+
});
|
|
2247
|
+
if (opts.json) {
|
|
2248
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
if (stats.length === 0) {
|
|
2252
|
+
console.log("No hook calls recorded.");
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
for (const row of stats) {
|
|
2256
|
+
console.log(
|
|
2257
|
+
`${row.agent.padEnd(12)} ${row.event.padEnd(16)} total=${row.total_calls} ok=${row.ok_calls} err=${row.error_calls} avg=${row.avg_duration_ms.toFixed(1)}ms max=${row.max_duration_ms}ms last=${row.last_called_at}`
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
program.command("scan").description("Scan a repository and bootstrap memories").argument("[path]", "Repository path", ".").option("-s, --session <id>", "Session ID").action((path, opts) => {
|
|
2262
|
+
const db = initDb();
|
|
2263
|
+
const repoPath = resolve6(path);
|
|
2264
|
+
const ids = scanAndStore(db, repoPath);
|
|
2265
|
+
const artifact = writeRepoContextArtifact(db, {
|
|
2266
|
+
repo: inferRepoSlugFromPath(repoPath),
|
|
2267
|
+
repo_path: repoPath
|
|
2268
|
+
});
|
|
2269
|
+
const scanned = ids.map((id) => getMemory(db, id)).filter((mem) => mem != null);
|
|
2270
|
+
const activeCount = scanned.filter((mem) => mem.status === "active").length;
|
|
2271
|
+
const candidateCount = scanned.filter((mem) => mem.status === "candidate").length;
|
|
2272
|
+
console.log(`Scanned ${repoPath}`);
|
|
2273
|
+
console.log(`Created ${ids.length} memories (${activeCount} active, ${candidateCount} candidate).`);
|
|
2274
|
+
if (artifact.output_path) {
|
|
2275
|
+
console.log(`Updated ${artifact.output_path}`);
|
|
2276
|
+
}
|
|
2277
|
+
createActivityEvent(db, {
|
|
2278
|
+
session_id: opts.session ?? null,
|
|
2279
|
+
repo: scanned[0]?.repo ?? null,
|
|
2280
|
+
source: "cli",
|
|
2281
|
+
event_type: "scan",
|
|
2282
|
+
memory_ids: ids,
|
|
2283
|
+
request: { repo_path: repoPath },
|
|
2284
|
+
result: { created: ids.length, active: activeCount, candidate: candidateCount }
|
|
2285
|
+
});
|
|
2286
|
+
if (ids.length > 0) {
|
|
2287
|
+
console.log("\nMemories:");
|
|
2288
|
+
for (const id of ids) {
|
|
2289
|
+
const mem = getMemory(db, id);
|
|
2290
|
+
if (mem) {
|
|
2291
|
+
console.log(
|
|
2292
|
+
` [${mem.status}] (${mem.confidence.toFixed(2)}) ${mem.type}: ${mem.text}`
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
console.log(
|
|
2297
|
+
"\nUse `recall confirm <id>` to promote candidates, or `recall reject <id>` to discard."
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
});
|
|
2301
|
+
program.command("repos").description("List repositories known to Recall").action(() => {
|
|
2302
|
+
const db = initDb();
|
|
2303
|
+
const repos = listRepos(db);
|
|
2304
|
+
if (repos.length === 0) {
|
|
2305
|
+
console.log("No repositories found.");
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
for (const repo of repos) {
|
|
2309
|
+
console.log(repo);
|
|
2310
|
+
}
|
|
2311
|
+
console.log(`
|
|
2312
|
+
${repos.length} repos total.`);
|
|
2313
|
+
});
|
|
2314
|
+
program.command("list").description("List memories").option("-r, --repo <repo>", "Filter by repository").option(
|
|
2315
|
+
"-s, --status <status>",
|
|
2316
|
+
"Filter by status (transient|candidate|active|rejected)"
|
|
2317
|
+
).option("-t, --type <type>", "Filter by type").option("-n, --limit <n>", "Limit results").option("--offset <n>", "Skip first N results", "0").action((opts) => {
|
|
2318
|
+
const db = initDb();
|
|
2319
|
+
const items = queryMemories(db, {
|
|
2320
|
+
repo: opts.repo,
|
|
2321
|
+
status: opts.status,
|
|
2322
|
+
type: opts.type,
|
|
2323
|
+
limit: opts.limit ? parseInt(opts.limit, 10) : void 0,
|
|
2324
|
+
offset: opts.offset ? parseInt(opts.offset, 10) : void 0
|
|
2325
|
+
});
|
|
2326
|
+
if (items.length === 0) {
|
|
2327
|
+
console.log("No memories found.");
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
for (const m of items) {
|
|
2331
|
+
const prefix = m.id.slice(0, 8);
|
|
2332
|
+
console.log(
|
|
2333
|
+
`${prefix} [${m.status.padEnd(9)}] (${m.confidence.toFixed(2)}) ${m.type.padEnd(14)} ${m.text}`
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
console.log(`
|
|
2337
|
+
${items.length} memories total.`);
|
|
2338
|
+
});
|
|
2339
|
+
program.command("show").description("Show memory details").argument("<id>", "Memory ID (full or prefix)").action((idPrefix) => {
|
|
2340
|
+
const db = initDb();
|
|
2341
|
+
const mem = findByPrefix(db, idPrefix);
|
|
2342
|
+
if (!mem) {
|
|
2343
|
+
console.error(`Memory not found: ${idPrefix}`);
|
|
2344
|
+
process.exit(1);
|
|
2345
|
+
}
|
|
2346
|
+
console.log(JSON.stringify(mem, null, 2));
|
|
2347
|
+
});
|
|
2348
|
+
program.command("confirm").description("Confirm a memory (promote to active)").argument("<id>", "Memory ID (full or prefix)").action((idPrefix) => {
|
|
2349
|
+
const db = initDb();
|
|
2350
|
+
const mem = findByPrefix(db, idPrefix);
|
|
2351
|
+
if (!mem) {
|
|
2352
|
+
console.error(`Memory not found: ${idPrefix}`);
|
|
2353
|
+
process.exit(1);
|
|
2354
|
+
}
|
|
2355
|
+
const ok2 = confirmMemory(db, mem.id);
|
|
2356
|
+
if (ok2) {
|
|
2357
|
+
console.log(`Confirmed: ${mem.id.slice(0, 8)} \u2192 active`);
|
|
2358
|
+
} else {
|
|
2359
|
+
console.error("Could not confirm (may be rejected).");
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
program.command("reject").description("Reject a memory (never inject again)").argument("<id>", "Memory ID (full or prefix)").action((idPrefix) => {
|
|
2363
|
+
const db = initDb();
|
|
2364
|
+
const mem = findByPrefix(db, idPrefix);
|
|
2365
|
+
if (!mem) {
|
|
2366
|
+
console.error(`Memory not found: ${idPrefix}`);
|
|
2367
|
+
process.exit(1);
|
|
2368
|
+
}
|
|
2369
|
+
rejectMemory(db, mem.id);
|
|
2370
|
+
console.log(`Rejected: ${mem.id.slice(0, 8)}`);
|
|
2371
|
+
});
|
|
2372
|
+
program.command("compile").description("Compile active memories into injection pack").requiredOption("-r, --repo <repo>", "Repository name").option("-p, --path <path>", "File path for scoping").option("-q, --query <text>", "Optional query text for hybrid reranking").option("--include-candidates", "Allow strong candidate memories into hybrid ranking").option("-s, --session <id>", "Session ID").option("--threshold <n>", "Confidence threshold (default: dynamic from quality profile)").action(async (opts) => {
|
|
2373
|
+
const db = initDb();
|
|
2374
|
+
const result = opts.query || opts.includeCandidates ? await compileContextHybrid(db, {
|
|
2375
|
+
repo: opts.repo,
|
|
2376
|
+
path: opts.path,
|
|
2377
|
+
query_text: opts.query,
|
|
2378
|
+
config: {
|
|
2379
|
+
...opts.threshold ? { confidence_threshold: parseFloat(opts.threshold) } : {},
|
|
2380
|
+
include_candidates: opts.includeCandidates ?? false
|
|
2381
|
+
}
|
|
2382
|
+
}) : compileContext(db, {
|
|
2383
|
+
repo: opts.repo,
|
|
2384
|
+
path: opts.path,
|
|
2385
|
+
config: opts.threshold ? { confidence_threshold: parseFloat(opts.threshold) } : {}
|
|
2386
|
+
});
|
|
2387
|
+
createActivityEvent(db, {
|
|
2388
|
+
session_id: opts.session ?? null,
|
|
2389
|
+
repo: opts.repo,
|
|
2390
|
+
path: opts.path ?? null,
|
|
2391
|
+
source: "cli",
|
|
2392
|
+
event_type: "compile",
|
|
2393
|
+
memory_ids: result.memories_included,
|
|
2394
|
+
request: {
|
|
2395
|
+
threshold: opts.threshold ? parseFloat(opts.threshold) : null,
|
|
2396
|
+
query_text: opts.query ?? null,
|
|
2397
|
+
include_candidates: opts.includeCandidates ?? false
|
|
2398
|
+
},
|
|
2399
|
+
result: {
|
|
2400
|
+
included: result.memories_included,
|
|
2401
|
+
dropped: result.memories_dropped,
|
|
2402
|
+
history_included: result.history_included,
|
|
2403
|
+
token_estimate: result.token_estimate
|
|
2404
|
+
}
|
|
2405
|
+
});
|
|
2406
|
+
if (!result.text) {
|
|
2407
|
+
console.log("No context above threshold. Nothing to inject.");
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
console.log(result.text);
|
|
2411
|
+
console.log(`---`);
|
|
2412
|
+
console.log(
|
|
2413
|
+
`${result.memories_included.length} memories included, ${result.history_included.length} history snippets included, ${result.memories_dropped.length} dropped, ~${result.token_estimate} tokens`
|
|
2414
|
+
);
|
|
2415
|
+
});
|
|
2416
|
+
program.command("correct").description("Report a correction to learn from").argument("<text>", "Correction text").option("-r, --repo <repo>", "Repository name").option("-p, --path <path>", "File path context").option("-s, --session <id>", "Session ID", "cli").action(async (text, opts) => {
|
|
2417
|
+
const db = initDb();
|
|
2418
|
+
const ids = await processCorrection(db, text, {
|
|
2419
|
+
sessionId: opts.session,
|
|
2420
|
+
repo: opts.repo,
|
|
2421
|
+
path: opts.path
|
|
2422
|
+
});
|
|
2423
|
+
createActivityEvent(db, {
|
|
2424
|
+
session_id: opts.session,
|
|
2425
|
+
repo: opts.repo ?? null,
|
|
2426
|
+
path: opts.path ?? null,
|
|
2427
|
+
source: "cli",
|
|
2428
|
+
event_type: "correction",
|
|
2429
|
+
memory_ids: ids,
|
|
2430
|
+
request: { text },
|
|
2431
|
+
result: { created: ids }
|
|
2432
|
+
});
|
|
2433
|
+
if (ids.length === 0) {
|
|
2434
|
+
console.log("No correction pattern detected.");
|
|
2435
|
+
console.log(
|
|
2436
|
+
`Try: "don't use X, use Y", "always do Z", "let's use editorconfig defaults", or "review said to use W"`
|
|
2437
|
+
);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
console.log(`Created ${ids.length} candidate(s):`);
|
|
2441
|
+
for (const id of ids) {
|
|
2442
|
+
const mem = getMemory(db, id);
|
|
2443
|
+
if (mem)
|
|
2444
|
+
console.log(` ${id.slice(0, 8)}: ${mem.text}`);
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
program.command("review").description("Report review feedback").argument("<feedback>", "Review feedback text").option("-r, --repo <repo>", "Repository name").option("-p, --path <path>", "File path context").option("-s, --session <id>", "Session ID", "cli-review").option("--reviewer <name>", "Reviewer name").action(async (feedback, opts) => {
|
|
2448
|
+
const db = initDb();
|
|
2449
|
+
const ids = await processReviewFeedback(db, feedback, {
|
|
2450
|
+
sessionId: opts.session,
|
|
2451
|
+
repo: opts.repo,
|
|
2452
|
+
path: opts.path,
|
|
2453
|
+
reviewer: opts.reviewer
|
|
2454
|
+
});
|
|
2455
|
+
createActivityEvent(db, {
|
|
2456
|
+
session_id: opts.session,
|
|
2457
|
+
repo: opts.repo ?? null,
|
|
2458
|
+
path: opts.path ?? null,
|
|
2459
|
+
source: "cli",
|
|
2460
|
+
event_type: "review",
|
|
2461
|
+
memory_ids: ids,
|
|
2462
|
+
request: { feedback, reviewer: opts.reviewer ?? null },
|
|
2463
|
+
result: { created: ids }
|
|
2464
|
+
});
|
|
2465
|
+
console.log(`Created ${ids.length} candidate(s) from review feedback.`);
|
|
2466
|
+
for (const id of ids) {
|
|
2467
|
+
const mem = getMemory(db, id);
|
|
2468
|
+
if (mem) console.log(` ${id.slice(0, 8)}: ${mem.text}`);
|
|
2469
|
+
}
|
|
2470
|
+
});
|
|
2471
|
+
program.command("export").description("Export memories as markdown instruction files").requiredOption("-r, --repo <repo>", "Repository name").option(
|
|
2472
|
+
"-f, --format <format>",
|
|
2473
|
+
"Export format: claude | codex | markdown | context",
|
|
2474
|
+
"markdown"
|
|
2475
|
+
).option("-o, --output <path>", "Output file path").action((opts) => {
|
|
2476
|
+
const db = initDb();
|
|
2477
|
+
let content;
|
|
2478
|
+
switch (opts.format) {
|
|
2479
|
+
case "claude":
|
|
2480
|
+
content = exportClaude(db, opts.repo);
|
|
2481
|
+
break;
|
|
2482
|
+
case "codex":
|
|
2483
|
+
content = exportCodex(db, opts.repo);
|
|
2484
|
+
break;
|
|
2485
|
+
case "context": {
|
|
2486
|
+
const artifact = writeRepoContextArtifact(db, { repo: opts.repo });
|
|
2487
|
+
if (!artifact.output_path) {
|
|
2488
|
+
console.error(`Could not resolve local repo path for ${opts.repo}`);
|
|
2489
|
+
process.exit(1);
|
|
2490
|
+
}
|
|
2491
|
+
content = readFileSync5(artifact.output_path, "utf-8");
|
|
2492
|
+
if (!opts.output) {
|
|
2493
|
+
console.log(content);
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
break;
|
|
2497
|
+
}
|
|
2498
|
+
default:
|
|
2499
|
+
content = exportMarkdown(db, opts.repo);
|
|
2500
|
+
}
|
|
2501
|
+
if (opts.output) {
|
|
2502
|
+
writeFileSync5(opts.output, content);
|
|
2503
|
+
console.log(`Exported to ${opts.output}`);
|
|
2504
|
+
} else {
|
|
2505
|
+
console.log(content);
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
program.command("publish").description("Write repo-local .recall/context.md for the current repo").argument("[path]", "Repository path", ".").action((path) => {
|
|
2509
|
+
const db = initDb();
|
|
2510
|
+
const repoPath = resolve6(path);
|
|
2511
|
+
const artifact = writeRepoContextArtifact(db, {
|
|
2512
|
+
repo: inferRepoSlugFromPath(repoPath),
|
|
2513
|
+
repo_path: repoPath
|
|
2514
|
+
});
|
|
2515
|
+
if (!artifact.output_path) {
|
|
2516
|
+
console.error(`Could not write repo-local context for ${repoPath}`);
|
|
2517
|
+
process.exit(1);
|
|
2518
|
+
}
|
|
2519
|
+
console.log(`Wrote ${artifact.output_path}`);
|
|
2520
|
+
});
|
|
2521
|
+
var historyCmd = program.command("history").description("Inspect rolled-up history snippets");
|
|
2522
|
+
historyCmd.command("list").description("List history snippets").option("-r, --repo <repo>", "Filter by repository").option("-s, --session <id>", "Filter by session id").option("-k, --kind <kind>", "Filter by kind").option("-n, --limit <n>", "Limit results", "20").action((opts) => {
|
|
2523
|
+
const db = initDb();
|
|
2524
|
+
const items = listHistorySnippets(db, {
|
|
2525
|
+
repo: opts.repo,
|
|
2526
|
+
session_id: opts.session,
|
|
2527
|
+
kind: opts.kind,
|
|
2528
|
+
limit: parseInt(opts.limit, 10)
|
|
2529
|
+
});
|
|
2530
|
+
if (items.length === 0) {
|
|
2531
|
+
console.log("No history snippets found.");
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
for (const item of items) {
|
|
2535
|
+
console.log(`${item.id.slice(0, 8)} [${item.kind}] repo=${item.repo ?? "-"} session=${item.session_id ?? "-"} ${item.text.split("\n")[0]}`);
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
historyCmd.command("search").description("Search history snippets with hybrid lexical/vector retrieval").argument("<query>", "Search query").option("-r, --repo <repo>", "Filter by repository").option("-n, --limit <n>", "Limit results", "10").action(async (query, opts) => {
|
|
2539
|
+
const db = initDb();
|
|
2540
|
+
const results = await searchHistorySnippets(db, query, {
|
|
2541
|
+
repo: opts.repo,
|
|
2542
|
+
limit: parseInt(opts.limit, 10)
|
|
2543
|
+
});
|
|
2544
|
+
if (results.length === 0) {
|
|
2545
|
+
console.log("No matching history snippets found.");
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
for (const result of results) {
|
|
2549
|
+
console.log(
|
|
2550
|
+
`${result.snippet.id.slice(0, 8)} (score=${result.score.toFixed(3)} vec=${result.similarity.toFixed(3)} lex=${result.lexical_score.toFixed(3)}) [${result.snippet.kind}] ${result.snippet.text.split("\n")[0]}`
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
});
|
|
2554
|
+
program.command("serve").description("Start the MCP server (stdio transport)").action(async () => {
|
|
2555
|
+
await import("./mcp.js");
|
|
2556
|
+
});
|
|
2557
|
+
var syncCmd = program.command("sync").description("Sync memories with remote server");
|
|
2558
|
+
syncCmd.command("push").description("Push local memories to remote").action(async () => {
|
|
2559
|
+
const db = initDb();
|
|
2560
|
+
const config = loadSyncConfig();
|
|
2561
|
+
if (!config) {
|
|
2562
|
+
console.error("No sync config. Set RECALL_SYNC_URL and RECALL_SYNC_KEY.");
|
|
2563
|
+
process.exit(1);
|
|
2564
|
+
}
|
|
2565
|
+
const result = await sync(db, config);
|
|
2566
|
+
console.log(`Pushed: ${result.pushed}, Pulled: ${result.pulled}, Conflicts: ${result.conflicts}`);
|
|
2567
|
+
if (result.errors.length > 0) {
|
|
2568
|
+
console.error("Errors:", result.errors.join("; "));
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
syncCmd.command("pull").description("Pull team memories from remote").action(async () => {
|
|
2572
|
+
const db = initDb();
|
|
2573
|
+
const config = loadSyncConfig();
|
|
2574
|
+
if (!config) {
|
|
2575
|
+
console.error("No sync config. Set RECALL_SYNC_URL and RECALL_SYNC_KEY.");
|
|
2576
|
+
process.exit(1);
|
|
2577
|
+
}
|
|
2578
|
+
const result = await sync(db, config);
|
|
2579
|
+
console.log(`Pulled: ${result.pulled}, Conflicts: ${result.conflicts}`);
|
|
2580
|
+
});
|
|
2581
|
+
var teamCmd = program.command("team").description("Manage teams");
|
|
2582
|
+
teamCmd.command("create").description("Create a new team").argument("<name>", "Team name").action(async (name) => {
|
|
2583
|
+
const config = loadSyncConfig();
|
|
2584
|
+
if (!config) {
|
|
2585
|
+
console.error("No sync config.");
|
|
2586
|
+
process.exit(1);
|
|
2587
|
+
}
|
|
2588
|
+
const teamId = await createTeam(config, name);
|
|
2589
|
+
console.log(`Team created: ${teamId}`);
|
|
2590
|
+
console.log(`Set RECALL_TEAM_ID=${teamId} to use this team.`);
|
|
2591
|
+
});
|
|
2592
|
+
teamCmd.command("join").description("Join an existing team").argument("<team-id>", "Team ID").action(async (teamId) => {
|
|
2593
|
+
const config = loadSyncConfig();
|
|
2594
|
+
if (!config) {
|
|
2595
|
+
console.error("No sync config.");
|
|
2596
|
+
process.exit(1);
|
|
2597
|
+
}
|
|
2598
|
+
await joinTeam(config, teamId);
|
|
2599
|
+
console.log(`Joined team: ${teamId}`);
|
|
2600
|
+
});
|
|
2601
|
+
var evalCmd = program.command("eval").description("Evaluation metrics");
|
|
2602
|
+
evalCmd.command("report").description("Show evaluation metrics report").option("-r, --repo <repo>", "Filter by repo").option("--since <date>", "Since date (ISO)").action((opts) => {
|
|
2603
|
+
const db = initDb();
|
|
2604
|
+
const metrics = computeMetrics(db, { repo: opts.repo, since: opts.since });
|
|
2605
|
+
console.log(formatMetricsReport(metrics));
|
|
2606
|
+
});
|
|
2607
|
+
evalCmd.command("start").description("Start an eval session").requiredOption("-r, --repo <repo>", "Repository name").action((opts) => {
|
|
2608
|
+
const db = initDb();
|
|
2609
|
+
const id = startEvalSession(db, opts.repo);
|
|
2610
|
+
console.log(`Eval session started: ${id}`);
|
|
2611
|
+
});
|
|
2612
|
+
evalCmd.command("end").description("End an eval session").argument("<session-id>", "Session ID").action((sessionId) => {
|
|
2613
|
+
const db = initDb();
|
|
2614
|
+
endEvalSession(db, sessionId);
|
|
2615
|
+
console.log(`Eval session ended: ${sessionId}`);
|
|
2616
|
+
});
|
|
2617
|
+
evalCmd.command("retrieval").description("Run retrieval eval fixtures against baseline vs hybrid retrieval").requiredOption("-f, --file <path>", "Fixture file path").option("-p, --provider <providers>", "Providers to compare (comma-separated: current,nomic,multilingual-e5,bge-small-en-v1.5)", "current").option("--json", "Emit raw JSON report").action(async (opts) => {
|
|
2618
|
+
const db = initDb();
|
|
2619
|
+
const input = loadRetrievalEvalFile(opts.file);
|
|
2620
|
+
const providers = String(opts.provider).split(",").map((value) => value.trim()).filter(Boolean);
|
|
2621
|
+
const report = await runRetrievalEval(db, input, { providers });
|
|
2622
|
+
if (opts.json) {
|
|
2623
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
console.log(formatRetrievalEvalReport(report));
|
|
2627
|
+
});
|
|
2628
|
+
var embeddingsCmd = program.command("embeddings").description("Manage canonical embedding state");
|
|
2629
|
+
embeddingsCmd.command("setup").description("Pre-fetch the active embedding model into the local cache").action(async () => {
|
|
2630
|
+
const config = loadEmbeddingConfigFromEnv();
|
|
2631
|
+
if (!config) {
|
|
2632
|
+
console.error("Embeddings are disabled. Unset RECALL_EMBEDDINGS_DISABLED=true to enable local embeddings.");
|
|
2633
|
+
process.exit(1);
|
|
2634
|
+
}
|
|
2635
|
+
const before = getEmbeddingModelInfo(config);
|
|
2636
|
+
if (!before.cached) {
|
|
2637
|
+
const approx = before.estimated_size_mb ? `~${before.estimated_size_mb}MB` : "download";
|
|
2638
|
+
console.log(`Fetching embedding model (one-time, ${approx}) -> ${before.cache_path}`);
|
|
2639
|
+
}
|
|
2640
|
+
const info = await ensureEmbeddingProviderReady(config);
|
|
2641
|
+
if (!info) {
|
|
2642
|
+
console.error("Failed to initialize embedding provider.");
|
|
2643
|
+
process.exit(1);
|
|
2644
|
+
}
|
|
2645
|
+
console.log(`Provider: ${info.provider}`);
|
|
2646
|
+
console.log(`Model: ${info.model}`);
|
|
2647
|
+
console.log(`Cache: ${info.cache_path}`);
|
|
2648
|
+
console.log(`Size: ${info.size_label}`);
|
|
2649
|
+
});
|
|
2650
|
+
embeddingsCmd.command("info").description("Show active embedding provider and cache details").action(() => {
|
|
2651
|
+
const info = getEmbeddingModelInfo();
|
|
2652
|
+
if (!info) {
|
|
2653
|
+
console.error("Embeddings are disabled. Unset RECALL_EMBEDDINGS_DISABLED=true to enable local embeddings.");
|
|
2654
|
+
process.exit(1);
|
|
2655
|
+
}
|
|
2656
|
+
console.log(`Provider: ${info.provider}`);
|
|
2657
|
+
console.log(`Model: ${info.model}`);
|
|
2658
|
+
console.log(`Dims: index=${info.index_dimensions} canonical=${info.canonical_dimensions}`);
|
|
2659
|
+
console.log(`Version: ${info.version}`);
|
|
2660
|
+
console.log(`Cached: ${info.cached ? "yes" : "no"}`);
|
|
2661
|
+
console.log(`Size: ${info.size_label}`);
|
|
2662
|
+
console.log(`Cache: ${info.cache_path}`);
|
|
2663
|
+
if (info.task_prefix) {
|
|
2664
|
+
console.log(`Prefix: ${info.task_prefix}`);
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
embeddingsCmd.command("bootstrap").description("Generate or refresh embeddings for eligible memories").option("-r, --repo <repo>", "Limit bootstrap to one repo").action(async (opts) => {
|
|
2668
|
+
const db = initDb();
|
|
2669
|
+
const config = loadEmbeddingConfigFromEnv();
|
|
2670
|
+
if (!config) {
|
|
2671
|
+
console.error("Embeddings are disabled. Unset RECALL_EMBEDDINGS_DISABLED=true to enable local embeddings.");
|
|
2672
|
+
process.exit(1);
|
|
2673
|
+
}
|
|
2674
|
+
const count = await bootstrapEmbeddings(db, config, {
|
|
2675
|
+
repo: opts.repo
|
|
2676
|
+
});
|
|
2677
|
+
console.log(`Bootstrapped ${count} embeddings.`);
|
|
2678
|
+
});
|
|
2679
|
+
embeddingsCmd.command("verify").description("Verify embedding coverage and stale content hashes").option("-r, --repo <repo>", "Limit verification to one repo").action((opts) => {
|
|
2680
|
+
const db = initDb();
|
|
2681
|
+
const config = loadEmbeddingConfigFromEnv();
|
|
2682
|
+
if (!config) {
|
|
2683
|
+
console.error("Embeddings are disabled. Unset RECALL_EMBEDDINGS_DISABLED=true to enable local embeddings.");
|
|
2684
|
+
process.exit(1);
|
|
2685
|
+
}
|
|
2686
|
+
const result = verifyEmbeddings(db, config, {
|
|
2687
|
+
repo: opts.repo
|
|
2688
|
+
});
|
|
2689
|
+
console.log(`Eligible: ${result.eligible}`);
|
|
2690
|
+
console.log(`Stored: ${result.stored}`);
|
|
2691
|
+
console.log(`Stale: ${result.stale}`);
|
|
2692
|
+
console.log(`Indexed: ${result.indexed}`);
|
|
2693
|
+
console.log(`Drift: ${result.index_drift}`);
|
|
2694
|
+
console.log(`Lexical: ${result.lexical_indexed}`);
|
|
2695
|
+
console.log(`LexDrift: ${result.lexical_drift}`);
|
|
2696
|
+
});
|
|
2697
|
+
embeddingsCmd.command("rebuild-index").description("Rebuild derived retrieval indexes from canonical memories").option("-r, --repo <repo>", "Limit rebuild to one repo").action((opts) => {
|
|
2698
|
+
const db = initDb();
|
|
2699
|
+
const config = loadEmbeddingConfigFromEnv();
|
|
2700
|
+
const result = rebuildEmbeddingIndex(db, config, {
|
|
2701
|
+
repo: opts.repo
|
|
2702
|
+
});
|
|
2703
|
+
console.log(`Rebuilt sqlite-vec index with ${result.vector_rows} rows.`);
|
|
2704
|
+
console.log(`Rebuilt FTS5 index with ${result.lexical_rows} rows.`);
|
|
2705
|
+
});
|
|
2706
|
+
program.command("search").description("Hybrid lexical + vector search across memories").argument("<query>", "Search query").option("-r, --repo <repo>", "Filter by repo").option("-n, --limit <n>", "Max results", "10").action(async (query, opts) => {
|
|
2707
|
+
const db = initDb();
|
|
2708
|
+
const config = loadEmbeddingConfigFromEnv();
|
|
2709
|
+
const results = await hybridSearch(db, query, config, {
|
|
2710
|
+
repo: opts.repo,
|
|
2711
|
+
limit: parseInt(opts.limit)
|
|
2712
|
+
});
|
|
2713
|
+
if (results.length === 0) {
|
|
2714
|
+
console.log("No matching memories found.");
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
for (const r of results) {
|
|
2718
|
+
console.log(
|
|
2719
|
+
`${r.memory.id.slice(0, 8)} (score=${r.score.toFixed(3)} vec=${r.similarity.toFixed(3)} lex=${r.lexical_score.toFixed(3)}) [${r.memory.status}] ${r.memory.text}`
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
});
|
|
2723
|
+
program.command("signal").description("Record an implicit feedback signal").argument("<memory-id>", "Memory ID").argument("<signal>", "Signal type: test_pass|test_fail|file_unchanged|file_rewritten|task_accepted|task_rejected").option("-s, --session <id>", "Session ID", "cli").action((memoryIdPrefix, signal, opts) => {
|
|
2724
|
+
const db = initDb();
|
|
2725
|
+
const mem = findByPrefix(db, memoryIdPrefix);
|
|
2726
|
+
if (!mem) {
|
|
2727
|
+
console.error(`Memory not found: ${memoryIdPrefix}`);
|
|
2728
|
+
process.exit(1);
|
|
2729
|
+
}
|
|
2730
|
+
const validSignals = ["test_pass", "test_fail", "file_unchanged", "file_rewritten", "task_accepted", "task_rejected"];
|
|
2731
|
+
if (!validSignals.includes(signal)) {
|
|
2732
|
+
console.error(`Invalid signal. Use: ${validSignals.join(", ")}`);
|
|
2733
|
+
process.exit(1);
|
|
2734
|
+
}
|
|
2735
|
+
const id = recordSignal(db, mem.id, opts.session, signal);
|
|
2736
|
+
createActivityEvent(db, {
|
|
2737
|
+
session_id: opts.session,
|
|
2738
|
+
repo: mem.repo,
|
|
2739
|
+
path: mem.path_scope,
|
|
2740
|
+
source: "cli",
|
|
2741
|
+
event_type: "signal",
|
|
2742
|
+
memory_ids: [mem.id],
|
|
2743
|
+
request: { signal },
|
|
2744
|
+
result: { signal_id: id }
|
|
2745
|
+
});
|
|
2746
|
+
console.log(`Signal recorded: ${id.slice(0, 8)}`);
|
|
2747
|
+
const stats = getSignalStats(db, mem.id);
|
|
2748
|
+
console.log("Stats:", JSON.stringify(stats));
|
|
2749
|
+
});
|
|
2750
|
+
program.command("scope").description("Analyze scope of a correction text").argument("<text>", "Correction text").option("-p, --path <path>", "Context file path").action((text, opts) => {
|
|
2751
|
+
const result = inferScope(text, opts.path);
|
|
2752
|
+
console.log(`Scope: ${result.scope}`);
|
|
2753
|
+
console.log(`Path scope: ${result.path_scope ?? "(none)"}`);
|
|
2754
|
+
console.log(`Confidence: ${result.confidence_modifier > 0 ? "+" : ""}${result.confidence_modifier}`);
|
|
2755
|
+
console.log(`Reason: ${result.reason}`);
|
|
2756
|
+
});
|
|
2757
|
+
var policyCmd = program.command("policy").description("Org-level policy management");
|
|
2758
|
+
policyCmd.command("create").description("Create a policy rule").requiredOption("--org <id>", "Organization ID").requiredOption("--type <type>", "Rule type: min_confidence|require_approval|allowed_sources|blocked_scopes|max_active_per_repo|require_evidence_count|auto_approve_pattern").requiredOption("--config <json>", "Rule config as JSON").action((opts) => {
|
|
2759
|
+
const db = initDb();
|
|
2760
|
+
const config = JSON.parse(opts.config);
|
|
2761
|
+
const id = createPolicy(db, opts.org, opts.type, config);
|
|
2762
|
+
console.log(`Policy created: ${id.slice(0, 8)}`);
|
|
2763
|
+
});
|
|
2764
|
+
policyCmd.command("list").description("List policies for an org").requiredOption("--org <id>", "Organization ID").action((opts) => {
|
|
2765
|
+
const db = initDb();
|
|
2766
|
+
const rules = listPolicies(db, opts.org);
|
|
2767
|
+
if (rules.length === 0) {
|
|
2768
|
+
console.log("No policies.");
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
for (const r of rules) {
|
|
2772
|
+
console.log(`${r.id.slice(0, 8)} [${r.enabled ? "on" : "off"}] ${r.rule_type} ${JSON.stringify(r.config)}`);
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
policyCmd.command("toggle").description("Enable/disable a policy").argument("<id>", "Policy ID").argument("<state>", "on or off").action((id, state) => {
|
|
2776
|
+
const db = initDb();
|
|
2777
|
+
togglePolicy(db, id, state === "on");
|
|
2778
|
+
console.log(`Policy ${id.slice(0, 8)} ${state === "on" ? "enabled" : "disabled"}.`);
|
|
2779
|
+
});
|
|
2780
|
+
policyCmd.command("delete").description("Delete a policy").argument("<id>", "Policy ID").action((id) => {
|
|
2781
|
+
const db = initDb();
|
|
2782
|
+
deletePolicy(db, id);
|
|
2783
|
+
console.log(`Policy ${id.slice(0, 8)} deleted.`);
|
|
2784
|
+
});
|
|
2785
|
+
policyCmd.command("check").description("Check a memory against org policies").requiredOption("--org <id>", "Organization ID").argument("<memory-id>", "Memory ID").action((memoryId, opts) => {
|
|
2786
|
+
const db = initDb();
|
|
2787
|
+
const mem = findByPrefix(db, memoryId);
|
|
2788
|
+
if (!mem) {
|
|
2789
|
+
console.error(`Memory not found: ${memoryId}`);
|
|
2790
|
+
process.exit(1);
|
|
2791
|
+
}
|
|
2792
|
+
const violations = evaluatePolicy(db, opts.org, mem);
|
|
2793
|
+
if (violations.length === 0) {
|
|
2794
|
+
console.log("No policy violations.");
|
|
2795
|
+
} else {
|
|
2796
|
+
for (const v of violations) {
|
|
2797
|
+
console.log(`[${v.blocking ? "BLOCK" : "WARN"}] ${v.rule_type}: ${v.message}`);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
});
|
|
2801
|
+
var approvalCmd = program.command("approval").description("Approval queue management");
|
|
2802
|
+
approvalCmd.command("request").description("Request approval for a memory").argument("<memory-id>", "Memory ID").requiredOption("--org <id>", "Organization ID").option("--by <name>", "Requested by", "cli").action((memoryId, opts) => {
|
|
2803
|
+
const db = initDb();
|
|
2804
|
+
const mem = findByPrefix(db, memoryId);
|
|
2805
|
+
if (!mem) {
|
|
2806
|
+
console.error(`Memory not found: ${memoryId}`);
|
|
2807
|
+
process.exit(1);
|
|
2808
|
+
}
|
|
2809
|
+
const id = requestApproval(db, mem.id, opts.org, opts.by);
|
|
2810
|
+
console.log(`Approval requested: ${id.slice(0, 8)}`);
|
|
2811
|
+
});
|
|
2812
|
+
approvalCmd.command("list").description("List pending approvals").requiredOption("--org <id>", "Organization ID").action((opts) => {
|
|
2813
|
+
const db = initDb();
|
|
2814
|
+
const pending = listPendingApprovals(db, opts.org);
|
|
2815
|
+
if (pending.length === 0) {
|
|
2816
|
+
console.log("No pending approvals.");
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
for (const a of pending) {
|
|
2820
|
+
console.log(`${a.id.slice(0, 8)} memory:${a.memory_id.slice(0, 8)} by:${a.requested_by} ${a.created_at}`);
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
approvalCmd.command("resolve").description("Approve or deny a request").argument("<approval-id>", "Approval ID").argument("<decision>", "approved or denied").option("--by <name>", "Reviewed by", "cli").option("--reason <reason>", "Reason").action((approvalId, decision, opts) => {
|
|
2824
|
+
const db = initDb();
|
|
2825
|
+
const ok2 = resolveApproval(db, approvalId, decision, opts.by, opts.reason);
|
|
2826
|
+
if (ok2) {
|
|
2827
|
+
console.log(`Approval ${approvalId.slice(0, 8)} \u2192 ${decision}`);
|
|
2828
|
+
} else {
|
|
2829
|
+
console.error("Approval not found.");
|
|
2830
|
+
}
|
|
2831
|
+
});
|
|
2832
|
+
program.command("health").description("Memory health scoring report").option("-r, --repo <repo>", "Filter by repo").option("--id <id>", "Score a single memory").action((opts) => {
|
|
2833
|
+
const db = initDb();
|
|
2834
|
+
if (opts.id) {
|
|
2835
|
+
const mem = findByPrefix(db, opts.id);
|
|
2836
|
+
if (!mem) {
|
|
2837
|
+
console.error(`Memory not found: ${opts.id}`);
|
|
2838
|
+
process.exit(1);
|
|
2839
|
+
}
|
|
2840
|
+
const score = computeHealthScore(db, mem.id);
|
|
2841
|
+
if (score) {
|
|
2842
|
+
console.log(`Score: ${(score.score * 100).toFixed(0)}%`);
|
|
2843
|
+
console.log(`Confidence: ${(score.confidence_component * 100).toFixed(0)}%`);
|
|
2844
|
+
console.log(`Freshness: ${(score.freshness_component * 100).toFixed(0)}%`);
|
|
2845
|
+
console.log(`Follow: ${(score.follow_rate_component * 100).toFixed(0)}%`);
|
|
2846
|
+
console.log(`Signal: ${(score.signal_ratio_component * 100).toFixed(0)}%`);
|
|
2847
|
+
}
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
const scores = computeAllHealthScores(db, opts.repo);
|
|
2851
|
+
console.log(formatHealthReport(scores));
|
|
2852
|
+
});
|
|
2853
|
+
var contradictCmd = program.command("contradictions").description("Detect and resolve contradictions");
|
|
2854
|
+
contradictCmd.command("detect").description("Scan for contradictions").option("-r, --repo <repo>", "Filter by repo").action((opts) => {
|
|
2855
|
+
const db = initDb();
|
|
2856
|
+
const found = detectContradictions(db, opts.repo);
|
|
2857
|
+
if (found.length === 0) {
|
|
2858
|
+
console.log("No new contradictions detected.");
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
for (const c of found) {
|
|
2862
|
+
console.log(`${c.id.slice(0, 8)} [${c.severity}] ${c.contradiction_type}: ${c.description}`);
|
|
2863
|
+
}
|
|
2864
|
+
console.log(`
|
|
2865
|
+
${found.length} contradiction(s) found.`);
|
|
2866
|
+
});
|
|
2867
|
+
contradictCmd.command("list").description("List contradictions").option("--resolved", "Show resolved only").option("--unresolved", "Show unresolved only").action((opts) => {
|
|
2868
|
+
const db = initDb();
|
|
2869
|
+
const resolved = opts.resolved ? true : opts.unresolved ? false : void 0;
|
|
2870
|
+
const items = listContradictions(db, { resolved });
|
|
2871
|
+
if (items.length === 0) {
|
|
2872
|
+
console.log("No contradictions.");
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
for (const c of items) {
|
|
2876
|
+
const status = c.resolved ? "resolved" : "open";
|
|
2877
|
+
console.log(`${c.id.slice(0, 8)} [${status}] ${c.severity} ${c.contradiction_type}: ${c.description}`);
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
contradictCmd.command("resolve").description("Resolve a contradiction by keeping one memory").argument("<contradiction-id>", "Contradiction ID").argument("<keep-memory-id>", "Memory ID to keep").option("--actor <name>", "Who resolved", "cli").option("--reason <reason>", "Resolution reason").action((cId, keepId, opts) => {
|
|
2881
|
+
const db = initDb();
|
|
2882
|
+
const ok2 = resolveContradiction(db, cId, keepId, opts.actor, opts.reason);
|
|
2883
|
+
if (ok2) {
|
|
2884
|
+
console.log(`Contradiction ${cId.slice(0, 8)} resolved. Kept ${keepId.slice(0, 8)}.`);
|
|
2885
|
+
} else {
|
|
2886
|
+
console.error("Contradiction not found.");
|
|
2887
|
+
}
|
|
2888
|
+
});
|
|
2889
|
+
contradictCmd.command("auto-resolve").description("Auto-resolve contradictions (higher confidence wins)").option("-r, --repo <repo>", "Filter by repo").action((opts) => {
|
|
2890
|
+
const db = initDb();
|
|
2891
|
+
const count = autoResolveContradictions(db, opts.repo);
|
|
2892
|
+
console.log(`Auto-resolved ${count} contradiction(s).`);
|
|
2893
|
+
});
|
|
2894
|
+
program.command("prune").description("Auto-prune stale and unhealthy memories").option("-r, --repo <repo>", "Limit pruning to one repo").option("--stale-days <n>", "Days before rejecting stale memories", "90").option("--rejected-days <n>", "Days before deleting rejected memories", "30").option("--transient-days <n>", "Days before deleting transient memories", "7").option("--min-health <n>", "Min health score for active memories", "0.2").option("--dry-run", "Preview without making changes").action((opts) => {
|
|
2895
|
+
const db = initDb();
|
|
2896
|
+
const result = pruneMemories(db, {
|
|
2897
|
+
repo: opts.repo,
|
|
2898
|
+
stale_days: parseInt(opts.staleDays),
|
|
2899
|
+
rejected_retention_days: parseInt(opts.rejectedDays),
|
|
2900
|
+
transient_retention_days: parseInt(opts.transientDays),
|
|
2901
|
+
min_health_score: parseFloat(opts.minHealth),
|
|
2902
|
+
dry_run: opts.dryRun ?? false
|
|
2903
|
+
});
|
|
2904
|
+
console.log(formatPruneReport(result, opts.dryRun ?? false));
|
|
2905
|
+
});
|
|
2906
|
+
var auditCmd = program.command("audit").description("View audit trail and rollback");
|
|
2907
|
+
auditCmd.command("show").description("Show audit trail for a memory").argument("<memory-id>", "Memory ID").action((memoryId) => {
|
|
2908
|
+
const db = initDb();
|
|
2909
|
+
const mem = findByPrefix(db, memoryId);
|
|
2910
|
+
if (!mem) {
|
|
2911
|
+
console.error(`Memory not found: ${memoryId}`);
|
|
2912
|
+
process.exit(1);
|
|
2913
|
+
}
|
|
2914
|
+
const entries = getAuditTrail(db, mem.id);
|
|
2915
|
+
console.log(formatAuditTrail(entries));
|
|
2916
|
+
});
|
|
2917
|
+
auditCmd.command("recent").description("Show recent audit entries").option("-n, --limit <n>", "Max entries", "50").action((opts) => {
|
|
2918
|
+
const db = initDb();
|
|
2919
|
+
const entries = getRecentAudit(db, parseInt(opts.limit));
|
|
2920
|
+
console.log(formatAuditTrail(entries));
|
|
2921
|
+
});
|
|
2922
|
+
auditCmd.command("rollback").description("Rollback a memory to a previous state").argument("<memory-id>", "Memory ID").argument("<audit-entry-id>", "Audit entry ID to rollback to").option("--actor <name>", "Who performed rollback", "cli").action((memoryId, auditEntryId, opts) => {
|
|
2923
|
+
const db = initDb();
|
|
2924
|
+
const mem = findByPrefix(db, memoryId);
|
|
2925
|
+
if (!mem) {
|
|
2926
|
+
console.error(`Memory not found: ${memoryId}`);
|
|
2927
|
+
process.exit(1);
|
|
2928
|
+
}
|
|
2929
|
+
const ok2 = rollbackMemory(db, mem.id, auditEntryId, opts.actor);
|
|
2930
|
+
if (ok2) {
|
|
2931
|
+
console.log(`Memory ${mem.id.slice(0, 8)} rolled back.`);
|
|
2932
|
+
} else {
|
|
2933
|
+
console.error("Rollback failed. Audit entry not found or no snapshot.");
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
2936
|
+
program.command("quality").description("Show repo quality profile and dynamic thresholds").option("-r, --repo <repo>", "Repository name").action((opts) => {
|
|
2937
|
+
const db = initDb();
|
|
2938
|
+
const profile = getRepoQualityProfile(db, opts.repo);
|
|
2939
|
+
console.log(`Stage: ${profile.stage}`);
|
|
2940
|
+
console.log(`Quality score: ${(profile.score * 100).toFixed(0)}%`);
|
|
2941
|
+
console.log(`Active memories: ${profile.active_count}`);
|
|
2942
|
+
console.log(`Total memories: ${profile.total_count}`);
|
|
2943
|
+
console.log(`Avg health: ${(profile.avg_health * 100).toFixed(0)}%`);
|
|
2944
|
+
console.log(`Override rate: ${(profile.override_rate * 100).toFixed(0)}%`);
|
|
2945
|
+
console.log(`Contradiction rate: ${(profile.contradiction_rate * 100).toFixed(0)}%`);
|
|
2946
|
+
console.log(`---`);
|
|
2947
|
+
console.log(`Repeat sessions needed: ${profile.repeat_sessions_required}`);
|
|
2948
|
+
console.log(`Compile threshold: ${profile.compile_confidence_threshold.toFixed(2)}`);
|
|
2949
|
+
console.log(`Dedup similarity: ${profile.dedup_similarity_threshold.toFixed(2)}`);
|
|
2950
|
+
});
|
|
2951
|
+
program.command("activity").description("List recent activity events").option("-r, --repo <repo>", "Filter by repo").option("-s, --session <id>", "Filter by session ID").option("--source <source>", "Filter by source: cli|daemon|mcp|system").option("--type <type>", "Filter by event type: compile|query|scan|correction|review|feedback|signal|session_start|session_event|session_end").option("--since <iso>", "Filter by created_at >= ISO timestamp").option("-n, --limit <n>", "Max events", "20").action((opts) => {
|
|
2952
|
+
const db = initDb();
|
|
2953
|
+
const events = listActivityEvents(db, {
|
|
2954
|
+
repo: opts.repo,
|
|
2955
|
+
session_id: opts.session,
|
|
2956
|
+
source: opts.source,
|
|
2957
|
+
event_type: opts.type,
|
|
2958
|
+
since: opts.since,
|
|
2959
|
+
limit: parseInt(opts.limit, 10)
|
|
2960
|
+
});
|
|
2961
|
+
if (events.length === 0) {
|
|
2962
|
+
console.log("No activity found.");
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
for (const event of events) {
|
|
2966
|
+
console.log(
|
|
2967
|
+
`${event.created_at} ${event.source}/${event.event_type} session:${event.session_id ?? "-"} repo:${event.repo ?? "-"} memories:${event.memory_ids.length}`
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
console.log(`
|
|
2971
|
+
${events.length} activity events total.`);
|
|
2972
|
+
});
|
|
2973
|
+
program.command("sessions").description("List recent activity sessions").option("-r, --repo <repo>", "Filter by repo").option("--source <source>", "Filter by source: cli|daemon|mcp|system").option("--type <type>", "Filter by event type: compile|query|scan|correction|review|feedback|signal|session_start|session_event|session_end").option("--since <iso>", "Filter by created_at >= ISO timestamp").option("-n, --limit <n>", "Max sessions", "20").action((opts) => {
|
|
2974
|
+
const db = initDb();
|
|
2975
|
+
const sessions = listActivitySessions(db, {
|
|
2976
|
+
repo: opts.repo,
|
|
2977
|
+
source: opts.source,
|
|
2978
|
+
event_type: opts.type,
|
|
2979
|
+
since: opts.since,
|
|
2980
|
+
limit: parseInt(opts.limit, 10)
|
|
2981
|
+
});
|
|
2982
|
+
if (sessions.length === 0) {
|
|
2983
|
+
console.log("No sessions found.");
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
for (const session of sessions) {
|
|
2987
|
+
console.log(
|
|
2988
|
+
`${session.last_at} ${session.session_id} repo:${session.repo ?? "-"} events:${session.event_count} types:${session.event_types.join(",")}`
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
console.log(`
|
|
2992
|
+
${sessions.length} sessions total.`);
|
|
2993
|
+
});
|
|
2994
|
+
var defaultLabel = defaultServiceLabel();
|
|
2995
|
+
var daemonCmd = program.command("daemon").description("Manage the local Recall HTTP daemon (launchd on macOS, systemd --user on Linux)");
|
|
2996
|
+
daemonCmd.command("install").description("Install and start the user service").option("--port <port>", "Daemon port", "7890").option("--data-dir <dir>", "Recall data dir").option("--label <label>", "Service label", defaultLabel).option("--node-path <path>", "Node executable path override").option("--daemon-script <path>", "Daemon script path override").action((opts) => {
|
|
2997
|
+
const status = installService({
|
|
2998
|
+
label: opts.label,
|
|
2999
|
+
port: parseInt(opts.port, 10),
|
|
3000
|
+
dataDir: opts.dataDir,
|
|
3001
|
+
nodePath: opts.nodePath,
|
|
3002
|
+
daemonScript: opts.daemonScript
|
|
3003
|
+
});
|
|
3004
|
+
console.log(getServiceInfo(status.label));
|
|
3005
|
+
});
|
|
3006
|
+
daemonCmd.command("start").description("Start the installed service").option("--label <label>", "Service label", defaultLabel).action((opts) => {
|
|
3007
|
+
const status = startService(opts.label);
|
|
3008
|
+
console.log(getServiceInfo(status.label));
|
|
3009
|
+
});
|
|
3010
|
+
daemonCmd.command("stop").description("Stop the service").option("--label <label>", "Service label", defaultLabel).action((opts) => {
|
|
3011
|
+
const status = stopService(opts.label);
|
|
3012
|
+
console.log(getServiceInfo(status.label));
|
|
3013
|
+
});
|
|
3014
|
+
daemonCmd.command("restart").description("Restart the service").option("--label <label>", "Service label", defaultLabel).action((opts) => {
|
|
3015
|
+
stopService(opts.label);
|
|
3016
|
+
const status = startService(opts.label);
|
|
3017
|
+
console.log(getServiceInfo(status.label));
|
|
3018
|
+
});
|
|
3019
|
+
daemonCmd.command("status").description("Show service status").option("--label <label>", "Service label", defaultLabel).action((opts) => {
|
|
3020
|
+
console.log(getServiceInfo(opts.label));
|
|
3021
|
+
});
|
|
3022
|
+
daemonCmd.command("uninstall").description("Remove the service").option("--label <label>", "Service label", defaultLabel).action((opts) => {
|
|
3023
|
+
const status = uninstallService(opts.label);
|
|
3024
|
+
console.log(getServiceInfo(status.label));
|
|
3025
|
+
});
|
|
3026
|
+
var maintenanceCmd = program.command("maintenance").description("Inspect and manage the delegated maintenance task queue");
|
|
3027
|
+
maintenanceCmd.command("stats").description("Show backlog counts, completion stats, and mean latency").action(async () => {
|
|
3028
|
+
const { getTaskStats } = await import("./tasks-UOLSPXJQ.js");
|
|
3029
|
+
const db = initDb();
|
|
3030
|
+
const stats = getTaskStats(db);
|
|
3031
|
+
console.log(`Total tasks: ${stats.total}`);
|
|
3032
|
+
console.log(`---`);
|
|
3033
|
+
console.log(`Pending: ${stats.by_status.pending}`);
|
|
3034
|
+
console.log(`Claimed: ${stats.by_status.claimed}`);
|
|
3035
|
+
console.log(`Completed: ${stats.by_status.completed}`);
|
|
3036
|
+
console.log(`Abandoned: ${stats.by_status.abandoned}`);
|
|
3037
|
+
console.log(`---`);
|
|
3038
|
+
console.log(`Last 24h completed: ${stats.completed_last_24h}`);
|
|
3039
|
+
console.log(`Last 24h abandoned: ${stats.abandoned_last_24h}`);
|
|
3040
|
+
if (stats.mean_completion_ms != null) {
|
|
3041
|
+
console.log(`Mean completion: ${(stats.mean_completion_ms / 1e3).toFixed(1)}s`);
|
|
3042
|
+
}
|
|
3043
|
+
if (stats.pending_oldest_created_at) {
|
|
3044
|
+
console.log(`Oldest pending: ${stats.pending_oldest_created_at}`);
|
|
3045
|
+
}
|
|
3046
|
+
console.log(`---`);
|
|
3047
|
+
console.log(`By kind:`);
|
|
3048
|
+
for (const [kind, count] of Object.entries(stats.by_kind)) {
|
|
3049
|
+
if (count === 0) continue;
|
|
3050
|
+
console.log(` ${kind.padEnd(22)} ${count}`);
|
|
3051
|
+
}
|
|
3052
|
+
});
|
|
3053
|
+
maintenanceCmd.command("list").description("List tasks (default: pending)").option("-s, --status <status>", "pending|claimed|completed|abandoned", "pending").option("-k, --kind <kind>", "Filter by kind").option("-r, --repo <repo>", "Filter by repo").option("-n, --limit <n>", "Max entries", "20").action(async (opts) => {
|
|
3054
|
+
const { listTasks } = await import("./tasks-UOLSPXJQ.js");
|
|
3055
|
+
const db = initDb();
|
|
3056
|
+
const tasks = listTasks(db, {
|
|
3057
|
+
status: opts.status,
|
|
3058
|
+
kinds: opts.kind ? [opts.kind] : void 0,
|
|
3059
|
+
repo: opts.repo,
|
|
3060
|
+
limit: parseInt(opts.limit, 10)
|
|
3061
|
+
});
|
|
3062
|
+
if (tasks.length === 0) {
|
|
3063
|
+
console.log(`No ${opts.status} tasks.`);
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
for (const t of tasks) {
|
|
3067
|
+
const age = t.created_at.slice(0, 19);
|
|
3068
|
+
const prefix = t.id.slice(0, 8);
|
|
3069
|
+
const repo = t.repo ?? "-";
|
|
3070
|
+
const attempts = t.attempts > 0 ? ` attempts=${t.attempts}` : "";
|
|
3071
|
+
const reason = t.failure_reason ? ` (${t.failure_reason.slice(0, 60)})` : "";
|
|
3072
|
+
console.log(`${prefix} p${t.priority} ${t.kind.padEnd(20)} ${t.status.padEnd(10)} ${repo.padEnd(30)} ${age}${attempts}${reason}`);
|
|
3073
|
+
}
|
|
3074
|
+
});
|
|
3075
|
+
maintenanceCmd.command("drop").description("Delete a task by id (or id prefix)").argument("<task-id>", "Task id or prefix").action(async (taskIdArg) => {
|
|
3076
|
+
const { deleteTask, listTasks } = await import("./tasks-UOLSPXJQ.js");
|
|
3077
|
+
const db = initDb();
|
|
3078
|
+
const all = listTasks(db, { limit: 1e4 });
|
|
3079
|
+
const matches = all.filter((t) => t.id === taskIdArg || t.id.startsWith(taskIdArg));
|
|
3080
|
+
if (matches.length === 0) {
|
|
3081
|
+
console.error(`No task matching "${taskIdArg}".`);
|
|
3082
|
+
process.exit(1);
|
|
3083
|
+
}
|
|
3084
|
+
if (matches.length > 1) {
|
|
3085
|
+
console.error(`Ambiguous prefix "${taskIdArg}". Matches:`);
|
|
3086
|
+
for (const t of matches) console.error(` ${t.id} ${t.kind} ${t.status}`);
|
|
3087
|
+
process.exit(1);
|
|
3088
|
+
}
|
|
3089
|
+
const ok2 = deleteTask(db, matches[0].id);
|
|
3090
|
+
if (ok2) console.log(`Dropped task ${matches[0].id}.`);
|
|
3091
|
+
else console.error("Drop failed.");
|
|
3092
|
+
});
|
|
3093
|
+
var credentialsCmd = maintenanceCmd.command("credentials").description("Manage LLM provider API keys in macOS Keychain (env fallback on other platforms)");
|
|
3094
|
+
credentialsCmd.command("list", { isDefault: true }).description("Show which providers have credentials available").action(async () => {
|
|
3095
|
+
const { listCredentials } = await import("./keychain-5QG52ANO.js");
|
|
3096
|
+
const creds = listCredentials();
|
|
3097
|
+
if (creds.length === 0) {
|
|
3098
|
+
console.log("No API keys configured. Set one with:");
|
|
3099
|
+
console.log(" recall maintenance credentials set openai <key>");
|
|
3100
|
+
console.log(" recall maintenance credentials set anthropic <key>");
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
for (const cred of creds) {
|
|
3104
|
+
const detail = cred.detail ? ` ${cred.detail}` : "";
|
|
3105
|
+
console.log(`${cred.provider.padEnd(14)} ${cred.source.padEnd(8)} ${cred.preview}${detail}`);
|
|
3106
|
+
}
|
|
3107
|
+
});
|
|
3108
|
+
credentialsCmd.command("set").description("Store credentials for an LLM provider in the macOS Keychain").argument("<provider>", "openai|anthropic|azure").argument("[key]", "API key (prompts via stdin if omitted; required for openai/anthropic and for azure unless --stdin-json is used)").option("--endpoint <url>", "Azure OpenAI resource endpoint (e.g. https://myresource.openai.azure.com)").option("--deployment <name>", "Azure OpenAI deployment name").option("--api-version <version>", "Azure OpenAI api-version (e.g. 2024-10-21)").option("--stdin-json", "Read the azure config (endpoint/deployment/api_version/key) as a JSON object from stdin").action(async (providerArg, keyArg, opts) => {
|
|
3109
|
+
const { setApiKey, setAzureConfig } = await import("./keychain-5QG52ANO.js");
|
|
3110
|
+
const provider = providerArg === "azure" ? "azure-openai" : providerArg;
|
|
3111
|
+
if (provider === "openai" || provider === "anthropic") {
|
|
3112
|
+
const key = keyArg ?? await readStdinKey();
|
|
3113
|
+
if (!key) {
|
|
3114
|
+
console.error("No API key provided (pass as argument or pipe via stdin).");
|
|
3115
|
+
process.exit(1);
|
|
3116
|
+
}
|
|
3117
|
+
try {
|
|
3118
|
+
setApiKey(provider, key);
|
|
3119
|
+
console.log(`Stored ${provider} API key in Keychain (service com.recall.llm).`);
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
console.error(`Failed to store key: ${err.message}`);
|
|
3122
|
+
process.exit(1);
|
|
3123
|
+
}
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
if (provider === "azure-openai") {
|
|
3127
|
+
let azureConfig;
|
|
3128
|
+
if (opts.stdinJson) {
|
|
3129
|
+
const body = await readStdinText();
|
|
3130
|
+
if (!body) {
|
|
3131
|
+
console.error("No JSON payload received on stdin.");
|
|
3132
|
+
process.exit(1);
|
|
3133
|
+
}
|
|
3134
|
+
try {
|
|
3135
|
+
azureConfig = JSON.parse(body);
|
|
3136
|
+
} catch (err) {
|
|
3137
|
+
console.error(`Failed to parse stdin JSON: ${err.message}`);
|
|
3138
|
+
process.exit(1);
|
|
3139
|
+
}
|
|
3140
|
+
} else {
|
|
3141
|
+
const key = keyArg ?? await readStdinKey();
|
|
3142
|
+
if (!opts.endpoint || !opts.deployment || !opts.apiVersion || !key) {
|
|
3143
|
+
console.error(
|
|
3144
|
+
"Azure setup requires --endpoint, --deployment, --api-version, and a key.\nExample: recall maintenance credentials set azure \\\n --endpoint https://myresource.openai.azure.com \\\n --deployment gpt-4o-mini \\\n --api-version 2024-10-21 \\\n <key>"
|
|
3145
|
+
);
|
|
3146
|
+
process.exit(1);
|
|
3147
|
+
}
|
|
3148
|
+
azureConfig = {
|
|
3149
|
+
endpoint: opts.endpoint,
|
|
3150
|
+
deployment: opts.deployment,
|
|
3151
|
+
api_version: opts.apiVersion,
|
|
3152
|
+
key
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
try {
|
|
3156
|
+
setAzureConfig(azureConfig);
|
|
3157
|
+
console.log(`Stored azure-openai config in Keychain (service com.recall.llm).`);
|
|
3158
|
+
console.log(` endpoint: ${azureConfig.endpoint}`);
|
|
3159
|
+
console.log(` deployment: ${azureConfig.deployment}`);
|
|
3160
|
+
console.log(` api_version: ${azureConfig.api_version}`);
|
|
3161
|
+
} catch (err) {
|
|
3162
|
+
console.error(`Failed to store azure config: ${err.message}`);
|
|
3163
|
+
process.exit(1);
|
|
3164
|
+
}
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
console.error(`Provider must be "openai", "anthropic", or "azure", got "${providerArg}".`);
|
|
3168
|
+
process.exit(1);
|
|
3169
|
+
});
|
|
3170
|
+
credentialsCmd.command("clear").description("Remove provider credentials from the macOS Keychain").argument("<provider>", "openai|anthropic|azure").action(async (providerArg) => {
|
|
3171
|
+
const { deleteApiKey } = await import("./keychain-5QG52ANO.js");
|
|
3172
|
+
const provider = providerArg === "azure" ? "azure-openai" : providerArg;
|
|
3173
|
+
if (provider !== "openai" && provider !== "anthropic" && provider !== "azure-openai") {
|
|
3174
|
+
console.error(`Provider must be "openai", "anthropic", or "azure", got "${providerArg}".`);
|
|
3175
|
+
process.exit(1);
|
|
3176
|
+
}
|
|
3177
|
+
const removed = deleteApiKey(provider);
|
|
3178
|
+
if (removed) console.log(`Removed ${provider} credentials from Keychain.`);
|
|
3179
|
+
else console.log(`No ${provider} credentials found in Keychain.`);
|
|
3180
|
+
});
|
|
3181
|
+
maintenanceCmd.command("dispatch").description("Run the daemon-owned dispatcher once against pending maintenance tasks (requires a configured LLM provider)").option("--provider <provider>", "openai|anthropic|azure-openai (defaults to whichever is configured)").option("--model <model>", "Model override").option("--kind <kind>", "Restrict to a single task kind (repeatable)", (value, acc = []) => [...acc, value], []).option("--repo <repo>", "Restrict to a single repo").option("--max <n>", "Max tasks to run this round", "5").option("--dry-run", "Show which tasks would be dispatched; do not call the LLM").option("--preview", "Show the actual prompts that would be sent (no provider needed; no LLM call)").option("--json", "Emit the raw JSON report").action(async (opts) => {
|
|
3182
|
+
const { dispatchPendingTasks, formatDispatchReport, buildPrompt } = await import("./dispatcher-UGMU6THT.js");
|
|
3183
|
+
const { listTasks } = await import("./tasks-UOLSPXJQ.js");
|
|
3184
|
+
const db = initDb();
|
|
3185
|
+
if (opts.preview) {
|
|
3186
|
+
const pending = listTasks(db, {
|
|
3187
|
+
status: "pending",
|
|
3188
|
+
kinds: opts.kind.length > 0 ? opts.kind : void 0,
|
|
3189
|
+
repo: opts.repo,
|
|
3190
|
+
limit: parseInt(opts.max, 10)
|
|
3191
|
+
});
|
|
3192
|
+
const previews = pending.map((task) => ({
|
|
3193
|
+
task_id: task.id,
|
|
3194
|
+
kind: task.kind,
|
|
3195
|
+
repo: task.repo,
|
|
3196
|
+
prompt: buildPrompt(task)
|
|
3197
|
+
}));
|
|
3198
|
+
if (opts.json) {
|
|
3199
|
+
console.log(JSON.stringify(previews, null, 2));
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
if (previews.length === 0) {
|
|
3203
|
+
console.log("No pending tasks to preview.");
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
for (const p of previews) {
|
|
3207
|
+
console.log(`# ${p.task_id.slice(0, 8)} ${p.kind} ${p.repo ?? "-"}`);
|
|
3208
|
+
if (!p.prompt) {
|
|
3209
|
+
console.log(" (no prompt builder for this kind)\n");
|
|
3210
|
+
continue;
|
|
3211
|
+
}
|
|
3212
|
+
console.log(`## system
|
|
3213
|
+
${p.prompt.system}
|
|
3214
|
+
`);
|
|
3215
|
+
console.log(`## user
|
|
3216
|
+
${p.prompt.user}
|
|
3217
|
+
`);
|
|
3218
|
+
if (p.prompt.max_output_tokens) console.log(`## max_output_tokens
|
|
3219
|
+
${p.prompt.max_output_tokens}
|
|
3220
|
+
`);
|
|
3221
|
+
console.log("---");
|
|
3222
|
+
}
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
const report = await dispatchPendingTasks(db, {
|
|
3226
|
+
provider: opts.provider,
|
|
3227
|
+
model: opts.model,
|
|
3228
|
+
kinds: opts.kind.length > 0 ? opts.kind : void 0,
|
|
3229
|
+
repo: opts.repo,
|
|
3230
|
+
maxTasks: parseInt(opts.max, 10),
|
|
3231
|
+
dryRun: Boolean(opts.dryRun)
|
|
3232
|
+
});
|
|
3233
|
+
if (opts.json) {
|
|
3234
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
console.log(formatDispatchReport(report));
|
|
3238
|
+
});
|
|
3239
|
+
maintenanceCmd.command("cleanup").description("Run deterministic, LLM-free cleanup: exact-text dedupe, fragment rejection, repeat-correction promotion").option("--apply", "Persist changes (default is dry-run)").option("--only <action>", "Restrict to one action: dedupe_exact_merge|reject_fragment_candidate|promote_repeat_correction").option("--revert <run-id>", "Revert a previous cleanup run (id or 8-char prefix)").option("--list", "List recent cleanup runs").option("--json", "Emit the raw JSON report").action(async (opts) => {
|
|
3240
|
+
const { runDeterministicCleanup, formatCleanupReport, revertCleanupRun, listCleanupRuns } = await import("./cleanup-TVOX2S2S.js");
|
|
3241
|
+
const db = initDb();
|
|
3242
|
+
if (opts.list) {
|
|
3243
|
+
const runs = listCleanupRuns(db);
|
|
3244
|
+
if (opts.json) {
|
|
3245
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
if (runs.length === 0) {
|
|
3249
|
+
console.log("No cleanup runs recorded.");
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
for (const r of runs) {
|
|
3253
|
+
const flag = r.reverted === r.total && r.total > 0 ? " [reverted]" : r.reverted > 0 ? ` [partial-revert ${r.reverted}/${r.total}]` : "";
|
|
3254
|
+
const actions = Object.entries(r.by_action).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
3255
|
+
console.log(`${r.run_id.slice(0, 8)} ${r.finished_at.slice(0, 19)} total=${r.total} ${actions}${flag}`);
|
|
3256
|
+
}
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
if (opts.revert) {
|
|
3260
|
+
const all = listCleanupRuns(db, 1e4);
|
|
3261
|
+
const match = all.find((r) => r.run_id === opts.revert || r.run_id.startsWith(opts.revert));
|
|
3262
|
+
if (!match) {
|
|
3263
|
+
console.error(`No cleanup run matching "${opts.revert}".`);
|
|
3264
|
+
process.exit(1);
|
|
3265
|
+
}
|
|
3266
|
+
const result = revertCleanupRun(db, match.run_id);
|
|
3267
|
+
if (opts.json) {
|
|
3268
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
console.log(`Reverted ${result.reverted} entries from run ${result.run_id.slice(0, 8)}; skipped ${result.skipped}.`);
|
|
3272
|
+
for (const [reason, count] of Object.entries(result.reasons)) {
|
|
3273
|
+
console.log(` ${reason}: ${count}`);
|
|
3274
|
+
}
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
const report = runDeterministicCleanup(db, {
|
|
3278
|
+
dryRun: !opts.apply,
|
|
3279
|
+
only: opts.only
|
|
3280
|
+
});
|
|
3281
|
+
if (opts.json) {
|
|
3282
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
console.log(formatCleanupReport(report));
|
|
3286
|
+
});
|
|
3287
|
+
maintenanceCmd.command("quality").description("Show injection outcome distribution and followed-rate over a window").option("--since <iso>", "Window start (default: last 14 days)").option("--snapshot", "Persist this report as a baseline for later comparison").option("--note <text>", "Optional note attached to a snapshot").option("--history", "List recent snapshots and the diff against the latest one").option("--json", "Emit raw JSON").action(async (opts) => {
|
|
3288
|
+
const {
|
|
3289
|
+
computeQualityReport,
|
|
3290
|
+
formatQualityReport,
|
|
3291
|
+
recordQualitySnapshot,
|
|
3292
|
+
listQualitySnapshots,
|
|
3293
|
+
diffQualitySnapshots
|
|
3294
|
+
} = await import("./quality-Z7LPMMBC.js");
|
|
3295
|
+
const db = initDb();
|
|
3296
|
+
if (opts.history) {
|
|
3297
|
+
const snaps = listQualitySnapshots(db);
|
|
3298
|
+
if (opts.json) {
|
|
3299
|
+
console.log(JSON.stringify(snaps, null, 2));
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
if (snaps.length === 0) {
|
|
3303
|
+
console.log("No quality snapshots recorded.");
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
for (const s of snaps) {
|
|
3307
|
+
const rate = s.followed_rate_resolved != null ? `${(s.followed_rate_resolved * 100).toFixed(1)}%` : "n/a";
|
|
3308
|
+
console.log(`${s.taken_at.slice(0, 19)} followed=${rate} resolved=${s.injections_resolved} history=${s.history_injections_total} rules=${s.active_rule_count} cand=${s.candidate_correction_count}${s.notes ? ` (${s.notes})` : ""}`);
|
|
3309
|
+
}
|
|
3310
|
+
if (snaps.length >= 2) {
|
|
3311
|
+
const diff = diffQualitySnapshots(snaps[snaps.length - 1], snaps[0]);
|
|
3312
|
+
console.log("");
|
|
3313
|
+
console.log(`\u0394 since first snapshot (${diff.days_apart.toFixed(1)}d):`);
|
|
3314
|
+
console.log(` followed rate: ${diff.followed_rate_delta_pp >= 0 ? "+" : ""}${diff.followed_rate_delta_pp.toFixed(1)}pp`);
|
|
3315
|
+
console.log(` resolved: ${diff.resolved_delta >= 0 ? "+" : ""}${diff.resolved_delta}`);
|
|
3316
|
+
console.log(` followed: ${diff.followed_delta >= 0 ? "+" : ""}${diff.followed_delta}`);
|
|
3317
|
+
console.log(` contradicted: ${diff.contradicted_delta >= 0 ? "+" : ""}${diff.contradicted_delta}`);
|
|
3318
|
+
console.log(` active rules: ${diff.active_rule_delta >= 0 ? "+" : ""}${diff.active_rule_delta}`);
|
|
3319
|
+
console.log(` candidates: ${diff.candidate_delta >= 0 ? "+" : ""}${diff.candidate_delta}`);
|
|
3320
|
+
console.log(` history injects: ${diff.history_injections_delta >= 0 ? "+" : ""}${diff.history_injections_delta}`);
|
|
3321
|
+
console.log(` history snippets:${diff.history_snippets_delta >= 0 ? "+" : ""}${diff.history_snippets_delta}`);
|
|
3322
|
+
}
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
const report = computeQualityReport(db, { sinceIso: opts.since });
|
|
3326
|
+
if (opts.snapshot) {
|
|
3327
|
+
const row = recordQualitySnapshot(db, report, opts.note);
|
|
3328
|
+
if (opts.json) {
|
|
3329
|
+
console.log(JSON.stringify({ snapshot: row, report }, null, 2));
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
console.log(formatQualityReport(report));
|
|
3333
|
+
console.log("");
|
|
3334
|
+
console.log(`Recorded snapshot ${row.id.slice(0, 8)} at ${row.taken_at.slice(0, 19)}`);
|
|
3335
|
+
return;
|
|
3336
|
+
}
|
|
3337
|
+
if (opts.json) {
|
|
3338
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
console.log(formatQualityReport(report));
|
|
3342
|
+
});
|
|
3343
|
+
maintenanceCmd.command("usage").description("Summarize LLM API usage (tokens, cost) across recent maintenance runs").option("--since <iso>", "Window start (default: last 30 days)").option("--limit <n>", "Recent-call rows to show", "10").option("--json", "Emit raw JSON summary").action(async (opts) => {
|
|
3344
|
+
const { summarizeUsage, formatUsageReport } = await import("./usage-CY3V72YN.js");
|
|
3345
|
+
const db = initDb();
|
|
3346
|
+
const summary = summarizeUsage(db, {
|
|
3347
|
+
sinceIso: opts.since,
|
|
3348
|
+
recentLimit: parseInt(opts.limit, 10)
|
|
3349
|
+
});
|
|
3350
|
+
if (opts.json) {
|
|
3351
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
3354
|
+
console.log(formatUsageReport(summary));
|
|
3355
|
+
});
|
|
3356
|
+
async function readStdinKey() {
|
|
3357
|
+
const raw = await readStdinText();
|
|
3358
|
+
if (!raw) return null;
|
|
3359
|
+
const trimmed = raw.trim();
|
|
3360
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3361
|
+
}
|
|
3362
|
+
async function readStdinText() {
|
|
3363
|
+
if (process.stdin.isTTY) return null;
|
|
3364
|
+
const chunks = [];
|
|
3365
|
+
for await (const chunk of process.stdin) {
|
|
3366
|
+
chunks.push(Buffer.from(chunk));
|
|
3367
|
+
}
|
|
3368
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
3369
|
+
return text.length > 0 ? text : null;
|
|
3370
|
+
}
|
|
3371
|
+
function loadSyncConfig() {
|
|
3372
|
+
const url = process.env.RECALL_SYNC_URL;
|
|
3373
|
+
const key = process.env.RECALL_SYNC_KEY;
|
|
3374
|
+
if (!url || !key) return null;
|
|
3375
|
+
return {
|
|
3376
|
+
remote_url: url,
|
|
3377
|
+
api_key: key,
|
|
3378
|
+
team_id: process.env.RECALL_TEAM_ID,
|
|
3379
|
+
auto_sync: false,
|
|
3380
|
+
sync_interval_seconds: 300
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
function formatSetupStep(step) {
|
|
3384
|
+
if (!step.enabled) return `skipped (${step.message})`;
|
|
3385
|
+
return step.ok ? `ok (${step.message})` : `error (${step.message})`;
|
|
3386
|
+
}
|
|
3387
|
+
function formatAgentName(agent) {
|
|
3388
|
+
return agent === "claude-code" ? "Claude Code" : "Codex";
|
|
3389
|
+
}
|
|
3390
|
+
function collectAgents(value, previous) {
|
|
3391
|
+
return [...previous, value];
|
|
3392
|
+
}
|
|
3393
|
+
async function confirmSetupWrite(scope) {
|
|
3394
|
+
const { createInterface } = await import("readline/promises");
|
|
3395
|
+
const rl = createInterface({
|
|
3396
|
+
input: process.stdin,
|
|
3397
|
+
output: process.stdout
|
|
3398
|
+
});
|
|
3399
|
+
try {
|
|
3400
|
+
const answer = await rl.question(`Update ${scope} agent config files for Recall? [y/N] `);
|
|
3401
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
3402
|
+
} finally {
|
|
3403
|
+
rl.close();
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
function findByPrefix(db, prefix) {
|
|
3407
|
+
const exact = getMemory(db, prefix);
|
|
3408
|
+
if (exact) return exact;
|
|
3409
|
+
const all = listMemories(db);
|
|
3410
|
+
const matches = all.filter((m) => m.id.startsWith(prefix));
|
|
3411
|
+
if (matches.length === 1) return matches[0];
|
|
3412
|
+
if (matches.length > 1) {
|
|
3413
|
+
console.error(`Ambiguous prefix "${prefix}". Matches:`);
|
|
3414
|
+
for (const m of matches) console.error(` ${m.id}`);
|
|
3415
|
+
process.exit(1);
|
|
3416
|
+
}
|
|
3417
|
+
return void 0;
|
|
3418
|
+
}
|
|
3419
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
3420
|
+
await program.parseAsync(process.argv);
|
|
3421
|
+
}
|
|
3422
|
+
export {
|
|
3423
|
+
program
|
|
3424
|
+
};
|
|
3425
|
+
//# sourceMappingURL=cli.js.map
|