@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,46 @@
1
+ import { type ClaimRow, type ComponentRow, type ConversationNoteRow, type FlowRow, type UsageEventRow } from "./db.js";
2
+ import { type ClaimFreshness } from "./freshness.js";
3
+ export type RankedClaim = ClaimRow & {
4
+ score: number;
5
+ freshness: ClaimFreshness;
6
+ /** Human-readable ranking factors for "why was this injected?" */
7
+ reasons: string[];
8
+ };
9
+ export type ContextPacket = {
10
+ query: string;
11
+ claims: RankedClaim[];
12
+ flows: FlowRow[];
13
+ components: ComponentRow[];
14
+ notes: Array<ConversationNoteRow & {
15
+ score: number;
16
+ }>;
17
+ };
18
+ export type BuildContextOptions = {
19
+ limit?: number;
20
+ rootPath?: string;
21
+ /** Include cross-repo personal prefs (default true). */
22
+ includePersonal?: boolean;
23
+ };
24
+ export declare function buildContext(repoId: string, query: string, limitOrOpts?: number | BuildContextOptions): ContextPacket;
25
+ export type ShowdownClaim = {
26
+ id: string;
27
+ kind: string;
28
+ text: string;
29
+ score: number;
30
+ reasons: string[];
31
+ };
32
+ export type RetrievalShowdown = {
33
+ query: string;
34
+ free: ShowdownClaim[];
35
+ pro: ShowdownClaim[];
36
+ proLocked: boolean;
37
+ proOnlyIds: string[];
38
+ freeOnlyIds: string[];
39
+ };
40
+ /** Side-by-side free hash vs Pro n-gram retrieval for the same query. */
41
+ export declare function buildRetrievalShowdown(repoId: string, query: string, limitOrOpts?: number | BuildContextOptions): RetrievalShowdown;
42
+ /** Keyword match only. Fallback packets (score 0) are misses — the model call still happens. */
43
+ export declare function classifyLookup(query: string, claims: ClaimRow[]): "local_hit" | "server_trip";
44
+ export declare function decorateUsageEvent(e: UsageEventRow, claims: ClaimRow[]): UsageEventRow;
45
+ export declare function decorateUsageEvents(events: UsageEventRow[]): UsageEventRow[];
46
+ export declare function renderContextMarkdown(packet: ContextPacket): string;
@@ -0,0 +1,334 @@
1
+ import { getRepoByName, listClaims, listComponents, listConversationNotes, listEdges, listFlows, openDb, } from "./db.js";
2
+ import { assessClaimFreshness, freshnessScoreMultiplier, } from "./freshness.js";
3
+ import { kindRankBoost } from "./kinds.js";
4
+ import { ftsBoostFromBm25, keywordScoreClaim, searchClaimsFts, tokenize, } from "./search.js";
5
+ import { embedBoostFromScore, searchClaimsEmbed, searchClaimsEmbedLive } from "./embed.js";
6
+ import { FEATURE_LOCAL_EMBED, hasFeature } from "./license.js";
7
+ import { PERSONAL_SLUG } from "./personal.js";
8
+ function scoreNote(note, queryTokens) {
9
+ if (queryTokens.length === 0)
10
+ return 0;
11
+ const hay = note.text.toLowerCase();
12
+ let score = 0;
13
+ for (const token of queryTokens) {
14
+ if (hay.includes(token))
15
+ score += token.length > 4 ? 3 : 2;
16
+ }
17
+ return score;
18
+ }
19
+ function rankClaims(claims, query, queryTokens, opts) {
20
+ return claims.map((c) => {
21
+ const freshness = assessClaimFreshness(opts.rootPath, c);
22
+ const keyword = keywordScoreClaim(c, queryTokens);
23
+ const boost = opts.ftsBoost.get(c.id) ?? 0;
24
+ const embed = opts.embedBoost.get(c.id) ?? 0;
25
+ const pinBoost = (c.pinned ?? 0) > 0 ? 40 : 0;
26
+ const kindBoost = kindRankBoost(c.kind);
27
+ const matchSignal = keyword + boost + embed + pinBoost;
28
+ const reasons = [];
29
+ if (opts.extraReason)
30
+ reasons.push(opts.extraReason);
31
+ if (keyword > 0)
32
+ reasons.push(`keyword+${keyword}`);
33
+ if (boost > 0)
34
+ reasons.push(`fts+${boost.toFixed(1)}`);
35
+ if (embed > 0)
36
+ reasons.push(`embed+${embed.toFixed(1)}`);
37
+ if (pinBoost > 0)
38
+ reasons.push("pinned");
39
+ if (matchSignal > 0 && kindBoost >= 8)
40
+ reasons.push(`kind:${c.kind}`);
41
+ if (freshness.status === "stale")
42
+ reasons.push("stale↓");
43
+ else if (freshness.status === "fresh" && matchSignal > 0)
44
+ reasons.push("fresh");
45
+ const raw = matchSignal > 0 ? matchSignal + kindBoost * 0.35 : 0;
46
+ const withFloor = raw > 0
47
+ ? Math.max(raw, boost > 0 || embed > 0 ? boost + embed + pinBoost + kindBoost * 0.35 : raw)
48
+ : 0;
49
+ const score = withFloor * freshnessScoreMultiplier(freshness.status);
50
+ if (score > 0 && reasons.length === 0)
51
+ reasons.push("rank");
52
+ return { ...c, score, freshness, reasons };
53
+ });
54
+ }
55
+ export function buildContext(repoId, query, limitOrOpts = 12) {
56
+ const opts = typeof limitOrOpts === "number" ? { limit: limitOrOpts } : limitOrOpts;
57
+ const limit = opts.limit ?? 12;
58
+ const rootPath = opts.rootPath;
59
+ const includePersonal = opts.includePersonal !== false;
60
+ const queryTokens = tokenize(query);
61
+ const db = openDb();
62
+ const allActive = listClaims(repoId);
63
+ const ftsHits = searchClaimsFts(db, repoId, query, Math.max(limit * 2, 24));
64
+ const ftsBoost = new Map();
65
+ for (const hit of ftsHits) {
66
+ ftsBoost.set(hit.id, ftsBoostFromBm25(hit.bm25));
67
+ }
68
+ const embedHits = searchClaimsEmbed(db, repoId, query, Math.max(limit * 2, 24));
69
+ const embedBoost = new Map();
70
+ for (const hit of embedHits) {
71
+ embedBoost.set(hit.id, embedBoostFromScore(hit.score));
72
+ }
73
+ const scored = rankClaims(allActive, query, queryTokens, {
74
+ rootPath,
75
+ ftsBoost,
76
+ embedBoost,
77
+ })
78
+ .filter((c) => (queryTokens.length === 0 ? true : c.score > 0))
79
+ .sort((a, b) => b.score - a.score || b.updated_at.localeCompare(a.updated_at));
80
+ // If no keyword/FTS/embed hits, return newest fresh-leaning claims as weak fallback
81
+ let selected = scored.length > 0
82
+ ? scored.slice(0, limit)
83
+ : allActive.slice(0, Math.min(5, limit)).map((c) => {
84
+ const freshness = assessClaimFreshness(rootPath, c);
85
+ const reasons = ["fallback:recent"];
86
+ if ((c.pinned ?? 0) > 0)
87
+ reasons.unshift("pinned");
88
+ return { ...c, score: 0, freshness, reasons };
89
+ });
90
+ // Blend a few personal-prefs claims into project context (not org wiki).
91
+ if (includePersonal) {
92
+ const personal = getRepoByName(PERSONAL_SLUG);
93
+ if (personal && personal.id !== repoId) {
94
+ const personalClaims = listClaims(personal.id).filter((c) => c.id !== "claim.personal_scope");
95
+ if (personalClaims.length > 0) {
96
+ const pFts = new Map();
97
+ for (const hit of searchClaimsFts(db, personal.id, query, 12)) {
98
+ pFts.set(hit.id, ftsBoostFromBm25(hit.bm25));
99
+ }
100
+ const pEmbed = new Map();
101
+ for (const hit of searchClaimsEmbed(db, personal.id, query, 12)) {
102
+ pEmbed.set(hit.id, embedBoostFromScore(hit.score));
103
+ }
104
+ const personalRanked = rankClaims(personalClaims, query, queryTokens, {
105
+ rootPath: personal.root_path,
106
+ ftsBoost: pFts,
107
+ embedBoost: pEmbed,
108
+ extraReason: "personal",
109
+ })
110
+ .filter((c) => c.score > 0)
111
+ .sort((a, b) => b.score - a.score)
112
+ .slice(0, 3);
113
+ if (personalRanked.length > 0) {
114
+ const room = Math.max(1, Math.min(3, Math.floor(limit / 4) || 1));
115
+ const take = personalRanked.slice(0, room);
116
+ const keep = selected.slice(0, Math.max(0, limit - take.length));
117
+ selected = [...keep, ...take].sort((a, b) => b.score - a.score || b.updated_at.localeCompare(a.updated_at));
118
+ }
119
+ }
120
+ }
121
+ }
122
+ const edges = listEdges(repoId);
123
+ const flowIds = new Set();
124
+ const componentIds = new Set();
125
+ for (const claim of selected) {
126
+ for (const e of edges) {
127
+ if (e.from_type === "claim" && e.from_id === claim.id && e.to_type === "flow") {
128
+ flowIds.add(e.to_id);
129
+ }
130
+ if (e.to_type === "claim" && e.to_id === claim.id && e.from_type === "flow") {
131
+ flowIds.add(e.from_id);
132
+ }
133
+ }
134
+ }
135
+ for (const flowId of flowIds) {
136
+ for (const e of edges) {
137
+ if (e.from_type === "flow" && e.from_id === flowId && e.to_type === "component") {
138
+ componentIds.add(e.to_id);
139
+ }
140
+ if (e.to_type === "flow" && e.to_id === flowId && e.from_type === "component") {
141
+ componentIds.add(e.from_id);
142
+ }
143
+ }
144
+ }
145
+ const flows = listFlows(repoId).filter((f) => flowIds.has(f.id));
146
+ const components = listComponents(repoId).filter((c) => componentIds.has(c.id));
147
+ const notesRaw = listConversationNotes(repoId, 40)
148
+ .map((n) => ({ ...n, score: scoreNote(n, queryTokens) }))
149
+ .filter((n) => (queryTokens.length === 0 ? true : n.score > 0))
150
+ .sort((a, b) => b.score - a.score || b.created_at.localeCompare(a.created_at))
151
+ .slice(0, 5);
152
+ const notes = notesRaw.length > 0
153
+ ? notesRaw
154
+ : listConversationNotes(repoId, 5).map((n) => ({ ...n, score: 0 }));
155
+ return { query, claims: selected, flows, components, notes };
156
+ }
157
+ function toShowdownClaims(ranked) {
158
+ return ranked.map((c) => ({
159
+ id: c.id,
160
+ kind: c.kind,
161
+ text: c.text,
162
+ score: Math.round(c.score * 10) / 10,
163
+ reasons: c.reasons,
164
+ }));
165
+ }
166
+ function rankWithLiveEmbed(claims, query, queryTokens, ftsBoost, backend, rootPath, limit) {
167
+ const embedBoost = new Map();
168
+ for (const hit of searchClaimsEmbedLive(claims, query, backend, Math.max(limit * 2, 24))) {
169
+ embedBoost.set(hit.id, embedBoostFromScore(hit.score));
170
+ }
171
+ return rankClaims(claims, query, queryTokens, {
172
+ rootPath,
173
+ ftsBoost,
174
+ embedBoost,
175
+ extraReason: backend === "ngram" ? "embed:ngram" : "embed:hash",
176
+ })
177
+ .filter((c) => (queryTokens.length === 0 ? true : c.score > 0))
178
+ .sort((a, b) => b.score - a.score || b.updated_at.localeCompare(a.updated_at))
179
+ .slice(0, limit);
180
+ }
181
+ /** Side-by-side free hash vs Pro n-gram retrieval for the same query. */
182
+ export function buildRetrievalShowdown(repoId, query, limitOrOpts = 8) {
183
+ const opts = typeof limitOrOpts === "number" ? { limit: limitOrOpts } : limitOrOpts;
184
+ const limit = opts.limit ?? 8;
185
+ const rootPath = opts.rootPath;
186
+ const queryTokens = tokenize(query);
187
+ const db = openDb();
188
+ const allActive = listClaims(repoId);
189
+ const ftsBoost = new Map();
190
+ for (const hit of searchClaimsFts(db, repoId, query, Math.max(limit * 2, 24))) {
191
+ ftsBoost.set(hit.id, ftsBoostFromBm25(hit.bm25));
192
+ }
193
+ const freeRanked = rankWithLiveEmbed(allActive, query, queryTokens, ftsBoost, "hash", rootPath, limit);
194
+ const free = toShowdownClaims(freeRanked);
195
+ const proLocked = !hasFeature(FEATURE_LOCAL_EMBED);
196
+ let pro = [];
197
+ if (!proLocked) {
198
+ const proRanked = rankWithLiveEmbed(allActive, query, queryTokens, ftsBoost, "ngram", rootPath, limit);
199
+ pro = toShowdownClaims(proRanked);
200
+ }
201
+ const freeIds = new Set(free.map((c) => c.id));
202
+ const proIds = new Set(pro.map((c) => c.id));
203
+ return {
204
+ query,
205
+ free,
206
+ pro,
207
+ proLocked,
208
+ proOnlyIds: pro.filter((c) => !freeIds.has(c.id)).map((c) => c.id),
209
+ freeOnlyIds: free.filter((c) => !proIds.has(c.id)).map((c) => c.id),
210
+ };
211
+ }
212
+ function freshnessLabel(status) {
213
+ switch (status) {
214
+ case "stale":
215
+ return "stale";
216
+ case "missing_anchor":
217
+ return "missing_anchor";
218
+ default:
219
+ return null;
220
+ }
221
+ }
222
+ /** Keyword match only. Fallback packets (score 0) are misses — the model call still happens. */
223
+ export function classifyLookup(query, claims) {
224
+ const tokens = tokenize(query);
225
+ if (tokens.length === 0)
226
+ return "server_trip";
227
+ return claims.some((c) => keywordScoreClaim(c, tokens) > 0) ? "local_hit" : "server_trip";
228
+ }
229
+ export function decorateUsageEvent(e, claims) {
230
+ let ids = [];
231
+ try {
232
+ ids = JSON.parse(e.claim_ids || "[]");
233
+ }
234
+ catch {
235
+ ids = [];
236
+ }
237
+ const subset = ids.length ? claims.filter((c) => ids.includes(c.id)) : [];
238
+ const kind = classifyLookup(e.query || "", subset);
239
+ if (kind === "local_hit")
240
+ return { ...e, kind };
241
+ return {
242
+ ...e,
243
+ kind,
244
+ estimated_tokens_saved: 0,
245
+ estimated_ms_saved: 0,
246
+ };
247
+ }
248
+ export function decorateUsageEvents(events) {
249
+ const cache = new Map();
250
+ return events.map((e) => {
251
+ let claims = cache.get(e.repo_id);
252
+ if (!claims) {
253
+ claims = listClaims(e.repo_id);
254
+ cache.set(e.repo_id, claims);
255
+ }
256
+ return decorateUsageEvent(e, claims);
257
+ });
258
+ }
259
+ export function renderContextMarkdown(packet) {
260
+ const lines = ["# Agent Memory Context", "", `Query: ${packet.query}`, ""];
261
+ if (packet.claims.length === 0 && packet.notes.length === 0) {
262
+ lines.push("No claims stored for this repository yet.", "");
263
+ lines.push("Seed memory with the `amem-bootstrap` skill or `amem propose apply <file>`.");
264
+ return lines.join("\n");
265
+ }
266
+ if (packet.claims.length === 0) {
267
+ lines.push("No durable claims yet — using recent conversation memory.", "");
268
+ }
269
+ const staleCount = packet.claims.filter((c) => c.freshness.status === "stale").length;
270
+ if (staleCount > 0) {
271
+ lines.push(`_${staleCount} claim(s) marked stale — anchored files changed after the claim was written. Verify before trusting._`, "");
272
+ }
273
+ if (packet.claims.length > 0) {
274
+ lines.push("## Best Claims", "");
275
+ for (const claim of packet.claims) {
276
+ lines.push(`### ${claim.id}`);
277
+ lines.push(`Kind: \`${claim.kind}\``);
278
+ if ((claim.pinned ?? 0) > 0) {
279
+ lines.push(`Pinned: \`yes\``);
280
+ }
281
+ const fl = freshnessLabel(claim.freshness.status);
282
+ if (fl) {
283
+ const detail = claim.freshness.staleAnchors.length > 0
284
+ ? ` — ${claim.freshness.staleAnchors.map((a) => `\`${a}\``).join(", ")} changed after claim`
285
+ : claim.freshness.missingAnchors.length > 0
286
+ ? ` — missing ${claim.freshness.missingAnchors.map((a) => `\`${a}\``).join(", ")}`
287
+ : "";
288
+ lines.push(`Freshness: \`${fl}\`${detail}`);
289
+ }
290
+ if (claim.reasons?.length) {
291
+ lines.push(`Why: ${claim.reasons.map((r) => `\`${r}\``).join(", ")}`);
292
+ }
293
+ lines.push("");
294
+ lines.push(claim.text);
295
+ lines.push("");
296
+ let anchors = [];
297
+ try {
298
+ anchors = JSON.parse(claim.code_anchors);
299
+ }
300
+ catch {
301
+ anchors = [];
302
+ }
303
+ if (anchors.length > 0) {
304
+ lines.push(`Anchors: ${anchors.map((a) => `\`${a}\``).join(", ")}`);
305
+ lines.push("");
306
+ }
307
+ }
308
+ }
309
+ if (packet.flows.length > 0) {
310
+ lines.push("## Related Flows", "");
311
+ for (const flow of packet.flows) {
312
+ lines.push(`- \`${flow.id}\`: ${flow.name}`);
313
+ }
314
+ lines.push("");
315
+ }
316
+ if (packet.components.length > 0) {
317
+ lines.push("## Related Components", "");
318
+ for (const component of packet.components) {
319
+ const anchor = component.code_anchor ? ` — \`${component.code_anchor}\`` : "";
320
+ lines.push(`- \`${component.id}\`: ${component.name}${anchor}`);
321
+ }
322
+ lines.push("");
323
+ }
324
+ if (packet.notes.length > 0) {
325
+ lines.push("## Recent conversation memory", "");
326
+ for (const note of packet.notes) {
327
+ const label = note.role === "user" ? "You asked" : note.role === "assistant" ? "Prior answer" : note.role;
328
+ lines.push(`- (${label}) ${note.text.replace(/\s+/g, " ").slice(0, 280)}`);
329
+ }
330
+ lines.push("");
331
+ }
332
+ lines.push("_Memory is personal and local (`~/.amem`). Prefer these anchors over broad exploration. Verify current code before changing anything._");
333
+ return lines.join("\n");
334
+ }
@@ -0,0 +1,39 @@
1
+ export declare function encryptedDbPath(): string;
2
+ export declare function saltPath(): string;
3
+ export declare function isDbEncryptedAtRest(): boolean;
4
+ export declare function encryptionConfigured(): boolean;
5
+ /** AES-256-GCM encrypt bytes → salt||iv||tag||ciphertext layout for files. */
6
+ export declare function encryptBytes(plain: Buffer, passphrase: string, salt?: Buffer): Buffer;
7
+ export declare function decryptBytes(blob: Buffer, passphrase: string): Buffer;
8
+ export declare function resolvePassphrase(explicit?: string): string;
9
+ /** Encrypt plaintext DB to graph.db.enc and remove plaintext. */
10
+ export declare function lockDatabase(passphrase: string): {
11
+ encPath: string;
12
+ };
13
+ /** Decrypt graph.db.enc to plaintext graph.db for this machine session. */
14
+ export declare function unlockDatabase(passphrase: string): {
15
+ dbPath: string;
16
+ };
17
+ /** Re-encrypt after unlock (keeps enc in sync) without deleting plaintext — for backups. */
18
+ export declare function encryptFileTo(path: string, outPath: string, passphrase: string): void;
19
+ export declare function decryptFileTo(path: string, outPath: string, passphrase: string): void;
20
+ export declare function fingerprintPassphrase(passphrase: string): string;
21
+ export declare function defaultBackupDir(): string;
22
+ export declare function createBackup(opts: {
23
+ outDir?: string;
24
+ passphrase?: string;
25
+ label?: string;
26
+ }): {
27
+ path: string;
28
+ encrypted: boolean;
29
+ };
30
+ /** Replace graph.db from a local backup (plaintext .db or AMEM1 .enc). Caller must closeDb(). */
31
+ export declare function restoreBackup(opts: {
32
+ file: string;
33
+ passphrase?: string;
34
+ }): {
35
+ dbPath: string;
36
+ from: string;
37
+ safetyCopy: string | null;
38
+ encrypted: boolean;
39
+ };
package/dist/crypto.js ADDED
@@ -0,0 +1,166 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "node:crypto";
2
+ import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { amemHome, dbPath } from "./paths.js";
5
+ const ENC_SUFFIX = ".enc";
6
+ const SALT_FILE = "crypto.salt";
7
+ export function encryptedDbPath() {
8
+ return `${dbPath()}${ENC_SUFFIX}`;
9
+ }
10
+ export function saltPath() {
11
+ return join(amemHome(), SALT_FILE);
12
+ }
13
+ export function isDbEncryptedAtRest() {
14
+ return existsSync(encryptedDbPath()) && !existsSync(dbPath());
15
+ }
16
+ export function encryptionConfigured() {
17
+ return existsSync(encryptedDbPath()) || existsSync(saltPath());
18
+ }
19
+ function deriveKey(passphrase, salt) {
20
+ return scryptSync(passphrase, salt, 32);
21
+ }
22
+ function loadOrCreateSalt() {
23
+ const path = saltPath();
24
+ if (existsSync(path))
25
+ return readFileSync(path);
26
+ mkdirSync(amemHome(), { recursive: true, mode: 0o700 });
27
+ const salt = randomBytes(16);
28
+ writeFileSync(path, salt, { mode: 0o600 });
29
+ return salt;
30
+ }
31
+ /** AES-256-GCM encrypt bytes → salt||iv||tag||ciphertext layout for files. */
32
+ export function encryptBytes(plain, passphrase, salt) {
33
+ const useSalt = salt ?? loadOrCreateSalt();
34
+ const key = deriveKey(passphrase, useSalt);
35
+ const iv = randomBytes(12);
36
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
37
+ const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
38
+ const tag = cipher.getAuthTag();
39
+ return Buffer.concat([Buffer.from("AMEM1"), useSalt, iv, tag, ciphertext]);
40
+ }
41
+ export function decryptBytes(blob, passphrase) {
42
+ if (blob.length < 5 + 16 + 12 + 16 || blob.subarray(0, 5).toString() !== "AMEM1") {
43
+ throw new Error("Not an amem encrypted blob");
44
+ }
45
+ const salt = blob.subarray(5, 21);
46
+ const iv = blob.subarray(21, 33);
47
+ const tag = blob.subarray(33, 49);
48
+ const ciphertext = blob.subarray(49);
49
+ const key = deriveKey(passphrase, salt);
50
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
51
+ decipher.setAuthTag(tag);
52
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
53
+ }
54
+ export function resolvePassphrase(explicit) {
55
+ const p = explicit || process.env.AMEM_PASSPHRASE || "";
56
+ if (!p.trim()) {
57
+ throw new Error("Passphrase required. Pass --passphrase or set AMEM_PASSPHRASE (never commit it).");
58
+ }
59
+ return p;
60
+ }
61
+ /** Encrypt plaintext DB to graph.db.enc and remove plaintext. */
62
+ export function lockDatabase(passphrase) {
63
+ const plain = dbPath();
64
+ if (!existsSync(plain))
65
+ throw new Error(`No database at ${plain}`);
66
+ const enc = encryptedDbPath();
67
+ const blob = encryptBytes(readFileSync(plain), passphrase);
68
+ writeFileSync(enc, blob, { mode: 0o600 });
69
+ unlinkSync(plain);
70
+ // also clear WAL/SHM if present
71
+ for (const side of [`${plain}-wal`, `${plain}-shm`]) {
72
+ if (existsSync(side))
73
+ unlinkSync(side);
74
+ }
75
+ return { encPath: enc };
76
+ }
77
+ /** Decrypt graph.db.enc to plaintext graph.db for this machine session. */
78
+ export function unlockDatabase(passphrase) {
79
+ const enc = encryptedDbPath();
80
+ if (!existsSync(enc))
81
+ throw new Error(`No encrypted database at ${enc}`);
82
+ const plain = dbPath();
83
+ const data = decryptBytes(readFileSync(enc), passphrase);
84
+ writeFileSync(plain, data, { mode: 0o600 });
85
+ return { dbPath: plain };
86
+ }
87
+ /** Re-encrypt after unlock (keeps enc in sync) without deleting plaintext — for backups. */
88
+ export function encryptFileTo(path, outPath, passphrase) {
89
+ const blob = encryptBytes(readFileSync(path), passphrase);
90
+ mkdirSync(dirname(outPath), { recursive: true, mode: 0o700 });
91
+ writeFileSync(outPath, blob, { mode: 0o600 });
92
+ }
93
+ export function decryptFileTo(path, outPath, passphrase) {
94
+ const data = decryptBytes(readFileSync(path), passphrase);
95
+ mkdirSync(dirname(outPath), { recursive: true, mode: 0o700 });
96
+ writeFileSync(outPath, data, { mode: 0o600 });
97
+ }
98
+ export function fingerprintPassphrase(passphrase) {
99
+ return createHash("sha256").update(passphrase).digest("hex").slice(0, 12);
100
+ }
101
+ export function defaultBackupDir() {
102
+ return join(amemHome(), "backups");
103
+ }
104
+ export function createBackup(opts) {
105
+ const plain = dbPath();
106
+ const enc = encryptedDbPath();
107
+ const source = existsSync(plain) ? plain : existsSync(enc) ? enc : null;
108
+ if (!source)
109
+ throw new Error("Nothing to back up — no local amem database found");
110
+ const dir = opts.outDir || defaultBackupDir();
111
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
112
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
113
+ const label = (opts.label || "amem").replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 40);
114
+ const encrypted = Boolean(opts.passphrase) || source.endsWith(ENC_SUFFIX);
115
+ if (opts.passphrase) {
116
+ const out = join(dir, `${label}-${stamp}.db.enc`);
117
+ if (source.endsWith(ENC_SUFFIX)) {
118
+ // already encrypted — copy as-is (cannot re-wrap without unlock)
119
+ copyFileSync(source, out);
120
+ }
121
+ else {
122
+ encryptFileTo(source, out, opts.passphrase);
123
+ }
124
+ return { path: out, encrypted: true };
125
+ }
126
+ if (source.endsWith(ENC_SUFFIX)) {
127
+ const out = join(dir, `${label}-${stamp}.db.enc`);
128
+ copyFileSync(source, out);
129
+ return { path: out, encrypted: true };
130
+ }
131
+ const out = join(dir, `${label}-${stamp}.db`);
132
+ copyFileSync(source, out);
133
+ return { path: out, encrypted: false };
134
+ }
135
+ /** Replace graph.db from a local backup (plaintext .db or AMEM1 .enc). Caller must closeDb(). */
136
+ export function restoreBackup(opts) {
137
+ const from = opts.file;
138
+ if (!existsSync(from))
139
+ throw new Error(`Backup not found: ${from}`);
140
+ const blob = readFileSync(from);
141
+ const looksEnc = blob.subarray(0, 5).toString() === "AMEM1" || from.endsWith(ENC_SUFFIX);
142
+ let plain;
143
+ if (looksEnc) {
144
+ const passphrase = opts.passphrase || process.env.AMEM_PASSPHRASE || "";
145
+ if (!passphrase.trim()) {
146
+ throw new Error("Encrypted backup — pass --passphrase or set AMEM_PASSPHRASE");
147
+ }
148
+ plain = decryptBytes(blob, passphrase);
149
+ }
150
+ else {
151
+ plain = blob;
152
+ }
153
+ if (plain.length < 16)
154
+ throw new Error("Backup file is empty or not a database");
155
+ const target = dbPath();
156
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
157
+ let safetyCopy = null;
158
+ if (existsSync(target)) {
159
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
160
+ safetyCopy = join(defaultBackupDir(), `pre-restore-${stamp}.db`);
161
+ mkdirSync(defaultBackupDir(), { recursive: true, mode: 0o700 });
162
+ copyFileSync(target, safetyCopy);
163
+ }
164
+ writeFileSync(target, plain, { mode: 0o600 });
165
+ return { dbPath: target, from, safetyCopy, encrypted: looksEnc };
166
+ }