mnemos-capture 1.7.0 → 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.
@@ -2,13 +2,19 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "f
2
2
  import { join, resolve } from "path";
3
3
  import { execFileSync } from "child_process";
4
4
  import { callMcp, initializeMcp } from "./hooks.js";
5
+ import { syncCodeMap } from "./code-map.js";
6
+ import { currentRepo } from "./session-start.js";
5
7
  import { getCurrentVersion } from "./version-check.js";
6
8
  /**
7
9
  * Links commits to the captures they apply, with any coding agent or none.
8
10
  * The commit message carries one `xkg-capture: <filename>` trailer per
9
11
  * capture; a git post-commit hook reports each one to apply_capture with the
10
- * commit sha. The server counts a capture and commit once, so an agent that
11
- * also calls apply_capture itself does not double count.
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.
12
18
  */
13
19
  const PACKAGE = "@xmu-labs/xkg";
14
20
  const MARKER = "# xkg";
@@ -29,44 +35,117 @@ export function captureTrailers(message) {
29
35
  const found = [...trailers.matchAll(/^xkg-capture:[ \t]*(\S+)[ \t]*$/gim)].map((m) => m[1]);
30
36
  return [...new Set(found)];
31
37
  }
32
- /**
33
- * Installs or updates xkg's line in the repo's post-commit hook and returns the hook's path.
34
- * The line holds no credential (the hooks directory may be tracked); the key comes from
35
- * `xkg config` at run time. It reads the sha before going to the background, so a quick
36
- * next commit can't be reported in its place. It runs the CLI version that installed it, so
37
- * a new release reaches a repo only when the hook is installed again.
38
- */
39
- export function installPostCommitHook(cwd = process.cwd(), version = getCurrentVersion()) {
40
- const hooksDir = resolve(cwd, git(cwd, "rev-parse", "--git-path", "hooks"));
41
- const path = join(hooksDir, "post-commit");
42
- const pin = version === "0.0.0" ? "latest" : version;
43
- const line = `xkg_sha=$(git rev-parse HEAD); npx -y ${PACKAGE}@${pin} post-commit --sha "$xkg_sha" >/dev/null 2>&1 & ${MARKER}`;
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);
44
41
  const existing = existsSync(path) ? readFileSync(path, "utf-8") : "#!/bin/sh\n";
45
42
  if (!/^#!\s*\S*\b(?:env\s+)?(?:sh|bash|zsh|dash|ksh)\b/.test(existing.split("\n")[0] ?? "")) {
46
43
  throw new Error(`${path} is not a shell script; add this line to it yourself:\n${line}`);
47
44
  }
48
- const kept = existing.split("\n").filter((l) => !l.endsWith(MARKER) || !l.includes(" post-commit "));
45
+ const kept = existing.split("\n").filter((l) => !(l.endsWith(MARKER) && isOurs(l)));
49
46
  const body = kept.join("\n").replace(/\n*$/, "\n");
50
47
  mkdirSync(hooksDir, { recursive: true });
51
48
  writeFileSync(path, `${body}${line}\n`);
52
49
  chmodSync(path, 0o755);
53
50
  return path;
54
51
  }
55
- /** Runs after each commit: reports every `xkg-capture:` trailer of `sha` to apply_capture. Never throws. */
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
+ */
56
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) {
57
114
  try {
58
115
  const full = git(cwd, "rev-parse", "--verify", `${sha}^{commit}`);
59
- const trailers = captureTrailers(git(cwd, "log", "-1", "--format=%B", full));
60
- if (trailers.length === 0)
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)
61
142
  return;
62
- const files = git(cwd, "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", full).split("\n").filter(Boolean);
63
- const target = files.slice(0, 5).join(", ") + (files.length > 5 ? ` and ${files.length - 5} more` : "");
64
143
  // One session for all of this commit's reports. Without one they still count, as "unknown".
65
144
  const session = await initializeMcp(apiKey, HOOK_CLIENT, 10000)
66
145
  .then((res) => (res.ok ? res.headers.get("mcp-session-id") : null))
67
146
  .catch(() => null);
68
- for (const filename of trailers) {
69
- await callMcp(apiKey, "apply_capture", { filename, commit_sha: full, target_file: target }, 10000, session).catch(() => null);
147
+ for (const args of reports) {
148
+ await callMcp(apiKey, "apply_capture", args, 10000, session).catch(() => null);
70
149
  }
71
150
  }
72
151
  catch {
package/dist/cli/index.js CHANGED
@@ -42,7 +42,8 @@ async function main() {
42
42
  return;
43
43
  }
44
44
  if (command === "setup-git-hook") {
45
- const { installPostCommitHook, checkHookConnection } = await import("./git-hook.js");
45
+ const { installGitHooks, checkHookConnection } = await import("./git-hook.js");
46
+ const { syncCodeMap } = await import("./code-map.js");
46
47
  const { readConfig, setConfigField } = await import("./config.js");
47
48
  const keyIdx = args.indexOf("--key");
48
49
  const keyArg = keyIdx !== -1 ? args[keyIdx + 1] : undefined;
@@ -54,9 +55,10 @@ async function main() {
54
55
  process.exit(1);
55
56
  }
56
57
  try {
57
- const path = installPostCommitHook();
58
- console.log(`xkg git hook installed: ${path}`);
58
+ const paths = installGitHooks();
59
+ console.log(`xkg git hooks installed: ${paths.join(", ")}`);
59
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.");
60
62
  }
61
63
  catch (err) {
62
64
  console.error(err instanceof Error && err.message.includes("not a shell script")
@@ -73,6 +75,47 @@ async function main() {
73
75
  console.error(`The hook is installed but can't report yet: ${check.reason}`);
74
76
  process.exit(1);
75
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 });
76
119
  return;
77
120
  }
78
121
  if (command === "post-commit") {
@@ -92,18 +135,23 @@ async function main() {
92
135
  runConfigCommand(args.slice(1));
93
136
  return;
94
137
  }
95
- if (command === "kos") {
96
- const { kos } = await import("./kos.js");
138
+ if (command === "agent" || command === "kos") {
139
+ const { runAgent, RENAMED_NOTE } = await import("./agent.js");
140
+ if (command === "kos")
141
+ console.error(RENAMED_NOTE);
97
142
  const { readConfig } = await import("./config.js");
98
143
  const keyIdx = args.indexOf("--key");
99
144
  const apiKey = keyIdx !== -1 ? args[keyIdx + 1] : readConfig().key;
100
- if (!apiKey) {
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) {
101
149
  console.error("No API key. Pass --key YOUR_KEY or store it: xkg config set key YOUR_KEY");
102
150
  process.exit(1);
103
151
  }
104
152
  const planIdx = args.indexOf("--plan");
105
153
  const plan = planIdx !== -1 ? args[planIdx + 1] : undefined;
106
- await kos(apiKey, { plan });
154
+ await runAgent(apiKey ?? "", { plan, spec });
107
155
  return;
108
156
  }
109
157
  // Default: open the hosted app
@@ -121,6 +169,13 @@ async function main() {
121
169
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
122
170
  exec(`${openCmd} ${HOSTED_URL}`);
123
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
+ }
124
179
  function printHelp() {
125
180
  console.log(`
126
181
  xkg — A knowledge graph for your AI workers
@@ -132,9 +187,14 @@ function printHelp() {
132
187
  xkg setup-hooks --key KEY --briefing Install full briefing hook (uses LLM)
133
188
  xkg setup-hooks --key KEY --vault Install vault hook (PreToolUse)
134
189
  xkg setup-hooks --key KEY --briefing --vault Install both hooks
135
- xkg setup-git-hook --key KEY Record commits with an xkg-capture trailer (any agent)
136
- xkg config set agent "claude -p" Set which AI worker kos drives
137
- xkg kos --key KEY [--plan FILE] Implement a plan in an isolated worktree
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
138
198
  xkg help Show this help
139
199
 
140
200
  npx -y @xmu-labs/xkg@latest … Same CLI, via the published package
@@ -147,12 +207,12 @@ function printHelp() {
147
207
  apply_stats — Captures applied per week, and how many came with a commit
148
208
  curate — Validate URLs and flag stale captures
149
209
 
150
- kos — implement plans with your own AI worker (model-agnostic):
210
+ xkg agent — implement plans with your own AI worker (model-agnostic):
151
211
  1. Tell xkg which assistant to drive (once):
152
212
  xkg config set agent "claude -p" # or "codex exec", "aider --yes --message"
153
213
  2. Generate a plan via the generate_plan MCP tool.
154
- 3. Run: xkg kos --key YOUR_KEY
155
- kos creates an isolated git worktree, hands the plan to your assistant,
214
+ 3. Run: xkg agent --key YOUR_KEY
215
+ It creates an isolated git worktree, hands the plan to your assistant,
156
216
  and reports the branch + the plan's Verification Checklist when done.
157
217
 
158
218
  Get started:
@@ -0,0 +1,73 @@
1
+ import { execFileSync } from "child_process";
2
+ import { callMcp, initializeMcp } from "./hooks.js";
3
+ import { repoFromRemote } from "./code-map.js";
4
+ /** The name this command's MCP session carries, so the server can tell where a session began. */
5
+ export const SESSION_START_CLIENT = "xkg-session-start";
6
+ /**
7
+ * How each agent's SessionStart hook reads added context. Agents not listed
8
+ * here get plain text, which Claude Code and Codex also accept.
9
+ */
10
+ const FORMATS = {
11
+ claude: hookSpecificOutput,
12
+ codex: hookSpecificOutput,
13
+ gemini: hookSpecificOutput,
14
+ copilot: (text) => ({ additionalContext: text }),
15
+ cursor: (text) => ({ additional_context: text }),
16
+ };
17
+ function hookSpecificOutput(text) {
18
+ return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text } };
19
+ }
20
+ function message(count) {
21
+ const ask = count === 1
22
+ ? "You have 1 new xkg capture. Want to see it and apply it here?"
23
+ : `You have ${count} new xkg captures. Want to see them and apply one here?`;
24
+ return (`xkg: ${count} new capture${count === 1 ? "" : "s"} in the user's inbox.\n` +
25
+ `Before starting the user's task, ask them: "${ask}"\n` +
26
+ "If they say yes, call the xkg `briefing` tool with this repo's name, branch and last few commit messages, " +
27
+ "then check each candidate against the code before suggesting where it applies.");
28
+ }
29
+ /** `owner/name` of the repo at `cwd` from its origin remote, or null outside a repo or without an origin. */
30
+ export function currentRepo(cwd = process.cwd()) {
31
+ try {
32
+ const url = execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf-8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
33
+ return repoFromRemote(url);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** What the server's briefing starts with when captures wait for the repo. */
40
+ const OFFER_PREFIX = "xkg: ";
41
+ /**
42
+ * Runs from any agent's SessionStart hook. In a repo with captures waiting for
43
+ * it (HANDOFFS.md), prints the server's offer as is, so the hook and a briefing
44
+ * call give the same words. Otherwise counts new captures and, when there are
45
+ * some, prints the question the agent should ask. Either way shaped for that
46
+ * agent. Never fails or delays a session for long: on any error it prints nothing.
47
+ */
48
+ export async function sessionStart(apiKey, opts = {}) {
49
+ // One limit for the whole command, so a hung server can't hold the session twice as long.
50
+ const deadline = Date.now() + (opts.timeoutMs ?? 3000);
51
+ const remaining = () => Math.max(1, deadline - Date.now());
52
+ try {
53
+ const session = await initializeMcp(apiKey, SESSION_START_CLIENT, remaining())
54
+ .then((res) => (res.ok ? res.headers.get("mcp-session-id") : null))
55
+ .catch(() => null);
56
+ const format = opts.agent ? FORMATS[opts.agent] : undefined;
57
+ const print = (body) => process.stdout.write((format ? JSON.stringify(format(body)) : body) + "\n");
58
+ const repo = opts.repo === undefined ? currentRepo() : opts.repo;
59
+ if (repo) {
60
+ const offer = await callMcp(apiKey, "briefing", { repo }, remaining(), session).catch(() => null);
61
+ if (offer?.startsWith(OFFER_PREFIX))
62
+ return void print(offer);
63
+ }
64
+ const text = await callMcp(apiKey, "list_inbox", {}, remaining(), session);
65
+ const count = parseInt(text?.match(/^(\d+)\s+capture/)?.[1] ?? "0", 10);
66
+ if (count <= 0)
67
+ return;
68
+ print(message(count));
69
+ }
70
+ catch {
71
+ // Silent fail: never interrupt a session start
72
+ }
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnemos-capture",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "xkg — Turn what you discover into context your AI workers can use. Capture once, then brief, plan, and execute with any MCP-compatible tool.",
5
5
  "type": "module",
6
6
  "bin": {