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.
- package/README.md +146 -44
- 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 +101 -22
- package/dist/cli/index.js +73 -13
- package/dist/cli/session-start.js +73 -0
- package/package.json +1 -1
package/dist/cli/git-hook.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
34
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
|
69
|
-
await callMcp(apiKey, "apply_capture",
|
|
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 {
|
|
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
|
|
58
|
-
console.log(`xkg git
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
xkg
|
|
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
|
-
|
|
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
|
|
155
|
-
|
|
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