mnemos-capture 1.6.0 → 1.7.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 +38 -26
- package/dist/cli/git-hook.js +91 -0
- package/dist/cli/hooks.js +78 -35
- package/dist/cli/index.js +49 -1
- package/dist/cli/mcp-server.js +45 -20
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<br>
|
|
3
3
|
<a href="https://xkg.xmu.sh">
|
|
4
|
-
<
|
|
5
|
-
<source media="(prefers-color-scheme: dark)" srcset="public/xkg-hero-dark.gif">
|
|
6
|
-
<img src="public/xkg-hero-light.gif" width="160" height="160" alt="xkg">
|
|
7
|
-
</picture>
|
|
4
|
+
<img src="public/brand/xkg-icon-dark.png" width="160" height="160" alt="xkg">
|
|
8
5
|
</a>
|
|
9
6
|
<br>
|
|
10
7
|
<br>
|
|
11
8
|
<p>
|
|
12
|
-
<a href="https://www.npmjs.com/package/
|
|
13
|
-
<img src="https://img.shields.io/badge/MCP-compatible-
|
|
14
|
-
<img src="https://img.shields.io/badge/license-MIT-
|
|
9
|
+
<a href="https://www.npmjs.com/package/@xmu-labs/xkg"><img src="https://img.shields.io/npm/v/@xmu-labs/xkg?color=777493&labelColor=11131A&style=flat" alt="npm" /></a>
|
|
10
|
+
<img src="https://img.shields.io/badge/MCP-compatible-B8B5D0?labelColor=11131A&style=flat" alt="MCP" />
|
|
11
|
+
<img src="https://img.shields.io/badge/license-MIT-F2F1F6?labelColor=11131A&style=flat" alt="MIT" />
|
|
15
12
|
</p>
|
|
16
13
|
</div>
|
|
17
14
|
|
|
@@ -53,7 +50,7 @@ You find useful stuff, you capture through the web app, then xkg connects it int
|
|
|
53
50
|
xkg will continuously compound that knowledge over time — keeping AI workers' context up to date as human knowledge evolves.
|
|
54
51
|
|
|
55
52
|
> [!IMPORTANT]
|
|
56
|
-
> We are migrating from **mnemos** / **Xmu** to **xkg**. The app now lives at [xkg.xmu.sh](https://xkg.xmu.sh) and the code at [xmu-labs/xkg](https://github.com/xmu-labs/xkg). Connectors set up with `mnemos-capture.vercel.app` keep working
|
|
53
|
+
> We are migrating from **mnemos** / **Xmu** to **xkg**. The app now lives at [xkg.xmu.sh](https://xkg.xmu.sh) and the code at [xmu-labs/xkg](https://github.com/xmu-labs/xkg). The npm package is now `@xmu-labs/xkg`. Connectors set up with `mnemos-capture.vercel.app` and commands that run `npx mnemos-capture@latest` keep working: every release is published under both names. Sorry for the inconvenience.
|
|
57
54
|
|
|
58
55
|
## Start here
|
|
59
56
|
|
|
@@ -95,7 +92,7 @@ https://xkg.xmu.sh/api/mcp
|
|
|
95
92
|
```
|
|
96
93
|
|
|
97
94
|
```bash
|
|
98
|
-
npx -y
|
|
95
|
+
npx -y @xmu-labs/xkg@latest serve-mcp --key YOUR_API_KEY
|
|
99
96
|
```
|
|
100
97
|
|
|
101
98
|
The CLI binary is **`xkg`** (also `xmu` and `mnemos-capture`). An MCP server already registered as `xmu` or `mnemos`, or set up with `mnemos-capture@latest`, keeps working.
|
|
@@ -126,7 +123,7 @@ Name the connector `xkg`, paste `https://xkg.xmu.sh/api/mcp`, sign in with GitHu
|
|
|
126
123
|
The same command works in every CLI — Cursor, Codex, Gemini CLI, VS Code, Claude Code, or anything that can launch a local MCP server:
|
|
127
124
|
|
|
128
125
|
```bash
|
|
129
|
-
npx -y
|
|
126
|
+
npx -y @xmu-labs/xkg@latest serve-mcp --key YOUR_API_KEY
|
|
130
127
|
```
|
|
131
128
|
|
|
132
129
|
Register that command in the tool you use.
|
|
@@ -140,7 +137,7 @@ Add this to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (all projects):
|
|
|
140
137
|
"mcpServers": {
|
|
141
138
|
"xkg": {
|
|
142
139
|
"command": "npx",
|
|
143
|
-
"args": ["-y", "
|
|
140
|
+
"args": ["-y", "@xmu-labs/xkg@latest", "serve-mcp", "--key", "YOUR_API_KEY"]
|
|
144
141
|
}
|
|
145
142
|
}
|
|
146
143
|
}
|
|
@@ -157,7 +154,7 @@ Add this to `.vscode/mcp.json`:
|
|
|
157
154
|
"servers": {
|
|
158
155
|
"xkg": {
|
|
159
156
|
"command": "npx",
|
|
160
|
-
"args": ["-y", "
|
|
157
|
+
"args": ["-y", "@xmu-labs/xkg@latest", "serve-mcp", "--key", "YOUR_API_KEY"]
|
|
161
158
|
}
|
|
162
159
|
}
|
|
163
160
|
}
|
|
@@ -172,7 +169,7 @@ Codex CLI — add this to `~/.codex/config.toml`:
|
|
|
172
169
|
```toml
|
|
173
170
|
[mcp_servers.xkg]
|
|
174
171
|
command = "npx"
|
|
175
|
-
args = ["-y", "
|
|
172
|
+
args = ["-y", "@xmu-labs/xkg@latest", "serve-mcp", "--key", "YOUR_API_KEY"]
|
|
176
173
|
```
|
|
177
174
|
|
|
178
175
|
Codex App: Settings → MCP → add the same `npx` command.
|
|
@@ -180,13 +177,13 @@ Codex App: Settings → MCP → add the same `npx` command.
|
|
|
180
177
|
#### Gemini CLI
|
|
181
178
|
|
|
182
179
|
```bash
|
|
183
|
-
gemini mcp add xkg npx -y
|
|
180
|
+
gemini mcp add xkg npx -y @xmu-labs/xkg@latest serve-mcp --key YOUR_API_KEY
|
|
184
181
|
```
|
|
185
182
|
|
|
186
183
|
#### Claude Code
|
|
187
184
|
|
|
188
185
|
```bash
|
|
189
|
-
claude mcp add xkg -- npx -y
|
|
186
|
+
claude mcp add xkg -- npx -y @xmu-labs/xkg@latest serve-mcp --key YOUR_API_KEY
|
|
190
187
|
```
|
|
191
188
|
|
|
192
189
|
Or the URL, no API key:
|
|
@@ -198,7 +195,7 @@ claude mcp add --transport http xkg https://xkg.xmu.sh/api/mcp
|
|
|
198
195
|
Optional session hooks (MCP works without them):
|
|
199
196
|
|
|
200
197
|
```bash
|
|
201
|
-
npx -y
|
|
198
|
+
npx -y @xmu-labs/xkg@latest setup-hooks --key YOUR_API_KEY --briefing --vault
|
|
202
199
|
```
|
|
203
200
|
|
|
204
201
|
Hooks are local files — they run in the CLI and editor extensions, not in the browser.
|
|
@@ -212,7 +209,7 @@ Settings → Developer → Edit Config. Add the same `npx` block to `claude_desk
|
|
|
212
209
|
"mcpServers": {
|
|
213
210
|
"xkg": {
|
|
214
211
|
"command": "npx",
|
|
215
|
-
"args": ["-y", "
|
|
212
|
+
"args": ["-y", "@xmu-labs/xkg@latest", "serve-mcp", "--key", "YOUR_API_KEY"]
|
|
216
213
|
}
|
|
217
214
|
}
|
|
218
215
|
}
|
|
@@ -227,7 +224,7 @@ If the tool can launch a local process, register the same `npx` command. If it h
|
|
|
227
224
|
"mcpServers": {
|
|
228
225
|
"xkg": {
|
|
229
226
|
"command": "npx",
|
|
230
|
-
"args": ["-y", "
|
|
227
|
+
"args": ["-y", "@xmu-labs/xkg@latest", "serve-mcp", "--key", "YOUR_API_KEY"]
|
|
231
228
|
}
|
|
232
229
|
}
|
|
233
230
|
}
|
|
@@ -256,14 +253,14 @@ No MCP support yet? Capture in the [xkg app](https://xkg.xmu.sh) and connect lat
|
|
|
256
253
|
4. **Execute** — Hand the plan to your worker in the current session, or run it in an isolated git worktree:
|
|
257
254
|
|
|
258
255
|
```bash
|
|
259
|
-
npx -y
|
|
260
|
-
npx -y
|
|
256
|
+
npx -y @xmu-labs/xkg@latest config set agent "claude -p"
|
|
257
|
+
npx -y @xmu-labs/xkg@latest kos --key YOUR_API_KEY
|
|
261
258
|
```
|
|
262
259
|
|
|
263
260
|
`kos` creates a worktree, loads the plan as the contract, launches the assistant you configured, and prints the verification checklist when it finishes.
|
|
264
261
|
|
|
265
262
|
```bash
|
|
266
|
-
npx -y
|
|
263
|
+
npx -y @xmu-labs/xkg@latest config set agent "codex exec"
|
|
267
264
|
```
|
|
268
265
|
|
|
269
266
|
### Example capture
|
|
@@ -317,7 +314,8 @@ You don't have to memorize these. Ask in plain language: "save this", "what do I
|
|
|
317
314
|
| `briefing` | Get project-specific recommendations |
|
|
318
315
|
| `generate_plan` | Turn selected captures into an implementation plan |
|
|
319
316
|
| `list_plans` | List or read saved plans |
|
|
320
|
-
| `apply_capture` | Mark a capture as applied |
|
|
317
|
+
| `apply_capture` | Mark a capture as applied, with the commit that applied it |
|
|
318
|
+
| `apply_stats` | Captures applied per week, and how many came with a commit |
|
|
321
319
|
| `archive_capture` | Archive a capture |
|
|
322
320
|
| `delete_capture` | Delete a capture |
|
|
323
321
|
| `apply_to_context` | Get file-level suggestions for the current task |
|
|
@@ -331,20 +329,23 @@ You don't have to memorize these. Ask in plain language: "save this", "what do I
|
|
|
331
329
|
No global install required. The binary is **`xkg`** (also `xmu` and `mnemos-capture`). Via npx:
|
|
332
330
|
|
|
333
331
|
```bash
|
|
334
|
-
npx -y
|
|
332
|
+
npx -y @xmu-labs/xkg@latest <command>
|
|
335
333
|
```
|
|
336
334
|
|
|
337
335
|
| Command | What it does |
|
|
338
336
|
| --- | --- |
|
|
339
|
-
| `npx -y
|
|
337
|
+
| `npx -y @xmu-labs/xkg@latest` | Open the hosted app |
|
|
340
338
|
| `serve-mcp --key YOUR_API_KEY` | Run the local MCP proxy |
|
|
341
339
|
| `setup-hooks --key YOUR_API_KEY [--briefing] [--vault]` | Install Claude Code hooks |
|
|
340
|
+
| `setup-git-hook --key YOUR_API_KEY` | In a repo: record commits whose message has an `xkg-capture: <filename>` trailer (works with any coding agent). The key is stored in `~/.xkg/config.json`, not in the hook. Setup checks that the hook can reach xkg, and the hook runs the CLI version that installed it (run setup again to update) |
|
|
342
341
|
| `config set agent "claude -p"` | Set which assistant `kos` should drive |
|
|
343
342
|
| `kos --key YOUR_API_KEY` | Implement the latest plan in an isolated worktree |
|
|
344
343
|
| `inbox-check --key YOUR_API_KEY [--briefing]` | Debug the session hook |
|
|
345
344
|
| `vault-check --key YOUR_API_KEY` | Debug the vault hook |
|
|
346
345
|
| `help` | Show help |
|
|
347
346
|
|
|
347
|
+
What changed in each version: [GitHub Releases](https://github.com/xmu-labs/xkg/releases).
|
|
348
|
+
|
|
348
349
|
## Your context stays in GitHub
|
|
349
350
|
|
|
350
351
|
Captures live as structured Markdown in **your** GitHub repository. Private by default.
|
|
@@ -372,6 +373,7 @@ The whole point of this repo being public is that you can check these claims rat
|
|
|
372
373
|
| `pin_hash` | salted scrypt (`src/lib/pin.ts`) |
|
|
373
374
|
| `api_key` | hashed — not recoverable |
|
|
374
375
|
| `llm_provider`, `llm_api_key` | encrypted |
|
|
376
|
+
| `display_name`, `avatar_data` | optional in-app name and photo; never written back to GitHub |
|
|
375
377
|
| `token_version` | bumped to revoke everything |
|
|
376
378
|
| login attempts, OAuth clients/codes, refresh-token ids | auth plumbing |
|
|
377
379
|
|
|
@@ -396,7 +398,11 @@ Your MCP key is shown exactly once. If you lose it, generate a new one.
|
|
|
396
398
|
|
|
397
399
|
**PIN unlock is device-bound.** It only works on a device that has already signed in with GitHub. A PIN on its own is not enough to reach your account.
|
|
398
400
|
|
|
399
|
-
**Revoking access.** Generating a new MCP key invalidates the old key, every signed-in session, and every connected MCP client. Sessions also expire after 30 days.
|
|
401
|
+
**Revoking access.** Generating a new MCP key invalidates the old key, every other signed-in session, and every connected MCP client. Sessions also expire after 30 days.
|
|
402
|
+
|
|
403
|
+
**Deleting your account.** Settings → Delete account removes your account row and every record keyed to it, and asks GitHub to revoke xkg's access (`src/lib/account.ts`). Your captures stay in your repo.
|
|
404
|
+
|
|
405
|
+
The hosted service's [Privacy Policy](https://xkg.xmu.sh/privacy) and [Terms of Service](https://xkg.xmu.sh/terms) say the same in legal form.
|
|
400
406
|
|
|
401
407
|
## Cost
|
|
402
408
|
|
|
@@ -423,7 +429,13 @@ Working on the code? See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
423
429
|
|
|
424
430
|
## License
|
|
425
431
|
|
|
426
|
-
[MIT License](./LICENSE)
|
|
432
|
+
[MIT License](./LICENSE). Contributions are welcome under the same licence; see
|
|
433
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
434
|
+
|
|
435
|
+
**Trademarks.** The MIT licence covers the code, not names or logos: the xkg name and logo are not
|
|
436
|
+
licensed for use as your own brand. Claude, ChatGPT, Codex, Gemini, Grok, Cursor and VS Code are
|
|
437
|
+
trademarks of their respective owners, named here only to say which tools xkg works with; no
|
|
438
|
+
endorsement is implied.
|
|
427
439
|
|
|
428
440
|
If you use xkg in published work:
|
|
429
441
|
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { getCurrentVersion } from "./version-check.js";
|
|
6
|
+
/**
|
|
7
|
+
* Links commits to the captures they apply, with any coding agent or none.
|
|
8
|
+
* The commit message carries one `xkg-capture: <filename>` trailer per
|
|
9
|
+
* 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
|
+
*/
|
|
13
|
+
const PACKAGE = "@xmu-labs/xkg";
|
|
14
|
+
const MARKER = "# xkg";
|
|
15
|
+
/** The MCP client name the hook's reports carry, so the server counts them by agent. */
|
|
16
|
+
export const HOOK_CLIENT = "xkg-git-hook";
|
|
17
|
+
function git(cwd, ...args) {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
19
|
+
}
|
|
20
|
+
/** `xkg-capture` values from the commit message's trailer block only (as git parses it), once each. */
|
|
21
|
+
export function captureTrailers(message) {
|
|
22
|
+
let trailers;
|
|
23
|
+
try {
|
|
24
|
+
trailers = execFileSync("git", ["interpret-trailers", "--parse"], { input: message, encoding: "utf-8", timeout: 5000 });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const found = [...trailers.matchAll(/^xkg-capture:[ \t]*(\S+)[ \t]*$/gim)].map((m) => m[1]);
|
|
30
|
+
return [...new Set(found)];
|
|
31
|
+
}
|
|
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}`;
|
|
44
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "#!/bin/sh\n";
|
|
45
|
+
if (!/^#!\s*\S*\b(?:env\s+)?(?:sh|bash|zsh|dash|ksh)\b/.test(existing.split("\n")[0] ?? "")) {
|
|
46
|
+
throw new Error(`${path} is not a shell script; add this line to it yourself:\n${line}`);
|
|
47
|
+
}
|
|
48
|
+
const kept = existing.split("\n").filter((l) => !l.endsWith(MARKER) || !l.includes(" post-commit "));
|
|
49
|
+
const body = kept.join("\n").replace(/\n*$/, "\n");
|
|
50
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
51
|
+
writeFileSync(path, `${body}${line}\n`);
|
|
52
|
+
chmodSync(path, 0o755);
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
/** Runs after each commit: reports every `xkg-capture:` trailer of `sha` to apply_capture. Never throws. */
|
|
56
|
+
export async function postCommit(apiKey, sha, cwd = process.cwd()) {
|
|
57
|
+
try {
|
|
58
|
+
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)
|
|
61
|
+
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
|
+
// One session for all of this commit's reports. Without one they still count, as "unknown".
|
|
65
|
+
const session = await initializeMcp(apiKey, HOOK_CLIENT, 10000)
|
|
66
|
+
.then((res) => (res.ok ? res.headers.get("mcp-session-id") : null))
|
|
67
|
+
.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);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Never disturb a commit
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Checks that the hook can reach xkg with this key. Reports no capture. */
|
|
77
|
+
export async function checkHookConnection(apiKey) {
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await initializeMcp(apiKey, HOOK_CLIENT, 10000);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { ok: false, reason: `Could not reach xkg: ${err instanceof Error ? err.message : String(err)}` };
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 401) {
|
|
86
|
+
return { ok: false, reason: `xkg rejected the API key (HTTP 401). Run setup-git-hook again with a current key.` };
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
return { ok: false, reason: `xkg answered HTTP ${res.status}.` };
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
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,6 +41,52 @@ async function main() {
|
|
|
41
41
|
await vaultCheck(apiKey);
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
|
+
if (command === "setup-git-hook") {
|
|
45
|
+
const { installPostCommitHook, checkHookConnection } = await import("./git-hook.js");
|
|
46
|
+
const { readConfig, setConfigField } = await import("./config.js");
|
|
47
|
+
const keyIdx = args.indexOf("--key");
|
|
48
|
+
const keyArg = keyIdx !== -1 ? args[keyIdx + 1] : undefined;
|
|
49
|
+
// The hook file may be tracked in git, so the key lives in ~/.xkg/config.json instead
|
|
50
|
+
if (keyArg)
|
|
51
|
+
setConfigField("key", keyArg);
|
|
52
|
+
if (!readConfig().key) {
|
|
53
|
+
console.error("Usage: npx @xmu-labs/xkg setup-git-hook --key YOUR_API_KEY (run inside the repo)");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const path = installPostCommitHook();
|
|
58
|
+
console.log(`xkg git hook installed: ${path}`);
|
|
59
|
+
console.log("Commits with an `xkg-capture: <filename>` trailer are now recorded as applied.");
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error(err instanceof Error && err.message.includes("not a shell script")
|
|
63
|
+
? err.message
|
|
64
|
+
: "Not a git repository. Run this inside the repo you code in.");
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
// The hook runs in the background with its output discarded, so check now that it can report.
|
|
68
|
+
const check = await checkHookConnection(readConfig().key);
|
|
69
|
+
if (check.ok) {
|
|
70
|
+
console.log("Checked: the hook reached xkg with your key.");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.error(`The hook is installed but can't report yet: ${check.reason}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (command === "post-commit") {
|
|
79
|
+
const { postCommit } = await import("./git-hook.js");
|
|
80
|
+
const { readConfig } = await import("./config.js");
|
|
81
|
+
const keyIdx = args.indexOf("--key");
|
|
82
|
+
const apiKey = keyIdx !== -1 ? args[keyIdx + 1] : readConfig().key;
|
|
83
|
+
const shaIdx = args.indexOf("--sha");
|
|
84
|
+
const sha = shaIdx !== -1 ? args[shaIdx + 1] : "HEAD";
|
|
85
|
+
if (!apiKey || !sha)
|
|
86
|
+
process.exit(0); // silent fail in hook context
|
|
87
|
+
await postCommit(apiKey, sha);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
44
90
|
if (command === "config") {
|
|
45
91
|
const { runConfigCommand } = await import("./config.js");
|
|
46
92
|
runConfigCommand(args.slice(1));
|
|
@@ -84,8 +130,9 @@ function printHelp() {
|
|
|
84
130
|
xkg serve-mcp --key KEY Start the MCP server for your AI worker
|
|
85
131
|
xkg setup-hooks --key KEY Install inbox count hook (fast)
|
|
86
132
|
xkg setup-hooks --key KEY --briefing Install full briefing hook (uses LLM)
|
|
87
|
-
xkg setup-hooks --key KEY --vault Install vault hook (
|
|
133
|
+
xkg setup-hooks --key KEY --vault Install vault hook (PreToolUse)
|
|
88
134
|
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)
|
|
89
136
|
xkg config set agent "claude -p" Set which AI worker kos drives
|
|
90
137
|
xkg kos --key KEY [--plan FILE] Implement a plan in an isolated worktree
|
|
91
138
|
xkg help Show this help
|
|
@@ -97,6 +144,7 @@ function printHelp() {
|
|
|
97
144
|
generate_plan — Turn selected captures into a full implementation plan
|
|
98
145
|
list_plans — List or read saved implementation plans
|
|
99
146
|
vault_scan — Scan all captures for relevance to current activity
|
|
147
|
+
apply_stats — Captures applied per week, and how many came with a commit
|
|
100
148
|
curate — Validate URLs and flag stale captures
|
|
101
149
|
|
|
102
150
|
kos — implement plans with your own AI worker (model-agnostic):
|
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`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mnemos-capture",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"prepublishOnly": "npm run build:cli",
|
|
17
17
|
"test": "vitest run",
|
|
18
18
|
"test:watch": "vitest",
|
|
19
|
-
"test:e2e": "playwright test",
|
|
19
|
+
"test:e2e": "playwright test --project=network",
|
|
20
|
+
"test:ui": "playwright test --project=ui",
|
|
20
21
|
"lint": "eslint .",
|
|
21
22
|
"typecheck": "tsc --noEmit"
|
|
22
23
|
},
|
|
@@ -40,7 +41,10 @@
|
|
|
40
41
|
"type": "git",
|
|
41
42
|
"url": "git+https://github.com/xmu-labs/xkg.git"
|
|
42
43
|
},
|
|
43
|
-
"homepage": "https://
|
|
44
|
+
"homepage": "https://xkg.xmu.sh",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/xmu-labs/xkg/issues"
|
|
47
|
+
},
|
|
44
48
|
"files": [
|
|
45
49
|
"dist/cli/",
|
|
46
50
|
"LICENSE",
|