@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,82 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ function runGit(cwd, args) {
6
+ try {
7
+ return execFileSync("git", args, {
8
+ cwd,
9
+ encoding: "utf8",
10
+ stdio: ["ignore", "pipe", "ignore"],
11
+ }).trim();
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ export function findGitRoot(startPath = process.cwd()) {
18
+ const out = runGit(resolve(startPath), ["rev-parse", "--show-toplevel"]);
19
+ return out && existsSync(out) ? out : null;
20
+ }
21
+ /** Normalize remotes so ssh/https clones of the same repo share a key. */
22
+ export function normalizeRemoteUrl(url) {
23
+ // Strip trailing slashes before ".git" so "repo.git/" still collapses to "repo".
24
+ let u = url.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
25
+ const scp = /^git@([^:]+):(.+)$/.exec(u);
26
+ if (scp) {
27
+ u = `https://${scp[1]}/${scp[2]}`;
28
+ }
29
+ u = u.replace(/^ssh:\/\/git@/i, "https://");
30
+ u = u.replace(/^git:\/\//i, "https://");
31
+ u = u.replace(/^(https?:\/\/)[^@]+@/i, "$1");
32
+ return u.toLowerCase().replace(/\/+$/, "").replace(/\.git$/i, "");
33
+ }
34
+ export function detectRepoIdentity(cwd = process.cwd()) {
35
+ const rootPath = findGitRoot(cwd) ?? resolve(cwd);
36
+ const remoteRaw = runGit(rootPath, ["remote", "get-url", "origin"]);
37
+ const remoteUrl = remoteRaw ? normalizeRemoteUrl(remoteRaw) : null;
38
+ const defaultBranch = runGit(rootPath, ["symbolic-ref", "refs/remotes/origin/HEAD"])?.replace(/^refs\/remotes\/origin\//, "") ??
39
+ runGit(rootPath, ["rev-parse", "--abbrev-ref", "HEAD"]) ??
40
+ "main";
41
+ const repoName = remoteUrl?.split("/").filter(Boolean).pop() ??
42
+ rootPath.split(/[/\\]/).filter(Boolean).pop() ??
43
+ "repo";
44
+ const keySource = remoteUrl ?? `path:${rootPath}`;
45
+ const repoKey = createHash("sha256").update(keySource).digest("hex").slice(0, 16);
46
+ return {
47
+ rootPath,
48
+ remoteUrl,
49
+ repoKey,
50
+ repoName,
51
+ defaultBranch,
52
+ };
53
+ }
54
+ export function slugifyWorkspace(name) {
55
+ const slug = name
56
+ .trim()
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, "-")
59
+ .replace(/^-+|-+$/g, "");
60
+ if (!slug)
61
+ throw new Error("Workspace name must contain letters or numbers");
62
+ return slug;
63
+ }
64
+ export function workspaceIdentity(name, rootPath) {
65
+ const slug = slugifyWorkspace(name);
66
+ const repoKey = createHash("sha256").update(`workspace:${slug}`).digest("hex").slice(0, 16);
67
+ return {
68
+ rootPath: resolve(rootPath),
69
+ remoteUrl: `amem://workspace/${slug}`,
70
+ repoKey,
71
+ repoName: slug,
72
+ defaultBranch: "main",
73
+ };
74
+ }
75
+ export function parseWorkspaceSlug(remoteUrl) {
76
+ const m = /^amem:\/\/workspace\/([^/]+)$/.exec(remoteUrl ?? "");
77
+ return m?.[1] ?? null;
78
+ }
79
+ export function newId(prefix) {
80
+ const id = randomUUID();
81
+ return prefix ? `${prefix}_${id}` : id;
82
+ }
@@ -0,0 +1,7 @@
1
+ import { type RepoRow } from "./db.js";
2
+ export declare function pinnedRulesPath(repoRoot: string): string;
3
+ export declare function renderPinnedRules(repo: RepoRow): string;
4
+ export declare function syncPinnedRules(repo: RepoRow): {
5
+ path: string;
6
+ pinned: number;
7
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Compile pinned facts into a local Cursor rule file.
3
+ * Default writes into the repo .cursor/rules folder. Memory DB is unchanged.
4
+ */
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { listClaims } from "./db.js";
8
+ import { parseAnchors } from "./freshness.js";
9
+ import { FEATURE_RULES_SYNC, requireFeature } from "./license.js";
10
+ export function pinnedRulesPath(repoRoot) {
11
+ return join(repoRoot, ".cursor", "rules", "amem-pinned.mdc");
12
+ }
13
+ export function renderPinnedRules(repo) {
14
+ const pinned = listClaims(repo.id).filter((c) => Number(c.pinned || 0) > 0);
15
+ const lines = [
16
+ "---",
17
+ "description: Pinned amem facts. Generated — review before committing.",
18
+ "alwaysApply: true",
19
+ "---",
20
+ "",
21
+ "<!-- Generated by amem. Contains no memory database. Safe to gitignore. -->",
22
+ "",
23
+ `# Pinned memory for ${repo.repo_name}`,
24
+ "",
25
+ ];
26
+ if (pinned.length === 0) {
27
+ lines.push("_No pinned facts._", "");
28
+ }
29
+ else {
30
+ for (const claim of pinned) {
31
+ const anchors = parseAnchors(claim.code_anchors);
32
+ const where = anchors.length ? ` — \`${anchors.join("`, `")}\`` : "";
33
+ lines.push(`- **${claim.kind}** ${claim.text.trim()}${where}`);
34
+ }
35
+ lines.push("");
36
+ }
37
+ return `${lines.join("\n")}\n`;
38
+ }
39
+ export function syncPinnedRules(repo) {
40
+ requireFeature(FEATURE_RULES_SYNC, "Cursor rule sync");
41
+ const path = pinnedRulesPath(repo.root_path);
42
+ mkdirSync(join(repo.root_path, ".cursor", "rules"), { recursive: true });
43
+ const body = renderPinnedRules(repo);
44
+ writeFileSync(path, body, "utf8");
45
+ const pinned = listClaims(repo.id).filter((c) => Number(c.pinned || 0) > 0).length;
46
+ return { path, pinned };
47
+ }
@@ -0,0 +1,49 @@
1
+ export type SavingsExportInput = {
2
+ scope: string;
3
+ days: number;
4
+ repoName?: string;
5
+ generatedAt?: string;
6
+ aggregate: {
7
+ pricing?: {
8
+ usdPerMillionInputTokens?: number;
9
+ basis?: string;
10
+ };
11
+ totals?: {
12
+ queries?: number;
13
+ estimatedTokensSaved?: number;
14
+ estimatedUsdSaved?: number;
15
+ estimatedMsSaved?: number;
16
+ localHits?: number;
17
+ serverTrips?: number;
18
+ hitRate?: number;
19
+ avgLocalMs?: number | null;
20
+ anchorsAvoided?: number;
21
+ };
22
+ monthly?: {
23
+ estimatedTokensSaved?: number;
24
+ estimatedUsdSaved?: number;
25
+ estimatedMsSaved?: number;
26
+ queries?: number;
27
+ sampleQueries?: number;
28
+ trendDays?: number;
29
+ };
30
+ byPlatform?: Array<{
31
+ platform: string;
32
+ queries: number;
33
+ estimatedTokensSaved: number;
34
+ estimatedUsdSaved?: number;
35
+ localHits?: number;
36
+ serverTrips?: number;
37
+ }>;
38
+ };
39
+ };
40
+ export type SavingsExportJson = SavingsExportInput & {
41
+ disclaimer: string;
42
+ filenameBase: string;
43
+ };
44
+ export declare function savingsDisclaimer(): string;
45
+ export declare function buildSavingsExport(input: SavingsExportInput): SavingsExportJson;
46
+ export declare function formatSavingsMarkdown(report: SavingsExportJson): string;
47
+ /** Minimal single-font PDF for expense-review printouts (no extra dependency). */
48
+ export declare function buildTextPdf(title: string, body: string): Buffer;
49
+ export declare function savingsPdf(report: SavingsExportJson): Buffer;
@@ -0,0 +1,141 @@
1
+ import { estimateUsdSaved, USD_PER_MILLION_INPUT_TOKENS } from "./estimate.js";
2
+ function stamp(iso) {
3
+ return (iso || new Date().toISOString()).replace(/[:.]/g, "-").slice(0, 19);
4
+ }
5
+ export function savingsDisclaimer() {
6
+ return "Proxy for avoided file reads / input tokens — not a Cursor or model bill. Money uses estimated tokens × $3/1M input (Sonnet-class). Hit rate counts keyword matches only.";
7
+ }
8
+ export function buildSavingsExport(input) {
9
+ const generatedAt = input.generatedAt || new Date().toISOString();
10
+ const filenameBase = `amem-savings-${input.scope}-${input.days}d-${stamp(generatedAt)}`;
11
+ return {
12
+ ...input,
13
+ generatedAt,
14
+ pricing: {
15
+ usdPerMillionInputTokens: input.aggregate.pricing?.usdPerMillionInputTokens ?? USD_PER_MILLION_INPUT_TOKENS,
16
+ basis: input.aggregate.pricing?.basis ?? "input",
17
+ },
18
+ disclaimer: savingsDisclaimer(),
19
+ filenameBase,
20
+ };
21
+ }
22
+ export function formatSavingsMarkdown(report) {
23
+ const t = report.aggregate.totals ?? {};
24
+ const m = report.aggregate.monthly ?? {};
25
+ const platforms = report.aggregate.byPlatform ?? [];
26
+ const hit = t.hitRate != null ? `${Math.round(t.hitRate * 100)}%` : "—";
27
+ const lines = [
28
+ `# amem savings report`,
29
+ ``,
30
+ `- Scope: ${report.scope}${report.repoName ? ` (${report.repoName})` : ""}`,
31
+ `- Window: ${report.days} days`,
32
+ `- Generated: ${report.generatedAt}`,
33
+ `- Disclaimer: ${report.disclaimer}`,
34
+ ``,
35
+ `## Totals`,
36
+ ``,
37
+ `| Metric | Value |`,
38
+ `| --- | --- |`,
39
+ `| Queries | ${t.queries ?? 0} |`,
40
+ `| Keyword hits / misses | ${t.localHits ?? 0} / ${t.serverTrips ?? 0} |`,
41
+ `| Hit rate | ${hit} |`,
42
+ `| Est. tokens saved | ${t.estimatedTokensSaved ?? 0} |`,
43
+ `| Est. USD saved | $${(t.estimatedUsdSaved ?? estimateUsdSaved(t.estimatedTokensSaved ?? 0)).toFixed(2)} |`,
44
+ `| Est. time saved (ms) | ${t.estimatedMsSaved ?? 0} |`,
45
+ `| Avg local lookup (ms) | ${t.avgLocalMs ?? "—"} |`,
46
+ `| Anchors avoided | ${t.anchorsAvoided ?? 0} |`,
47
+ ``,
48
+ `## Monthly projection`,
49
+ ``,
50
+ `- Est. tokens / month: ${m.estimatedTokensSaved ?? 0}`,
51
+ `- Est. $ / month: $${(m.estimatedUsdSaved ?? 0).toFixed(2)}`,
52
+ `- From ${m.sampleQueries ?? 0} calls over ${m.trendDays ?? 0} day(s)`,
53
+ ``,
54
+ `## By host`,
55
+ ``,
56
+ ];
57
+ if (platforms.length === 0)
58
+ lines.push(`(no usage yet)`);
59
+ for (const p of platforms) {
60
+ lines.push(`- ${p.platform}: ${p.queries} queries, ~${p.estimatedTokensSaved} tokens, ~$${(p.estimatedUsdSaved ?? 0).toFixed(2)}, ${p.localHits ?? 0} hits / ${p.serverTrips ?? 0} misses`);
61
+ }
62
+ lines.push("");
63
+ return lines.join("\n");
64
+ }
65
+ function pdfEscape(text) {
66
+ return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
67
+ }
68
+ function wrapLine(line, width = 88) {
69
+ if (line.length <= width)
70
+ return [line || " "];
71
+ const out = [];
72
+ let rest = line;
73
+ while (rest.length > width) {
74
+ let cut = rest.lastIndexOf(" ", width);
75
+ if (cut < 40)
76
+ cut = width;
77
+ out.push(rest.slice(0, cut));
78
+ rest = rest.slice(cut).trimStart();
79
+ }
80
+ if (rest)
81
+ out.push(rest);
82
+ return out;
83
+ }
84
+ /** Minimal single-font PDF for expense-review printouts (no extra dependency). */
85
+ export function buildTextPdf(title, body) {
86
+ const wrapped = body.split("\n").flatMap((line) => wrapLine(line));
87
+ const perPage = 58;
88
+ const pages = [];
89
+ for (let i = 0; i < wrapped.length; i += perPage) {
90
+ pages.push(wrapped.slice(i, i + perPage));
91
+ }
92
+ if (pages.length === 0)
93
+ pages.push([title]);
94
+ const objs = [];
95
+ const add = (bodyObj) => {
96
+ objs.push(bodyObj);
97
+ return objs.length;
98
+ };
99
+ add("<< /Type /Catalog /Pages 2 0 R >>");
100
+ const pageIds = [];
101
+ const contentIds = [];
102
+ for (let i = 0; i < pages.length; i++) {
103
+ contentIds.push(0);
104
+ pageIds.push(0);
105
+ }
106
+ add("PLACEHOLDER_PAGES");
107
+ const fontId = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
108
+ for (let i = 0; i < pages.length; i++) {
109
+ const lines = pages[i];
110
+ const stream = [
111
+ "BT",
112
+ "/F1 11 Tf",
113
+ "50 742 Td",
114
+ "14 TL",
115
+ ...lines.map((line, idx) => `${idx === 0 ? "" : "T* "}(${pdfEscape(line)}) Tj`),
116
+ "ET",
117
+ ].join("\n");
118
+ const contentId = add(`<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`);
119
+ contentIds[i] = contentId;
120
+ const pageId = add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents ${contentId} 0 R /Resources << /Font << /F1 ${fontId} 0 R >> >> >>`);
121
+ pageIds[i] = pageId;
122
+ }
123
+ objs[1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageIds.length} >>`;
124
+ const chunks = [Buffer.from("%PDF-1.4\n")];
125
+ const offsets = [0];
126
+ for (let i = 0; i < objs.length; i++) {
127
+ offsets.push(chunks.reduce((n, c) => n + c.length, 0));
128
+ chunks.push(Buffer.from(`${i + 1} 0 obj\n${objs[i]}\nendobj\n`));
129
+ }
130
+ const xrefAt = chunks.reduce((n, c) => n + c.length, 0);
131
+ let xref = `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
132
+ for (let i = 1; i <= objs.length; i++) {
133
+ xref += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
134
+ }
135
+ chunks.push(Buffer.from(xref));
136
+ chunks.push(Buffer.from(`trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`));
137
+ return Buffer.concat(chunks);
138
+ }
139
+ export function savingsPdf(report) {
140
+ return buildTextPdf("amem savings report", formatSavingsMarkdown(report));
141
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type ScannedRepo = {
2
+ name: string;
3
+ path: string;
4
+ remote: string | null;
5
+ };
6
+ export declare function scanRoots(): string[];
7
+ export declare function scanGitRepos(options?: {
8
+ roots?: string[];
9
+ maxDepth?: number;
10
+ limit?: number;
11
+ timeoutMs?: number;
12
+ }): {
13
+ repos: ScannedRepo[];
14
+ truncated: boolean;
15
+ scannedRoots: string[];
16
+ };
package/dist/scan.js ADDED
@@ -0,0 +1,109 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, resolve } from "node:path";
4
+ const SKIP_DIRS = new Set([
5
+ ".cache",
6
+ ".cursor",
7
+ ".git",
8
+ ".local",
9
+ ".npm",
10
+ ".nvm",
11
+ ".Trash",
12
+ "Applications",
13
+ "Library",
14
+ "Movies",
15
+ "Music",
16
+ "Pictures",
17
+ "Public",
18
+ "Caches",
19
+ "DerivedData",
20
+ "Downloads",
21
+ "node_modules",
22
+ "vendor",
23
+ "dist",
24
+ "build",
25
+ "coverage",
26
+ ".next",
27
+ ".turbo",
28
+ "Pods",
29
+ "tmp",
30
+ ]);
31
+ function isGitRepo(dir) {
32
+ return existsSync(join(dir, ".git"));
33
+ }
34
+ function readRemote(repoRoot) {
35
+ try {
36
+ let gitPath = join(repoRoot, ".git");
37
+ const st = statSync(gitPath);
38
+ if (st.isFile()) {
39
+ const raw = readFileSync(gitPath, "utf8");
40
+ const m = /gitdir:\s*(.+)/i.exec(raw);
41
+ if (!m)
42
+ return null;
43
+ gitPath = resolve(repoRoot, m[1].trim());
44
+ }
45
+ const config = readFileSync(join(gitPath, "config"), "utf8");
46
+ const origin = /\[remote "origin"\][^\[]*url\s*=\s*(.+)/.exec(config);
47
+ return origin?.[1]?.trim() ?? null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function walk(dir, depth, maxDepth, limit, out, deadline) {
54
+ if (out.length >= limit || Date.now() > deadline)
55
+ return;
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir, { withFileTypes: true });
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ if (isGitRepo(dir)) {
64
+ out.push({
65
+ name: basename(dir),
66
+ path: dir,
67
+ remote: readRemote(dir),
68
+ });
69
+ return;
70
+ }
71
+ if (depth >= maxDepth)
72
+ return;
73
+ for (const entry of entries) {
74
+ if (out.length >= limit || Date.now() > deadline)
75
+ return;
76
+ if (!entry.isDirectory() || entry.isSymbolicLink())
77
+ continue;
78
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
79
+ continue;
80
+ walk(join(dir, entry.name), depth + 1, maxDepth, limit, out, deadline);
81
+ }
82
+ }
83
+ export function scanRoots() {
84
+ const fromEnv = process.env.AMEM_SCAN_ROOTS;
85
+ if (fromEnv) {
86
+ return fromEnv
87
+ .split(":")
88
+ .map((p) => p.trim())
89
+ .filter(Boolean);
90
+ }
91
+ const home = homedir();
92
+ return [home];
93
+ }
94
+ export function scanGitRepos(options = {}) {
95
+ const roots = (options.roots ?? scanRoots()).filter((p) => existsSync(p));
96
+ const maxDepth = options.maxDepth ?? 5;
97
+ const limit = options.limit ?? 150;
98
+ const deadline = Date.now() + (options.timeoutMs ?? 8000);
99
+ const found = [];
100
+ for (const root of roots) {
101
+ walk(resolve(root), 0, maxDepth, limit, found, deadline);
102
+ }
103
+ found.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
104
+ return {
105
+ repos: found,
106
+ truncated: found.length >= limit || Date.now() > deadline,
107
+ scannedRoots: roots,
108
+ };
109
+ }
@@ -0,0 +1,25 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { ClaimRow } from "./db.js";
3
+ /** Tokenize for keyword fallback / conflict heuristics. */
4
+ export declare function tokenize(text: string): string[];
5
+ /** Build a safe FTS5 MATCH string from a free-text query (OR of quoted terms). */
6
+ export declare function buildFtsMatchQuery(query: string): string | null;
7
+ export declare function ensureClaimsFts(db: Database.Database): void;
8
+ export declare function upsertClaimFts(db: Database.Database, claim: ClaimRow): void;
9
+ export declare function removeClaimFts(db: Database.Database, repoId: string, claimId: string): void;
10
+ export declare function reindexRepoClaimsFts(db: Database.Database, repoId: string): void;
11
+ export declare function reindexAllClaimsFts(db: Database.Database): void;
12
+ export type FtsHit = {
13
+ id: string;
14
+ bm25: number;
15
+ };
16
+ /**
17
+ * Rank active claims via FTS5 + bm25 (more negative = better).
18
+ * Returns empty on MATCH errors or empty query.
19
+ */
20
+ export declare function searchClaimsFts(db: Database.Database, repoId: string, query: string, limit?: number): FtsHit[];
21
+ /** Convert bm25 (lower/more negative is better) into a positive boost. */
22
+ export declare function ftsBoostFromBm25(bm25: number): number;
23
+ export declare function keywordScoreClaim(claim: ClaimRow, queryTokens: string[]): number;
24
+ /** Jaccard similarity over token sets (0–1). */
25
+ export declare function tokenJaccard(a: string, b: string): number;
package/dist/search.js ADDED
@@ -0,0 +1,150 @@
1
+ const FTS_TABLE = "claims_fts";
2
+ const FTS_MAP = "claims_fts_map";
3
+ /** Tokenize for keyword fallback / conflict heuristics. */
4
+ export function tokenize(text) {
5
+ return text
6
+ .toLowerCase()
7
+ .split(/[^a-z0-9_./-]+/)
8
+ .filter((t) => t.length > 1);
9
+ }
10
+ /** Build a safe FTS5 MATCH string from a free-text query (OR of quoted terms). */
11
+ export function buildFtsMatchQuery(query) {
12
+ const tokens = tokenize(query)
13
+ .map((t) => t.replace(/"/g, "").replace(/'/g, ""))
14
+ .filter((t) => t.length > 1 && !/^[.]+$/.test(t));
15
+ if (tokens.length === 0)
16
+ return null;
17
+ // Cap terms so pathological queries stay cheap
18
+ const terms = tokens.slice(0, 12).map((t) => `"${t}"`);
19
+ return terms.join(" OR ");
20
+ }
21
+ export function ensureClaimsFts(db) {
22
+ db.exec(`
23
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${FTS_TABLE} USING fts5(
24
+ kind,
25
+ text,
26
+ anchors,
27
+ id_text,
28
+ repo_id UNINDEXED,
29
+ claim_id UNINDEXED,
30
+ tokenize = 'porter unicode61'
31
+ );
32
+ CREATE TABLE IF NOT EXISTS ${FTS_MAP} (
33
+ repo_id TEXT NOT NULL,
34
+ claim_id TEXT NOT NULL,
35
+ fts_rowid INTEGER NOT NULL,
36
+ PRIMARY KEY (repo_id, claim_id)
37
+ );
38
+ `);
39
+ }
40
+ function deleteFtsRow(db, repoId, claimId) {
41
+ const mapped = db
42
+ .prepare(`SELECT fts_rowid AS rowid FROM ${FTS_MAP} WHERE repo_id = ? AND claim_id = ?`)
43
+ .get(repoId, claimId);
44
+ if (!mapped)
45
+ return;
46
+ db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(mapped.rowid);
47
+ db.prepare(`DELETE FROM ${FTS_MAP} WHERE repo_id = ? AND claim_id = ?`).run(repoId, claimId);
48
+ }
49
+ export function upsertClaimFts(db, claim) {
50
+ if ((claim.status ?? "active") !== "active") {
51
+ deleteFtsRow(db, claim.repo_id, claim.id);
52
+ return;
53
+ }
54
+ deleteFtsRow(db, claim.repo_id, claim.id);
55
+ const result = db
56
+ .prepare(`INSERT INTO ${FTS_TABLE} (kind, text, anchors, id_text, repo_id, claim_id)
57
+ VALUES (?, ?, ?, ?, ?, ?)`)
58
+ .run(claim.kind, claim.text, claim.code_anchors, claim.id, claim.repo_id, claim.id);
59
+ const rowid = Number(result.lastInsertRowid);
60
+ db.prepare(`INSERT INTO ${FTS_MAP} (repo_id, claim_id, fts_rowid) VALUES (?, ?, ?)`).run(claim.repo_id, claim.id, rowid);
61
+ }
62
+ export function removeClaimFts(db, repoId, claimId) {
63
+ deleteFtsRow(db, repoId, claimId);
64
+ }
65
+ export function reindexRepoClaimsFts(db, repoId) {
66
+ const mapped = db
67
+ .prepare(`SELECT claim_id, fts_rowid FROM ${FTS_MAP} WHERE repo_id = ?`)
68
+ .all(repoId);
69
+ for (const row of mapped) {
70
+ db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(row.fts_rowid);
71
+ }
72
+ db.prepare(`DELETE FROM ${FTS_MAP} WHERE repo_id = ?`).run(repoId);
73
+ const claims = db
74
+ .prepare(`SELECT * FROM claims WHERE repo_id = ? AND COALESCE(status, 'active') = 'active'`)
75
+ .all(repoId);
76
+ for (const claim of claims) {
77
+ upsertClaimFts(db, claim);
78
+ }
79
+ }
80
+ export function reindexAllClaimsFts(db) {
81
+ const mapped = db
82
+ .prepare(`SELECT fts_rowid FROM ${FTS_MAP}`)
83
+ .all();
84
+ for (const row of mapped) {
85
+ try {
86
+ db.prepare(`DELETE FROM ${FTS_TABLE} WHERE rowid = ?`).run(row.fts_rowid);
87
+ }
88
+ catch {
89
+ // ignore missing row
90
+ }
91
+ }
92
+ db.exec(`DELETE FROM ${FTS_MAP}`);
93
+ const repos = db.prepare(`SELECT id FROM repos`).all();
94
+ for (const r of repos) {
95
+ reindexRepoClaimsFts(db, r.id);
96
+ }
97
+ }
98
+ /**
99
+ * Rank active claims via FTS5 + bm25 (more negative = better).
100
+ * Returns empty on MATCH errors or empty query.
101
+ */
102
+ export function searchClaimsFts(db, repoId, query, limit = 24) {
103
+ const match = buildFtsMatchQuery(query);
104
+ if (!match)
105
+ return [];
106
+ try {
107
+ return db
108
+ .prepare(`SELECT claim_id AS id, bm25(${FTS_TABLE}) AS bm25
109
+ FROM ${FTS_TABLE}
110
+ WHERE ${FTS_TABLE} MATCH ? AND repo_id = ?
111
+ ORDER BY bm25
112
+ LIMIT ?`)
113
+ .all(match, repoId, limit);
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ }
119
+ /** Convert bm25 (lower/more negative is better) into a positive boost. */
120
+ export function ftsBoostFromBm25(bm25) {
121
+ return 18 / (1 + Math.abs(bm25));
122
+ }
123
+ export function keywordScoreClaim(claim, queryTokens) {
124
+ if (queryTokens.length === 0)
125
+ return 0;
126
+ const hay = `${claim.id} ${claim.kind} ${claim.text} ${claim.code_anchors}`.toLowerCase();
127
+ let score = 0;
128
+ for (const token of queryTokens) {
129
+ if (hay.includes(token)) {
130
+ score += token.length > 4 ? 3 : 2;
131
+ if (claim.text.toLowerCase().includes(token))
132
+ score += 1;
133
+ if (claim.id.toLowerCase().includes(token))
134
+ score += 2;
135
+ }
136
+ }
137
+ return score;
138
+ }
139
+ /** Jaccard similarity over token sets (0–1). */
140
+ export function tokenJaccard(a, b) {
141
+ const sa = new Set(tokenize(a));
142
+ const sb = new Set(tokenize(b));
143
+ if (sa.size === 0 || sb.size === 0)
144
+ return 0;
145
+ let inter = 0;
146
+ for (const t of sa)
147
+ if (sb.has(t))
148
+ inter += 1;
149
+ return inter / (sa.size + sb.size - inter);
150
+ }
@@ -0,0 +1,20 @@
1
+ export declare const SERVICE_LABEL = "co.amem.ui";
2
+ export declare const SYSTEMD_UNIT = "amem-ui.service";
3
+ export type ServicePlatform = "darwin" | "linux" | "win32" | "unsupported";
4
+ export declare function servicePlatform(): ServicePlatform;
5
+ export declare function isServiceSupported(): boolean;
6
+ export declare function launchAgentPath(): string;
7
+ export declare function systemdUserUnitPath(): string;
8
+ export declare function windowsStartupCmdPath(): string;
9
+ export declare function serviceInstallPath(): string;
10
+ export declare function isServiceInstalled(): boolean;
11
+ export declare function installLoginService(): {
12
+ path: string;
13
+ installed: boolean;
14
+ platform: ServicePlatform;
15
+ };
16
+ export declare function uninstallLoginService(): {
17
+ path: string;
18
+ installed: boolean;
19
+ platform: ServicePlatform;
20
+ };