@iamem/amem 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/activity.d.ts +27 -0
  4. package/dist/activity.js +202 -0
  5. package/dist/api/routes.d.ts +31 -0
  6. package/dist/api/routes.js +1345 -0
  7. package/dist/attest.d.ts +52 -0
  8. package/dist/attest.js +192 -0
  9. package/dist/backup-schedule.d.ts +25 -0
  10. package/dist/backup-schedule.js +216 -0
  11. package/dist/capture.d.ts +34 -0
  12. package/dist/capture.js +257 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +1007 -0
  15. package/dist/context.d.ts +46 -0
  16. package/dist/context.js +334 -0
  17. package/dist/crypto.d.ts +39 -0
  18. package/dist/crypto.js +166 -0
  19. package/dist/db.d.ts +192 -0
  20. package/dist/db.js +666 -0
  21. package/dist/draft-quality.d.ts +19 -0
  22. package/dist/draft-quality.js +85 -0
  23. package/dist/embed.d.ts +76 -0
  24. package/dist/embed.js +331 -0
  25. package/dist/estimate.d.ts +32 -0
  26. package/dist/estimate.js +69 -0
  27. package/dist/freshness.d.ts +15 -0
  28. package/dist/freshness.js +93 -0
  29. package/dist/hook.d.ts +16 -0
  30. package/dist/hook.js +177 -0
  31. package/dist/hygiene-schedule.d.ts +28 -0
  32. package/dist/hygiene-schedule.js +221 -0
  33. package/dist/hygiene.d.ts +61 -0
  34. package/dist/hygiene.js +196 -0
  35. package/dist/install/claude.d.ts +6 -0
  36. package/dist/install/claude.js +69 -0
  37. package/dist/install/cursor.d.ts +7 -0
  38. package/dist/install/cursor.js +80 -0
  39. package/dist/install/hosts.d.ts +21 -0
  40. package/dist/install/hosts.js +186 -0
  41. package/dist/install/skills.d.ts +8 -0
  42. package/dist/install/skills.js +67 -0
  43. package/dist/it-pack.d.ts +20 -0
  44. package/dist/it-pack.js +84 -0
  45. package/dist/kinds.d.ts +18 -0
  46. package/dist/kinds.js +106 -0
  47. package/dist/license.d.ts +48 -0
  48. package/dist/license.js +172 -0
  49. package/dist/mcp.d.ts +40 -0
  50. package/dist/mcp.js +435 -0
  51. package/dist/paths.d.ts +11 -0
  52. package/dist/paths.js +55 -0
  53. package/dist/personal.d.ts +7 -0
  54. package/dist/personal.js +44 -0
  55. package/dist/platforms.d.ts +11 -0
  56. package/dist/platforms.js +32 -0
  57. package/dist/policy.d.ts +46 -0
  58. package/dist/policy.js +254 -0
  59. package/dist/prefs.d.ts +6 -0
  60. package/dist/prefs.js +11 -0
  61. package/dist/proposal.d.ts +90 -0
  62. package/dist/proposal.js +376 -0
  63. package/dist/publish.d.ts +28 -0
  64. package/dist/publish.js +57 -0
  65. package/dist/remember-contract.d.ts +23 -0
  66. package/dist/remember-contract.js +117 -0
  67. package/dist/repo-identity.d.ts +15 -0
  68. package/dist/repo-identity.js +82 -0
  69. package/dist/rules-sync.d.ts +7 -0
  70. package/dist/rules-sync.js +47 -0
  71. package/dist/savings-export.d.ts +49 -0
  72. package/dist/savings-export.js +141 -0
  73. package/dist/scan.d.ts +16 -0
  74. package/dist/scan.js +109 -0
  75. package/dist/search.d.ts +25 -0
  76. package/dist/search.js +150 -0
  77. package/dist/service.d.ts +20 -0
  78. package/dist/service.js +254 -0
  79. package/dist/shop.d.ts +9 -0
  80. package/dist/shop.js +15 -0
  81. package/dist/ui/server.d.ts +21 -0
  82. package/dist/ui/server.js +268 -0
  83. package/dist/vault.d.ts +25 -0
  84. package/dist/vault.js +42 -0
  85. package/dist/workspace-setup.d.ts +8 -0
  86. package/dist/workspace-setup.js +55 -0
  87. package/docs/agent-install-prompt.md +41 -0
  88. package/docs/backlog.md +59 -0
  89. package/docs/enterprise-endpoint.md +98 -0
  90. package/docs/license.md +54 -0
  91. package/docs/npm-release.md +38 -0
  92. package/docs/remember-contract.md +42 -0
  93. package/package.json +64 -0
  94. package/scripts/mdm-offboard.sh +14 -0
  95. package/skills/amem-bootstrap/SKILL.md +83 -0
  96. package/skills/amem-update-working-memory/SKILL.md +54 -0
  97. package/templates/cursor-rule.mdc +23 -0
  98. package/templates/mdm/co.amem.managed.plist +33 -0
  99. package/templates/policy.deny-default.toml +20 -0
  100. package/templates/policy.example.toml +22 -0
  101. package/ui-static/app.js +3966 -0
  102. package/ui-static/index.html +178 -0
  103. package/ui-static/orbit.js +389 -0
  104. package/ui-static/styles.css +2411 -0
@@ -0,0 +1,1345 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { estimateUsdSaved, metricsFromPacket, USD_PER_MILLION_INPUT_TOKENS } from "../estimate.js";
5
+ import { buildActivityGraph, speedForEvent } from "../activity.js";
6
+ import { getRepoByCwd, getRepoById, getRepoByName, renameWorkspace, getSetupState, insertUsageEvent, listClaims, listClaimsAll, listComponents, listComponentsAll, listEdges, listEdgesAll, listFlows, listFlowsAll, listRepos, listSessions, listSessionsAll, listUsageEvents, listProposalDrafts, listProposalDraftsAll, countProposalDrafts, countProposalDraftsAll, getProposalDraft, setProposalDraftStatus, updateClaim, setClaimPinned, deleteClaim, setReportedOnLatest, setReportedTokensSaved, upsertRepo, upsertSetupState, wipeRepo, openDb, closeDb, } from "../db.js";
7
+ import { buildContext, buildRetrievalShowdown, decorateUsageEvents, renderContextMarkdown } from "../context.js";
8
+ import { installClaude, claudeInstallHealth } from "../install/claude.js";
9
+ import { installCursor, cursorInstallHealth } from "../install/cursor.js";
10
+ import { hostInstallHealth, installHost } from "../install/hosts.js";
11
+ import { decorateDraft, decorateDrafts } from "../draft-quality.js";
12
+ import { buildSavingsExport, formatSavingsMarkdown, savingsPdf, } from "../savings-export.js";
13
+ import { assertPlatformAllowed, assertRemoteAllowed, loadPolicy, } from "../policy.js";
14
+ import { applyProposal, applySupersedes, parseProposalJson, validateProposal } from "../proposal.js";
15
+ import { detectRepoIdentity, normalizeRemoteUrl, parseWorkspaceSlug, slugifyWorkspace, workspaceIdentity, } from "../repo-identity.js";
16
+ import { HOST_INSTALL_IDS, KNOWN_PLATFORMS, normalizePlatforms } from "../platforms.js";
17
+ import { amemHome, amemHomeWriteIssue, dbPath, tryEnsureDir } from "../paths.js";
18
+ import { buildAttestReport } from "../attest.js";
19
+ import { scanGitRepos } from "../scan.js";
20
+ import { provisionWorkspace } from "../workspace-setup.js";
21
+ import { installLoginService, isServiceInstalled, isServiceSupported, servicePlatform, uninstallLoginService, } from "../service.js";
22
+ import { searchClaimsFts, tokenize } from "../search.js";
23
+ import { rememberContract } from "../remember-contract.js";
24
+ import { vaultStatus } from "../vault.js";
25
+ import { shopStatus } from "../shop.js";
26
+ import { prefsStatus, setAutoApplyAll } from "../prefs.js";
27
+ import { applyPendingDrafts } from "../capture.js";
28
+ import { applyLicenseJson, licenseStatus } from "../license.js";
29
+ import { embedStatus, embedIndexHealth, embedIndexIssues, reindexAllEmbeds, setEmbedBackend } from "../embed.js";
30
+ import { createBackup, lockDatabase, restoreBackup, unlockDatabase, } from "../crypto.js";
31
+ import { decayStaleClaims, hygieneReport, hygienePreview, hygienePreviewAll, mergeDuplicate, acceptSafeCleanups } from "../hygiene.js";
32
+ import { hygieneScheduleStatus, installHygieneSchedule, uninstallHygieneSchedule, writeHygieneHelperScript, } from "../hygiene-schedule.js";
33
+ import { syncPinnedRules } from "../rules-sync.js";
34
+ import { writeItPack } from "../it-pack.js";
35
+ import { installBackupSchedule, uninstallBackupSchedule, } from "../backup-schedule.js";
36
+ import { ensurePersonalWorkspace, PERSONAL_SLUG } from "../personal.js";
37
+ export const API_FEATURES = ["vault", "license", "embed", "recipe", "backup", "shop"];
38
+ export function normalizeApiPath(pathname) {
39
+ const cut = (pathname.split("?")[0] || pathname).trim();
40
+ let path = cut.startsWith("/") ? cut : `/${cut}`;
41
+ try {
42
+ path = decodeURIComponent(path);
43
+ }
44
+ catch {
45
+ // keep raw path
46
+ }
47
+ if (path.length > 1 && path.endsWith("/"))
48
+ path = path.slice(0, -1);
49
+ return path;
50
+ }
51
+ export function apiHealth() {
52
+ return { ok: true, version: "0.1.0", features: [...API_FEATURES] };
53
+ }
54
+ function ok(body) {
55
+ return { status: 200, body };
56
+ }
57
+ function err(status, message) {
58
+ return { status, body: { error: message } };
59
+ }
60
+ function bodyField(body, key) {
61
+ if (!body || typeof body !== "object")
62
+ return undefined;
63
+ const value = body[key];
64
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
65
+ }
66
+ function resolveScope(req) {
67
+ const repoId = req.searchParams.get("repo") || bodyField(req.body, "repoId");
68
+ const workspace = req.searchParams.get("workspace") || bodyField(req.body, "workspace");
69
+ const rawPath = req.searchParams.get("path") || bodyField(req.body, "path");
70
+ if (repoId && repoId !== "current" && repoId !== "all") {
71
+ const found = getRepoById(repoId);
72
+ if (!found)
73
+ return { ok: false, response: err(404, "Repo not found") };
74
+ return { ok: true, identity: identityForRepo(found), repo: found };
75
+ }
76
+ if (workspace) {
77
+ const found = getRepoByName(workspace);
78
+ if (!found) {
79
+ return {
80
+ ok: false,
81
+ response: err(404, `Workspace not found: ${workspace}. Run amem init --workspace ${workspace}`),
82
+ };
83
+ }
84
+ return { ok: true, identity: identityForRepo(found), repo: found };
85
+ }
86
+ const cwd = rawPath ? resolve(rawPath) : req.cwd;
87
+ if (rawPath && !existsSync(cwd)) {
88
+ return { ok: false, response: err(400, `Path not found: ${cwd}`) };
89
+ }
90
+ return { ok: true, identity: detectRepoIdentity(cwd), repo: getRepoByCwd(cwd) };
91
+ }
92
+ function identityForRepo(repo) {
93
+ const slug = parseWorkspaceSlug(repo.remote_url);
94
+ if (slug)
95
+ return workspaceIdentity(slug, repo.root_path);
96
+ return detectRepoIdentity(repo.root_path);
97
+ }
98
+ function summarizeRepo(r) {
99
+ const setup = getSetupState(r.id);
100
+ const slug = parseWorkspaceSlug(r.remote_url);
101
+ return {
102
+ id: r.id,
103
+ repo_key: r.repo_key,
104
+ repo_name: r.repo_name,
105
+ root_path: r.root_path,
106
+ remote_url: r.remote_url,
107
+ platform: r.platform,
108
+ kind: slug ? "workspace" : "git",
109
+ slug,
110
+ personal: slug === PERSONAL_SLUG,
111
+ setup_completed: Boolean(setup?.setup_completed_at),
112
+ counts: {
113
+ claims: listClaims(r.id).length,
114
+ flows: listFlows(r.id).length,
115
+ components: listComponents(r.id).length,
116
+ },
117
+ };
118
+ }
119
+ function statusPayload(identity, repo) {
120
+ const setup = repo ? getSetupState(repo.id) : null;
121
+ const loaded = loadPolicy();
122
+ const slug = repo ? parseWorkspaceSlug(repo.remote_url) : null;
123
+ return {
124
+ amemHome: amemHome(),
125
+ dbPath: dbPath(),
126
+ identity,
127
+ repo: repo ? { ...repo, kind: slug ? "workspace" : "git", slug } : null,
128
+ kind: repo ? (slug ? "workspace" : "git") : null,
129
+ slug,
130
+ setup,
131
+ policy: loaded.policy,
132
+ clients: KNOWN_PLATFORMS,
133
+ doctor: [amemHomeWriteIssue(), ...doctorIssues(repo, identity.rootPath)].filter((issue) => Boolean(issue)),
134
+ prefs: prefsStatus(),
135
+ counts: repo
136
+ ? {
137
+ claims: listClaims(repo.id).length,
138
+ flows: listFlows(repo.id).length,
139
+ components: listComponents(repo.id).length,
140
+ edges: listEdges(repo.id).length,
141
+ }
142
+ : null,
143
+ repos: listRepos().map(summarizeRepo),
144
+ vault: vaultStatus(),
145
+ recipe: {
146
+ version: rememberContract().version,
147
+ mcpUrlTemplate: rememberContract().mcpUrlTemplate,
148
+ },
149
+ license: licenseStatus(),
150
+ embed: embedStatus(),
151
+ };
152
+ }
153
+ function doctorIssues(repo, rootPath) {
154
+ const issues = [];
155
+ if (!repo) {
156
+ issues.push("Repo not initialized");
157
+ return issues;
158
+ }
159
+ let platforms = [];
160
+ try {
161
+ const setup = getSetupState(repo.id);
162
+ platforms = setup ? JSON.parse(setup.platforms) : [];
163
+ }
164
+ catch {
165
+ platforms = [];
166
+ }
167
+ if (platforms.length === 0 && repo.platform)
168
+ platforms = [repo.platform];
169
+ if (platforms.includes("cursor") || repo.platform === "cursor") {
170
+ issues.push(...cursorInstallHealth(rootPath));
171
+ }
172
+ if (platforms.includes("claude") || repo.platform === "claude") {
173
+ issues.push(...claudeInstallHealth());
174
+ }
175
+ for (const host of ["continue", "zed", "windsurf"]) {
176
+ if (platforms.includes(host) || repo.platform === host) {
177
+ issues.push(...hostInstallHealth(host));
178
+ }
179
+ }
180
+ if (platforms.length === 0) {
181
+ issues.push(...cursorInstallHealth(rootPath));
182
+ issues.push(...claudeInstallHealth());
183
+ }
184
+ return issues;
185
+ }
186
+ function dayStamp(iso) {
187
+ return iso.slice(0, 10);
188
+ }
189
+ function inclusiveDaySpan(fromIso, toMs) {
190
+ const from = Date.parse(`${dayStamp(fromIso)}T00:00:00Z`);
191
+ const to = Date.parse(`${new Date(toMs).toISOString().slice(0, 10)}T00:00:00Z`);
192
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from)
193
+ return 1;
194
+ return Math.floor((to - from) / 86_400_000) + 1;
195
+ }
196
+ function projectMonthly(events, windowDays) {
197
+ const empty = {
198
+ trendDays: 0,
199
+ sampleQueries: 0,
200
+ estimatedTokensSaved: 0,
201
+ estimatedUsdSaved: 0,
202
+ estimatedMsSaved: 0,
203
+ queries: 0,
204
+ anchorsAvoided: 0,
205
+ };
206
+ if (events.length === 0)
207
+ return empty;
208
+ const trendCap = Math.min(7, Math.max(1, windowDays));
209
+ const now = Date.now();
210
+ const cutoff = now - trendCap * 86_400_000;
211
+ const recent = events.filter((e) => Date.parse(e.created_at) >= cutoff);
212
+ const sample = recent.length > 0 ? recent : events;
213
+ const oldest = sample.reduce((a, e) => (e.created_at < a ? e.created_at : a), sample[0].created_at);
214
+ const trendDays = Math.min(trendCap, inclusiveDaySpan(oldest, now));
215
+ const scale = 30 / trendDays;
216
+ let tokens = 0;
217
+ let ms = 0;
218
+ let anchors = 0;
219
+ for (const e of sample) {
220
+ const speed = speedForEvent(e);
221
+ tokens += e.estimated_tokens_saved;
222
+ ms += speed.estimatedMsSaved;
223
+ anchors += speed.anchorsCount;
224
+ }
225
+ return {
226
+ trendDays,
227
+ sampleQueries: sample.length,
228
+ estimatedTokensSaved: Math.round(tokens * scale),
229
+ estimatedUsdSaved: estimateUsdSaved(Math.round(tokens * scale)),
230
+ estimatedMsSaved: Math.round(ms * scale),
231
+ queries: Math.round(sample.length * scale),
232
+ anchorsAvoided: Math.round(anchors * scale),
233
+ };
234
+ }
235
+ function aggregateUsage(events, windowDays = 30) {
236
+ const byPlatform = {};
237
+ const byDay = {};
238
+ let localMsTotal = 0;
239
+ let localMsSamples = 0;
240
+ let estimatedMsSaved = 0;
241
+ let localHits = 0;
242
+ let serverTrips = 0;
243
+ let anchorsAvoided = 0;
244
+ for (const e of events) {
245
+ const speed = speedForEvent(e);
246
+ estimatedMsSaved += speed.estimatedMsSaved;
247
+ anchorsAvoided += speed.anchorsCount;
248
+ if (speed.kind === "local_hit")
249
+ localHits += 1;
250
+ else
251
+ serverTrips += 1;
252
+ if (speed.localMs != null) {
253
+ localMsTotal += speed.localMs;
254
+ localMsSamples += 1;
255
+ }
256
+ const p = e.platform || "unknown";
257
+ if (!byPlatform[p]) {
258
+ byPlatform[p] = {
259
+ platform: p,
260
+ queries: 0,
261
+ estimatedTokensSaved: 0,
262
+ reportedTokensSaved: 0,
263
+ estimatedMsSaved: 0,
264
+ localHits: 0,
265
+ serverTrips: 0,
266
+ localMsTotal: 0,
267
+ localMsSamples: 0,
268
+ lastUsed: null,
269
+ };
270
+ }
271
+ byPlatform[p].queries += 1;
272
+ byPlatform[p].estimatedTokensSaved += e.estimated_tokens_saved;
273
+ byPlatform[p].reportedTokensSaved += e.reported_tokens_saved ?? 0;
274
+ byPlatform[p].estimatedMsSaved += speed.estimatedMsSaved;
275
+ if (speed.kind === "local_hit")
276
+ byPlatform[p].localHits += 1;
277
+ else
278
+ byPlatform[p].serverTrips += 1;
279
+ if (speed.localMs != null) {
280
+ byPlatform[p].localMsTotal += speed.localMs;
281
+ byPlatform[p].localMsSamples += 1;
282
+ }
283
+ if (!byPlatform[p].lastUsed || e.created_at > byPlatform[p].lastUsed) {
284
+ byPlatform[p].lastUsed = e.created_at;
285
+ }
286
+ const day = e.created_at.slice(0, 10);
287
+ if (!byDay[day]) {
288
+ byDay[day] = {
289
+ day,
290
+ estimatedTokensSaved: 0,
291
+ reportedTokensSaved: 0,
292
+ estimatedMsSaved: 0,
293
+ queries: 0,
294
+ localHits: 0,
295
+ serverTrips: 0,
296
+ };
297
+ }
298
+ byDay[day].estimatedTokensSaved += e.estimated_tokens_saved;
299
+ byDay[day].reportedTokensSaved += e.reported_tokens_saved ?? 0;
300
+ byDay[day].estimatedMsSaved += speed.estimatedMsSaved;
301
+ byDay[day].queries += 1;
302
+ if (speed.kind === "local_hit")
303
+ byDay[day].localHits += 1;
304
+ else
305
+ byDay[day].serverTrips += 1;
306
+ }
307
+ const queries = events.length;
308
+ const estimatedTokensSaved = events.reduce((s, e) => s + e.estimated_tokens_saved, 0);
309
+ return {
310
+ pricing: {
311
+ usdPerMillionInputTokens: USD_PER_MILLION_INPUT_TOKENS,
312
+ basis: "input",
313
+ },
314
+ byPlatform: Object.values(byPlatform).map((p) => ({
315
+ ...p,
316
+ estimatedUsdSaved: estimateUsdSaved(p.estimatedTokensSaved),
317
+ avgLocalMs: p.localMsSamples ? Math.round(p.localMsTotal / p.localMsSamples) : null,
318
+ })),
319
+ byDay: Object.values(byDay)
320
+ .sort((a, b) => a.day.localeCompare(b.day))
321
+ .map((d) => ({
322
+ ...d,
323
+ estimatedUsdSaved: estimateUsdSaved(d.estimatedTokensSaved),
324
+ })),
325
+ totals: {
326
+ queries,
327
+ estimatedTokensSaved,
328
+ estimatedUsdSaved: estimateUsdSaved(estimatedTokensSaved),
329
+ reportedTokensSaved: events.reduce((s, e) => s + (e.reported_tokens_saved ?? 0), 0),
330
+ estimatedMsSaved,
331
+ localHits,
332
+ serverTrips,
333
+ hitRate: queries ? localHits / queries : 0,
334
+ avgLocalMs: localMsSamples ? Math.round(localMsTotal / localMsSamples) : null,
335
+ anchorsAvoided,
336
+ },
337
+ monthly: projectMonthly(events, windowDays),
338
+ };
339
+ }
340
+ function createWorkspace(body) {
341
+ const name = bodyField(body, "name");
342
+ if (!name)
343
+ return err(400, "name required");
344
+ let slug;
345
+ try {
346
+ slug = slugifyWorkspace(name);
347
+ }
348
+ catch (error) {
349
+ return err(400, error instanceof Error ? error.message : String(error));
350
+ }
351
+ const platform = bodyField(body, "platform") ?? "app";
352
+ const rawPath = bodyField(body, "path");
353
+ const root = rawPath ? resolve(rawPath) : join(amemHome(), "workspaces", slug);
354
+ tryEnsureDir(root);
355
+ const identity = workspaceIdentity(slug, root);
356
+ const created = upsertRepo(identity, platform);
357
+ const repo = name.trim() === slug ? created : renameWorkspace(created.id, name.trim());
358
+ upsertSetupState(repo.id, [platform], true);
359
+ const ready = provisionWorkspace(repo, platform);
360
+ return ok({
361
+ workspace: slug,
362
+ name: repo.repo_name,
363
+ repo: summarizeRepo(repo),
364
+ ready,
365
+ mcp: {
366
+ url: `http://127.0.0.1:7843/mcp?workspace=${encodeURIComponent(slug)}`,
367
+ },
368
+ });
369
+ }
370
+ function renameWorkspaceApi(body) {
371
+ const repoId = bodyField(body, "repoId") || bodyField(body, "id");
372
+ const name = bodyField(body, "name");
373
+ if (!repoId)
374
+ return err(400, "repoId required");
375
+ if (!name)
376
+ return err(400, "name required");
377
+ try {
378
+ const repo = renameWorkspace(repoId, name);
379
+ const slug = parseWorkspaceSlug(repo.remote_url);
380
+ return ok({
381
+ workspace: slug,
382
+ name: repo.repo_name,
383
+ repo: summarizeRepo(repo),
384
+ mcp: slug
385
+ ? { url: `http://127.0.0.1:7843/mcp?workspace=${encodeURIComponent(slug)}` }
386
+ : undefined,
387
+ });
388
+ }
389
+ catch (error) {
390
+ return err(400, error instanceof Error ? error.message : String(error));
391
+ }
392
+ }
393
+ function runContext(repo, body, searchParams) {
394
+ const query = bodyField(body, "query") || searchParams.get("query") || "";
395
+ if (!query.trim())
396
+ return err(400, "query required");
397
+ const platform = bodyField(body, "platform") || searchParams.get("platform") || repo.platform || "unknown";
398
+ const sessionId = bodyField(body, "sessionId") || searchParams.get("sessionId") || undefined;
399
+ const result = logContextUsage({
400
+ repoId: repo.id,
401
+ platform,
402
+ sessionId,
403
+ query,
404
+ });
405
+ return ok({
406
+ markdown: result.markdown,
407
+ event: result.event,
408
+ workspace: parseWorkspaceSlug(repo.remote_url) ?? repo.repo_name,
409
+ });
410
+ }
411
+ function runRemember(repo, body) {
412
+ const text = bodyField(body, "text");
413
+ if (!text)
414
+ return err(400, "text required");
415
+ const kind = bodyField(body, "kind") ?? "session";
416
+ const id = bodyField(body, "id") ??
417
+ `claim.remember_${createHash("sha256").update(text).digest("hex").slice(0, 12)}`;
418
+ const payload = body && typeof body === "object" ? body : {};
419
+ const anchorsRaw = payload.anchors;
420
+ const anchors = Array.isArray(anchorsRaw)
421
+ ? anchorsRaw.filter((a) => typeof a === "string" && Boolean(a.trim())).slice(0, 8)
422
+ : [];
423
+ if (anchors.length === 0) {
424
+ anchors.push(parseWorkspaceSlug(repo.remote_url) ?? repo.repo_name);
425
+ }
426
+ const applied = applyProposal(repo.id, {
427
+ claims: [
428
+ {
429
+ id,
430
+ kind,
431
+ text,
432
+ code_anchors: anchors,
433
+ source_ref: bodyField(body, "source") ?? "api",
434
+ },
435
+ ],
436
+ });
437
+ return ok({
438
+ applied,
439
+ claimId: id,
440
+ workspace: parseWorkspaceSlug(repo.remote_url) ?? repo.repo_name,
441
+ });
442
+ }
443
+ function bodyNumber(body, key) {
444
+ if (!body || typeof body !== "object")
445
+ return undefined;
446
+ const value = body[key];
447
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
448
+ }
449
+ function runVaultLock(body) {
450
+ const passphrase = bodyField(body, "passphrase");
451
+ if (!passphrase)
452
+ return err(400, "passphrase required");
453
+ try {
454
+ closeDb();
455
+ const result = lockDatabase(passphrase);
456
+ return ok({ ...vaultStatus(), ...result });
457
+ }
458
+ catch (error) {
459
+ return err(400, error instanceof Error ? error.message : String(error));
460
+ }
461
+ }
462
+ function runVaultUnlock(body) {
463
+ const passphrase = bodyField(body, "passphrase");
464
+ if (!passphrase)
465
+ return err(400, "passphrase required");
466
+ try {
467
+ closeDb();
468
+ const result = unlockDatabase(passphrase);
469
+ openDb();
470
+ return ok({ ...vaultStatus(), ...result });
471
+ }
472
+ catch (error) {
473
+ return err(400, error instanceof Error ? error.message : String(error));
474
+ }
475
+ }
476
+ function runVaultBackup(body) {
477
+ try {
478
+ const result = createBackup({
479
+ passphrase: bodyField(body, "passphrase"),
480
+ label: bodyField(body, "label"),
481
+ outDir: bodyField(body, "outDir"),
482
+ });
483
+ return ok({ ...result, vault: vaultStatus() });
484
+ }
485
+ catch (error) {
486
+ return err(400, error instanceof Error ? error.message : String(error));
487
+ }
488
+ }
489
+ function runVaultRestore(body) {
490
+ const file = bodyField(body, "file") || bodyField(body, "path");
491
+ if (!file)
492
+ return err(400, "file required");
493
+ try {
494
+ closeDb();
495
+ const result = restoreBackup({
496
+ file,
497
+ passphrase: bodyField(body, "passphrase"),
498
+ });
499
+ openDb();
500
+ return ok({ ...result, vault: vaultStatus() });
501
+ }
502
+ catch (error) {
503
+ return err(400, error instanceof Error ? error.message : String(error));
504
+ }
505
+ }
506
+ function runVaultSchedule(body) {
507
+ try {
508
+ const hour = bodyNumber(body, "hour");
509
+ const result = installBackupSchedule({
510
+ outDir: bodyField(body, "outDir"),
511
+ hour,
512
+ });
513
+ return ok({ ...result, vault: vaultStatus() });
514
+ }
515
+ catch (error) {
516
+ return err(400, error instanceof Error ? error.message : String(error));
517
+ }
518
+ }
519
+ export function handleApi(req) {
520
+ const method = (req.method || "GET").toUpperCase();
521
+ const pathname = normalizeApiPath(req.pathname);
522
+ const { searchParams, body, cwd } = req;
523
+ if (method === "GET" && pathname === "/api/health") {
524
+ return ok(apiHealth());
525
+ }
526
+ if (method === "GET" && pathname === "/api/license") {
527
+ return ok(licenseStatus());
528
+ }
529
+ if (method === "POST" && pathname === "/api/license/apply") {
530
+ try {
531
+ const raw = body?.json ??
532
+ body?.text ??
533
+ body;
534
+ if (raw == null || (typeof raw === "object" && raw !== null && !("kind" in raw) && Object.keys(raw).length === 0)) {
535
+ return err(400, "Provide license JSON in body.json or body.text");
536
+ }
537
+ if (typeof raw === "string" && !raw.trim()) {
538
+ return err(400, "Provide license JSON in body.json or body.text");
539
+ }
540
+ return ok(applyLicenseJson(raw));
541
+ }
542
+ catch (error) {
543
+ return err(400, error instanceof Error ? error.message : String(error));
544
+ }
545
+ }
546
+ if (method === "GET" && pathname === "/api/shop") {
547
+ return ok(shopStatus());
548
+ }
549
+ if (method === "GET" && pathname === "/api/prefs") {
550
+ return ok(prefsStatus());
551
+ }
552
+ if (method === "POST" && pathname === "/api/prefs") {
553
+ const raw = body?.autoApplyAll;
554
+ if (typeof raw !== "boolean")
555
+ return err(400, "autoApplyAll must be a boolean");
556
+ setAutoApplyAll(raw);
557
+ const flushed = raw ? applyPendingDrafts() : { applied: [], skipped: 0 };
558
+ return ok({ ...prefsStatus(), flushed });
559
+ }
560
+ if (method === "GET" && pathname === "/api/embed") {
561
+ const db = openDb();
562
+ return ok({
563
+ ...embedStatus(),
564
+ index: embedIndexHealth(db),
565
+ issues: embedIndexIssues(db),
566
+ });
567
+ }
568
+ if (method === "POST" && pathname === "/api/embed") {
569
+ const backend = bodyField(body, "backend");
570
+ if (backend !== "hash" && backend !== "ngram" && backend !== "external") {
571
+ return err(400, "backend must be hash, ngram, or external");
572
+ }
573
+ try {
574
+ const argsRaw = body?.args;
575
+ const args = Array.isArray(argsRaw) ? argsRaw.filter((a) => typeof a === "string") : undefined;
576
+ return ok(setEmbedBackend(backend, {
577
+ command: bodyField(body, "command") || bodyField(body, "cmd"),
578
+ args,
579
+ dim: bodyNumber(body, "dim"),
580
+ }));
581
+ }
582
+ catch (error) {
583
+ return err(403, error instanceof Error ? error.message : String(error));
584
+ }
585
+ }
586
+ if (method === "POST" && pathname === "/api/embed/reindex") {
587
+ return ok(reindexAllEmbeds(openDb()));
588
+ }
589
+ if (method === "GET" && pathname === "/api/recipe") {
590
+ return ok(rememberContract());
591
+ }
592
+ if (method === "GET" && pathname === "/api/vault") {
593
+ return ok(vaultStatus());
594
+ }
595
+ if (method === "POST" && pathname === "/api/vault/lock") {
596
+ return runVaultLock(body);
597
+ }
598
+ if (method === "POST" && pathname === "/api/vault/unlock") {
599
+ return runVaultUnlock(body);
600
+ }
601
+ if (method === "POST" && pathname === "/api/vault/backup") {
602
+ return runVaultBackup(body);
603
+ }
604
+ if (method === "POST" && pathname === "/api/vault/restore") {
605
+ return runVaultRestore(body);
606
+ }
607
+ if (method === "POST" && pathname === "/api/vault/backup/schedule") {
608
+ return runVaultSchedule(body);
609
+ }
610
+ if (method === "POST" && pathname === "/api/vault/backup/unschedule") {
611
+ try {
612
+ const result = uninstallBackupSchedule();
613
+ return ok({ ...result, vault: vaultStatus() });
614
+ }
615
+ catch (error) {
616
+ return err(400, error instanceof Error ? error.message : String(error));
617
+ }
618
+ }
619
+ if (method === "POST" && pathname === "/api/workspaces/personal") {
620
+ const platform = bodyField(body, "platform") ?? "app";
621
+ const repo = ensurePersonalWorkspace(platform);
622
+ upsertSetupState(repo.id, [platform], true);
623
+ return ok({
624
+ workspace: PERSONAL_SLUG,
625
+ name: repo.repo_name,
626
+ repo: summarizeRepo(repo),
627
+ mcp: {
628
+ url: `http://127.0.0.1:7843/mcp?workspace=${encodeURIComponent(PERSONAL_SLUG)}`,
629
+ },
630
+ });
631
+ }
632
+ if (method === "GET" && pathname === "/api/repos") {
633
+ return ok({ repos: listRepos().map(summarizeRepo) });
634
+ }
635
+ if (method === "POST" && pathname === "/api/workspaces") {
636
+ return createWorkspace(body);
637
+ }
638
+ if (method === "POST" && pathname === "/api/workspaces/rename") {
639
+ return renameWorkspaceApi(body);
640
+ }
641
+ if (method === "GET" && pathname === "/api/scan") {
642
+ const scanned = scanGitRepos();
643
+ const bound = listRepos();
644
+ return ok({
645
+ ...scanned,
646
+ repos: scanned.repos.map((r) => ({
647
+ ...r,
648
+ tracking: bound.some((b) => {
649
+ if (b.root_path === r.path)
650
+ return true;
651
+ if (r.remote && b.remote_url && normalizeRemoteUrl(r.remote) === b.remote_url)
652
+ return true;
653
+ return false;
654
+ }),
655
+ })),
656
+ });
657
+ }
658
+ if (method === "POST" && pathname === "/api/track") {
659
+ const payload = body && typeof body === "object" ? body : {};
660
+ const paths = (payload.paths ?? []).filter((p) => typeof p === "string" && p.trim());
661
+ const platforms = normalizePlatforms(payload.platforms);
662
+ if (paths.length === 0)
663
+ return err(400, "Select at least one repository");
664
+ if (platforms.length === 0)
665
+ return err(400, "Select at least one LLM client");
666
+ const policy = loadPolicy().policy;
667
+ try {
668
+ for (const platform of platforms)
669
+ assertPlatformAllowed(platform, policy);
670
+ }
671
+ catch (e) {
672
+ return err(403, e instanceof Error ? e.message : String(e));
673
+ }
674
+ const tracked = [];
675
+ for (const raw of paths) {
676
+ const root = resolve(raw);
677
+ if (!existsSync(root))
678
+ continue;
679
+ const identity = detectRepoIdentity(root);
680
+ let current = upsertRepo(identity, platforms[0]);
681
+ for (const platform of platforms) {
682
+ current = upsertRepo(identity, platform);
683
+ if (platform === "cursor")
684
+ installCursor(identity.rootPath);
685
+ else if (platform === "claude")
686
+ installClaude(identity.rootPath);
687
+ else if (HOST_INSTALL_IDS.has(platform)) {
688
+ installHost(platform, {
689
+ repoRoot: identity.rootPath,
690
+ workspace: identity.repoName,
691
+ });
692
+ }
693
+ }
694
+ upsertSetupState(current.id, platforms, true);
695
+ tracked.push(summarizeRepo(current));
696
+ }
697
+ return ok({ tracked, repos: listRepos().map(summarizeRepo) });
698
+ }
699
+ if (method === "GET" && pathname === "/api/service") {
700
+ return ok({
701
+ platform: process.platform,
702
+ servicePlatform: servicePlatform(),
703
+ supported: isServiceSupported(),
704
+ installed: isServiceInstalled(),
705
+ });
706
+ }
707
+ if (method === "POST" && pathname === "/api/service") {
708
+ const enabled = body?.enabled;
709
+ if (typeof enabled !== "boolean")
710
+ return err(400, "enabled must be true or false");
711
+ try {
712
+ const result = enabled ? installLoginService() : uninstallLoginService();
713
+ return ok({ ...result, supported: isServiceSupported() });
714
+ }
715
+ catch (error) {
716
+ const message = error instanceof Error ? error.message : String(error);
717
+ return err(400, message);
718
+ }
719
+ }
720
+ if (method === "GET" && pathname.startsWith("/api/repos/")) {
721
+ const id = pathname.slice("/api/repos/".length);
722
+ const found = getRepoById(id);
723
+ if (!found)
724
+ return err(404, "Repo not found");
725
+ return ok({ repo: found, ...summarizeRepo(found) });
726
+ }
727
+ if (method === "GET" && pathname === "/api/hygiene/preview") {
728
+ const days = Number(searchParams.get("days") ?? "90");
729
+ const unused = Number.isFinite(days) ? days : 90;
730
+ if (searchParams.get("scope") === "all") {
731
+ return ok({ ...hygienePreviewAll(unused), schedule: hygieneScheduleStatus() });
732
+ }
733
+ const repoId = searchParams.get("repo");
734
+ const found = repoId ? getRepoById(repoId) : getRepoByCwd(cwd);
735
+ if (!found)
736
+ return err(400, "Repo not initialized");
737
+ return ok({ ...hygienePreview(found.id, unused), schedule: hygieneScheduleStatus() });
738
+ }
739
+ if (method === "GET" && pathname === "/api/hygiene/schedule") {
740
+ return ok(hygieneScheduleStatus());
741
+ }
742
+ if (method === "POST" && pathname === "/api/hygiene/schedule") {
743
+ try {
744
+ const hour = bodyNumber(body, "hour");
745
+ if (hour !== undefined && (!Number.isFinite(hour) || hour < 0 || hour > 23)) {
746
+ return err(400, "hour must be 0–23");
747
+ }
748
+ const result = installHygieneSchedule({ hour });
749
+ const helper = writeHygieneHelperScript();
750
+ return ok({ ...result, helper, ...hygieneScheduleStatus() });
751
+ }
752
+ catch (error) {
753
+ return err(403, error instanceof Error ? error.message : String(error));
754
+ }
755
+ }
756
+ if (method === "POST" && pathname === "/api/hygiene/unschedule") {
757
+ try {
758
+ const result = uninstallHygieneSchedule();
759
+ return ok({ ...result, ...hygieneScheduleStatus() });
760
+ }
761
+ catch (error) {
762
+ return err(400, error instanceof Error ? error.message : String(error));
763
+ }
764
+ }
765
+ const scoped = resolveScope(req);
766
+ if (!scoped.ok)
767
+ return scoped.response;
768
+ const { identity, repo } = scoped;
769
+ if (method === "GET" && pathname === "/api/status") {
770
+ return ok(statusPayload(identity, repo));
771
+ }
772
+ if (method === "GET" && pathname === "/api/attest") {
773
+ return ok(buildAttestReport(cwd));
774
+ }
775
+ if (method === "POST" && pathname === "/api/setup") {
776
+ const platforms = body?.platforms ?? [];
777
+ const valid = normalizePlatforms(platforms);
778
+ if (valid.length === 0)
779
+ return err(400, "Select at least one LLM client");
780
+ const policy = loadPolicy().policy;
781
+ try {
782
+ assertRemoteAllowed(identity.remoteUrl, policy);
783
+ for (const platform of valid)
784
+ assertPlatformAllowed(platform, policy);
785
+ }
786
+ catch (e) {
787
+ return err(403, e instanceof Error ? e.message : String(e));
788
+ }
789
+ let current = upsertRepo(identity, valid[0]);
790
+ const results = [];
791
+ for (const platform of valid) {
792
+ current = upsertRepo(identity, platform);
793
+ if (platform === "cursor")
794
+ results.push(installCursor(identity.rootPath));
795
+ else if (platform === "claude")
796
+ results.push(installClaude(identity.rootPath));
797
+ else if (HOST_INSTALL_IDS.has(platform)) {
798
+ results.push(installHost(platform, {
799
+ repoRoot: identity.rootPath,
800
+ workspace: identity.repoName,
801
+ }));
802
+ }
803
+ }
804
+ const setup = upsertSetupState(current.id, valid, true);
805
+ return ok({
806
+ repo: current,
807
+ setup,
808
+ installs: results,
809
+ doctor: doctorIssues(current, identity.rootPath),
810
+ status: statusPayload(identity, current),
811
+ });
812
+ }
813
+ if ((method === "POST" || method === "GET") && pathname === "/api/context") {
814
+ if (!repo)
815
+ return err(400, "Unknown workspace. Pass workspace= or run amem init.");
816
+ return runContext(repo, body, searchParams);
817
+ }
818
+ if (method === "POST" && pathname === "/api/retrieval/showdown") {
819
+ if (!repo)
820
+ return err(400, "Unknown workspace. Pass workspace= or run amem init.");
821
+ const query = bodyField(body, "query") ||
822
+ bodyField(body, "q") ||
823
+ searchParams.get("q") ||
824
+ searchParams.get("query") ||
825
+ "";
826
+ if (!query.trim())
827
+ return err(400, "query is required");
828
+ const limit = bodyNumber(body, "limit") ?? Number(searchParams.get("limit") ?? "8");
829
+ return ok(buildRetrievalShowdown(repo.id, query.trim(), {
830
+ limit: Number.isFinite(limit) ? Math.min(20, Math.max(1, limit)) : 8,
831
+ rootPath: identity.rootPath,
832
+ }));
833
+ }
834
+ if (method === "POST" && pathname === "/api/remember") {
835
+ if (!repo)
836
+ return err(400, "Unknown workspace. Pass workspace= or run amem init.");
837
+ return runRemember(repo, body);
838
+ }
839
+ if (method === "POST" && pathname === "/api/bootstrap") {
840
+ if (!repo)
841
+ return err(400, "Run setup first");
842
+ const proposalRaw = body?.proposal;
843
+ if (!proposalRaw)
844
+ return err(400, "Missing proposal");
845
+ const proposal = typeof proposalRaw === "string" ? parseProposalJson(proposalRaw) : proposalRaw;
846
+ const policy = loadPolicy().policy;
847
+ try {
848
+ assertRemoteAllowed(identity.remoteUrl, policy);
849
+ }
850
+ catch (e) {
851
+ return err(403, e instanceof Error ? e.message : String(e));
852
+ }
853
+ const validated = validateProposal(proposal, policy);
854
+ if (!validated.ok)
855
+ return err(400, validated.errors.join("; "));
856
+ const applied = applyProposal(repo.id, proposal, policy);
857
+ upsertSetupState(repo.id, (() => {
858
+ try {
859
+ return JSON.parse(getSetupState(repo.id)?.platforms ?? "[]");
860
+ }
861
+ catch {
862
+ return repo.platform ? [repo.platform] : [];
863
+ }
864
+ })(), true);
865
+ return ok({ applied });
866
+ }
867
+ if (method === "GET" && pathname === "/api/hygiene") {
868
+ if (!repo)
869
+ return err(400, "Repo not initialized");
870
+ try {
871
+ const days = Number(searchParams.get("days") ?? "90");
872
+ return ok({
873
+ ...hygieneReport(repo.id, Number.isFinite(days) ? days : 90),
874
+ schedule: hygieneScheduleStatus(),
875
+ });
876
+ }
877
+ catch (error) {
878
+ return err(403, error instanceof Error ? error.message : String(error));
879
+ }
880
+ }
881
+ if (method === "POST" && pathname === "/api/hygiene/decay") {
882
+ if (!repo)
883
+ return err(400, "Repo not initialized");
884
+ try {
885
+ const days = bodyNumber(body, "days") ?? 90;
886
+ return ok(decayStaleClaims(repo.id, days));
887
+ }
888
+ catch (error) {
889
+ return err(403, error instanceof Error ? error.message : String(error));
890
+ }
891
+ }
892
+ if (method === "POST" && pathname === "/api/hygiene/accept-safe") {
893
+ if (!repo)
894
+ return err(400, "Repo not initialized");
895
+ try {
896
+ const days = bodyNumber(body, "days") ?? 90;
897
+ return ok(acceptSafeCleanups(repo.id, days));
898
+ }
899
+ catch (error) {
900
+ return err(403, error instanceof Error ? error.message : String(error));
901
+ }
902
+ }
903
+ if (method === "POST" && pathname === "/api/hygiene/merge") {
904
+ if (!repo)
905
+ return err(400, "Repo not initialized");
906
+ const keepId = bodyField(body, "keepId");
907
+ const dropId = bodyField(body, "dropId");
908
+ if (!keepId || !dropId)
909
+ return err(400, "keepId and dropId required");
910
+ try {
911
+ return ok(mergeDuplicate(repo.id, keepId, dropId));
912
+ }
913
+ catch (error) {
914
+ return err(400, error instanceof Error ? error.message : String(error));
915
+ }
916
+ }
917
+ if (method === "POST" && pathname === "/api/rules/sync") {
918
+ if (!repo)
919
+ return err(400, "Repo not initialized");
920
+ try {
921
+ return ok(syncPinnedRules(repo));
922
+ }
923
+ catch (error) {
924
+ return err(403, error instanceof Error ? error.message : String(error));
925
+ }
926
+ }
927
+ if (method === "POST" && pathname === "/api/it-pack") {
928
+ try {
929
+ const outDir = bodyField(body, "out") || join(amemHome(), "it-pack");
930
+ return ok(writeItPack(outDir));
931
+ }
932
+ catch (error) {
933
+ return err(400, error instanceof Error ? error.message : String(error));
934
+ }
935
+ }
936
+ if (method === "GET" && pathname === "/api/graph") {
937
+ const all = searchParams.get("scope") === "all";
938
+ if (!all && !repo)
939
+ return err(400, "Repo not initialized");
940
+ const days = Number(searchParams.get("days") ?? "30");
941
+ const repoId = all ? undefined : repo.id;
942
+ const events = decorateUsageEvents(listUsageEvents({ repoId: repoId ?? null, days }));
943
+ const recentClaimIds = new Set();
944
+ for (const e of events.slice(0, 20)) {
945
+ try {
946
+ for (const id of JSON.parse(e.claim_ids))
947
+ recentClaimIds.add(id);
948
+ }
949
+ catch {
950
+ // ignore
951
+ }
952
+ }
953
+ const pendingTotal = all ? countProposalDraftsAll("pending") : countProposalDrafts(repo.id, "pending");
954
+ const draftLimit = Math.min(500, Math.max(pendingTotal, 100));
955
+ return ok({
956
+ scope: all ? "all" : "current",
957
+ components: all ? listComponentsAll() : listComponents(repo.id),
958
+ flows: all ? listFlowsAll() : listFlows(repo.id),
959
+ claims: all ? listClaimsAll() : listClaims(repo.id),
960
+ edges: all ? listEdgesAll() : listEdges(repo.id),
961
+ drafts: decorateDrafts(all
962
+ ? listProposalDraftsAll({ status: "pending", limit: draftLimit })
963
+ : listProposalDrafts(repo.id, { status: "pending", limit: draftLimit })),
964
+ pendingDraftTotal: pendingTotal,
965
+ recentClaimIds: [...recentClaimIds],
966
+ recentEvents: events.slice(0, 30),
967
+ activity: buildActivityGraph({
968
+ events,
969
+ sessions: all ? listSessionsAll() : listSessions(repo.id),
970
+ }),
971
+ });
972
+ }
973
+ if (method === "GET" && pathname === "/api/drafts") {
974
+ const all = searchParams.get("scope") === "all";
975
+ if (!all && !repo)
976
+ return err(400, "Repo not initialized");
977
+ const status = searchParams.get("status") ?? "pending";
978
+ const pendingTotal = status === "pending"
979
+ ? all
980
+ ? countProposalDraftsAll("pending")
981
+ : countProposalDrafts(repo.id, "pending")
982
+ : undefined;
983
+ const limit = Math.min(500, Number(searchParams.get("limit") || 500) || 500);
984
+ return ok({
985
+ drafts: decorateDrafts(all
986
+ ? listProposalDraftsAll({ status, limit })
987
+ : listProposalDrafts(repo.id, { status, limit })),
988
+ pendingDraftTotal: pendingTotal,
989
+ });
990
+ }
991
+ if (method === "POST" && pathname === "/api/drafts/reject-noisy") {
992
+ const all = searchParams.get("scope") === "all" || bodyField(body, "scope") === "all";
993
+ if (!all && !repo)
994
+ return err(400, "Repo not initialized");
995
+ const pending = decorateDrafts(all
996
+ ? listProposalDraftsAll({ status: "pending", limit: 500 })
997
+ : listProposalDrafts(repo.id, { status: "pending", limit: 500 }));
998
+ const noisy = pending.filter((d) => d.quality.reject);
999
+ for (const d of noisy)
1000
+ setProposalDraftStatus(d.id, "dismissed");
1001
+ return ok({ dismissed: noisy.map((d) => d.id), count: noisy.length });
1002
+ }
1003
+ if (method === "POST" && pathname === "/api/drafts/bulk") {
1004
+ const all = searchParams.get("scope") === "all" || bodyField(body, "scope") === "all";
1005
+ if (!all && !repo)
1006
+ return err(400, "Repo not initialized");
1007
+ const action = String(bodyField(body, "action") || "").trim();
1008
+ const idsRaw = body?.ids;
1009
+ const ids = Array.isArray(idsRaw)
1010
+ ? idsRaw.filter((x) => typeof x === "string" && Boolean(x.trim()))
1011
+ : [];
1012
+ const pending = decorateDrafts(all
1013
+ ? listProposalDraftsAll({ status: "pending", limit: 500 })
1014
+ : listProposalDrafts(repo.id, { status: "pending", limit: 500 }));
1015
+ const byId = new Map(pending.map((d) => [d.id, d]));
1016
+ const selected = ids.length > 0
1017
+ ? ids.map((id) => byId.get(id)).filter((d) => Boolean(d))
1018
+ : pending;
1019
+ const policy = loadPolicy().policy;
1020
+ const applied = [];
1021
+ const dismissed = [];
1022
+ const skipped = [];
1023
+ const dismissOne = (d) => {
1024
+ setProposalDraftStatus(d.id, "dismissed");
1025
+ dismissed.push(d.id);
1026
+ };
1027
+ const applyOne = (d, resolve = "keep") => {
1028
+ const liveConflicts = d.conflicts.filter((c) => !c.withinProposal);
1029
+ let proposal;
1030
+ try {
1031
+ proposal = parseProposalJson(d.proposal_json);
1032
+ }
1033
+ catch {
1034
+ skipped.push({ id: d.id, reason: "bad_json" });
1035
+ return;
1036
+ }
1037
+ if (resolve === "supersede" && liveConflicts.length > 0) {
1038
+ proposal = applySupersedes(proposal, liveConflicts.map((c) => c.otherId));
1039
+ }
1040
+ const target = getRepoById(d.repo_id);
1041
+ if (!target) {
1042
+ skipped.push({ id: d.id, reason: "missing_repo" });
1043
+ return;
1044
+ }
1045
+ applyProposal(target.id, proposal, policy);
1046
+ setProposalDraftStatus(d.id, "applied");
1047
+ applied.push(d.id);
1048
+ };
1049
+ if (action === "reject_noisy") {
1050
+ for (const d of selected) {
1051
+ if (d.quality.reject)
1052
+ dismissOne(d);
1053
+ }
1054
+ }
1055
+ else if (action === "dismiss_low" || action === "dismiss_junk") {
1056
+ for (const d of selected) {
1057
+ if (d.quality.reject || d.quality.label === "low")
1058
+ dismissOne(d);
1059
+ }
1060
+ }
1061
+ else if (action === "approve_high") {
1062
+ for (const d of selected) {
1063
+ if (d.quality.label === "high" && !d.quality.reject)
1064
+ applyOne(d, "keep");
1065
+ else if (ids.length)
1066
+ skipped.push({ id: d.id, reason: "not_high" });
1067
+ }
1068
+ }
1069
+ else if (action === "dismiss_all") {
1070
+ const confirmRaw = body?.confirm;
1071
+ if (confirmRaw !== true && String(confirmRaw || "").toLowerCase() !== "true") {
1072
+ return err(400, "confirm=true required to dismiss all pending drafts");
1073
+ }
1074
+ for (const d of pending)
1075
+ dismissOne(d);
1076
+ }
1077
+ else if (action === "dismiss_ids") {
1078
+ if (!ids.length)
1079
+ return err(400, "ids required");
1080
+ for (const d of selected)
1081
+ dismissOne(d);
1082
+ }
1083
+ else if (action === "apply_ids") {
1084
+ if (!ids.length)
1085
+ return err(400, "ids required");
1086
+ const resolve = bodyField(body, "resolve") === "supersede" ? "supersede" : "keep";
1087
+ for (const d of selected)
1088
+ applyOne(d, resolve);
1089
+ }
1090
+ else {
1091
+ return err(400, "action must be reject_noisy, dismiss_low, dismiss_junk, approve_high, apply_ids, dismiss_ids, or dismiss_all");
1092
+ }
1093
+ return ok({
1094
+ action,
1095
+ applied,
1096
+ dismissed: [...new Set(dismissed)],
1097
+ skipped,
1098
+ appliedCount: applied.length,
1099
+ dismissedCount: new Set(dismissed).size,
1100
+ });
1101
+ }
1102
+ if (method === "POST" && pathname === "/api/drafts/apply") {
1103
+ const draftId = bodyField(body, "id");
1104
+ if (!draftId)
1105
+ return err(400, "id required");
1106
+ const draft = getProposalDraft(draftId);
1107
+ if (!draft)
1108
+ return err(404, "Draft not found");
1109
+ const target = getRepoById(draft.repo_id);
1110
+ if (!target)
1111
+ return err(404, "Draft not found");
1112
+ if (repo && repo.id !== draft.repo_id && searchParams.get("scope") !== "all" && bodyField(body, "scope") !== "all") {
1113
+ return err(404, "Draft not found");
1114
+ }
1115
+ if (draft.status !== "pending")
1116
+ return err(400, `Draft is ${draft.status}`);
1117
+ let proposal;
1118
+ try {
1119
+ proposal = parseProposalJson(draft.proposal_json);
1120
+ }
1121
+ catch (error) {
1122
+ return err(400, error instanceof Error ? error.message : String(error));
1123
+ }
1124
+ const decorated = decorateDraft(draft);
1125
+ const liveConflicts = decorated.conflicts.filter((c) => !c.withinProposal);
1126
+ const resolve = bodyField(body, "resolve");
1127
+ if (liveConflicts.length > 0 && resolve !== "supersede" && resolve !== "keep") {
1128
+ return {
1129
+ status: 409,
1130
+ body: {
1131
+ error: "Draft conflicts with existing facts. Pass resolve=supersede or resolve=keep.",
1132
+ conflicts: liveConflicts,
1133
+ quality: decorated.quality,
1134
+ draft: decorated,
1135
+ },
1136
+ };
1137
+ }
1138
+ if (resolve === "supersede") {
1139
+ proposal = applySupersedes(proposal, liveConflicts.map((c) => c.otherId));
1140
+ }
1141
+ const policy = loadPolicy().policy;
1142
+ const applied = applyProposal(target.id, proposal, policy);
1143
+ setProposalDraftStatus(draftId, "applied");
1144
+ return ok({ applied, draft: getProposalDraft(draftId), resolve: resolve ?? "keep" });
1145
+ }
1146
+ if (method === "POST" && pathname === "/api/drafts/dismiss") {
1147
+ const draftId = bodyField(body, "id");
1148
+ if (!draftId)
1149
+ return err(400, "id required");
1150
+ const draft = getProposalDraft(draftId);
1151
+ if (!draft)
1152
+ return err(404, "Draft not found");
1153
+ if (repo && repo.id !== draft.repo_id && bodyField(body, "scope") !== "all") {
1154
+ return err(404, "Draft not found");
1155
+ }
1156
+ setProposalDraftStatus(draftId, "dismissed");
1157
+ return ok({ draft: getProposalDraft(draftId) });
1158
+ }
1159
+ if (method === "GET" && pathname === "/api/claims/search") {
1160
+ if (!repo)
1161
+ return err(400, "Repo not initialized");
1162
+ const q = searchParams.get("q") || bodyField(body, "q") || "";
1163
+ if (!q.trim())
1164
+ return ok({ claims: listClaims(repo.id).slice(0, 40) });
1165
+ const hits = searchClaimsFts(openDb(), repo.id, q, 40);
1166
+ const byId = new Map(listClaims(repo.id).map((c) => [c.id, c]));
1167
+ const ranked = hits.map((h) => byId.get(h.id)).filter(Boolean);
1168
+ if (ranked.length > 0)
1169
+ return ok({ claims: ranked });
1170
+ // keyword fallback
1171
+ const tokens = tokenize(q);
1172
+ const fallback = listClaims(repo.id)
1173
+ .map((c) => ({
1174
+ c,
1175
+ score: tokens.reduce((s, t) => s + (`${c.id} ${c.text} ${c.code_anchors}`.toLowerCase().includes(t) ? 1 : 0), 0),
1176
+ }))
1177
+ .filter((x) => x.score > 0)
1178
+ .sort((a, b) => b.score - a.score)
1179
+ .slice(0, 40)
1180
+ .map((x) => x.c);
1181
+ return ok({ claims: fallback });
1182
+ }
1183
+ if (method === "PATCH" && pathname === "/api/claims") {
1184
+ if (!repo)
1185
+ return err(400, "Repo not initialized");
1186
+ const id = bodyField(body, "id");
1187
+ if (!id)
1188
+ return err(400, "id required");
1189
+ const payload = body && typeof body === "object" ? body : {};
1190
+ const anchorsRaw = payload.code_anchors ?? payload.anchors;
1191
+ let code_anchors;
1192
+ if (Array.isArray(anchorsRaw)) {
1193
+ code_anchors = anchorsRaw.filter((a) => typeof a === "string" && Boolean(a.trim()));
1194
+ }
1195
+ else if (typeof anchorsRaw === "string") {
1196
+ code_anchors = anchorsRaw
1197
+ .split(",")
1198
+ .map((s) => s.trim())
1199
+ .filter(Boolean);
1200
+ }
1201
+ try {
1202
+ const updated = updateClaim(repo.id, id, {
1203
+ text: typeof payload.text === "string" ? payload.text : undefined,
1204
+ kind: typeof payload.kind === "string" ? payload.kind : undefined,
1205
+ code_anchors,
1206
+ pinned: typeof payload.pinned === "boolean" ? payload.pinned : undefined,
1207
+ });
1208
+ if (!updated)
1209
+ return err(404, "Claim not found");
1210
+ return ok({ claim: updated });
1211
+ }
1212
+ catch (error) {
1213
+ return err(400, error instanceof Error ? error.message : String(error));
1214
+ }
1215
+ }
1216
+ if (method === "POST" && pathname === "/api/claims/pin") {
1217
+ if (!repo)
1218
+ return err(400, "Repo not initialized");
1219
+ const id = bodyField(body, "id");
1220
+ if (!id)
1221
+ return err(400, "id required");
1222
+ const pinned = body?.pinned;
1223
+ if (typeof pinned !== "boolean")
1224
+ return err(400, "pinned must be true or false");
1225
+ const updated = setClaimPinned(repo.id, id, pinned);
1226
+ if (!updated)
1227
+ return err(404, "Claim not found");
1228
+ return ok({ claim: updated });
1229
+ }
1230
+ if (method === "DELETE" && pathname === "/api/claims") {
1231
+ if (!repo)
1232
+ return err(400, "Repo not initialized");
1233
+ const id = searchParams.get("id") || bodyField(body, "id");
1234
+ if (!id)
1235
+ return err(400, "id required");
1236
+ const removed = deleteClaim(repo.id, id);
1237
+ if (!removed)
1238
+ return err(404, "Claim not found");
1239
+ return ok({ deleted: id });
1240
+ }
1241
+ if (method === "GET" && pathname === "/api/usage/export") {
1242
+ const scope = searchParams.get("scope") ?? "current";
1243
+ const days = Number(searchParams.get("days") ?? "30");
1244
+ const format = (searchParams.get("format") || "json").toLowerCase();
1245
+ let events;
1246
+ if (scope === "all") {
1247
+ events = decorateUsageEvents(listUsageEvents({ days }));
1248
+ }
1249
+ else {
1250
+ if (!repo)
1251
+ return err(400, "Repo not initialized");
1252
+ events = decorateUsageEvents(listUsageEvents({ repoId: repo.id, days }));
1253
+ }
1254
+ const report = buildSavingsExport({
1255
+ scope,
1256
+ days,
1257
+ repoName: repo?.repo_name,
1258
+ aggregate: aggregateUsage(events, days),
1259
+ });
1260
+ if (format === "md" || format === "markdown") {
1261
+ return ok({
1262
+ filename: `${report.filenameBase}.md`,
1263
+ markdown: formatSavingsMarkdown(report),
1264
+ report,
1265
+ });
1266
+ }
1267
+ if (format === "pdf") {
1268
+ return ok({
1269
+ filename: `${report.filenameBase}.pdf`,
1270
+ mime: "application/pdf",
1271
+ contentBase64: savingsPdf(report).toString("base64"),
1272
+ report,
1273
+ });
1274
+ }
1275
+ return ok({ filename: `${report.filenameBase}.json`, report });
1276
+ }
1277
+ if (method === "GET" && pathname === "/api/usage") {
1278
+ const scope = searchParams.get("scope") ?? (searchParams.get("repo") === "all" ? "all" : "current");
1279
+ const days = Number(searchParams.get("days") ?? "30");
1280
+ let events;
1281
+ if (scope === "all") {
1282
+ events = decorateUsageEvents(listUsageEvents({ days }));
1283
+ }
1284
+ else {
1285
+ if (!repo)
1286
+ return err(400, "Repo not initialized");
1287
+ events = decorateUsageEvents(listUsageEvents({ repoId: repo.id, days }));
1288
+ }
1289
+ return ok({
1290
+ scope,
1291
+ days,
1292
+ events,
1293
+ aggregate: aggregateUsage(events, days),
1294
+ repos: listRepos().map((r) => ({ id: r.id, name: r.repo_name })),
1295
+ });
1296
+ }
1297
+ if (method === "POST" && pathname === "/api/usage/report") {
1298
+ const payload = body;
1299
+ if (typeof payload.saved !== "number" || payload.saved < 0) {
1300
+ return err(400, "saved must be a non-negative number");
1301
+ }
1302
+ if (payload.eventId) {
1303
+ return ok({ event: setReportedTokensSaved(payload.eventId, payload.saved) });
1304
+ }
1305
+ if (!repo)
1306
+ return err(400, "Repo not initialized");
1307
+ const platform = payload.platform ?? repo.platform ?? "unknown";
1308
+ return ok({ event: setReportedOnLatest(repo.id, platform, payload.saved) });
1309
+ }
1310
+ if (method === "POST" && pathname === "/api/wipe") {
1311
+ if (!repo)
1312
+ return err(400, "Nothing to wipe");
1313
+ const confirm = body?.yes;
1314
+ if (!confirm)
1315
+ return err(400, "Pass { yes: true } to wipe");
1316
+ wipeRepo(repo.id);
1317
+ return ok({ wiped: true });
1318
+ }
1319
+ return err(404, `Unknown route ${method} ${pathname}`);
1320
+ }
1321
+ export function logContextUsage(input) {
1322
+ const started = Date.now();
1323
+ const repo = getRepoById(input.repoId);
1324
+ const packet = buildContext(input.repoId, input.query, {
1325
+ rootPath: repo?.root_path,
1326
+ });
1327
+ const markdown = renderContextMarkdown(packet);
1328
+ const metrics = metricsFromPacket(packet, markdown);
1329
+ const localMs = Math.max(0, Date.now() - started);
1330
+ const event = insertUsageEvent({
1331
+ repoId: input.repoId,
1332
+ platform: input.platform,
1333
+ sessionId: input.sessionId,
1334
+ query: input.query,
1335
+ claimIds: metrics.claimIds,
1336
+ anchorsCount: metrics.anchorsCount,
1337
+ claimsCount: metrics.claimsCount,
1338
+ packetTokens: metrics.packetTokens,
1339
+ estimatedTokensSaved: metrics.estimatedTokensSaved,
1340
+ localMs,
1341
+ estimatedMsSaved: metrics.estimatedMsSaved,
1342
+ kind: metrics.kind,
1343
+ });
1344
+ return { markdown, event, packet };
1345
+ }