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
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "engrm",
3
+ "version": "0.1.0",
4
+ "description": "Cross-device, team-shared memory layer for AI coding agents",
5
+ "type": "module",
6
+ "main": "src/server.ts",
7
+ "bin": {
8
+ "engrm": "src/cli.ts"
9
+ },
10
+ "scripts": {
11
+ "start": "bun run src/server.ts",
12
+ "init": "bun run src/cli.ts init",
13
+ "test": "bun test"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.12.1",
17
+ "@xenova/transformers": "^2.17.2",
18
+ "sqlite-vec": "^0.1.7-alpha.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "^1.2.19",
22
+ "typescript": "^5.8.3"
23
+ },
24
+ "license": "FSL-1.1-ALv2",
25
+ "author": "Unimpossible Consultants",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/unimpossible/candengo-mem"
29
+ }
30
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { jaccardSimilarity, findDuplicate, DEDUP_THRESHOLD } from "./dedup.js";
3
+
4
+ describe("jaccardSimilarity", () => {
5
+ test("identical strings return 1.0", () => {
6
+ expect(jaccardSimilarity("fix auth bug", "fix auth bug")).toBe(1.0);
7
+ });
8
+
9
+ test("completely different strings return 0.0", () => {
10
+ expect(jaccardSimilarity("alpha beta gamma", "delta epsilon zeta")).toBe(
11
+ 0.0
12
+ );
13
+ });
14
+
15
+ test("both empty strings return 1.0", () => {
16
+ expect(jaccardSimilarity("", "")).toBe(1.0);
17
+ });
18
+
19
+ test("one empty string returns 0.0", () => {
20
+ expect(jaccardSimilarity("hello", "")).toBe(0.0);
21
+ expect(jaccardSimilarity("", "hello")).toBe(0.0);
22
+ });
23
+
24
+ test("case insensitive", () => {
25
+ expect(jaccardSimilarity("Fix Auth Bug", "fix auth bug")).toBe(1.0);
26
+ });
27
+
28
+ test("punctuation stripped", () => {
29
+ expect(
30
+ jaccardSimilarity("fix: auth bug!", "fix auth bug")
31
+ ).toBe(1.0);
32
+ });
33
+
34
+ test("partial overlap returns correct ratio", () => {
35
+ // "fix auth" vs "fix login" → intersection={fix}, union={fix,auth,login}
36
+ const sim = jaccardSimilarity("fix auth", "fix login");
37
+ expect(sim).toBeCloseTo(1 / 3, 5);
38
+ });
39
+
40
+ test("high similarity for near-duplicates", () => {
41
+ // "fix authentication token refresh bug" vs "fix authentication token refresh issue"
42
+ // shared: {fix, authentication, token, refresh} = 4
43
+ // union: {fix, authentication, token, refresh, bug, issue} = 6
44
+ // Jaccard = 4/6 = 0.667
45
+ const sim = jaccardSimilarity(
46
+ "fix authentication token refresh bug",
47
+ "fix authentication token refresh issue"
48
+ );
49
+ expect(sim).toBeCloseTo(4 / 6, 5);
50
+ });
51
+
52
+ test("whitespace-only strings treated as empty", () => {
53
+ expect(jaccardSimilarity(" ", " ")).toBe(1.0);
54
+ expect(jaccardSimilarity(" ", "hello")).toBe(0.0);
55
+ });
56
+ });
57
+
58
+ describe("findDuplicate", () => {
59
+ const candidates = [
60
+ { id: 1, title: "fix authentication token refresh bug" },
61
+ { id: 2, title: "add user profile page" },
62
+ { id: 3, title: "refactor database connection pool" },
63
+ ];
64
+
65
+ test("finds duplicate above threshold", () => {
66
+ // Candidate 1: "fix authentication token refresh bug"
67
+ // Query: "fix authentication token refresh bug typo"
68
+ // shared: {fix, authentication, token, refresh, bug} = 5
69
+ // union: {fix, authentication, token, refresh, bug, typo} = 6
70
+ // Jaccard = 5/6 = 0.833 > 0.8
71
+ const result = findDuplicate(
72
+ "fix authentication token refresh bug typo",
73
+ candidates
74
+ );
75
+ expect(result).not.toBeNull();
76
+ expect(result!.id).toBe(1);
77
+ });
78
+
79
+ test("returns null when no match above threshold", () => {
80
+ const result = findDuplicate("completely unrelated topic", candidates);
81
+ expect(result).toBeNull();
82
+ });
83
+
84
+ test("returns null for empty candidates", () => {
85
+ const result = findDuplicate("anything", []);
86
+ expect(result).toBeNull();
87
+ });
88
+
89
+ test("returns best match when multiple above threshold", () => {
90
+ const dupes = [
91
+ { id: 10, title: "fix auth token bug" },
92
+ { id: 11, title: "fix auth token bug in refresh" },
93
+ ];
94
+ const result = findDuplicate("fix auth token bug in refresh flow", dupes);
95
+ // Should pick the closer match
96
+ expect(result).not.toBeNull();
97
+ expect(result!.id).toBe(11);
98
+ });
99
+
100
+ test("threshold constant is 0.8", () => {
101
+ expect(DEDUP_THRESHOLD).toBe(0.8);
102
+ });
103
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Near-duplicate detection using Jaccard similarity on word tokens.
3
+ *
4
+ * From SPEC §2: Before saving a new observation, check title similarity
5
+ * against last 24h for the same project. If > 0.8, merge into existing.
6
+ */
7
+
8
+ /**
9
+ * Tokenise a string into lowercase word tokens.
10
+ * Strips punctuation, splits on whitespace.
11
+ */
12
+ function tokenise(text: string): Set<string> {
13
+ const cleaned = text
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9\s]/g, " ")
16
+ .trim();
17
+ const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
18
+ return new Set(tokens);
19
+ }
20
+
21
+ /**
22
+ * Compute Jaccard similarity between two strings.
23
+ * Returns a value between 0.0 (completely different) and 1.0 (identical).
24
+ *
25
+ * Jaccard = |A ∩ B| / |A ∪ B|
26
+ */
27
+ export function jaccardSimilarity(a: string, b: string): number {
28
+ const tokensA = tokenise(a);
29
+ const tokensB = tokenise(b);
30
+
31
+ if (tokensA.size === 0 && tokensB.size === 0) return 1.0;
32
+ if (tokensA.size === 0 || tokensB.size === 0) return 0.0;
33
+
34
+ let intersectionSize = 0;
35
+ for (const token of tokensA) {
36
+ if (tokensB.has(token)) intersectionSize++;
37
+ }
38
+
39
+ const unionSize = tokensA.size + tokensB.size - intersectionSize;
40
+ if (unionSize === 0) return 0.0;
41
+
42
+ return intersectionSize / unionSize;
43
+ }
44
+
45
+ /**
46
+ * Similarity threshold for considering two observations as duplicates.
47
+ * From SPEC §2: title similarity > 0.8 → merge.
48
+ */
49
+ export const DEDUP_THRESHOLD = 0.8;
50
+
51
+ export interface DedupCandidate {
52
+ id: number;
53
+ title: string;
54
+ }
55
+
56
+ /**
57
+ * Find the best matching duplicate from a list of candidates.
58
+ * Returns the candidate with the highest similarity above threshold, or null.
59
+ */
60
+ export function findDuplicate(
61
+ newTitle: string,
62
+ candidates: DedupCandidate[]
63
+ ): DedupCandidate | null {
64
+ let bestMatch: DedupCandidate | null = null;
65
+ let bestScore = 0;
66
+
67
+ for (const candidate of candidates) {
68
+ const similarity = jaccardSimilarity(newTitle, candidate.title);
69
+ if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
70
+ bestScore = similarity;
71
+ bestMatch = candidate;
72
+ }
73
+ }
74
+
75
+ return bestMatch;
76
+ }
@@ -0,0 +1,245 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractObservation, type ToolUseEvent } from "./extractor.js";
3
+
4
+ function makeEvent(
5
+ overrides: Partial<ToolUseEvent>
6
+ ): ToolUseEvent {
7
+ return {
8
+ session_id: "sess-001",
9
+ hook_event_name: "PostToolUse",
10
+ tool_name: "Bash",
11
+ tool_input: {},
12
+ tool_response: "",
13
+ cwd: "/tmp/project",
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe("extractObservation — skip rules", () => {
19
+ test("skips Read tool", () => {
20
+ expect(
21
+ extractObservation(makeEvent({ tool_name: "Read" }))
22
+ ).toBeNull();
23
+ });
24
+
25
+ test("skips Glob tool", () => {
26
+ expect(
27
+ extractObservation(makeEvent({ tool_name: "Glob" }))
28
+ ).toBeNull();
29
+ });
30
+
31
+ test("skips Grep tool", () => {
32
+ expect(
33
+ extractObservation(makeEvent({ tool_name: "Grep" }))
34
+ ).toBeNull();
35
+ });
36
+
37
+ test("skips WebSearch tool", () => {
38
+ expect(
39
+ extractObservation(makeEvent({ tool_name: "WebSearch" }))
40
+ ).toBeNull();
41
+ });
42
+
43
+ test("skips Agent tool", () => {
44
+ expect(
45
+ extractObservation(makeEvent({ tool_name: "Agent" }))
46
+ ).toBeNull();
47
+ });
48
+
49
+ test("skips navigational bash commands", () => {
50
+ const commands = ["ls", "pwd", "cd src", "echo hello", "git status", "git log", "node --version"];
51
+ for (const cmd of commands) {
52
+ expect(
53
+ extractObservation(
54
+ makeEvent({ tool_input: { command: cmd }, tool_response: "output" })
55
+ )
56
+ ).toBeNull();
57
+ }
58
+ });
59
+
60
+ test("skips bash with empty response", () => {
61
+ expect(
62
+ extractObservation(
63
+ makeEvent({ tool_input: { command: "some-cmd" }, tool_response: "" })
64
+ )
65
+ ).toBeNull();
66
+ });
67
+
68
+ test("skips engrm MCP tools (self-referential)", () => {
69
+ expect(
70
+ extractObservation(
71
+ makeEvent({
72
+ tool_name: "mcp__engrm__search",
73
+ tool_response: "some result that is long enough to normally trigger capture",
74
+ })
75
+ )
76
+ ).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("extractObservation — Edit", () => {
81
+ test("extracts from file edit", () => {
82
+ const result = extractObservation(
83
+ makeEvent({
84
+ tool_name: "Edit",
85
+ tool_input: {
86
+ file_path: "/project/src/auth.ts",
87
+ old_string: "const token = getToken();",
88
+ new_string: "const token = await refreshToken();",
89
+ },
90
+ tool_response: "Successfully edited",
91
+ })
92
+ );
93
+ expect(result).not.toBeNull();
94
+ expect(result!.type).toBe("change");
95
+ expect(result!.title).toContain("auth.ts");
96
+ expect(result!.files_modified).toEqual(["/project/src/auth.ts"]);
97
+ });
98
+
99
+ test("skips whitespace-only edit", () => {
100
+ const result = extractObservation(
101
+ makeEvent({
102
+ tool_name: "Edit",
103
+ tool_input: {
104
+ file_path: "/project/src/app.ts",
105
+ old_string: "const x = 1;",
106
+ new_string: "const x = 1; ",
107
+ },
108
+ tool_response: "ok",
109
+ })
110
+ );
111
+ expect(result).toBeNull();
112
+ });
113
+
114
+ test("skips edit with no file_path", () => {
115
+ const result = extractObservation(
116
+ makeEvent({
117
+ tool_name: "Edit",
118
+ tool_input: { old_string: "a", new_string: "b" },
119
+ tool_response: "ok",
120
+ })
121
+ );
122
+ expect(result).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe("extractObservation — Write", () => {
127
+ test("extracts from file creation", () => {
128
+ const result = extractObservation(
129
+ makeEvent({
130
+ tool_name: "Write",
131
+ tool_input: {
132
+ file_path: "/project/src/utils.ts",
133
+ content: "export function helper() {\n // substantial content here that is long enough\n return true;\n}",
134
+ },
135
+ tool_response: "File written successfully",
136
+ })
137
+ );
138
+ expect(result).not.toBeNull();
139
+ expect(result!.type).toBe("change");
140
+ expect(result!.title).toContain("Created utils.ts");
141
+ expect(result!.files_modified).toEqual(["/project/src/utils.ts"]);
142
+ });
143
+
144
+ test("skips tiny file writes", () => {
145
+ const result = extractObservation(
146
+ makeEvent({
147
+ tool_name: "Write",
148
+ tool_input: {
149
+ file_path: "/project/.gitkeep",
150
+ content: "",
151
+ },
152
+ tool_response: "ok",
153
+ })
154
+ );
155
+ expect(result).toBeNull();
156
+ });
157
+ });
158
+
159
+ describe("extractObservation — Bash errors", () => {
160
+ test("captures error output as bugfix", () => {
161
+ const result = extractObservation(
162
+ makeEvent({
163
+ tool_input: { command: "npm run build" },
164
+ tool_response:
165
+ "Error: Cannot find module './missing'\n at Module._resolveFilename\nexit code 1",
166
+ })
167
+ );
168
+ expect(result).not.toBeNull();
169
+ expect(result!.type).toBe("bugfix");
170
+ expect(result!.title).toContain("error");
171
+ });
172
+
173
+ test("captures test failure", () => {
174
+ const result = extractObservation(
175
+ makeEvent({
176
+ tool_input: { command: "bun test" },
177
+ tool_response: "3 pass\n2 fail\n5 expect() calls",
178
+ })
179
+ );
180
+ expect(result).not.toBeNull();
181
+ expect(result!.type).toBe("bugfix");
182
+ expect(result!.title).toContain("Test failure");
183
+ });
184
+
185
+ test("skips passing tests", () => {
186
+ const result = extractObservation(
187
+ makeEvent({
188
+ tool_input: { command: "bun test" },
189
+ tool_response: "147 pass\n0 fail",
190
+ })
191
+ );
192
+ expect(result).toBeNull();
193
+ });
194
+ });
195
+
196
+ describe("extractObservation — Bash dependencies", () => {
197
+ test("captures npm install", () => {
198
+ const result = extractObservation(
199
+ makeEvent({
200
+ tool_input: { command: "npm install express" },
201
+ tool_response: "added 57 packages in 3s",
202
+ })
203
+ );
204
+ expect(result).not.toBeNull();
205
+ expect(result!.type).toBe("change");
206
+ expect(result!.title).toContain("Dependency change");
207
+ });
208
+
209
+ test("captures bun add", () => {
210
+ const result = extractObservation(
211
+ makeEvent({
212
+ tool_input: { command: "bun add zod" },
213
+ tool_response: "installed zod@3.22.0",
214
+ })
215
+ );
216
+ expect(result).not.toBeNull();
217
+ expect(result!.type).toBe("change");
218
+ });
219
+ });
220
+
221
+ describe("extractObservation — MCP tools", () => {
222
+ test("captures non-candengo MCP tool with substantial response", () => {
223
+ const result = extractObservation(
224
+ makeEvent({
225
+ tool_name: "mcp__github__create_pull_request",
226
+ tool_input: { title: "Fix auth" },
227
+ tool_response: "a".repeat(150),
228
+ })
229
+ );
230
+ expect(result).not.toBeNull();
231
+ expect(result!.type).toBe("change");
232
+ expect(result!.title).toContain("github");
233
+ });
234
+
235
+ test("skips MCP tool with short response", () => {
236
+ const result = extractObservation(
237
+ makeEvent({
238
+ tool_name: "mcp__github__get_issue",
239
+ tool_input: {},
240
+ tool_response: "ok",
241
+ })
242
+ );
243
+ expect(result).toBeNull();
244
+ });
245
+ });