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,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ normaliseGitRemoteUrl,
4
+ projectNameFromCanonicalId,
5
+ detectProject,
6
+ } from "./projects.js";
7
+
8
+ describe("normaliseGitRemoteUrl", () => {
9
+ test("SSH-style git@github.com", () => {
10
+ expect(normaliseGitRemoteUrl("git@github.com:unimpossible/aimy-agent.git")).toBe(
11
+ "github.com/unimpossible/aimy-agent"
12
+ );
13
+ });
14
+
15
+ test("HTTPS with .git suffix", () => {
16
+ expect(
17
+ normaliseGitRemoteUrl("https://github.com/unimpossible/aimy-agent.git")
18
+ ).toBe("github.com/unimpossible/aimy-agent");
19
+ });
20
+
21
+ test("HTTPS without .git suffix", () => {
22
+ expect(
23
+ normaliseGitRemoteUrl("https://github.com/unimpossible/aimy-agent")
24
+ ).toBe("github.com/unimpossible/aimy-agent");
25
+ });
26
+
27
+ test("HTTPS with auth credentials", () => {
28
+ expect(
29
+ normaliseGitRemoteUrl(
30
+ "https://david@github.com/unimpossible/aimy-agent"
31
+ )
32
+ ).toBe("github.com/unimpossible/aimy-agent");
33
+ });
34
+
35
+ test("SSH protocol URL", () => {
36
+ expect(
37
+ normaliseGitRemoteUrl(
38
+ "ssh://git@github.com/unimpossible/aimy-agent.git"
39
+ )
40
+ ).toBe("github.com/unimpossible/aimy-agent");
41
+ });
42
+
43
+ test("all forms normalise to the same canonical ID", () => {
44
+ const forms = [
45
+ "git@github.com:unimpossible/aimy-agent.git",
46
+ "https://github.com/unimpossible/aimy-agent.git",
47
+ "https://david@github.com/unimpossible/aimy-agent",
48
+ "ssh://git@github.com/unimpossible/aimy-agent.git",
49
+ ];
50
+ const ids = forms.map(normaliseGitRemoteUrl);
51
+ const unique = new Set(ids);
52
+ expect(unique.size).toBe(1);
53
+ expect(ids[0]).toBe("github.com/unimpossible/aimy-agent");
54
+ });
55
+
56
+ test("lowercase host", () => {
57
+ expect(normaliseGitRemoteUrl("git@GitHub.COM:Org/Repo.git")).toBe(
58
+ "github.com/Org/Repo"
59
+ );
60
+ });
61
+
62
+ test("preserves path case", () => {
63
+ expect(
64
+ normaliseGitRemoteUrl("https://github.com/MyOrg/MyRepo.git")
65
+ ).toBe("github.com/MyOrg/MyRepo");
66
+ });
67
+
68
+ test("strips trailing slashes", () => {
69
+ expect(
70
+ normaliseGitRemoteUrl("https://github.com/org/repo/")
71
+ ).toBe("github.com/org/repo");
72
+ });
73
+
74
+ test("handles git:// protocol", () => {
75
+ expect(
76
+ normaliseGitRemoteUrl("git://github.com/org/repo.git")
77
+ ).toBe("github.com/org/repo");
78
+ });
79
+
80
+ test("does not replace port-like colons", () => {
81
+ // github.com:443/org/repo should NOT treat :443 as SSH-style
82
+ expect(
83
+ normaliseGitRemoteUrl("https://github.com:443/org/repo")
84
+ ).toBe("github.com:443/org/repo");
85
+ });
86
+
87
+ test("handles whitespace", () => {
88
+ expect(
89
+ normaliseGitRemoteUrl(" https://github.com/org/repo.git ")
90
+ ).toBe("github.com/org/repo");
91
+ });
92
+
93
+ test("non-GitHub hosts work", () => {
94
+ expect(
95
+ normaliseGitRemoteUrl("git@gitlab.com:team/project.git")
96
+ ).toBe("gitlab.com/team/project");
97
+ });
98
+
99
+ test("self-hosted GitLab", () => {
100
+ expect(
101
+ normaliseGitRemoteUrl("git@git.internal.company.com:team/project.git")
102
+ ).toBe("git.internal.company.com/team/project");
103
+ });
104
+ });
105
+
106
+ describe("projectNameFromCanonicalId", () => {
107
+ test("extracts repo name from canonical ID", () => {
108
+ expect(
109
+ projectNameFromCanonicalId("github.com/unimpossible/aimy-agent")
110
+ ).toBe("aimy-agent");
111
+ });
112
+
113
+ test("handles single-segment ID", () => {
114
+ expect(projectNameFromCanonicalId("my-project")).toBe("my-project");
115
+ });
116
+
117
+ test("handles local/ prefix", () => {
118
+ expect(projectNameFromCanonicalId("local/my-dir")).toBe("my-dir");
119
+ });
120
+ });
121
+
122
+ describe("detectProject", () => {
123
+ test("detects current repo (candengo-mem)", () => {
124
+ const result = detectProject("/Volumes/Data/devs/candengo-mem");
125
+ // This repo has a git remote
126
+ expect(result.canonical_id).toContain("candengo-mem");
127
+ expect(result.name).toBe("candengo-mem");
128
+ expect(result.local_path).toBe("/Volumes/Data/devs/candengo-mem");
129
+ });
130
+
131
+ test("falls back to directory name for non-git directory", () => {
132
+ const result = detectProject("/tmp");
133
+ expect(result.canonical_id).toBe("local/tmp");
134
+ expect(result.name).toBe("tmp");
135
+ expect(result.remote_url).toBeNull();
136
+ });
137
+ });
@@ -0,0 +1,184 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+
5
+ /**
6
+ * Normalise a git remote URL to a canonical project ID.
7
+ *
8
+ * All of these resolve to the same canonical ID:
9
+ * git@github.com:unimpossible/aimy-agent.git → github.com/unimpossible/aimy-agent
10
+ * https://github.com/unimpossible/aimy-agent.git → github.com/unimpossible/aimy-agent
11
+ * https://david@github.com/unimpossible/aimy-agent → github.com/unimpossible/aimy-agent
12
+ * ssh://git@github.com/unimpossible/aimy-agent.git → github.com/unimpossible/aimy-agent
13
+ *
14
+ * Rules (from SPEC §1):
15
+ * 1. Strip protocol (https://, git@, ssh://)
16
+ * 2. Replace : with / (for SSH-style URLs)
17
+ * 3. Strip .git suffix
18
+ * 4. Strip auth credentials (user@)
19
+ * 5. Lowercase the host
20
+ */
21
+ export function normaliseGitRemoteUrl(remoteUrl: string): string {
22
+ let url = remoteUrl.trim();
23
+
24
+ // Strip protocol
25
+ url = url.replace(/^(?:https?|ssh|git):\/\//, "");
26
+
27
+ // Strip auth credentials (anything before @ in host part)
28
+ // Handle: git@github.com:... and david@github.com/...
29
+ url = url.replace(/^[^@]+@/, "");
30
+
31
+ // Replace : with / for SSH-style URLs (github.com:org/repo → github.com/org/repo)
32
+ // But only if it looks like host:path (not a port like github.com:443/...)
33
+ url = url.replace(/^([^/:]+):(?!\d)/, "$1/");
34
+
35
+ // Strip .git suffix
36
+ url = url.replace(/\.git$/, "");
37
+
38
+ // Strip trailing slashes
39
+ url = url.replace(/\/+$/, "");
40
+
41
+ // Lowercase the host portion (everything before the first /)
42
+ const slashIndex = url.indexOf("/");
43
+ if (slashIndex !== -1) {
44
+ const host = url.substring(0, slashIndex).toLowerCase();
45
+ const path = url.substring(slashIndex);
46
+ url = host + path;
47
+ } else {
48
+ url = url.toLowerCase();
49
+ }
50
+
51
+ return url;
52
+ }
53
+
54
+ /**
55
+ * Extract a human-readable project name from a canonical ID.
56
+ * github.com/unimpossible/aimy-agent → aimy-agent
57
+ */
58
+ export function projectNameFromCanonicalId(canonicalId: string): string {
59
+ const parts = canonicalId.split("/");
60
+ return parts[parts.length - 1] ?? canonicalId;
61
+ }
62
+
63
+ /**
64
+ * Try to get the git remote origin URL for a directory.
65
+ * Returns null if not a git repo or no remote configured.
66
+ */
67
+ function getGitRemoteUrl(directory: string): string | null {
68
+ try {
69
+ const url = execSync("git remote get-url origin", {
70
+ cwd: directory,
71
+ encoding: "utf-8",
72
+ timeout: 5000,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ }).trim();
75
+ return url || null;
76
+ } catch {
77
+ // Not a git repo, or no origin remote
78
+ // Try any remote
79
+ try {
80
+ const remotes = execSync("git remote", {
81
+ cwd: directory,
82
+ encoding: "utf-8",
83
+ timeout: 5000,
84
+ stdio: ["pipe", "pipe", "pipe"],
85
+ })
86
+ .trim()
87
+ .split("\n")
88
+ .filter(Boolean);
89
+
90
+ if (remotes.length === 0) return null;
91
+
92
+ const url = execSync(`git remote get-url ${remotes[0]}`, {
93
+ cwd: directory,
94
+ encoding: "utf-8",
95
+ timeout: 5000,
96
+ stdio: ["pipe", "pipe", "pipe"],
97
+ }).trim();
98
+ return url || null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Project config file (.engrm.json) for non-git projects or overrides.
107
+ */
108
+ interface ProjectConfigFile {
109
+ project_id: string;
110
+ name?: string;
111
+ }
112
+
113
+ function readProjectConfigFile(directory: string): ProjectConfigFile | null {
114
+ const configPath = join(directory, ".engrm.json");
115
+ if (!existsSync(configPath)) return null;
116
+
117
+ try {
118
+ const raw = readFileSync(configPath, "utf-8");
119
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
120
+
121
+ if (typeof parsed["project_id"] !== "string" || !parsed["project_id"]) {
122
+ return null;
123
+ }
124
+
125
+ return {
126
+ project_id: parsed["project_id"],
127
+ name: typeof parsed["name"] === "string" ? parsed["name"] : undefined,
128
+ };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Detect the project identity for a given directory.
136
+ *
137
+ * Resolution order (from SPEC §1):
138
+ * 1. Git remote origin URL → normalised
139
+ * 2. Git remote (any remote if origin doesn't exist)
140
+ * 3. Manual project_id in .engrm.json
141
+ * 4. Last resort: directory name
142
+ *
143
+ * Returns { canonicalId, name, remoteUrl, localPath }
144
+ */
145
+ export interface DetectedProject {
146
+ canonical_id: string;
147
+ name: string;
148
+ remote_url: string | null;
149
+ local_path: string;
150
+ }
151
+
152
+ export function detectProject(directory: string): DetectedProject {
153
+ // Try git remote first (covers fallback #1 and #2)
154
+ const remoteUrl = getGitRemoteUrl(directory);
155
+ if (remoteUrl) {
156
+ const canonicalId = normaliseGitRemoteUrl(remoteUrl);
157
+ return {
158
+ canonical_id: canonicalId,
159
+ name: projectNameFromCanonicalId(canonicalId),
160
+ remote_url: remoteUrl,
161
+ local_path: directory,
162
+ };
163
+ }
164
+
165
+ // Try .engrm.json config file
166
+ const configFile = readProjectConfigFile(directory);
167
+ if (configFile) {
168
+ return {
169
+ canonical_id: configFile.project_id,
170
+ name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
171
+ remote_url: null,
172
+ local_path: directory,
173
+ };
174
+ }
175
+
176
+ // Last resort: directory name
177
+ const dirName = basename(directory);
178
+ return {
179
+ canonical_id: `local/${dirName}`,
180
+ name: dirName,
181
+ remote_url: null,
182
+ local_path: directory,
183
+ };
184
+ }