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.
- package/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- 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
|
+
}
|