@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,85 @@
1
+ import { listClaims } from "./db.js";
2
+ import { kindRankBoost } from "./kinds.js";
3
+ import { findClaimConflicts, parseProposalJson, } from "./proposal.js";
4
+ export const REJECT_SCORE = 25;
5
+ function labelFor(score) {
6
+ if (score < REJECT_SCORE)
7
+ return "reject";
8
+ if (score < 45)
9
+ return "low";
10
+ if (score < 60)
11
+ return "medium";
12
+ return "high";
13
+ }
14
+ /** Confidence 0–100 for a session-end / miss→learn proposal. */
15
+ export function scoreProposal(proposal) {
16
+ const reasons = [];
17
+ let score = 10;
18
+ const claims = proposal.claims ?? [];
19
+ if (claims.length === 0) {
20
+ return { score: 0, label: "reject", reasons: ["empty proposal"], reject: true };
21
+ }
22
+ for (const claim of claims) {
23
+ const kind = (claim.kind || "session").toLowerCase();
24
+ const kindPts = Math.min(30, kindRankBoost(kind) * 2);
25
+ score += kindPts;
26
+ reasons.push(`${kind} +${kindPts}`);
27
+ const anchors = (claim.code_anchors ?? []).filter((a) => a && a !== "README.md");
28
+ const anchorPts = Math.min(20, anchors.length * 10);
29
+ score += anchorPts;
30
+ if (anchorPts)
31
+ reasons.push(`${anchors.length} file anchor${anchors.length === 1 ? "" : "s"} +${anchorPts}`);
32
+ else
33
+ reasons.push("no real file anchors");
34
+ const text = (claim.text || "").trim();
35
+ if (text.length >= 60) {
36
+ score += 15;
37
+ reasons.push("specific takeaway +15");
38
+ }
39
+ else if (text.length >= 40) {
40
+ score += 8;
41
+ reasons.push("short takeaway +8");
42
+ }
43
+ else {
44
+ score -= 10;
45
+ reasons.push("thin text −10");
46
+ }
47
+ if (/\b(must|never|always|gotcha|constraint|idempotent|before|entrypoint)\b/i.test(text)) {
48
+ score += 10;
49
+ reasons.push("durable language +10");
50
+ }
51
+ if (/\b(ok|thanks|sure|please)\b/i.test(text) && text.length < 50) {
52
+ score -= 15;
53
+ reasons.push("chat noise −15");
54
+ }
55
+ if (kind === "session" && anchors.length === 0) {
56
+ score -= 20;
57
+ reasons.push("session without anchors −20");
58
+ }
59
+ }
60
+ score = Math.max(0, Math.min(100, score));
61
+ const label = labelFor(score);
62
+ return { score, label, reasons, reject: label === "reject" };
63
+ }
64
+ export function parseDraftProposal(draft) {
65
+ try {
66
+ return parseProposalJson(draft.proposal_json);
67
+ }
68
+ catch {
69
+ return { claims: [] };
70
+ }
71
+ }
72
+ export function decorateDraft(draft, existingActive = listClaims(draft.repo_id)) {
73
+ const proposal = parseDraftProposal(draft);
74
+ const quality = scoreProposal(proposal);
75
+ const conflicts = findClaimConflicts(proposal, existingActive.filter((c) => (c.status ?? "active") === "active"));
76
+ return { ...draft, quality, conflicts };
77
+ }
78
+ export function decorateDrafts(drafts) {
79
+ const byRepo = new Map();
80
+ return drafts.map((d) => {
81
+ if (!byRepo.has(d.repo_id))
82
+ byRepo.set(d.repo_id, listClaims(d.repo_id));
83
+ return decorateDraft(d, byRepo.get(d.repo_id));
84
+ });
85
+ }
@@ -0,0 +1,76 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { ClaimRow } from "./db.js";
3
+ export declare const HASH_DIM = 128;
4
+ export declare const NGRAM_DIM = 256;
5
+ /** @deprecated use HASH_DIM — kept so older tests keep compiling */
6
+ export declare const EMBED_DIM = 128;
7
+ export type EmbedBackend = "hash" | "ngram" | "external";
8
+ export type EmbedStatus = {
9
+ backend: EmbedBackend;
10
+ requested: EmbedBackend;
11
+ dim: number;
12
+ licensed: boolean;
13
+ path: string;
14
+ command?: string;
15
+ args: string[];
16
+ };
17
+ export declare function requestedEmbedBackend(): EmbedBackend;
18
+ export declare function activeEmbedBackend(): EmbedBackend;
19
+ export declare function embedDim(backend?: EmbedBackend): number;
20
+ export declare function embedCommand(): {
21
+ command?: string;
22
+ args: string[];
23
+ };
24
+ export declare function embedStatus(): EmbedStatus;
25
+ export declare function setEmbedBackend(backend: EmbedBackend, extra?: {
26
+ command?: string;
27
+ args?: string[];
28
+ dim?: number;
29
+ }): EmbedStatus;
30
+ export declare function embedExternal(text: string): Float32Array;
31
+ /** Local n-gram encoder: word tokens + char 3-grams + token pairs. No download. */
32
+ export declare function embedNgram(text: string): Float32Array;
33
+ export declare function embedText(text: string, backend?: EmbedBackend): Float32Array;
34
+ export declare function cosine(a: Float32Array, b: Float32Array): number;
35
+ export declare function vectorToBlob(vec: Float32Array): Buffer;
36
+ export declare function blobToVector(buf: Buffer): Float32Array;
37
+ export declare function ensureClaimsEmbed(db: Database.Database): void;
38
+ export declare function upsertClaimEmbed(db: Database.Database, claim: ClaimRow): void;
39
+ export declare function removeClaimEmbed(db: Database.Database, repoId: string, claimId: string): void;
40
+ export declare function reindexRepoEmbeds(db: Database.Database, repoId: string): void;
41
+ export declare function reindexAllEmbeds(db: Database.Database): {
42
+ repos: number;
43
+ claims: number;
44
+ };
45
+ export type EmbedIndexHealth = {
46
+ active: EmbedBackend;
47
+ dim: number;
48
+ total: number;
49
+ usable: number;
50
+ stale: number;
51
+ strandedBy: Array<{
52
+ backend: string;
53
+ dim: number;
54
+ count: number;
55
+ }>;
56
+ };
57
+ /**
58
+ * Stored vectors are only scored when their backend *and* dim match the active one, so a
59
+ * backend switch (usually a license change) silently drops facts out of semantic ranking
60
+ * without any error. This reports that drift so doctor/attest can tell the user to reindex.
61
+ */
62
+ export declare function embedIndexHealth(db: Database.Database): EmbedIndexHealth;
63
+ /** Human-readable warnings for doctor/attest. Empty when the index is consistent. */
64
+ export declare function embedIndexIssues(db: Database.Database): string[];
65
+ export type EmbedHit = {
66
+ id: string;
67
+ score: number;
68
+ };
69
+ export declare function searchClaimsEmbed(db: Database.Database, repoId: string, query: string, limit?: number): EmbedHit[];
70
+ /**
71
+ * Live embed ranking for a forced backend (hash vs ngram showdown).
72
+ * Scores active claim texts against the query without requiring stored rows for that backend.
73
+ */
74
+ export declare function searchClaimsEmbedLive(claims: ClaimRow[], query: string, backend: EmbedBackend, limit?: number): EmbedHit[];
75
+ /** Convert cosine (0..1-ish) into a ranking boost comparable to FTS. */
76
+ export declare function embedBoostFromScore(score: number): number;
package/dist/embed.js ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * On-device embeddings. Default is feature hashing (no download).
3
+ * Pro can switch to a local n-gram encoder (still no cloud, no model fetch).
4
+ */
5
+ import { execFileSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { FEATURE_LOCAL_EMBED, hasFeature } from "./license.js";
9
+ import { amemHome } from "./paths.js";
10
+ import { tokenize } from "./search.js";
11
+ export const HASH_DIM = 128;
12
+ export const NGRAM_DIM = 256;
13
+ /** @deprecated use HASH_DIM — kept so older tests keep compiling */
14
+ export const EMBED_DIM = HASH_DIM;
15
+ function embedSettingsPath() {
16
+ return join(amemHome(), "embed.json");
17
+ }
18
+ function readEmbedSettings() {
19
+ const path = embedSettingsPath();
20
+ if (!existsSync(path))
21
+ return {};
22
+ try {
23
+ return JSON.parse(readFileSync(path, "utf8"));
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ export function requestedEmbedBackend() {
30
+ const env = (process.env.AMEM_EMBED_BACKEND || "").trim().toLowerCase();
31
+ if (env === "hash" || env === "ngram" || env === "external")
32
+ return env;
33
+ const raw = readEmbedSettings().backend;
34
+ if (raw === "ngram" || raw === "external")
35
+ return raw;
36
+ return "hash";
37
+ }
38
+ export function activeEmbedBackend() {
39
+ const requested = requestedEmbedBackend();
40
+ if ((requested === "ngram" || requested === "external") && hasFeature(FEATURE_LOCAL_EMBED)) {
41
+ return requested;
42
+ }
43
+ return "hash";
44
+ }
45
+ export function embedDim(backend = activeEmbedBackend()) {
46
+ if (backend === "ngram")
47
+ return NGRAM_DIM;
48
+ if (backend === "external") {
49
+ const n = Number(process.env.AMEM_EMBED_DIM || readEmbedSettings().dim || 0);
50
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : HASH_DIM;
51
+ }
52
+ return HASH_DIM;
53
+ }
54
+ export function embedCommand() {
55
+ const settings = readEmbedSettings();
56
+ const command = (process.env.AMEM_EMBED_CMD || settings.command || "").trim() || undefined;
57
+ const args = Array.isArray(settings.args) ? settings.args.filter((a) => typeof a === "string") : [];
58
+ return { command, args };
59
+ }
60
+ export function embedStatus() {
61
+ const requested = requestedEmbedBackend();
62
+ const backend = activeEmbedBackend();
63
+ const { command, args } = embedCommand();
64
+ return {
65
+ backend,
66
+ requested,
67
+ dim: embedDim(backend),
68
+ licensed: hasFeature(FEATURE_LOCAL_EMBED),
69
+ path: embedSettingsPath(),
70
+ command,
71
+ args,
72
+ };
73
+ }
74
+ export function setEmbedBackend(backend, extra = {}) {
75
+ if ((backend === "ngram" || backend === "external") && !hasFeature(FEATURE_LOCAL_EMBED)) {
76
+ throw new Error("Local embeddings need an amem Pro or IT license. Buy at https://getamem.com then: amem license apply --file <amem-license.json>");
77
+ }
78
+ if (backend === "external" && !(extra.command || process.env.AMEM_EMBED_CMD || readEmbedSettings().command)) {
79
+ throw new Error("external embedder needs --cmd (stdin text → stdout JSON { vector: number[] })");
80
+ }
81
+ mkdirSync(amemHome(), { recursive: true, mode: 0o700 });
82
+ const prev = readEmbedSettings();
83
+ const next = {
84
+ backend,
85
+ command: extra.command ?? prev.command,
86
+ args: extra.args ?? prev.args,
87
+ dim: extra.dim ?? prev.dim,
88
+ };
89
+ writeFileSync(embedSettingsPath(), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
90
+ return embedStatus();
91
+ }
92
+ function parseExternalVector(raw) {
93
+ const trimmed = raw.trim();
94
+ let values = [];
95
+ try {
96
+ const parsed = JSON.parse(trimmed);
97
+ const list = Array.isArray(parsed) ? parsed : parsed.vector;
98
+ if (Array.isArray(list))
99
+ values = list.map((n) => Number(n));
100
+ }
101
+ catch {
102
+ values = trimmed.split(/[\s,]+/).map((n) => Number(n));
103
+ }
104
+ const clean = values.filter((n) => Number.isFinite(n));
105
+ if (clean.length < 4)
106
+ throw new Error("external embedder returned no usable vector");
107
+ return normalize(Float32Array.from(clean));
108
+ }
109
+ export function embedExternal(text) {
110
+ const { command, args } = embedCommand();
111
+ if (!command)
112
+ throw new Error("No AMEM_EMBED_CMD / embed.json command configured");
113
+ const out = execFileSync(command, args, {
114
+ input: text,
115
+ encoding: "utf8",
116
+ timeout: 8000,
117
+ maxBuffer: 2_000_000,
118
+ windowsHide: true,
119
+ });
120
+ return parseExternalVector(out);
121
+ }
122
+ function hashToken(token, dim) {
123
+ let h = 2166136261;
124
+ for (let i = 0; i < token.length; i++) {
125
+ h ^= token.charCodeAt(i);
126
+ h = Math.imul(h, 16777619);
127
+ }
128
+ return Math.abs(h) % dim;
129
+ }
130
+ function addToken(vec, token, dim) {
131
+ const i = hashToken(token, dim);
132
+ const sign = hashToken(`s:${token}`, dim) % 2 === 0 ? 1 : -1;
133
+ vec[i] += sign;
134
+ }
135
+ function normalize(vec) {
136
+ let norm = 0;
137
+ for (let i = 0; i < vec.length; i++)
138
+ norm += vec[i] * vec[i];
139
+ norm = Math.sqrt(norm) || 1;
140
+ for (let i = 0; i < vec.length; i++)
141
+ vec[i] = vec[i] / norm;
142
+ return vec;
143
+ }
144
+ function embedHash(text) {
145
+ const vec = new Float32Array(HASH_DIM);
146
+ const tokens = tokenize(text);
147
+ if (tokens.length === 0)
148
+ return vec;
149
+ for (const t of tokens)
150
+ addToken(vec, t, HASH_DIM);
151
+ return normalize(vec);
152
+ }
153
+ /** Local n-gram encoder: word tokens + char 3-grams + token pairs. No download. */
154
+ export function embedNgram(text) {
155
+ const vec = new Float32Array(NGRAM_DIM);
156
+ const tokens = tokenize(text);
157
+ const chars = text.toLowerCase().replace(/\s+/g, " ");
158
+ for (const t of tokens)
159
+ addToken(vec, t, NGRAM_DIM);
160
+ for (let i = 0; i < tokens.length - 1; i++) {
161
+ addToken(vec, `${tokens[i]}_${tokens[i + 1]}`, NGRAM_DIM);
162
+ }
163
+ for (let i = 0; i < chars.length - 2; i++) {
164
+ addToken(vec, `c:${chars.slice(i, i + 3)}`, NGRAM_DIM);
165
+ }
166
+ return normalize(vec);
167
+ }
168
+ export function embedText(text, backend = activeEmbedBackend()) {
169
+ if (backend === "ngram")
170
+ return embedNgram(text);
171
+ if (backend === "external") {
172
+ try {
173
+ return embedExternal(text);
174
+ }
175
+ catch {
176
+ return embedHash(text);
177
+ }
178
+ }
179
+ return embedHash(text);
180
+ }
181
+ export function cosine(a, b) {
182
+ let s = 0;
183
+ const n = Math.min(a.length, b.length);
184
+ for (let i = 0; i < n; i++)
185
+ s += a[i] * b[i];
186
+ return s;
187
+ }
188
+ export function vectorToBlob(vec) {
189
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
190
+ }
191
+ export function blobToVector(buf) {
192
+ return new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4));
193
+ }
194
+ export function ensureClaimsEmbed(db) {
195
+ db.exec(`
196
+ CREATE TABLE IF NOT EXISTS claims_embed (
197
+ repo_id TEXT NOT NULL,
198
+ claim_id TEXT NOT NULL,
199
+ dim INTEGER NOT NULL,
200
+ vector BLOB NOT NULL,
201
+ updated_at TEXT NOT NULL,
202
+ PRIMARY KEY (repo_id, claim_id)
203
+ );
204
+ `);
205
+ const cols = db.prepare(`PRAGMA table_info(claims_embed)`).all();
206
+ if (!cols.some((c) => c.name === "backend")) {
207
+ db.exec(`ALTER TABLE claims_embed ADD COLUMN backend TEXT NOT NULL DEFAULT 'hash'`);
208
+ }
209
+ }
210
+ export function upsertClaimEmbed(db, claim) {
211
+ ensureClaimsEmbed(db);
212
+ const backend = activeEmbedBackend();
213
+ const text = `${claim.id} ${claim.kind} ${claim.text} ${claim.code_anchors}`;
214
+ const vec = embedText(text, backend);
215
+ db.prepare(`INSERT INTO claims_embed (repo_id, claim_id, dim, vector, updated_at, backend)
216
+ VALUES (?, ?, ?, ?, ?, ?)
217
+ ON CONFLICT(repo_id, claim_id) DO UPDATE SET
218
+ dim = excluded.dim,
219
+ vector = excluded.vector,
220
+ updated_at = excluded.updated_at,
221
+ backend = excluded.backend`).run(claim.repo_id, claim.id, vec.length, vectorToBlob(vec), claim.updated_at, backend);
222
+ }
223
+ export function removeClaimEmbed(db, repoId, claimId) {
224
+ ensureClaimsEmbed(db);
225
+ db.prepare(`DELETE FROM claims_embed WHERE repo_id = ? AND claim_id = ?`).run(repoId, claimId);
226
+ }
227
+ export function reindexRepoEmbeds(db, repoId) {
228
+ ensureClaimsEmbed(db);
229
+ db.prepare(`DELETE FROM claims_embed WHERE repo_id = ?`).run(repoId);
230
+ const claims = db
231
+ .prepare(`SELECT * FROM claims WHERE repo_id = ? AND COALESCE(status, 'active') = 'active'`)
232
+ .all(repoId);
233
+ for (const c of claims)
234
+ upsertClaimEmbed(db, c);
235
+ }
236
+ export function reindexAllEmbeds(db) {
237
+ ensureClaimsEmbed(db);
238
+ const repos = db.prepare(`SELECT DISTINCT repo_id AS id FROM claims`).all();
239
+ let claims = 0;
240
+ for (const repo of repos) {
241
+ reindexRepoEmbeds(db, repo.id);
242
+ claims += db
243
+ .prepare(`SELECT COUNT(*) AS n FROM claims_embed WHERE repo_id = ?`)
244
+ .get(repo.id).n;
245
+ }
246
+ return { repos: repos.length, claims };
247
+ }
248
+ /**
249
+ * Stored vectors are only scored when their backend *and* dim match the active one, so a
250
+ * backend switch (usually a license change) silently drops facts out of semantic ranking
251
+ * without any error. This reports that drift so doctor/attest can tell the user to reindex.
252
+ */
253
+ export function embedIndexHealth(db) {
254
+ ensureClaimsEmbed(db);
255
+ const active = activeEmbedBackend();
256
+ const dim = embedDim(active);
257
+ const rows = db
258
+ .prepare(`SELECT COALESCE(backend, 'hash') AS backend, dim, COUNT(*) AS n
259
+ FROM claims_embed GROUP BY backend, dim`)
260
+ .all();
261
+ let total = 0;
262
+ let usable = 0;
263
+ const strandedBy = [];
264
+ for (const row of rows) {
265
+ total += row.n;
266
+ if (row.backend === active && row.dim === dim)
267
+ usable += row.n;
268
+ else
269
+ strandedBy.push({ backend: row.backend, dim: row.dim, count: row.n });
270
+ }
271
+ return { active, dim, total, usable, stale: total - usable, strandedBy };
272
+ }
273
+ /** Human-readable warnings for doctor/attest. Empty when the index is consistent. */
274
+ export function embedIndexIssues(db) {
275
+ let health;
276
+ try {
277
+ health = embedIndexHealth(db);
278
+ }
279
+ catch {
280
+ return [];
281
+ }
282
+ if (health.stale === 0)
283
+ return [];
284
+ const from = health.strandedBy
285
+ .map((s) => `${s.backend}/${s.dim}`)
286
+ .join(", ");
287
+ return [
288
+ `${health.stale} of ${health.total} facts are indexed with ${from} but the active embed backend is ${health.active}/${health.dim} — semantic search skips them. Run: amem embed reindex`,
289
+ ];
290
+ }
291
+ export function searchClaimsEmbed(db, repoId, query, limit = 24) {
292
+ return searchClaimsEmbedBackend(db, repoId, query, activeEmbedBackend(), limit);
293
+ }
294
+ /**
295
+ * Live embed ranking for a forced backend (hash vs ngram showdown).
296
+ * Scores active claim texts against the query without requiring stored rows for that backend.
297
+ */
298
+ export function searchClaimsEmbedLive(claims, query, backend, limit = 24) {
299
+ if (tokenize(query).length === 0)
300
+ return [];
301
+ const qv = embedText(query, backend);
302
+ const scored = claims
303
+ .map((c) => {
304
+ const text = `${c.id} ${c.kind} ${c.text} ${c.code_anchors}`;
305
+ return { id: c.id, score: cosine(qv, embedText(text, backend)) };
306
+ })
307
+ .filter((r) => r.score > 0.05)
308
+ .sort((a, b) => b.score - a.score)
309
+ .slice(0, limit);
310
+ return scored;
311
+ }
312
+ function searchClaimsEmbedBackend(db, repoId, query, backend, limit = 24) {
313
+ ensureClaimsEmbed(db);
314
+ const qv = embedText(query, backend);
315
+ if (tokenize(query).length === 0)
316
+ return [];
317
+ const rows = db
318
+ .prepare(`SELECT claim_id AS id, vector FROM claims_embed
319
+ WHERE repo_id = ? AND dim = ? AND COALESCE(backend, 'hash') = ?`)
320
+ .all(repoId, qv.length, backend);
321
+ const scored = rows
322
+ .map((r) => ({ id: r.id, score: cosine(qv, blobToVector(r.vector)) }))
323
+ .filter((r) => r.score > 0.05)
324
+ .sort((a, b) => b.score - a.score)
325
+ .slice(0, limit);
326
+ return scored;
327
+ }
328
+ /** Convert cosine (0..1-ish) into a ranking boost comparable to FTS. */
329
+ export function embedBoostFromScore(score) {
330
+ return Math.max(0, score) * 14;
331
+ }
@@ -0,0 +1,32 @@
1
+ import type { ContextPacket } from "./context.js";
2
+ /** Rough chars→tokens. */
3
+ export declare function estimateTokensFromText(text: string): number;
4
+ /**
5
+ * Proxy for exploration avoided:
6
+ * anchors_returned * 4000 + claims_returned * 200 - packet_tokens
7
+ */
8
+ export declare function estimateTokensSaved(input: {
9
+ anchorsCount: number;
10
+ claimsCount: number;
11
+ packetTokens: number;
12
+ }): number;
13
+ /** Typical Cursor/Claude tool round-trip to read a file (~1.2s) plus a little per claim. */
14
+ export declare const MS_PER_FILE_ROUNDTRIP = 1200;
15
+ export declare const MS_PER_CLAIM = 80;
16
+ export declare function estimateMsSaved(input: {
17
+ anchorsCount: number;
18
+ claimsCount: number;
19
+ }): number;
20
+ /** Mid-range frontier *input* $/1M tokens (Sonnet-class). Avoided exploration is input-side. Not a bill. */
21
+ export declare const USD_PER_MILLION_INPUT_TOKENS = 3;
22
+ export declare function estimateUsdSaved(tokens: number, usdPerMillion?: number): number;
23
+ export declare function eventKind(claimsCount: number, notesCount?: number): "local_hit" | "server_trip";
24
+ export declare function metricsFromPacket(packet: ContextPacket, markdown: string): {
25
+ claimIds: string[];
26
+ anchorsCount: number;
27
+ claimsCount: number;
28
+ packetTokens: number;
29
+ estimatedTokensSaved: number;
30
+ estimatedMsSaved: number;
31
+ kind: "local_hit" | "server_trip";
32
+ };
@@ -0,0 +1,69 @@
1
+ /** Rough chars→tokens. */
2
+ export function estimateTokensFromText(text) {
3
+ return Math.max(1, Math.ceil(text.length / 4));
4
+ }
5
+ /**
6
+ * Proxy for exploration avoided:
7
+ * anchors_returned * 4000 + claims_returned * 200 - packet_tokens
8
+ */
9
+ export function estimateTokensSaved(input) {
10
+ return Math.max(0, input.anchorsCount * 4000 + input.claimsCount * 200 - input.packetTokens);
11
+ }
12
+ /** Typical Cursor/Claude tool round-trip to read a file (~1.2s) plus a little per claim. */
13
+ export const MS_PER_FILE_ROUNDTRIP = 1200;
14
+ export const MS_PER_CLAIM = 80;
15
+ export function estimateMsSaved(input) {
16
+ return Math.max(0, input.anchorsCount * MS_PER_FILE_ROUNDTRIP + input.claimsCount * MS_PER_CLAIM);
17
+ }
18
+ /** Mid-range frontier *input* $/1M tokens (Sonnet-class). Avoided exploration is input-side. Not a bill. */
19
+ export const USD_PER_MILLION_INPUT_TOKENS = 3;
20
+ export function estimateUsdSaved(tokens, usdPerMillion = USD_PER_MILLION_INPUT_TOKENS) {
21
+ return Math.max(0, (Number(tokens) / 1_000_000) * usdPerMillion);
22
+ }
23
+ export function eventKind(claimsCount, notesCount = 0) {
24
+ return claimsCount > 0 || notesCount > 0 ? "local_hit" : "server_trip";
25
+ }
26
+ export function metricsFromPacket(packet, markdown) {
27
+ const matchedClaims = packet.claims.filter((c) => c.score > 0);
28
+ const matchedNotes = packet.notes.filter((n) => n.score > 0);
29
+ const kind = matchedClaims.length > 0 || matchedNotes.length > 0 ? "local_hit" : "server_trip";
30
+ const scored = kind === "local_hit" ? matchedClaims : [];
31
+ const claimIds = packet.claims.map((c) => c.id);
32
+ const anchorSet = new Set();
33
+ for (const claim of scored) {
34
+ try {
35
+ const anchors = JSON.parse(claim.code_anchors);
36
+ for (const a of anchors)
37
+ anchorSet.add(a);
38
+ }
39
+ catch {
40
+ // ignore
41
+ }
42
+ }
43
+ if (kind === "local_hit") {
44
+ for (const component of packet.components) {
45
+ if (component.code_anchor)
46
+ anchorSet.add(component.code_anchor);
47
+ }
48
+ }
49
+ const anchorsCount = anchorSet.size;
50
+ const claimsCount = scored.length;
51
+ const packetTokens = estimateTokensFromText(markdown);
52
+ const estimatedTokensSaved = kind === "local_hit"
53
+ ? estimateTokensSaved({
54
+ anchorsCount,
55
+ claimsCount,
56
+ packetTokens,
57
+ })
58
+ : 0;
59
+ const estimatedMsSaved = kind === "local_hit" ? estimateMsSaved({ anchorsCount, claimsCount }) : 0;
60
+ return {
61
+ claimIds,
62
+ anchorsCount,
63
+ claimsCount,
64
+ packetTokens,
65
+ estimatedTokensSaved,
66
+ estimatedMsSaved,
67
+ kind,
68
+ };
69
+ }
@@ -0,0 +1,15 @@
1
+ import type { ClaimRow } from "./db.js";
2
+ export type FreshnessStatus = "fresh" | "stale" | "missing_anchor" | "unanchored" | "unknown";
3
+ export type ClaimFreshness = {
4
+ status: FreshnessStatus;
5
+ staleAnchors: string[];
6
+ missingAnchors: string[];
7
+ };
8
+ export declare function parseAnchors(codeAnchorsJson: string): string[];
9
+ /**
10
+ * Compare claim.updated_at to filesystem mtimes of code_anchors.
11
+ * Missing paths → missing_anchor; any newer file → stale.
12
+ */
13
+ export declare function assessClaimFreshness(rootPath: string | undefined, claim: ClaimRow): ClaimFreshness;
14
+ /** Multiplier applied to retrieval score (1 = full trust). */
15
+ export declare function freshnessScoreMultiplier(status: FreshnessStatus): number;