heedvane 0.0.0-stage → 0.2.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.
Files changed (98) hide show
  1. package/README.md +62 -2
  2. package/dist/apply.d.ts +104 -0
  3. package/dist/apply.js +388 -0
  4. package/dist/backend/client.d.ts +40 -0
  5. package/dist/backend/client.js +32 -0
  6. package/dist/backend/credentials.d.ts +15 -0
  7. package/dist/backend/credentials.js +76 -0
  8. package/dist/backend/login.d.ts +33 -0
  9. package/dist/backend/login.js +265 -0
  10. package/dist/backend/repo-remote.d.ts +15 -0
  11. package/dist/backend/repo-remote.js +61 -0
  12. package/dist/backend/sign-in-change.d.ts +12 -0
  13. package/dist/backend/sign-in-change.js +72 -0
  14. package/dist/best-effort.d.ts +4 -0
  15. package/dist/best-effort.js +25 -0
  16. package/dist/cli.d.ts +29 -0
  17. package/dist/cli.js +666 -0
  18. package/dist/clients/definitions.d.ts +113 -0
  19. package/dist/clients/definitions.js +427 -0
  20. package/dist/clients/hermes-plugin.d.ts +33 -0
  21. package/dist/clients/hermes-plugin.js +247 -0
  22. package/dist/commands/heedvane.d.ts +15 -0
  23. package/dist/commands/heedvane.js +74 -0
  24. package/dist/commands/memory.d.ts +10 -0
  25. package/dist/commands/memory.js +106 -0
  26. package/dist/config-doc.d.ts +97 -0
  27. package/dist/config-doc.js +465 -0
  28. package/dist/dispatchers/posix.d.ts +41 -0
  29. package/dist/dispatchers/posix.js +208 -0
  30. package/dist/dispatchers/snippets.d.ts +6 -0
  31. package/dist/dispatchers/snippets.js +39 -0
  32. package/dist/dispatchers/windows.d.ts +15 -0
  33. package/dist/dispatchers/windows.js +50 -0
  34. package/dist/doctor.d.ts +37 -0
  35. package/dist/doctor.js +303 -0
  36. package/dist/events/contract.d.ts +54 -0
  37. package/dist/events/contract.js +87 -0
  38. package/dist/events/normalize.d.ts +36 -0
  39. package/dist/events/normalize.js +143 -0
  40. package/dist/guards.d.ts +18 -0
  41. package/dist/guards.js +28 -0
  42. package/dist/heed-file.d.ts +78 -0
  43. package/dist/heed-file.js +316 -0
  44. package/dist/installer.d.ts +47 -0
  45. package/dist/installer.js +174 -0
  46. package/dist/listener/background.d.ts +8 -0
  47. package/dist/listener/background.js +36 -0
  48. package/dist/listener/endpoint.d.ts +26 -0
  49. package/dist/listener/endpoint.js +125 -0
  50. package/dist/listener/forward.d.ts +36 -0
  51. package/dist/listener/forward.js +257 -0
  52. package/dist/listener/serve.d.ts +2 -0
  53. package/dist/listener/serve.js +155 -0
  54. package/dist/listener/server.d.ts +48 -0
  55. package/dist/listener/server.js +251 -0
  56. package/dist/listener/spool.d.ts +28 -0
  57. package/dist/listener/spool.js +60 -0
  58. package/dist/mcp-server.d.ts +9 -0
  59. package/dist/mcp-server.js +187 -0
  60. package/dist/mcp.d.ts +57 -0
  61. package/dist/mcp.js +83 -0
  62. package/dist/memory/private-memory.d.ts +12 -0
  63. package/dist/memory/private-memory.js +54 -0
  64. package/dist/memory/table-of-contents.d.ts +10 -0
  65. package/dist/memory/table-of-contents.js +80 -0
  66. package/dist/memory/team-memory.d.ts +60 -0
  67. package/dist/memory/team-memory.js +54 -0
  68. package/dist/plan.d.ts +77 -0
  69. package/dist/plan.js +288 -0
  70. package/dist/render.d.ts +32 -0
  71. package/dist/render.js +132 -0
  72. package/dist/reply/harness-output.d.ts +57 -0
  73. package/dist/reply/harness-output.js +88 -0
  74. package/dist/reply/reply.d.ts +65 -0
  75. package/dist/reply/reply.js +169 -0
  76. package/dist/reply/verdict.d.ts +28 -0
  77. package/dist/reply/verdict.js +17 -0
  78. package/dist/skill/bundle.d.ts +23 -0
  79. package/dist/skill/bundle.js +102 -0
  80. package/dist/skill/content.d.ts +16 -0
  81. package/dist/skill/content.js +182 -0
  82. package/dist/skills/approved.d.ts +31 -0
  83. package/dist/skills/approved.js +123 -0
  84. package/dist/skills/check.d.ts +21 -0
  85. package/dist/skills/check.js +83 -0
  86. package/dist/state.d.ts +51 -0
  87. package/dist/state.js +118 -0
  88. package/dist/steering/rules.d.ts +49 -0
  89. package/dist/steering/rules.js +165 -0
  90. package/dist/steering/steer.d.ts +29 -0
  91. package/dist/steering/steer.js +64 -0
  92. package/dist/steering/violations.d.ts +27 -0
  93. package/dist/steering/violations.js +24 -0
  94. package/dist/uninstall.d.ts +23 -0
  95. package/dist/uninstall.js +159 -0
  96. package/package.json +35 -4
  97. package/skills/heedvane-hooks/SKILL.md +87 -0
  98. package/skills/heedvane-hooks/references/events.md +69 -0
@@ -0,0 +1,247 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ const HERMES_PLUGIN_NAME = "heedvane-hooks";
5
+ /**
6
+ * The hooks this plugin registers with the Hermes host. `pre_tool_call` and `pre_llm_call` are directive
7
+ * hooks: Hermes acts on what they return. `on_session_end` fires once per turn despite its name;
8
+ * `on_session_finalize` is the session's end (https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks).
9
+ */
10
+ export const HERMES_EVENTS = [
11
+ "on_session_start",
12
+ "pre_llm_call",
13
+ "pre_tool_call",
14
+ "post_tool_call",
15
+ "pre_approval_request",
16
+ "post_approval_response",
17
+ "on_session_end",
18
+ "on_session_finalize",
19
+ ];
20
+ const PLUGIN_YAML = `name: heedvane-hooks
21
+ version: 1.0.0
22
+ description: 'Reports coding-agent lifecycle events to the heedvane listener.'
23
+ author: 'Heedvane'
24
+ kind: standalone
25
+ provides_hooks:
26
+ ${HERMES_EVENTS.map((event) => ` - ${event}\n`).join("")}`;
27
+ const pluginInitPy = (gating) => `"""Heedvane agent-hooks plugin for Hermes.
28
+
29
+ Reports coding-agent lifecycle events to the local heedvane listener
30
+ (http://127.0.0.1:<port>/hook/hermes) with the shared token.
31
+
32
+ The listener's answer goes back to the host: a tool block on pre_tool_call,
33
+ turn context on pre_llm_call. Best-effort by contract: a slow, absent, or
34
+ misconfigured listener must never block the host or raise into it, so any
35
+ failure answers None and the host carries on.
36
+ Configuration comes from the HEED_AGENT_HOOK_PORT / HEED_AGENT_HOOK_TOKEN
37
+ env vars, falling back to the user's ~/.heed/endpoint.env (KEY=VALUE lines).
38
+ Which sessions report follows heed.yaml gating.mode, baked in at setup: with
39
+ "pane" only sessions launched with HEED_SESSION set, with "always" every one.
40
+ """
41
+
42
+ import json
43
+ import os
44
+ import urllib.error
45
+ import urllib.request
46
+
47
+ # The hooks registered with the host via register(ctx).
48
+ EVENTS = (
49
+ ${HERMES_EVENTS.map((event) => ` "${event}",\n`).join("")})
50
+
51
+ REQUEST_TIMEOUT_S = 0.75
52
+
53
+ GATING = "${gating}"
54
+
55
+
56
+ def _endpoint_from_env():
57
+ port = os.environ.get("HEED_AGENT_HOOK_PORT")
58
+ token = os.environ.get("HEED_AGENT_HOOK_TOKEN")
59
+ if port and token:
60
+ return (port, token)
61
+ return None
62
+
63
+
64
+ def _endpoint_from_home_file():
65
+ """Parse ~/.heed/endpoint.env (KEY=VALUE lines, mode 0600)."""
66
+ path = os.path.join(os.path.expanduser("~"), ".heed", "endpoint.env")
67
+ try:
68
+ with open(path, "r", encoding="utf-8") as handle:
69
+ lines = handle.read().splitlines()
70
+ except OSError:
71
+ return None
72
+ values = {}
73
+ for raw in lines:
74
+ line = raw.strip()
75
+ if not line or line.startswith("#") or "=" not in line:
76
+ continue
77
+ key, _, value = line.partition("=")
78
+ value = value.strip()
79
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
80
+ value = value[1:-1]
81
+ values[key.strip()] = value
82
+ port = values.get("HEED_AGENT_HOOK_PORT")
83
+ token = values.get("HEED_AGENT_HOOK_TOKEN")
84
+ if port and token:
85
+ return (port, token)
86
+ return None
87
+
88
+
89
+ def _resolve_endpoint():
90
+ endpoint = _endpoint_from_env()
91
+ if endpoint is not None:
92
+ return endpoint
93
+ return _endpoint_from_home_file()
94
+
95
+
96
+ def _post_to_listener(hook_event_name, cwd, payload):
97
+ """POST one event to the listener and return its decoded answer, or None.
98
+ Best-effort: never raises."""
99
+ if GATING != "always" and not os.environ.get("HEED_SESSION"):
100
+ return None
101
+ endpoint = _resolve_endpoint()
102
+ if endpoint is None:
103
+ return None
104
+ port, token = endpoint
105
+ # Flat like every other harness: the host's fields (tool_name, tool_input, ...) at the top level.
106
+ event = dict(payload)
107
+ event["hook_event_name"] = hook_event_name
108
+ event["cwd"] = cwd
109
+ body = json.dumps(event, default=str).encode("utf-8")
110
+ request = urllib.request.Request(
111
+ "http://127.0.0.1:" + str(port) + "/hook/hermes",
112
+ data=body,
113
+ headers={
114
+ "Content-Type": "application/json",
115
+ "X-Heed-Agent-Hook-Token": token,
116
+ "X-Heed-Hook-Version": "1",
117
+ },
118
+ method="POST",
119
+ )
120
+ try:
121
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_S) as response:
122
+ return json.loads(response.read().decode("utf-8"))
123
+ except (OSError, urllib.error.URLError, ValueError):
124
+ # OSError covers socket errors/timeouts (URLError subclasses it);
125
+ # ValueError covers a malformed port or URL and an answer that is not
126
+ # JSON. Silent by design: a hook must never raise into the host.
127
+ return None
128
+
129
+
130
+ def _extract(payload):
131
+ """Return (cwd, normalized payload) from whatever the host hands us."""
132
+ if not isinstance(payload, dict):
133
+ payload = {"data": payload}
134
+ cwd = payload.get("cwd") or os.getcwd()
135
+ return cwd, payload
136
+
137
+
138
+ def _hook_payload(args, kwargs):
139
+ """The event's fields however the host passes them: a payload kwarg, keyword fields
140
+ (tool_name, args, session_id, ...) or one positional dict. Hermes' tool arguments
141
+ (args) become tool_input, the field every other harness uses."""
142
+ payload = kwargs.get("payload")
143
+ if payload is None:
144
+ payload = dict(kwargs) if kwargs else (args[0] if args else {})
145
+ if isinstance(payload, dict) and "tool_input" not in payload and isinstance(payload.get("args"), dict):
146
+ payload = dict(payload)
147
+ payload["tool_input"] = payload.pop("args")
148
+ return payload
149
+
150
+
151
+ def _make_hook(event):
152
+ """The host callback for one event. It returns the listener's answer when that is a
153
+ non-empty object ({"action": "block", ...} on pre_tool_call, {"context": ...} on
154
+ pre_llm_call), and None, Hermes' "no directive", otherwise."""
155
+ def _hook(*args, **kwargs):
156
+ try:
157
+ cwd, normalized = _extract(_hook_payload(args, kwargs))
158
+ answer = _post_to_listener(event, cwd, normalized)
159
+ except Exception: # absolute last resort: never raise into the host
160
+ return None
161
+ return answer if isinstance(answer, dict) and answer else None
162
+ _hook.__name__ = "heedvane_" + str(event)
163
+ return _hook
164
+
165
+
166
+ def register(ctx):
167
+ """Register every hook in EVENTS. Called by the Hermes plugin loader."""
168
+ for event in EVENTS:
169
+ ctx.register_hook(event, _make_hook(event))
170
+ `;
171
+ /** The two files that make up the Hermes plugin, ready to install. */
172
+ export function hermesPluginFiles(gating = "always") {
173
+ return [
174
+ { relativePath: "plugin.yaml", content: PLUGIN_YAML },
175
+ { relativePath: "__init__.py", content: pluginInitPy(gating) },
176
+ ];
177
+ }
178
+ async function pathExists(path) {
179
+ try {
180
+ await stat(path);
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
187
+ async function readFileOrNull(path) {
188
+ try {
189
+ return await readFile(path, "utf8");
190
+ }
191
+ catch (err) {
192
+ if (err.code === "ENOENT")
193
+ return null;
194
+ throw err;
195
+ }
196
+ }
197
+ function isHeedOwned(pluginYaml) {
198
+ try {
199
+ const manifest = parseYaml(pluginYaml);
200
+ return typeof manifest === "object" && manifest !== null && manifest.name === HERMES_PLUGIN_NAME;
201
+ }
202
+ catch {
203
+ return false; // unparseable manifest — not provably ours
204
+ }
205
+ }
206
+ /** Does `<home>/.hermes/plugins/heedvane-hooks/plugin.yaml` declare our plugin? Uninstall removes only then. */
207
+ export async function hermesPluginOwned(home) {
208
+ const manifest = await readFileOrNull(join(home, ".hermes", "plugins", HERMES_PLUGIN_NAME, "plugin.yaml"));
209
+ return manifest !== null && isHeedOwned(manifest);
210
+ }
211
+ /**
212
+ * Install the Hermes plugin into `<home>/.hermes/plugins/heedvane-hooks/`.
213
+ *
214
+ * - "skipped" — a foreign plugin owns the target directory. Whether Hermes is installed at all
215
+ * is the planner's decision (detected, or named with `--agent`).
216
+ * - "unchanged" — the dir is heed-owned and every file already matches.
217
+ * - "installed" — files were written (fresh install or update).
218
+ *
219
+ * A heed-owned directory is one whose plugin.yaml exists and contains
220
+ * `name: heedvane-hooks`. Differing heed-owned files are backed up to
221
+ * `<file>.heedvane.backup` before being replaced. Foreign plugin.yaml files
222
+ * are never overwritten.
223
+ */
224
+ export async function installHermesPlugin(home, gating = "always") {
225
+ const pluginDir = join(home, ".hermes", "plugins", HERMES_PLUGIN_NAME);
226
+ if (await pathExists(pluginDir)) {
227
+ const marker = await readFileOrNull(join(pluginDir, "plugin.yaml"));
228
+ if (marker === null || !isHeedOwned(marker)) {
229
+ return "skipped"; // foreign plugin owns this directory — never overwrite
230
+ }
231
+ }
232
+ let changed = false;
233
+ await mkdir(pluginDir, { recursive: true });
234
+ for (const file of hermesPluginFiles(gating)) {
235
+ const target = join(pluginDir, file.relativePath);
236
+ const current = await readFileOrNull(target);
237
+ if (current === file.content)
238
+ continue;
239
+ if (current !== null) {
240
+ // Heed-owned but drifted: keep the old bytes for operator recovery.
241
+ await writeFile(`${target}.heedvane.backup`, current, "utf8");
242
+ }
243
+ await writeFile(target, file.content, "utf8");
244
+ changed = true;
245
+ }
246
+ return changed ? "installed" : "unchanged";
247
+ }
@@ -0,0 +1,15 @@
1
+ export interface BackendCommandContext {
2
+ home: string;
3
+ out: (line: string) => void;
4
+ /** The positional words after the command. */
5
+ words: string[];
6
+ project: boolean;
7
+ readStdin: () => Promise<string>;
8
+ openBrowser: (url: string) => void;
9
+ }
10
+ /** `npx heedvane login`: browser sign-in through Heedvane's OAuth server (src/backend/login.ts). */
11
+ export declare function cmdLogin(ctx: BackendCommandContext): Promise<number>;
12
+ /** `npx heedvane auth org`: the key arrives on stdin, so it never lands in shell history. */
13
+ export declare function cmdAuth(ctx: BackendCommandContext): Promise<number>;
14
+ /** `npx heedvane skills sync [--project]`: home skill dirs by default, the current project's with --project. */
15
+ export declare function cmdSkills(ctx: BackendCommandContext): Promise<number>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The commands that talk to the Heedvane backend: `auth` stores a key, `skills sync` installs the
3
+ * organization's approved skills and caches the list the skill check enforces.
4
+ */
5
+ import { join } from "node:path";
6
+ import { callHeedvane } from "../backend/client.js";
7
+ import { backendAt, saveCredential } from "../backend/credentials.js";
8
+ import { withSignInChange } from "../backend/sign-in-change.js";
9
+ import { login } from "../backend/login.js";
10
+ import { heedvaneConfigAt } from "../heed-file.js";
11
+ import { syncApprovedSkills } from "../skills/approved.js";
12
+ import { errorMessage } from "../guards.js";
13
+ /** `npx heedvane login`: browser sign-in through Heedvane's OAuth server (src/backend/login.ts). */
14
+ export async function cmdLogin(ctx) {
15
+ const root = join(ctx.home, ".heed");
16
+ try {
17
+ const { url } = heedvaneConfigAt(root);
18
+ const tokens = await withSignInChange(root, url, () => login(root, url, { openBrowser: ctx.openBrowser, out: ctx.out }));
19
+ ctx.out(` ✓ signed in${tokens.organizationId === undefined ? "" : ` to organization ${tokens.organizationId}`} (~/.heed/login.json, mode 0600)`);
20
+ const probe = await callHeedvane({ url, credential: async () => ({ bearer: tokens.accessToken, identity: null }), identity: async () => null }, { method: "GET", path: "/api/agent/steering/rules", timeoutMs: 10_000 });
21
+ if (probe?.status === 401) {
22
+ ctx.out(` ⚠ ${url} does not accept the sign-in on its agent routes yet (401): steering, skills and event forwarding stay off with it.`);
23
+ ctx.out(" while signed in, no organization key is used; to use one until Heedvane accepts the sign-in, delete ~/.heed/login.json and run npx heedvane auth org < key-file");
24
+ }
25
+ ctx.out(" a running listener picks it up when restarted: npx heedvane listener start");
26
+ return 0;
27
+ }
28
+ catch (err) {
29
+ ctx.out(` ✗ sign-in failed: ${errorMessage(err)}`);
30
+ return 1;
31
+ }
32
+ }
33
+ /** `npx heedvane auth org`: the key arrives on stdin, so it never lands in shell history. */
34
+ export async function cmdAuth(ctx) {
35
+ const kind = ctx.words[0];
36
+ if (kind !== "org") {
37
+ ctx.out(" usage: npx heedvane auth org (reads the organization API key from stdin)");
38
+ return 2;
39
+ }
40
+ const key = (await ctx.readStdin()).trim();
41
+ if (key === "") {
42
+ ctx.out(" ✗ no key on stdin");
43
+ return 1;
44
+ }
45
+ const root = join(ctx.home, ".heed");
46
+ await withSignInChange(root, heedvaneConfigAt(root).url, () => saveCredential(root, kind, key));
47
+ ctx.out(` ✓ ${kind} key saved to ~/.heed/credentials.json (mode 0600)`);
48
+ return 0;
49
+ }
50
+ /** `npx heedvane skills sync [--project]`: home skill dirs by default, the current project's with --project. */
51
+ export async function cmdSkills(ctx) {
52
+ if (ctx.words[0] !== "sync") {
53
+ ctx.out(" usage: npx heedvane skills sync [--project]");
54
+ return 2;
55
+ }
56
+ const root = join(ctx.home, ".heed");
57
+ const backend = await backendAt(root, heedvaneConfigAt(root).url);
58
+ if (backend === null) {
59
+ ctx.out(" ✗ not signed in — run: npx heedvane login (or, headless: npx heedvane auth org < key-file)");
60
+ return 1;
61
+ }
62
+ try {
63
+ const synced = await syncApprovedSkills(backend, { root, base: ctx.project ? process.cwd() : ctx.home });
64
+ ctx.out(` ✓ ${synced.installed.length} approved skill(s) installed${synced.installed.length > 0 ? `: ${synced.installed.join(", ")}` : ""}`);
65
+ if (synced.removed.length > 0)
66
+ ctx.out(` ✓ no longer approved, removed: ${synced.removed.join(", ")}`);
67
+ return 0;
68
+ }
69
+ catch (err) {
70
+ ctx.out(` ✗ skills sync failed: ${errorMessage(err)}`);
71
+ ctx.out(" the previous approved list (if any) is unchanged");
72
+ return 1;
73
+ }
74
+ }
@@ -0,0 +1,10 @@
1
+ export interface MemoryCommandContext {
2
+ home: string;
3
+ out: (line: string) => void;
4
+ /** The words after `memory`: the subcommand, then its arguments. */
5
+ words: string[];
6
+ /** The value after `flag` in the raw arguments, if any. */
7
+ flag: (name: string) => string | undefined;
8
+ cwd: string;
9
+ }
10
+ export declare function cmdMemory(ctx: MemoryCommandContext): Promise<number>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `npx heedvane memory list | show | search`: the team memory's tree for agents without MCP, wrapping the
3
+ * same routes the MCP tools and the session-start table of contents use (src/memory/team-memory.ts). The
4
+ * repository is the current checkout's origin; a folder is passed exactly as `list` printed it.
5
+ */
6
+ import { join } from "node:path";
7
+ import { repoRemote } from "../backend/repo-remote.js";
8
+ import { backendAt } from "../backend/credentials.js";
9
+ import { heedvaneConfigAt } from "../heed-file.js";
10
+ import { isRecord } from "../guards.js";
11
+ import { askTeamMemory } from "../memory/team-memory.js";
12
+ const USAGE = [
13
+ ' usage: npx heedvane memory list [folder] [--offset <n>] browse the team memory of this checkout\'s repository',
14
+ " npx heedvane memory show <id> read one memory",
15
+ ' npx heedvane memory search "<question>" [--folder <folder>]',
16
+ ];
17
+ export async function cmdMemory(ctx) {
18
+ const [subcommand, ...rest] = ctx.words;
19
+ const argument = rest[0];
20
+ if (subcommand === "show" && argument !== undefined)
21
+ return ask(ctx, { action: "get", body: { id: argument }, print: showMemory, inRepository: false });
22
+ if (subcommand === "search" && argument !== undefined)
23
+ return ask(ctx, { action: "search", body: { query: argument, ...optional("folder", ctx.flag("--folder")) }, print: searchResult, inRepository: true });
24
+ if (subcommand === "list")
25
+ return ask(ctx, { action: "list", body: { ...optional("folder", argument), ...optional("offset", ctx.flag("--offset"), Number) }, print: (json, out) => listing(json, out, argument), inRepository: true });
26
+ for (const line of USAGE)
27
+ ctx.out(line);
28
+ return 2;
29
+ }
30
+ function optional(key, value, convert) {
31
+ return value === undefined || value === "" ? {} : { [key]: convert === undefined ? value : convert(value) };
32
+ }
33
+ async function ask(ctx, request) {
34
+ const gitOrigin = request.inRepository ? await repoRemote(ctx.cwd) : null;
35
+ if (gitOrigin === "") {
36
+ ctx.out(" ✗ not in a git checkout with an origin: team memory belongs to a repository");
37
+ return 1;
38
+ }
39
+ const root = join(ctx.home, ".heed");
40
+ const backend = await backendAt(root, heedvaneConfigAt(root).url);
41
+ if (backend === null) {
42
+ ctx.out(" ✗ not signed in — run: npx heedvane login (or, headless: npx heedvane auth org < key-file)");
43
+ return 1;
44
+ }
45
+ const answer = await askTeamMemory(backend, { action: request.action, body: gitOrigin === null ? request.body : { gitOrigin, ...request.body } });
46
+ if (answer.state === "unavailable") {
47
+ ctx.out(` ✗ team memory is not available: ${answer.error}${answer.status === null ? "" : ` (${answer.status})`}`);
48
+ return 1;
49
+ }
50
+ request.print(answer.json, ctx.out);
51
+ return 0;
52
+ }
53
+ const text = (value) => (typeof value === "string" ? value : "");
54
+ const day = (value) => text(value).slice(0, 10);
55
+ const commit = (value) => (typeof value === "string" ? ` at ${value.slice(0, 7)}` : "");
56
+ function memoryLine(memory) {
57
+ return `[${text(memory["id"])}] ${text(memory["title"])} · confirmed ${day(memory["validatedAt"])}${commit(memory["validatedCommitSha"])}`;
58
+ }
59
+ function records(value) {
60
+ return Array.isArray(value) ? value.filter(isRecord) : [];
61
+ }
62
+ function folderLine(folder) {
63
+ return ` ${text(folder["folder"])} (${String(folder["count"])})`;
64
+ }
65
+ function moreLine(shown, total, requested) {
66
+ return ` ${shown} of ${total} memories shown. More: npx heedvane memory list ${requested === undefined ? "" : `"${requested}" `}--offset ${shown}`;
67
+ }
68
+ const numberOr = (value, fallback) => (typeof value === "number" ? value : fallback);
69
+ function listing(json, out, requested) {
70
+ const folders = records(json["folders"]);
71
+ const memories = records(json["memories"]);
72
+ const total = numberOr(json["total"], memories.length);
73
+ const where = text(json["folder"]) || "the top level";
74
+ if (folders.length === 0 && memories.length === 0)
75
+ return out(` nothing in ${requested ?? where}`);
76
+ out(` ${where}`);
77
+ for (const folder of folders)
78
+ out(folderLine(folder));
79
+ for (const memory of memories)
80
+ out(` ${memoryLine(memory)}`);
81
+ const shown = numberOr(json["offset"], 0) + memories.length;
82
+ if (shown < total)
83
+ out(moreLine(shown, total, requested));
84
+ else if (memories.length > 0)
85
+ out(` ${total} memories`);
86
+ }
87
+ function showMemory(json, out) {
88
+ const memory = isRecord(json["memory"]) ? json["memory"] : {};
89
+ out(` ${memoryLine(memory)}`);
90
+ out(` folder: ${text(memory["folder"])} saved at ${text(memory["commitSha"]).slice(0, 7) || "an unrecorded commit"}`);
91
+ const paths = Array.isArray(memory["paths"]) ? memory["paths"].filter((path) => typeof path === "string") : [];
92
+ if (paths.length > 0)
93
+ out(` files: ${paths.join(", ")}`);
94
+ out("");
95
+ out(text(memory["content"]));
96
+ }
97
+ function searchResult(json, out) {
98
+ const hits = records(json["memories"]);
99
+ if (hits.length === 0)
100
+ return out(" no memory matches");
101
+ for (const hit of hits) {
102
+ out(` [${text(hit["id"])}] ${text(hit["title"])} (${typeof hit["score"] === "number" ? hit["score"].toFixed(2) : "?"}) · ${text(hit["folder"])} · confirmed ${day(hit["validatedAt"])}${commit(hit["validatedCommitSha"])}`);
103
+ out(text(hit["content"]));
104
+ out("");
105
+ }
106
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Per-format parse / patch / serialize for agent config files.
3
+ *
4
+ * Formats:
5
+ * - "json" — strict JSON (JSON.parse), servers key `mcpServers`
6
+ * - "jsonc" — JSON with comments (jsonc-parser), style preserved via
7
+ * format edits; servers key `mcpServers`
8
+ * - "toml" — smol-toml parse/stringify, servers key `mcp_servers`
9
+ * - "vscode" — strict JSON, servers key `servers`
10
+ * - "opencode" — strict JSON, servers key `mcp`
11
+ *
12
+ * A ParsedDoc carries the parsed value; for JSONC it also carries the original
13
+ * text so mutations can be applied as edits that preserve comments and the
14
+ * user's existing style. JSONC mutation MUST go through withServer /
15
+ * removeServer — mutating doc.value directly leaves the edit buffer stale.
16
+ */
17
+ export type ConfigFormat = "json" | "jsonc" | "toml" | "vscode" | "opencode";
18
+ /** Compat alias — A's plan/doctor/uninstall refer to the format type as DocFormat. */
19
+ export type DocFormat = ConfigFormat;
20
+ export interface ParsedDoc {
21
+ format: ConfigFormat;
22
+ value: Record<string, unknown>;
23
+ /** JSONC and TOML: current edit-buffer text (comments and style preserved). */
24
+ text?: string;
25
+ /** JSONC and TOML: whether any edit has been applied to the buffer. */
26
+ edited?: boolean;
27
+ }
28
+ /** Parse config text into a ParsedDoc. Throws ConfigParseError on malformed input. */
29
+ export declare function parseDoc(text: string, format: ConfigFormat): ParsedDoc;
30
+ /**
31
+ * Serialize a doc back to text.
32
+ * - JSON family: 2-space pretty JSON with trailing newline.
33
+ * - JSONC: if the doc carries an edit buffer (edited), return it verbatim —
34
+ * comments and surrounding style preserved. If the doc was never edited and
35
+ * `originalText` is given, return `originalText` unchanged (no semantic
36
+ * change => no byte change). Otherwise fall back to pretty JSON.
37
+ * - TOML: the edit buffer when it decodes to the doc's value (comments preserved),
38
+ * else smol-toml stringify.
39
+ */
40
+ export declare function serializeDoc(doc: ParsedDoc, format: ConfigFormat, originalText?: string): string;
41
+ /** The container key naming the document's own top level (Cursor's `version`), not a table inside it. */
42
+ export declare const TOP_LEVEL = ".";
43
+ /**
44
+ * Compat helper (A's API): read `doc[containerKey][entryName]` without the
45
+ * per-format servers-key machinery, or `doc[entryName]` for {@link TOP_LEVEL}.
46
+ * `undefined` when either level is missing or not a plain object.
47
+ */
48
+ export declare function readEntry(doc: ParsedDoc, containerKey: string, entryName: string): unknown;
49
+ /** The servers map of a doc, or undefined when absent. Throws ServersTableError when present but not an object. */
50
+ export declare function getServers(doc: ParsedDoc, format: ConfigFormat, serversKey?: string): Record<string, unknown> | undefined;
51
+ /** Set (or, with `undefined`, remove) one top-level key of a JSON or JSONC document, keeping JSONC comments. */
52
+ export declare function withTopLevelKey(doc: ParsedDoc, edit: {
53
+ key: string;
54
+ value: unknown;
55
+ }): ParsedDoc;
56
+ /** Where a server entry lives and what to write there. */
57
+ export interface ServerEdit {
58
+ serversKey: string;
59
+ name: string;
60
+ desired: unknown;
61
+ }
62
+ /** Names an existing server entry inside a doc. */
63
+ export interface ServerRef {
64
+ serversKey: string;
65
+ name: string;
66
+ }
67
+ /**
68
+ * Set `servers[name] = desired`, creating the servers table when missing.
69
+ * For JSONC the change is applied as a format edit on the preserved text.
70
+ * Returns the same doc (mutated), for chaining.
71
+ */
72
+ export declare function withServer(doc: ParsedDoc, format: ConfigFormat, edit: ServerEdit): ParsedDoc;
73
+ /** A hooks entry we own: its command (Cursor's flat entry) or one of its group's commands carries the snippet marker. */
74
+ export declare function isOurHookGroup(group: unknown): boolean;
75
+ /**
76
+ * The value an entry should hold once ours is applied. A `hooks` event holds a list of
77
+ * groups shared with the user's own hooks: theirs are kept in order and ours replaced.
78
+ * A top-level version keeps a higher one already there.
79
+ */
80
+ export declare function entryValue(containerKey: string, current: unknown, desired: unknown): unknown;
81
+ /**
82
+ * Delete `servers[name]` when present. No-op when the table or the entry is
83
+ * absent. For JSONC the deletion is a style-preserving edit.
84
+ */
85
+ export declare function removeServer(doc: ParsedDoc, format: ConfigFormat, ref: ServerRef): ParsedDoc;
86
+ /**
87
+ * Deep-equal over sorted keys (key order ignored, array order preserved).
88
+ * Returns true when `a` and `b` are semantically the same config value.
89
+ */
90
+ export declare function valuesEqual(a: unknown, b: unknown): boolean;
91
+ /**
92
+ * Marker detection: does the doc carry a heedvane-managed entry? True when
93
+ * `name` is the reserved name or carries an identity marker, or when ANY
94
+ * entry in the servers table is marker-carrying (our entry may live under a
95
+ * user-chosen name). False when the doc has no heedvane entry.
96
+ */
97
+ export declare function detectMarker(doc: ParsedDoc, format: ConfigFormat, ref: ServerRef): boolean;