engrm 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 (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. package/tsconfig.json +22 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Provisioning: exchange a cmt_ token (or OAuth code) for cvk_ API credentials.
3
+ *
4
+ * Calls POST /v1/mem/provision on Candengo Vector.
5
+ * Returns everything needed to write ~/.engrm/settings.json.
6
+ */
7
+
8
+ export const DEFAULT_CANDENGO_URL = "https://www.candengo.com";
9
+
10
+ export interface ProvisionRequest {
11
+ /** cmt_ provisioning token from web signup */
12
+ token?: string;
13
+ /** OAuth authorization code from browser flow */
14
+ code?: string;
15
+ /** Device name for identification (e.g. "MacBook Pro") */
16
+ device_name?: string;
17
+ }
18
+
19
+ export interface TeamInfo {
20
+ id: string;
21
+ name: string;
22
+ namespace: string;
23
+ }
24
+
25
+ export interface ProvisionResponse {
26
+ api_key: string; // cvk_...
27
+ site_id: string;
28
+ namespace: string;
29
+ user_id: string;
30
+ user_email: string;
31
+ teams: TeamInfo[];
32
+ }
33
+
34
+ export class ProvisionError extends Error {
35
+ constructor(
36
+ readonly status: number,
37
+ readonly detail: string
38
+ ) {
39
+ super(detail);
40
+ this.name = "ProvisionError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Exchange a provisioning token or OAuth code for API credentials.
46
+ */
47
+ export async function provision(
48
+ baseUrl: string,
49
+ request: ProvisionRequest
50
+ ): Promise<ProvisionResponse> {
51
+ const url = `${baseUrl.replace(/\/$/, "")}/v1/mem/provision`;
52
+
53
+ const response = await fetch(url, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify(request),
57
+ });
58
+
59
+ if (!response.ok) {
60
+ let detail: string;
61
+ try {
62
+ const body = (await response.json()) as { detail?: string };
63
+ detail = body.detail ?? `HTTP ${response.status}`;
64
+ } catch {
65
+ detail = `HTTP ${response.status}`;
66
+ }
67
+
68
+ if (response.status === 401 || response.status === 403) {
69
+ throw new ProvisionError(
70
+ response.status,
71
+ "Invalid or expired provisioning token"
72
+ );
73
+ }
74
+ if (response.status === 409) {
75
+ throw new ProvisionError(
76
+ response.status,
77
+ "Token has already been used"
78
+ );
79
+ }
80
+ throw new ProvisionError(response.status, detail);
81
+ }
82
+
83
+ const data = (await response.json()) as ProvisionResponse;
84
+
85
+ // Validate response
86
+ if (!data.api_key?.startsWith("cvk_")) {
87
+ throw new ProvisionError(0, "Server returned invalid API key format");
88
+ }
89
+ if (!data.site_id || !data.namespace || !data.user_id) {
90
+ throw new ProvisionError(0, "Server returned incomplete credentials");
91
+ }
92
+
93
+ return data;
94
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ // We test the internal logic by importing helpers and mocking the file paths.
8
+ // Since register.ts uses hardcoded paths (~/.claude.json, ~/.claude/settings.json),
9
+ // we test the JSON merge logic directly.
10
+
11
+ describe("register: JSON merge logic", () => {
12
+ let tmpDir: string;
13
+ let claudeJson: string;
14
+ let claudeSettings: string;
15
+
16
+ beforeEach(() => {
17
+ tmpDir = join(tmpdir(), `engrm-register-test-${randomBytes(4).toString("hex")}`);
18
+ mkdirSync(tmpDir, { recursive: true });
19
+ mkdirSync(join(tmpDir, ".claude"), { recursive: true });
20
+ claudeJson = join(tmpDir, ".claude.json");
21
+ claudeSettings = join(tmpDir, ".claude", "settings.json");
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ it("creates claude.json from scratch with MCP server", () => {
29
+ // Simulate what registerMcpServer does
30
+ const config: Record<string, unknown> = {};
31
+ const servers: Record<string, unknown> = {};
32
+ servers["engrm"] = {
33
+ type: "stdio",
34
+ command: "/usr/local/bin/bun",
35
+ args: ["run", "/path/to/server.ts"],
36
+ };
37
+ config["mcpServers"] = servers;
38
+
39
+ writeFileSync(claudeJson, JSON.stringify(config, null, 2));
40
+ const result = JSON.parse(readFileSync(claudeJson, "utf-8"));
41
+
42
+ expect(result.mcpServers.engrm).toBeDefined();
43
+ expect(result.mcpServers.engrm.command).toBe("/usr/local/bin/bun");
44
+ expect(result.mcpServers.engrm.type).toBe("stdio");
45
+ });
46
+
47
+ it("preserves existing MCP servers when adding engrm", () => {
48
+ const existing = {
49
+ numStartups: 5,
50
+ mcpServers: {
51
+ "other-server": {
52
+ type: "stdio",
53
+ command: "node",
54
+ args: ["other.js"],
55
+ },
56
+ },
57
+ };
58
+ writeFileSync(claudeJson, JSON.stringify(existing));
59
+
60
+ // Read, merge, write
61
+ const config = JSON.parse(readFileSync(claudeJson, "utf-8"));
62
+ const servers = config.mcpServers ?? {};
63
+ servers["engrm"] = { type: "stdio", command: "bun", args: ["run", "server.ts"] };
64
+ config.mcpServers = servers;
65
+ writeFileSync(claudeJson, JSON.stringify(config, null, 2));
66
+
67
+ const result = JSON.parse(readFileSync(claudeJson, "utf-8"));
68
+ expect(result.numStartups).toBe(5);
69
+ expect(result.mcpServers["other-server"]).toBeDefined();
70
+ expect(result.mcpServers.engrm).toBeDefined();
71
+ });
72
+
73
+ it("replaces existing engrm MCP config with new paths", () => {
74
+ const existing = {
75
+ mcpServers: {
76
+ engrm: { type: "stdio", command: "bun", args: ["run", "/old/path/server.ts"] },
77
+ },
78
+ };
79
+ writeFileSync(claudeJson, JSON.stringify(existing));
80
+
81
+ const config = JSON.parse(readFileSync(claudeJson, "utf-8"));
82
+ config.mcpServers.engrm = { type: "stdio", command: "bun", args: ["run", "/new/path/server.ts"] };
83
+ writeFileSync(claudeJson, JSON.stringify(config, null, 2));
84
+
85
+ const result = JSON.parse(readFileSync(claudeJson, "utf-8"));
86
+ expect(result.mcpServers.engrm.args[1]).toBe("/new/path/server.ts");
87
+ });
88
+
89
+ it("creates hooks config from scratch", () => {
90
+ const settings: Record<string, unknown> = {};
91
+ settings["hooks"] = {
92
+ SessionStart: [
93
+ { hooks: [{ type: "command", command: "bun run /path/hooks/session-start.ts" }] },
94
+ ],
95
+ PostToolUse: [
96
+ {
97
+ matcher: "Edit|Write|Bash",
98
+ hooks: [{ type: "command", command: "bun run /path/hooks/post-tool-use.ts" }],
99
+ },
100
+ ],
101
+ Stop: [
102
+ { hooks: [{ type: "command", command: "bun run /path/hooks/stop.ts" }] },
103
+ ],
104
+ };
105
+ writeFileSync(claudeSettings, JSON.stringify(settings, null, 2));
106
+
107
+ const result = JSON.parse(readFileSync(claudeSettings, "utf-8"));
108
+ expect(result.hooks.SessionStart).toHaveLength(1);
109
+ expect(result.hooks.PostToolUse).toHaveLength(1);
110
+ expect(result.hooks.Stop).toHaveLength(1);
111
+ });
112
+
113
+ it("preserves non-engrm hooks when adding engrm hooks", () => {
114
+ const existing = {
115
+ hooks: {
116
+ PostToolUse: [
117
+ {
118
+ matcher: "Bash",
119
+ hooks: [{ type: "command", command: "/path/to/other-hook.sh" }],
120
+ },
121
+ ],
122
+ },
123
+ };
124
+ writeFileSync(claudeSettings, JSON.stringify(existing));
125
+
126
+ const settings = JSON.parse(readFileSync(claudeSettings, "utf-8"));
127
+ const hooks = settings.hooks ?? {};
128
+
129
+ // Add engrm hooks — keep others
130
+ const postToolUse = hooks.PostToolUse ?? [];
131
+ const otherHooks = postToolUse.filter(
132
+ (e: { hooks: { command: string }[] }) =>
133
+ !e.hooks?.some((h: { command: string }) => h.command.includes("engrm"))
134
+ );
135
+ hooks.PostToolUse = [
136
+ ...otherHooks,
137
+ {
138
+ matcher: "Edit|Write|Bash",
139
+ hooks: [{ type: "command", command: "bun run /path/engrm/hooks/post-tool-use.ts" }],
140
+ },
141
+ ];
142
+ settings.hooks = hooks;
143
+ writeFileSync(claudeSettings, JSON.stringify(settings, null, 2));
144
+
145
+ const result = JSON.parse(readFileSync(claudeSettings, "utf-8"));
146
+ expect(result.hooks.PostToolUse).toHaveLength(2);
147
+ expect(result.hooks.PostToolUse[0].hooks[0].command).toBe("/path/to/other-hook.sh");
148
+ expect(result.hooks.PostToolUse[1].hooks[0].command).toContain("engrm");
149
+ });
150
+
151
+ it("preserves other settings fields", () => {
152
+ const existing = {
153
+ skipDangerousModePermissionPrompt: true,
154
+ attribution: { commit: "", pr: "" },
155
+ };
156
+ writeFileSync(claudeSettings, JSON.stringify(existing));
157
+
158
+ const settings = JSON.parse(readFileSync(claudeSettings, "utf-8"));
159
+ settings.hooks = { Stop: [{ hooks: [{ type: "command", command: "test" }] }] };
160
+ writeFileSync(claudeSettings, JSON.stringify(settings, null, 2));
161
+
162
+ const result = JSON.parse(readFileSync(claudeSettings, "utf-8"));
163
+ expect(result.skipDangerousModePermissionPrompt).toBe(true);
164
+ expect(result.attribution).toBeDefined();
165
+ expect(result.hooks.Stop).toHaveLength(1);
166
+ });
167
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Auto-register Engrm MCP server + hooks in Claude Code config.
3
+ *
4
+ * - MCP server → ~/.claude.json (mcpServers.engrm)
5
+ * - Hooks → ~/.claude/settings.json (hooks.SessionStart, PostToolUse, Stop)
6
+ *
7
+ * Merges into existing config — never overwrites other servers or hooks.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join, dirname } from "node:path";
13
+
14
+ // --- Paths ---
15
+
16
+ const CLAUDE_JSON = join(homedir(), ".claude.json");
17
+ const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
18
+
19
+ /**
20
+ * Resolve the absolute path to the bun binary.
21
+ * Checks common locations; falls back to "bun" (assumes PATH).
22
+ */
23
+ function findBun(): string {
24
+ const candidates = [
25
+ join(homedir(), ".bun", "bin", "bun"),
26
+ "/usr/local/bin/bun",
27
+ "/opt/homebrew/bin/bun",
28
+ ];
29
+ for (const p of candidates) {
30
+ if (existsSync(p)) return p;
31
+ }
32
+ // If running under bun, process.execPath is the bun binary
33
+ if (process.execPath && process.execPath.endsWith("bun")) {
34
+ return process.execPath;
35
+ }
36
+ return "bun";
37
+ }
38
+
39
+ /**
40
+ * Resolve the package root directory (where src/ and hooks/ live).
41
+ * Works both in dev (running from checkout) and when installed via npx.
42
+ */
43
+ function findPackageRoot(): string {
44
+ // __dirname equivalent for ESM: import.meta.dir (Bun) or derive from import.meta.url
45
+ // This file is at src/register.ts, so package root is one level up
46
+ const thisDir =
47
+ typeof import.meta.dir === "string"
48
+ ? import.meta.dir
49
+ : dirname(new URL(import.meta.url).pathname);
50
+ return join(thisDir, "..");
51
+ }
52
+
53
+ // --- JSON helpers ---
54
+
55
+ function readJsonFile(path: string): Record<string, unknown> {
56
+ if (!existsSync(path)) return {};
57
+ try {
58
+ const raw = readFileSync(path, "utf-8");
59
+ const parsed = JSON.parse(raw);
60
+ if (typeof parsed === "object" && parsed !== null) {
61
+ return parsed as Record<string, unknown>;
62
+ }
63
+ } catch {
64
+ // Corrupt or empty file — start fresh
65
+ }
66
+ return {};
67
+ }
68
+
69
+ function writeJsonFile(path: string, data: Record<string, unknown>): void {
70
+ const dir = dirname(path);
71
+ if (!existsSync(dir)) {
72
+ mkdirSync(dir, { recursive: true });
73
+ }
74
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
75
+ }
76
+
77
+ // --- MCP registration ---
78
+
79
+ export function registerMcpServer(): { path: string; added: boolean } {
80
+ const bun = findBun();
81
+ const root = findPackageRoot();
82
+ const serverPath = join(root, "src", "server.ts");
83
+
84
+ const config = readJsonFile(CLAUDE_JSON);
85
+ const servers = (config["mcpServers"] ?? {}) as Record<string, unknown>;
86
+
87
+ servers["engrm"] = {
88
+ type: "stdio",
89
+ command: bun,
90
+ args: ["run", serverPath],
91
+ };
92
+
93
+ config["mcpServers"] = servers;
94
+ writeJsonFile(CLAUDE_JSON, config);
95
+
96
+ return { path: CLAUDE_JSON, added: true };
97
+ }
98
+
99
+ // --- Hooks registration ---
100
+
101
+ interface HookEntry {
102
+ matcher?: string;
103
+ hooks: { type: string; command: string }[];
104
+ }
105
+
106
+ export function registerHooks(): { path: string; added: boolean } {
107
+ const bun = findBun();
108
+ const root = findPackageRoot();
109
+
110
+ const sessionStartCmd = `${bun} run ${join(root, "hooks", "session-start.ts")}`;
111
+ const postToolUseCmd = `${bun} run ${join(root, "hooks", "post-tool-use.ts")}`;
112
+ const stopCmd = `${bun} run ${join(root, "hooks", "stop.ts")}`;
113
+
114
+ const settings = readJsonFile(CLAUDE_SETTINGS);
115
+ const hooks = (settings["hooks"] ?? {}) as Record<string, HookEntry[]>;
116
+
117
+ // Replace any existing engrm hooks, preserve others
118
+ hooks["SessionStart"] = replaceEngrmHook(
119
+ hooks["SessionStart"],
120
+ { hooks: [{ type: "command", command: sessionStartCmd }] },
121
+ "session-start.ts"
122
+ );
123
+
124
+ hooks["PostToolUse"] = replaceEngrmHook(
125
+ hooks["PostToolUse"],
126
+ {
127
+ matcher: "Edit|Write|Bash",
128
+ hooks: [{ type: "command", command: postToolUseCmd }],
129
+ },
130
+ "post-tool-use.ts"
131
+ );
132
+
133
+ hooks["Stop"] = replaceEngrmHook(
134
+ hooks["Stop"],
135
+ { hooks: [{ type: "command", command: stopCmd }] },
136
+ "stop.ts"
137
+ );
138
+
139
+ settings["hooks"] = hooks;
140
+ writeJsonFile(CLAUDE_SETTINGS, settings);
141
+
142
+ return { path: CLAUDE_SETTINGS, added: true };
143
+ }
144
+
145
+ /**
146
+ * Replace any existing engrm hook entry in the array, or append.
147
+ * Identifies engrm hooks by checking if the command contains our hook filename.
148
+ */
149
+ function replaceEngrmHook(
150
+ existing: HookEntry[] | undefined,
151
+ newEntry: HookEntry,
152
+ hookFilename: string
153
+ ): HookEntry[] {
154
+ if (!existing || !Array.isArray(existing)) return [newEntry];
155
+
156
+ const isEngrmHook = (entry: HookEntry): boolean =>
157
+ entry.hooks?.some(
158
+ (h) =>
159
+ h.command?.includes("engrm") || h.command?.includes(hookFilename)
160
+ ) ?? false;
161
+
162
+ // Remove old engrm entries, add new one
163
+ const others = existing.filter((e) => !isEngrmHook(e));
164
+ return [...others, newEntry];
165
+ }
166
+
167
+ /**
168
+ * Register both MCP server and hooks. Returns summary for CLI output.
169
+ */
170
+ export function registerAll(): {
171
+ mcp: { path: string; added: boolean };
172
+ hooks: { path: string; added: boolean };
173
+ } {
174
+ return {
175
+ mcp: registerMcpServer(),
176
+ hooks: registerHooks(),
177
+ };
178
+ }