mnemos-capture 1.6.1 → 1.8.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/README.md +157 -51
- package/dist/cli/{kos.js → agent.js} +64 -25
- package/dist/cli/code-map.js +546 -0
- package/dist/cli/config.js +1 -1
- package/dist/cli/git-hook.js +170 -0
- package/dist/cli/hooks.js +78 -35
- package/dist/cli/index.js +118 -10
- package/dist/cli/mcp-server.js +45 -20
- package/dist/cli/session-start.js +73 -0
- package/package.json +3 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import { callMcp, initializeMcp } from "./hooks.js";
|
|
5
|
+
import { syncCodeMap } from "./code-map.js";
|
|
6
|
+
import { currentRepo } from "./session-start.js";
|
|
7
|
+
import { getCurrentVersion } from "./version-check.js";
|
|
8
|
+
/**
|
|
9
|
+
* Links commits to the captures they apply, with any coding agent or none.
|
|
10
|
+
* The commit message carries one `xkg-capture: <filename>` trailer per
|
|
11
|
+
* capture; a git post-commit hook reports each one to apply_capture with the
|
|
12
|
+
* commit sha and the repo. The server counts a capture and commit once, so an
|
|
13
|
+
* agent that also calls apply_capture itself does not double count. A
|
|
14
|
+
* `git revert` of such a commit is reported as reverted, with no agent.
|
|
15
|
+
*
|
|
16
|
+
* The same hooks, plus post-merge and post-checkout, keep the repo's code map
|
|
17
|
+
* (code-map.ts) fresh, so every agent gets it through git alone.
|
|
18
|
+
*/
|
|
19
|
+
const PACKAGE = "@xmu-labs/xkg";
|
|
20
|
+
const MARKER = "# xkg";
|
|
21
|
+
/** The MCP client name the hook's reports carry, so the server counts them by agent. */
|
|
22
|
+
export const HOOK_CLIENT = "xkg-git-hook";
|
|
23
|
+
function git(cwd, ...args) {
|
|
24
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
25
|
+
}
|
|
26
|
+
/** `xkg-capture` values from the commit message's trailer block only (as git parses it), once each. */
|
|
27
|
+
export function captureTrailers(message) {
|
|
28
|
+
let trailers;
|
|
29
|
+
try {
|
|
30
|
+
trailers = execFileSync("git", ["interpret-trailers", "--parse"], { input: message, encoding: "utf-8", timeout: 5000 });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const found = [...trailers.matchAll(/^xkg-capture:[ \t]*(\S+)[ \t]*$/gim)].map((m) => m[1]);
|
|
36
|
+
return [...new Set(found)];
|
|
37
|
+
}
|
|
38
|
+
/** Writes `line` as xkg's only line in a shell hook, keeping the user's own lines. */
|
|
39
|
+
function installHookLine(hooksDir, name, line, isOurs) {
|
|
40
|
+
const path = join(hooksDir, name);
|
|
41
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "#!/bin/sh\n";
|
|
42
|
+
if (!/^#!\s*\S*\b(?:env\s+)?(?:sh|bash|zsh|dash|ksh)\b/.test(existing.split("\n")[0] ?? "")) {
|
|
43
|
+
throw new Error(`${path} is not a shell script; add this line to it yourself:\n${line}`);
|
|
44
|
+
}
|
|
45
|
+
const kept = existing.split("\n").filter((l) => !(l.endsWith(MARKER) && isOurs(l)));
|
|
46
|
+
const body = kept.join("\n").replace(/\n*$/, "\n");
|
|
47
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
48
|
+
writeFileSync(path, `${body}${line}\n`);
|
|
49
|
+
chmodSync(path, 0o755);
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
52
|
+
function hooksDirOf(cwd) {
|
|
53
|
+
return resolve(cwd, git(cwd, "rev-parse", "--git-path", "hooks"));
|
|
54
|
+
}
|
|
55
|
+
function runner(version) {
|
|
56
|
+
return `npx -y ${PACKAGE}@${version === "0.0.0" ? "latest" : version}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Installs or updates xkg's line in the repo's post-commit hook and returns the hook's path.
|
|
60
|
+
* The line holds no credential (the hooks directory may be tracked); the key comes from
|
|
61
|
+
* `xkg config` at run time. It reads the sha before going to the background, so a quick
|
|
62
|
+
* next commit can't be reported in its place. It runs the CLI version that installed it, so
|
|
63
|
+
* a new release reaches a repo only when the hook is installed again.
|
|
64
|
+
*/
|
|
65
|
+
export function installPostCommitHook(cwd = process.cwd(), version = getCurrentVersion()) {
|
|
66
|
+
const line = `xkg_sha=$(git rev-parse HEAD); ${runner(version)} post-commit --sha "$xkg_sha" >/dev/null 2>&1 & ${MARKER}`;
|
|
67
|
+
return installHookLine(hooksDirOf(cwd), "post-commit", line, (l) => l.includes(" post-commit "));
|
|
68
|
+
}
|
|
69
|
+
/** Where an agent writes a handoff spec in the code repo. Kept out of git locally, never committed. */
|
|
70
|
+
const LOCAL_DIR = ".xkg/";
|
|
71
|
+
/** Adds `.xkg/` to the repo's local exclude file (not .gitignore, which is shared), once. */
|
|
72
|
+
export function excludeLocalDir(cwd) {
|
|
73
|
+
const path = resolve(cwd, git(cwd, "rev-parse", "--git-path", "info/exclude"));
|
|
74
|
+
const current = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
75
|
+
if (current.split("\n").some((l) => l.trim() === LOCAL_DIR || l.trim() === "/.xkg/"))
|
|
76
|
+
return;
|
|
77
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
78
|
+
writeFileSync(path, `${current.replace(/\n*$/, current ? "\n" : "")}${LOCAL_DIR}\n`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Installs the post-commit hook, plus post-merge and post-checkout lines that sync the
|
|
82
|
+
* code map after a pull or a branch switch (not after a checkout of single files),
|
|
83
|
+
* and keeps `.xkg/` out of git. Returns the three hooks' paths.
|
|
84
|
+
*/
|
|
85
|
+
export function installGitHooks(cwd = process.cwd(), version = getCurrentVersion()) {
|
|
86
|
+
excludeLocalDir(cwd);
|
|
87
|
+
const commit = installPostCommitHook(cwd, version);
|
|
88
|
+
const dir = hooksDirOf(cwd);
|
|
89
|
+
const sync = `${runner(version)} sync-map >/dev/null 2>&1 & ${MARKER}`;
|
|
90
|
+
const isSync = (l) => l.includes(" sync-map ");
|
|
91
|
+
return [
|
|
92
|
+
commit,
|
|
93
|
+
installHookLine(dir, "post-merge", sync, isSync),
|
|
94
|
+
installHookLine(dir, "post-checkout", `[ "$3" = "1" ] && ${sync}`, isSync),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Runs after each commit: reports every `xkg-capture:` trailer of `sha` to apply_capture
|
|
99
|
+
* (and, for a revert, the trailers of the commit it undid), then syncs the code map if it
|
|
100
|
+
* changed. Never throws.
|
|
101
|
+
*/
|
|
102
|
+
export async function postCommit(apiKey, sha, cwd = process.cwd()) {
|
|
103
|
+
// The map first: the server keeps only an apply's files that are in it, so a file the commit added must be there
|
|
104
|
+
await syncCodeMap(apiKey, cwd);
|
|
105
|
+
await reportTrailers(apiKey, sha, cwd);
|
|
106
|
+
}
|
|
107
|
+
/** Changed files sent per report; the server keeps those in the repo's code map. */
|
|
108
|
+
const MAX_FILES = 20;
|
|
109
|
+
/** The commit a `git revert` undid, from git's own message ("This reverts commit <sha>."). */
|
|
110
|
+
export function revertedCommit(message) {
|
|
111
|
+
return message.match(/^This reverts commit ([0-9a-f]{7,40})\b/m)?.[1] ?? null;
|
|
112
|
+
}
|
|
113
|
+
async function reportTrailers(apiKey, sha, cwd) {
|
|
114
|
+
try {
|
|
115
|
+
const full = git(cwd, "rev-parse", "--verify", `${sha}^{commit}`);
|
|
116
|
+
const message = git(cwd, "log", "-1", "--format=%B", full);
|
|
117
|
+
const repo = currentRepo(cwd);
|
|
118
|
+
const inRepo = repo ? { repo } : {};
|
|
119
|
+
const reports = [];
|
|
120
|
+
const trailers = captureTrailers(message);
|
|
121
|
+
if (trailers.length) {
|
|
122
|
+
// NUL-separated, so paths come as they are and not as git quotes them ("caf\303\251.ts")
|
|
123
|
+
const files = git(cwd, "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", full).split("\0").filter(Boolean);
|
|
124
|
+
const target = files.slice(0, 5).join(", ") + (files.length > 5 ? ` and ${files.length - 5} more` : "");
|
|
125
|
+
for (const filename of trailers)
|
|
126
|
+
reports.push({ filename, commit_sha: full, target_file: target, files: files.slice(0, MAX_FILES), ...inRepo });
|
|
127
|
+
}
|
|
128
|
+
// A revert of a commit that applied captures undoes those applies
|
|
129
|
+
const undone = revertedCommit(message);
|
|
130
|
+
if (undone) {
|
|
131
|
+
try {
|
|
132
|
+
const original = git(cwd, "rev-parse", "--verify", `${undone}^{commit}`);
|
|
133
|
+
for (const filename of captureTrailers(git(cwd, "log", "-1", "--format=%B", original))) {
|
|
134
|
+
reports.push({ filename, commit_sha: original, outcome: "reverted", ...inRepo });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// The reverted commit isn't in this clone
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (reports.length === 0)
|
|
142
|
+
return;
|
|
143
|
+
// One session for all of this commit's reports. Without one they still count, as "unknown".
|
|
144
|
+
const session = await initializeMcp(apiKey, HOOK_CLIENT, 10000)
|
|
145
|
+
.then((res) => (res.ok ? res.headers.get("mcp-session-id") : null))
|
|
146
|
+
.catch(() => null);
|
|
147
|
+
for (const args of reports) {
|
|
148
|
+
await callMcp(apiKey, "apply_capture", args, 10000, session).catch(() => null);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Never disturb a commit
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Checks that the hook can reach xkg with this key. Reports no capture. */
|
|
156
|
+
export async function checkHookConnection(apiKey) {
|
|
157
|
+
let res;
|
|
158
|
+
try {
|
|
159
|
+
res = await initializeMcp(apiKey, HOOK_CLIENT, 10000);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
return { ok: false, reason: `Could not reach xkg: ${err instanceof Error ? err.message : String(err)}` };
|
|
163
|
+
}
|
|
164
|
+
if (res.status === 401) {
|
|
165
|
+
return { ok: false, reason: `xkg rejected the API key (HTTP 401). Run setup-git-hook again with a current key.` };
|
|
166
|
+
}
|
|
167
|
+
if (!res.ok)
|
|
168
|
+
return { ok: false, reason: `xkg answered HTTP ${res.status}.` };
|
|
169
|
+
return { ok: true };
|
|
170
|
+
}
|
package/dist/cli/hooks.js
CHANGED
|
@@ -102,8 +102,12 @@ export function setupHooks(apiKey, opts = {}) {
|
|
|
102
102
|
// skipIfExists (serve-mcp auto-install): preserve user-configured hooks but
|
|
103
103
|
// silently migrate hooks that pin a version or use the old package name.
|
|
104
104
|
if (existingCmd && opts.skipIfExists) {
|
|
105
|
-
if (isCurrentHook(existingCmd))
|
|
105
|
+
if (isCurrentHook(existingCmd)) {
|
|
106
|
+
// A vault hook left under the old PreToolCall key never ran; move it.
|
|
107
|
+
if (findVaultHook(hooks.PreToolCall ?? []))
|
|
108
|
+
setupPreToolHook(apiKey, { silent: opts.silent, skipIfExists: true });
|
|
106
109
|
return;
|
|
110
|
+
}
|
|
107
111
|
// Migration: preserve the existing --briefing flag when upgrading to @latest
|
|
108
112
|
opts = { ...opts, briefing: existingCmd.includes("--briefing") };
|
|
109
113
|
}
|
|
@@ -130,41 +134,57 @@ export function setupHooks(apiKey, opts = {}) {
|
|
|
130
134
|
console.log("At the start of each Claude Code session you'll see your inbox count.");
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
if (opts.vault) {
|
|
137
|
+
if (opts.vault || findVaultHook(hooks.PreToolCall ?? [])) {
|
|
134
138
|
setupPreToolHook(apiKey, { silent: opts.silent, skipIfExists: opts.skipIfExists });
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
for (const group of preToolCall) {
|
|
143
|
-
for (const h of group.hooks ?? []) {
|
|
144
|
-
if (typeof h.command === "string" && isXkgPreToolHook(h.command)) {
|
|
145
|
-
existingCmd = h.command;
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
if (existingCmd)
|
|
150
|
-
break;
|
|
151
|
-
}
|
|
152
|
-
if (existingCmd && opts.skipIfExists) {
|
|
153
|
-
if (isCurrentHook(existingCmd))
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
const command = `npx -y ${PACKAGE}@latest vault-check --key ${apiKey}`;
|
|
157
|
-
const filtered = preToolCall
|
|
141
|
+
/** Tools whose edits trigger a vault scan. Also the PreToolUse matcher. */
|
|
142
|
+
const EDITING_TOOLS = ["Edit", "Write", "MultiEdit"];
|
|
143
|
+
/** Removes xkg vault hooks from one hook event's groups, dropping groups left empty. */
|
|
144
|
+
function withoutVaultHook(groups) {
|
|
145
|
+
return groups
|
|
158
146
|
.map((group) => ({
|
|
159
147
|
...group,
|
|
160
148
|
hooks: (group.hooks ?? []).filter((h) => !(typeof h.command === "string" && isXkgPreToolHook(h.command))),
|
|
161
149
|
}))
|
|
162
150
|
.filter((group) => (group.hooks ?? []).length > 0);
|
|
163
|
-
|
|
164
|
-
|
|
151
|
+
}
|
|
152
|
+
function findVaultHook(groups) {
|
|
153
|
+
for (const group of groups) {
|
|
154
|
+
for (const h of group.hooks ?? []) {
|
|
155
|
+
if (typeof h.command === "string" && isXkgPreToolHook(h.command))
|
|
156
|
+
return h.command;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Installs the vault hook on PreToolUse for file edits.
|
|
163
|
+
*
|
|
164
|
+
* Earlier versions wrote it under `PreToolCall`, which Claude Code has no such
|
|
165
|
+
* event for, so it never ran. Any hook left there is moved to PreToolUse.
|
|
166
|
+
*/
|
|
167
|
+
export function setupPreToolHook(apiKey, opts = {}) {
|
|
168
|
+
const settings = readSettings();
|
|
169
|
+
const hooks = settings.hooks ?? {};
|
|
170
|
+
const preToolUse = hooks.PreToolUse ?? [];
|
|
171
|
+
const legacy = hooks.PreToolCall ?? [];
|
|
172
|
+
const existingCmd = findVaultHook(preToolUse);
|
|
173
|
+
if (existingCmd && opts.skipIfExists && isCurrentHook(existingCmd) && !findVaultHook(legacy))
|
|
174
|
+
return;
|
|
175
|
+
const command = `npx -y ${PACKAGE}@latest vault-check --key ${apiKey}`;
|
|
176
|
+
const filtered = withoutVaultHook(preToolUse);
|
|
177
|
+
filtered.push({ matcher: EDITING_TOOLS.join("|"), hooks: [{ type: "command", command }] });
|
|
178
|
+
const next = { ...hooks, PreToolUse: filtered };
|
|
179
|
+
const legacyLeft = withoutVaultHook(legacy);
|
|
180
|
+
if (legacyLeft.length > 0)
|
|
181
|
+
next.PreToolCall = legacyLeft;
|
|
182
|
+
else
|
|
183
|
+
delete next.PreToolCall;
|
|
184
|
+
settings.hooks = next;
|
|
165
185
|
writeSettings(settings);
|
|
166
186
|
if (!opts.silent) {
|
|
167
|
-
console.log("xkg vault hook installed (
|
|
187
|
+
console.log("xkg vault hook installed (PreToolUse).");
|
|
168
188
|
console.log("Your knowledge vault will surface relevant captures as you edit files.");
|
|
169
189
|
}
|
|
170
190
|
}
|
|
@@ -203,13 +223,31 @@ function detectProjectContext() {
|
|
|
203
223
|
// Local agent instructions may contain private maintainer notes; do not upload them.
|
|
204
224
|
return parts.length > 0 ? parts.join("\n\n") : "current project";
|
|
205
225
|
}
|
|
206
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Opens an MCP session that names the caller, so the server can tell which
|
|
228
|
+
* agent made the calls on it. Returns the raw reply; callers read the status
|
|
229
|
+
* or the Mcp-Session-Id header.
|
|
230
|
+
*/
|
|
231
|
+
export async function initializeMcp(apiKey, client, timeoutMs = 15000) {
|
|
232
|
+
return fetch(HOSTED_URL, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
235
|
+
body: JSON.stringify({
|
|
236
|
+
jsonrpc: "2.0",
|
|
237
|
+
method: "initialize",
|
|
238
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: client } },
|
|
239
|
+
id: 1,
|
|
240
|
+
}),
|
|
241
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
export async function callMcp(apiKey, toolName, toolArgs, timeoutMs = 15000, sessionId) {
|
|
245
|
+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
|
246
|
+
if (sessionId)
|
|
247
|
+
headers["Mcp-Session-Id"] = sessionId;
|
|
207
248
|
const res = await fetch(HOSTED_URL, {
|
|
208
249
|
method: "POST",
|
|
209
|
-
headers
|
|
210
|
-
"Content-Type": "application/json",
|
|
211
|
-
Authorization: `Bearer ${apiKey}`,
|
|
212
|
-
},
|
|
250
|
+
headers,
|
|
213
251
|
body: JSON.stringify({
|
|
214
252
|
jsonrpc: "2.0",
|
|
215
253
|
method: "tools/call",
|
|
@@ -236,8 +274,7 @@ export async function vaultCheck(apiKey) {
|
|
|
236
274
|
return;
|
|
237
275
|
}
|
|
238
276
|
// Only act on file-editing tool calls
|
|
239
|
-
|
|
240
|
-
if (!hookData.tool_name || !editingTools.includes(hookData.tool_name))
|
|
277
|
+
if (!hookData.tool_name || !EDITING_TOOLS.includes(hookData.tool_name))
|
|
241
278
|
return;
|
|
242
279
|
const filePath = hookData.tool_input?.["file_path"] ??
|
|
243
280
|
hookData.tool_input?.["path"];
|
|
@@ -256,7 +293,11 @@ export async function vaultCheck(apiKey) {
|
|
|
256
293
|
const filenameMatches = text.match(/(?:inbox|applied|archived)\/[\w-]+\.md/g) ?? [];
|
|
257
294
|
if (filenameMatches.length > 0)
|
|
258
295
|
appendSessionSurfaced(filenameMatches);
|
|
259
|
-
|
|
296
|
+
// Plain stdout from a PreToolUse hook only reaches the debug log; the agent
|
|
297
|
+
// reads it only as additionalContext.
|
|
298
|
+
process.stdout.write(JSON.stringify({
|
|
299
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: text.trim() },
|
|
300
|
+
}) + "\n");
|
|
260
301
|
}
|
|
261
302
|
catch {
|
|
262
303
|
// Silent fail — never interrupt a tool call
|
|
@@ -286,7 +327,9 @@ export async function inboxCheck(apiKey, opts = {}) {
|
|
|
286
327
|
if (applyNow.length > 0) {
|
|
287
328
|
const filenames = applyNow.map((s) => `'${s.filename}'`).join(", ");
|
|
288
329
|
process.stdout.write(`\n---\nxkg: ${applyNow.length} insight${applyNow.length === 1 ? "" : "s"} ready to apply now.\n` +
|
|
289
|
-
`Run generate_plan with selected_captures: [${filenames}] to get a full implementation plan.\n`
|
|
330
|
+
`Run generate_plan with selected_captures: [${filenames}] to get a full implementation plan.\n` +
|
|
331
|
+
`When you commit a change that applies one, add a trailer per capture to the commit message (e.g. xkg-capture: ${applyNow[0].filename}), ` +
|
|
332
|
+
`then call apply_capture with its filename and the commit sha (git rev-parse HEAD). xkg counts each capture and commit once.\n`);
|
|
290
333
|
}
|
|
291
334
|
return;
|
|
292
335
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -41,23 +41,117 @@ async function main() {
|
|
|
41
41
|
await vaultCheck(apiKey);
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
|
+
if (command === "setup-git-hook") {
|
|
45
|
+
const { installGitHooks, checkHookConnection } = await import("./git-hook.js");
|
|
46
|
+
const { syncCodeMap } = await import("./code-map.js");
|
|
47
|
+
const { readConfig, setConfigField } = await import("./config.js");
|
|
48
|
+
const keyIdx = args.indexOf("--key");
|
|
49
|
+
const keyArg = keyIdx !== -1 ? args[keyIdx + 1] : undefined;
|
|
50
|
+
// The hook file may be tracked in git, so the key lives in ~/.xkg/config.json instead
|
|
51
|
+
if (keyArg)
|
|
52
|
+
setConfigField("key", keyArg);
|
|
53
|
+
if (!readConfig().key) {
|
|
54
|
+
console.error("Usage: npx @xmu-labs/xkg setup-git-hook --key YOUR_API_KEY (run inside the repo)");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const paths = installGitHooks();
|
|
59
|
+
console.log(`xkg git hooks installed: ${paths.join(", ")}`);
|
|
60
|
+
console.log("Commits with an `xkg-capture: <filename>` trailer are now recorded as applied.");
|
|
61
|
+
console.log("Commits, pulls and branch switches keep this repo's code map (file and function names, never code) up to date.");
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error(err instanceof Error && err.message.includes("not a shell script")
|
|
65
|
+
? err.message
|
|
66
|
+
: "Not a git repository. Run this inside the repo you code in.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
// The hook runs in the background with its output discarded, so check now that it can report.
|
|
70
|
+
const check = await checkHookConnection(readConfig().key);
|
|
71
|
+
if (check.ok) {
|
|
72
|
+
console.log("Checked: the hook reached xkg with your key.");
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
console.error(`The hook is installed but can't report yet: ${check.reason}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
console.log(syncMessage(await syncCodeMap(readConfig().key, process.cwd(), { force: true })));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (command === "map") {
|
|
82
|
+
const { syncCodeMap } = await import("./code-map.js");
|
|
83
|
+
const { readConfig } = await import("./config.js");
|
|
84
|
+
const keyIdx = args.indexOf("--key");
|
|
85
|
+
const apiKey = keyIdx !== -1 ? args[keyIdx + 1] : readConfig().key;
|
|
86
|
+
if (!apiKey) {
|
|
87
|
+
console.error("No API key. Pass --key YOUR_KEY or store it: xkg config set key YOUR_KEY");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const result = await syncCodeMap(apiKey, process.cwd(), { force: true });
|
|
91
|
+
if (result.status === "synced") {
|
|
92
|
+
console.log(syncMessage(result));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.error(syncMessage(result));
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (command === "sync-map") {
|
|
101
|
+
const { syncCodeMap } = await import("./code-map.js");
|
|
102
|
+
const { readConfig } = await import("./config.js");
|
|
103
|
+
const apiKey = readConfig().key;
|
|
104
|
+
if (!apiKey)
|
|
105
|
+
process.exit(0); // silent fail in hook context
|
|
106
|
+
await syncCodeMap(apiKey);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (command === "session-start") {
|
|
110
|
+
const { sessionStart } = await import("./session-start.js");
|
|
111
|
+
const { readConfig } = await import("./config.js");
|
|
112
|
+
const keyIdx = args.indexOf("--key");
|
|
113
|
+
const apiKey = keyIdx !== -1 ? args[keyIdx + 1] : readConfig().key;
|
|
114
|
+
const agentIdx = args.indexOf("--agent");
|
|
115
|
+
const agent = agentIdx !== -1 ? args[agentIdx + 1] : undefined;
|
|
116
|
+
if (!apiKey)
|
|
117
|
+
return process.exit(0); // silent fail in hook context
|
|
118
|
+
await sessionStart(apiKey, { agent });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (command === "post-commit") {
|
|
122
|
+
const { postCommit } = await import("./git-hook.js");
|
|
123
|
+
const { readConfig } = await import("./config.js");
|
|
124
|
+
const keyIdx = args.indexOf("--key");
|
|
125
|
+
const apiKey = keyIdx !== -1 ? args[keyIdx + 1] : readConfig().key;
|
|
126
|
+
const shaIdx = args.indexOf("--sha");
|
|
127
|
+
const sha = shaIdx !== -1 ? args[shaIdx + 1] : "HEAD";
|
|
128
|
+
if (!apiKey || !sha)
|
|
129
|
+
process.exit(0); // silent fail in hook context
|
|
130
|
+
await postCommit(apiKey, sha);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
44
133
|
if (command === "config") {
|
|
45
134
|
const { runConfigCommand } = await import("./config.js");
|
|
46
135
|
runConfigCommand(args.slice(1));
|
|
47
136
|
return;
|
|
48
137
|
}
|
|
49
|
-
if (command === "kos") {
|
|
50
|
-
const {
|
|
138
|
+
if (command === "agent" || command === "kos") {
|
|
139
|
+
const { runAgent, RENAMED_NOTE } = await import("./agent.js");
|
|
140
|
+
if (command === "kos")
|
|
141
|
+
console.error(RENAMED_NOTE);
|
|
51
142
|
const { readConfig } = await import("./config.js");
|
|
52
143
|
const keyIdx = args.indexOf("--key");
|
|
53
144
|
const apiKey = keyIdx !== -1 ? args[keyIdx + 1] : readConfig().key;
|
|
54
|
-
|
|
145
|
+
const specIdx = args.indexOf("--spec");
|
|
146
|
+
const spec = specIdx !== -1 ? args[specIdx + 1] : undefined;
|
|
147
|
+
// A local spec is read from disk, so it needs no key
|
|
148
|
+
if (!apiKey && !spec) {
|
|
55
149
|
console.error("No API key. Pass --key YOUR_KEY or store it: xkg config set key YOUR_KEY");
|
|
56
150
|
process.exit(1);
|
|
57
151
|
}
|
|
58
152
|
const planIdx = args.indexOf("--plan");
|
|
59
153
|
const plan = planIdx !== -1 ? args[planIdx + 1] : undefined;
|
|
60
|
-
await
|
|
154
|
+
await runAgent(apiKey ?? "", { plan, spec });
|
|
61
155
|
return;
|
|
62
156
|
}
|
|
63
157
|
// Default: open the hosted app
|
|
@@ -75,6 +169,13 @@ async function main() {
|
|
|
75
169
|
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
76
170
|
exec(`${openCmd} ${HOSTED_URL}`);
|
|
77
171
|
}
|
|
172
|
+
function syncMessage(result) {
|
|
173
|
+
if (result.status === "synced" || result.status === "unchanged")
|
|
174
|
+
return `Synced ${result.repo}: ${result.files} files`;
|
|
175
|
+
if (result.status === "skipped")
|
|
176
|
+
return `Code map not synced: ${result.reason}`;
|
|
177
|
+
return `Code map not synced${result.repo ? ` for ${result.repo}` : ""}: ${result.reason} Run \`xkg map\` to try again.`;
|
|
178
|
+
}
|
|
78
179
|
function printHelp() {
|
|
79
180
|
console.log(`
|
|
80
181
|
xkg — A knowledge graph for your AI workers
|
|
@@ -84,10 +185,16 @@ function printHelp() {
|
|
|
84
185
|
xkg serve-mcp --key KEY Start the MCP server for your AI worker
|
|
85
186
|
xkg setup-hooks --key KEY Install inbox count hook (fast)
|
|
86
187
|
xkg setup-hooks --key KEY --briefing Install full briefing hook (uses LLM)
|
|
87
|
-
xkg setup-hooks --key KEY --vault Install vault hook (
|
|
188
|
+
xkg setup-hooks --key KEY --vault Install vault hook (PreToolUse)
|
|
88
189
|
xkg setup-hooks --key KEY --briefing --vault Install both hooks
|
|
89
|
-
xkg
|
|
90
|
-
|
|
190
|
+
xkg setup-git-hook --key KEY Record commits with an xkg-capture trailer and keep the code map fresh (any agent);
|
|
191
|
+
keeps .xkg/ (handoff specs) out of git
|
|
192
|
+
xkg map [--key KEY] Send this repo's code map now (file paths and exported names, never code)
|
|
193
|
+
xkg session-start [--agent NAME] For an agent's session-start hook: offer captures waiting for this repo, else new ones
|
|
194
|
+
(NAME: claude, codex, gemini, copilot, cursor; else plain text)
|
|
195
|
+
xkg config set agent "claude -p" Set which AI worker xkg agent drives
|
|
196
|
+
xkg agent --key KEY [--plan FILE] Implement a plan in an isolated worktree
|
|
197
|
+
xkg agent --spec PATH Apply a handoff spec your agent wrote (.xkg/handoffs/) in an isolated worktree
|
|
91
198
|
xkg help Show this help
|
|
92
199
|
|
|
93
200
|
npx -y @xmu-labs/xkg@latest … Same CLI, via the published package
|
|
@@ -97,14 +204,15 @@ function printHelp() {
|
|
|
97
204
|
generate_plan — Turn selected captures into a full implementation plan
|
|
98
205
|
list_plans — List or read saved implementation plans
|
|
99
206
|
vault_scan — Scan all captures for relevance to current activity
|
|
207
|
+
apply_stats — Captures applied per week, and how many came with a commit
|
|
100
208
|
curate — Validate URLs and flag stale captures
|
|
101
209
|
|
|
102
|
-
|
|
210
|
+
xkg agent — implement plans with your own AI worker (model-agnostic):
|
|
103
211
|
1. Tell xkg which assistant to drive (once):
|
|
104
212
|
xkg config set agent "claude -p" # or "codex exec", "aider --yes --message"
|
|
105
213
|
2. Generate a plan via the generate_plan MCP tool.
|
|
106
|
-
3. Run: xkg
|
|
107
|
-
|
|
214
|
+
3. Run: xkg agent --key YOUR_KEY
|
|
215
|
+
It creates an isolated git worktree, hands the plan to your assistant,
|
|
108
216
|
and reports the branch + the plan's Verification Checklist when done.
|
|
109
217
|
|
|
110
218
|
Get started:
|
package/dist/cli/mcp-server.js
CHANGED
|
@@ -11,25 +11,8 @@ const pending = new Set();
|
|
|
11
11
|
function rpcError(id, message) {
|
|
12
12
|
sendMessage({ jsonrpc: "2.0", id, error: { code: -32603, message } });
|
|
13
13
|
}
|
|
14
|
-
async function proxyToHosted(
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
res = await fetch(HOSTED_URL, {
|
|
18
|
-
method: "POST",
|
|
19
|
-
headers: {
|
|
20
|
-
"Content-Type": "application/json",
|
|
21
|
-
Authorization: `Bearer ${apiKey}`,
|
|
22
|
-
},
|
|
23
|
-
body: JSON.stringify(msg),
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
catch (err) {
|
|
27
|
-
const detail = err instanceof Error ? err.message : "unknown error";
|
|
28
|
-
rpcError(msg.id, `Could not reach xkg at ${HOSTED_URL}: ${detail}`);
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
const body = await res.text().catch(() => "");
|
|
32
|
-
const result = interpretResponse(res.status, res.headers.get("content-type"), body);
|
|
14
|
+
async function proxyToHosted(hosted, msg) {
|
|
15
|
+
const result = await hosted.send(msg);
|
|
33
16
|
if ("error" in result) {
|
|
34
17
|
rpcError(msg.id, result.error);
|
|
35
18
|
return;
|
|
@@ -61,6 +44,47 @@ export function interpretResponse(status, contentType, body) {
|
|
|
61
44
|
return { error: `xkg returned malformed JSON (HTTP ${status}).` };
|
|
62
45
|
}
|
|
63
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The connection to the hosted server. It keeps the Mcp-Session-Id that
|
|
49
|
+
* `initialize` returns and sends it on every later call, so the server knows
|
|
50
|
+
* which agent is calling. When the session expires (404), it replays the
|
|
51
|
+
* client's `initialize` for a new one and retries the call once.
|
|
52
|
+
*/
|
|
53
|
+
export function createHostedSession(apiKey, url, fetchFn = fetch) {
|
|
54
|
+
let sessionId = null;
|
|
55
|
+
let initializeMsg = null;
|
|
56
|
+
async function post(msg) {
|
|
57
|
+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
|
58
|
+
if (sessionId && msg.method !== "initialize")
|
|
59
|
+
headers["Mcp-Session-Id"] = sessionId;
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await fetchFn(url, { method: "POST", headers, body: JSON.stringify(msg) });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const detail = err instanceof Error ? err.message : "unknown error";
|
|
66
|
+
return { error: `Could not reach xkg at ${url}: ${detail}` };
|
|
67
|
+
}
|
|
68
|
+
if (msg.method === "initialize")
|
|
69
|
+
sessionId = res.headers.get("mcp-session-id") ?? sessionId;
|
|
70
|
+
const body = await res.text().catch(() => "");
|
|
71
|
+
return { ...interpretResponse(res.status, res.headers.get("content-type"), body), status: res.status };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
async send(msg) {
|
|
75
|
+
if (msg.method === "initialize")
|
|
76
|
+
initializeMsg = msg;
|
|
77
|
+
const hadSession = sessionId !== null && msg.method !== "initialize";
|
|
78
|
+
let { status, ...reply } = await post(msg);
|
|
79
|
+
if (status === 404 && hadSession && initializeMsg) {
|
|
80
|
+
sessionId = null;
|
|
81
|
+
await post(initializeMsg);
|
|
82
|
+
({ status, ...reply } = await post(msg));
|
|
83
|
+
}
|
|
84
|
+
return reply;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
64
88
|
/** Track a proxied request so shutdown can await it. */
|
|
65
89
|
function track(p) {
|
|
66
90
|
pending.add(p);
|
|
@@ -98,6 +122,7 @@ export async function serveMcp() {
|
|
|
98
122
|
// Non-fatal — hook setup should never block the MCP server from starting
|
|
99
123
|
}
|
|
100
124
|
process.stderr.write("xkg MCP server starting (proxying to hosted instance)...\n");
|
|
125
|
+
const hosted = createHostedSession(apiKey, HOSTED_URL);
|
|
101
126
|
let buffer = "";
|
|
102
127
|
process.stdin.setEncoding("utf-8");
|
|
103
128
|
process.stdin.on("data", (chunk) => {
|
|
@@ -113,7 +138,7 @@ export async function serveMcp() {
|
|
|
113
138
|
// Handle notifications/initialized locally (no response needed)
|
|
114
139
|
if (msg.method === "notifications/initialized")
|
|
115
140
|
continue;
|
|
116
|
-
track(proxyToHosted(
|
|
141
|
+
track(proxyToHosted(hosted, msg));
|
|
117
142
|
}
|
|
118
143
|
catch {
|
|
119
144
|
process.stderr.write(`Failed to parse: ${line}\n`);
|