arcroom 0.0.1-beta

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/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { codeSetup, forgetBlind, gitRoot, repoKey, writeSetup } from './blind.js';
5
+ import { daemon } from './daemon.js';
6
+ import { doctor, print } from './doctor.js';
7
+ import { approveChecks, checksHash, isApproved, loadChecks, readApprovals } from './done.js';
8
+ import { syncTree } from './tree.js';
9
+ import { api, ARCROOM_HOME, loadConfig, setOff } from './home.js';
10
+ import { hook } from './hook.js';
11
+ import { init } from './init.js';
12
+ import { mcp } from './mcp.js';
13
+ import { readStats, statusText } from './signs.js';
14
+ import { sessionUsage, tokensOf } from './usage.js';
15
+ const [cmd, ...args] = process.argv.slice(2);
16
+ if (cmd === 'init')
17
+ await init(args);
18
+ else if (cmd === 'hook')
19
+ await hook();
20
+ else if (cmd === 'daemon')
21
+ await daemon();
22
+ else if (cmd === 'mcp')
23
+ await mcp();
24
+ else if (cmd === 'doctor') {
25
+ if (!print(await doctor()))
26
+ process.exitCode = 1;
27
+ }
28
+ else if (cmd === 'whoami') {
29
+ const me = await api(loadConfig(), '/v1/me');
30
+ console.log(`${me.email ?? 'shared key (no person)'} in workspace "${me.workspace}" (key ${me.key_label ?? 'unlabelled'}, made ${me.key_created_at})`);
31
+ }
32
+ else if (cmd === 'status') {
33
+ // Claude Code status line: gets the session JSON on stdin, prints one line. Must be fast and never fail.
34
+ let input = '';
35
+ for await (const chunk of process.stdin)
36
+ input += chunk;
37
+ let session = '';
38
+ let used = 0;
39
+ try {
40
+ const raw = JSON.parse(input);
41
+ session = raw.session_id ?? '';
42
+ if (session)
43
+ used = tokensOf(sessionUsage(session, raw.transcript_path));
44
+ }
45
+ catch { }
46
+ console.log(statusText(session ? readStats(session) : readStats('none'), used));
47
+ }
48
+ else if (cmd === 'rule') {
49
+ // arcroom rule [--tag <tag>] [--max-hits N] [--idle-days D] (no limits = remove the rule)
50
+ const opt = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
51
+ const num = (name) => (opt(name) === undefined ? null : Number(opt(name)));
52
+ const res = await api(loadConfig(), '/v1/rules', { tag: opt('--tag') ?? null, max_hits: num('--max-hits'), idle_days: num('--idle-days') });
53
+ console.log(res.rule ? `rule set: ${JSON.stringify(res.rule)}` : 'rule removed');
54
+ }
55
+ else if (cmd === 'wrong' || cmd === 'share') {
56
+ // arcroom wrong [memo id] [reason]: the memory just handed over (or the one named) was wrong
57
+ // arcroom share [memo id]: share your new steps with the team before their second pass
58
+ const lastFile = join(ARCROOM_HOME, 'last-recall.json');
59
+ const named = /^[0-9a-f-]{36}$/i.test(args[0] ?? '') ? args.shift() : undefined;
60
+ const last = named ? { memo_id: named, intent: named } : existsSync(lastFile) ? JSON.parse(readFileSync(lastFile, 'utf8')) : undefined;
61
+ if (!last)
62
+ console.log('no memory was handed over yet; pass its id (see the Memory page)');
63
+ else if (cmd === 'share') {
64
+ await api(loadConfig(), '/v1/memory/share', { memo_id: last.memo_id });
65
+ console.log(`shared with the team: ${last.intent}`);
66
+ }
67
+ else {
68
+ const r = await api(loadConfig(), '/v1/memory/wrong', { memo_id: last.memo_id, reason: args.join(' ') || null });
69
+ const what = r.deleted
70
+ ? 'deleted (2 people said wrong)'
71
+ : r.steps_broken
72
+ ? 'steps broken, the last good version takes over'
73
+ : r.answer_thrown_away
74
+ ? 'answer thrown away'
75
+ : `noted (${r.reports} of 2 reports delete it)`;
76
+ console.log(`reported wrong: ${last.intent} · ${what}`);
77
+ }
78
+ }
79
+ else if (cmd === 'code') {
80
+ // arcroom code [status|sync|on|off]: the Code wing for the repo you are in (paths and git hashes, never code)
81
+ const s = codeSetup(process.cwd());
82
+ const sub = args[0] ?? 'status';
83
+ if (!s)
84
+ console.log('not inside a git repo');
85
+ else if (sub === 'off' || sub === 'on') {
86
+ writeSetup(s.root, { code: sub === 'off' ? 'off' : undefined });
87
+ forgetBlind(s.root); // the person here chose it: this laptop stops holding the repo to Blind
88
+ console.log(`Code wing ${sub} for ${s.root} (commit .arcroom.json so the team gets the same setting)`);
89
+ }
90
+ else if (s.mode !== 'standard')
91
+ console.log(`Code wing is ${s.mode} for this repo${s.mode === 'off' ? ': arcroom code on turns it on' : ''}`);
92
+ else if (sub === 'sync') {
93
+ const r = await syncTree(loadConfig(), s);
94
+ console.log(`sent ${r.nodes} folders and files (repo ${repoKey(s)})`);
95
+ }
96
+ else {
97
+ const repos = await api(loadConfig(), '/v1/code/tree');
98
+ const me = repos.find((x) => x.repo === repoKey(s));
99
+ console.log(me
100
+ ? `${me.repo}: ${me.folders} folders, ${me.files} files, ${me.editing} agent(s) editing now, default branch ${me.default_branch}`
101
+ : 'this repo has not sent its tree yet: arcroom code sync');
102
+ }
103
+ }
104
+ else if (cmd === 'off' || cmd === 'on') {
105
+ // arcroom off | on: ArcRoom for the repo you are in, on this laptop only (nothing traced or sent while off)
106
+ const root = gitRoot(process.cwd()) ?? process.cwd();
107
+ setOff(root, cmd === 'off');
108
+ console.log(cmd === 'off' ? `ArcRoom is off for ${root}: nothing from it is recorded or sent` : `ArcRoom is on again for ${root}`);
109
+ }
110
+ else if (cmd === 'checks') {
111
+ // arcroom checks [approve <code>]: the done-checks in this folder's .arcroom.json, and whether they may run.
112
+ // Approving needs the code printed next to the exact commands shown, so a later change to the file is never approved.
113
+ const checks = loadChecks(process.cwd());
114
+ const list = checks.map((c) => ` ${JSON.stringify(c)}`).join('\n');
115
+ const code = checksHash(checks).slice(0, 8);
116
+ if (!checks.length)
117
+ console.log('no doneChecks in .arcroom.json here');
118
+ else if (args[0] === 'approve' && !process.stdin.isTTY)
119
+ // an agent's shell has no terminal: approving stays a step the person takes themselves (N14)
120
+ console.log('not approved: run arcroom checks approve yourself, in a terminal (not through an agent)');
121
+ else if (args[0] === 'approve') {
122
+ const done = approveChecks(process.cwd(), args[1] ?? '');
123
+ console.log(done ? `approved for ${process.cwd()} (after editing files, the agent is asked to run them with its Bash tool before it stops):\n${list}` : 'not approved: run arcroom checks, read the commands, then arcroom checks approve <the code it prints>');
124
+ }
125
+ else if (isApproved(process.cwd(), checks, readApprovals()))
126
+ console.log(`${list}\napproved`);
127
+ else
128
+ console.log(`${list}\nnot approved: once approved, the agent is asked to run these with its Bash tool after it edits files (each run goes through its permission prompt).
129
+ To allow them: arcroom checks approve ${code}`);
130
+ }
131
+ else if (cmd === 'decide') {
132
+ // arcroom decide "<what was chosen>" [--why ...] [--over "A, B"] [--context ...] [--by ...] [--source <url>] [--replaces <memo id>]
133
+ const opt = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
134
+ const text = args[0];
135
+ if (!text || text.startsWith('--'))
136
+ console.log('usage: arcroom decide "<what was chosen>" [--why ...] [--over "A, B"] [--context ...] [--by ...] [--source <url>] [--replaces <memo id>]');
137
+ else {
138
+ const over = opt('--over')?.split(',').map((o) => o.trim()).filter(Boolean);
139
+ const url = opt('--source');
140
+ const r = await api(loadConfig(), '/v1/memory/remember', {
141
+ kind: 'decision',
142
+ text,
143
+ why: opt('--why') ?? null,
144
+ ...(over ? { options: [text, ...over] } : {}),
145
+ ...(opt('--context') ? { context: opt('--context') } : {}),
146
+ ...(opt('--by') ? { decided_by: opt('--by') } : {}),
147
+ ...(url ? { source: { kind: /\/pull\//.test(url) ? 'pr' : /\/issues\//.test(url) ? 'issue' : 'other', url } } : {}),
148
+ ...(opt('--replaces') ? { replaces: opt('--replaces') } : {}),
149
+ });
150
+ console.log(`${r.saved ? 'decision saved' : 'decision already saved; detail updated'}${r.replaced ? ', the earlier one is replaced' : ''} (${r.memo_id})`);
151
+ }
152
+ }
153
+ else
154
+ console.log('usage: arcroom init [--project <id>] (asks for the key; or set ARCROOM_API_KEY) [--url <arcroom api url>] | arcroom whoami | arcroom doctor | arcroom rule [--tag t] [--max-hits n] [--idle-days d] | arcroom wrong [memo id] [reason] | arcroom share [memo id] | arcroom decide "<choice>" [--why ...] [--over "A, B"] | arcroom code [status|sync|on|off] | arcroom checks [approve] | arcroom off | arcroom on');
package/dist/init.js ADDED
@@ -0,0 +1,190 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { AGENTS, CURSOR_HOOKS, opencodePlugin } from './agents.js';
7
+ import { stopDaemon } from './daemon.js';
8
+ import { onPath } from './git.js';
9
+ import { apiUrl, ARCROOM_HOME, DEFAULT_URL } from './home.js';
10
+ const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd'];
11
+ const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
12
+ const MARK = '.arcroom/runtime'; // identifies our hook entries in a settings file
13
+ // node by its full path, never the word "node": an agent starting a hook inside a repo must not run a node.exe planted there
14
+ const NODE = process.execPath;
15
+ const nodeCmd = JSON.stringify(NODE);
16
+ /** Puts ArcRoom's hook on every event: replaces an earlier ArcRoom entry, keeps everyone else's hooks. */
17
+ export function withArcroomHooks(settings, command, anyTool = '*') {
18
+ const hooks = { ...settings.hooks };
19
+ for (const event of EVENTS) {
20
+ const others = (hooks[event] ?? []).filter((g) => !g.hooks.some((h) => h.command.includes(MARK)));
21
+ const ours = { hooks: [{ type: 'command', command }] };
22
+ if (TOOL_EVENTS.has(event))
23
+ ours.matcher = anyTool;
24
+ hooks[event] = [...others, ours];
25
+ }
26
+ return { ...settings, hooks };
27
+ }
28
+ /** Cursor's .cursor/hooks.json with ArcRoom on its six lifecycle events, keeping everyone else's hooks. */
29
+ export function withCursorHooks(current, command) {
30
+ const hooks = { ...current.hooks };
31
+ for (const event of CURSOR_HOOKS)
32
+ hooks[event] = [...(hooks[event] ?? []).filter((h) => !h.command.includes(MARK)), { command }];
33
+ return { ...current, version: current.version ?? 1, hooks };
34
+ }
35
+ /** Codex's config.toml with an [mcp_servers.arcroom] table (an earlier one replaced, everything else kept). */
36
+ export function withCodexMcp(toml, entry, node = NODE) {
37
+ const others = toml.replace(/\n?\[mcp_servers\.arcroom\][\s\S]*?(?=\n\[|$)/, '').trimEnd();
38
+ const ours = `[mcp_servers.arcroom]\ncommand = ${JSON.stringify(node)}\nargs = [${JSON.stringify(entry)}, "mcp"]\n`;
39
+ return (others ? `${others}\n\n` : '') + ours;
40
+ }
41
+ const readJson = (file) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {});
42
+ const writeJson = (file, value) => {
43
+ mkdirSync(dirname(file), { recursive: true });
44
+ writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
45
+ };
46
+ export async function init(args) {
47
+ const arg = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
48
+ const agent = (arg('--agent') ?? 'claude');
49
+ if (!AGENTS.includes(agent)) {
50
+ console.error(`--agent takes one of: ${AGENTS.join(', ')}`);
51
+ process.exit(1);
52
+ }
53
+ const configFile = join(ARCROOM_HOME, 'config.json');
54
+ const saved = existsSync(configFile) ? JSON.parse(readFileSync(configFile, 'utf8')) : {}; // re-running init reuses saved values
55
+ let key = arg('--key') ?? process.env.ARCROOM_API_KEY ?? saved.apiKey;
56
+ if (arg('--key'))
57
+ console.error('Note: a key typed on the command line stays in your shell history. Next time run arcroom init and paste it when asked, or set ARCROOM_API_KEY.');
58
+ const url = arg('--url') ?? saved.url ?? DEFAULT_URL;
59
+ try {
60
+ apiUrl(url);
61
+ }
62
+ catch (e) {
63
+ console.error(`${e.message}.`);
64
+ process.exit(1);
65
+ }
66
+ if (!key) {
67
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
68
+ key = (await rl.question('ArcRoom API key: ')).trim();
69
+ rl.close();
70
+ }
71
+ if (!key?.startsWith('ark_')) {
72
+ console.error('That is not an ArcRoom key (it starts with ark_).');
73
+ process.exit(1);
74
+ }
75
+ // 1. Remember the key for this machine.
76
+ mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
77
+ writeFileSync(configFile, JSON.stringify({ apiKey: key, url }, null, 2), { mode: 0o600 });
78
+ stopDaemon(); // the next hook event starts the updated one
79
+ // 2. Copy the hook runtime to a stable place (npx's own copy is temporary).
80
+ const runtime = join(ARCROOM_HOME, 'runtime');
81
+ cpSync(dirname(fileURLToPath(import.meta.url)), runtime, {
82
+ recursive: true,
83
+ filter: (src) => !src.includes('.test.'),
84
+ });
85
+ writeFileSync(join(runtime, 'package.json'), '{ "type": "module" }\n');
86
+ // 3. Wire Claude Code's hooks in this project to it, and pre-approve our MCP server (local file: paths are per machine).
87
+ const entry = join(runtime, 'index.js').replaceAll('\\', '/');
88
+ const file = join(process.cwd(), '.claude', 'settings.local.json');
89
+ const current = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
90
+ // The website's project for this folder (from "Connect a machine"); re-running init keeps the one set before.
91
+ const project = arg('--project') ?? JSON.stringify(current.hooks ?? {}).match(/--project ([0-9a-f-]{36})/)?.[1];
92
+ if (project && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(project)) {
93
+ console.error('--project takes the project id the website shows.');
94
+ process.exit(1);
95
+ }
96
+ if (agent !== 'claude')
97
+ return initOther(agent, entry, project);
98
+ const settings = withArcroomHooks(current, `${nodeCmd} "${entry}" hook${project ? ` --project ${project}` : ''}`);
99
+ // never pre-approve a server from the shared .mcp.json by name: a pull request could change what that name runs
100
+ const approved = (current.enabledMcpjsonServers ?? []).filter((n) => n !== 'arcroom');
101
+ if (approved.length)
102
+ settings.enabledMcpjsonServers = approved;
103
+ else
104
+ delete settings.enabledMcpjsonServers;
105
+ // Live badge at the bottom of Claude Code ("ArcRoom · live · 2 reused"), unless the project has its own status line.
106
+ const theirs = current.statusLine;
107
+ if (!theirs || theirs.command?.includes(MARK))
108
+ settings.statusLine = { type: 'command', command: `${nodeCmd} "${entry}" status`, padding: 0 };
109
+ mkdirSync(dirname(file), { recursive: true });
110
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
111
+ // 4. Register the MCP server (find_task / claim_task / complete_task) for this project: in Claude Code's local scope
112
+ // (this person, this folder) rather than the shared .mcp.json. An entry an earlier init wrote there is taken out.
113
+ const mcpFile = join(process.cwd(), '.mcp.json');
114
+ const mcpConfig = existsSync(mcpFile) ? JSON.parse(readFileSync(mcpFile, 'utf8')) : {};
115
+ const local = addLocalMcp(entry);
116
+ if (local) {
117
+ if (String(mcpConfig.mcpServers?.arcroom?.args?.[0] ?? '').replaceAll('\\', '/').includes(MARK)) {
118
+ delete mcpConfig.mcpServers.arcroom;
119
+ writeFileSync(mcpFile, JSON.stringify(mcpConfig, null, 2) + '\n');
120
+ }
121
+ }
122
+ else {
123
+ // no claude command found: the shared file, which Claude Code asks about before running
124
+ mcpConfig.mcpServers = { ...mcpConfig.mcpServers, arcroom: { command: NODE, args: [entry, 'mcp'] } };
125
+ writeFileSync(mcpFile, JSON.stringify(mcpConfig, null, 2) + '\n');
126
+ }
127
+ console.log(`ArcRoom is on for this project.
128
+ key ${join(ARCROOM_HOME, 'config.json')}
129
+ hooks ${file}
130
+ tools ${local ? 'Claude Code local scope (claude mcp list)' : `${mcpFile} (approve "arcroom" when Claude Code asks)`}
131
+ Start a new Claude Code session here to begin capturing runs.`);
132
+ }
133
+ /** `claude mcp add --scope local arcroom`: false when the claude command is not on PATH or the add failed. */
134
+ function addLocalMcp(entry) {
135
+ const claude = onPath(process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude']);
136
+ if (!claude)
137
+ return false;
138
+ const run = (args) => claude.endsWith('.cmd') // an npm install on Windows: a batch file runs through cmd, every argument quoted
139
+ ? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[claude, ...args].map((a) => `"${a}"`).join(' ')}"`], {
140
+ windowsVerbatimArguments: true,
141
+ encoding: 'utf8',
142
+ })
143
+ : spawnSync(claude, args, { encoding: 'utf8' });
144
+ run(['mcp', 'remove', '--scope', 'local', 'arcroom']); // none yet is fine
145
+ return run(['mcp', 'add', '--scope', 'local', 'arcroom', '--', NODE, entry, 'mcp']).status === 0;
146
+ }
147
+ /** Steps 3 and 4 for Codex, Cursor and OpenCode: their own hook and MCP files, in this project. */
148
+ function initOther(agent, entry, project) {
149
+ const cwd = process.cwd();
150
+ const command = `${nodeCmd} "${entry}" hook --agent ${agent}${project ? ` --project ${project}` : ''}`;
151
+ const written = [];
152
+ if (agent === 'codex') {
153
+ // same events and payloads as Claude Code; its matchers are regular expressions
154
+ const hooksFile = join(cwd, '.codex', 'hooks.json');
155
+ writeJson(hooksFile, withArcroomHooks(readJson(hooksFile), command, '.*'));
156
+ const configFile = join(cwd, '.codex', 'config.toml');
157
+ writeFileSync(configFile, withCodexMcp(existsSync(configFile) ? readFileSync(configFile, 'utf8') : '', entry));
158
+ written.push(hooksFile, configFile);
159
+ }
160
+ else if (agent === 'cursor') {
161
+ const hooksFile = join(cwd, '.cursor', 'hooks.json');
162
+ writeJson(hooksFile, withCursorHooks(readJson(hooksFile), command));
163
+ const mcpFile = join(cwd, '.cursor', 'mcp.json');
164
+ const mcp = readJson(mcpFile);
165
+ writeJson(mcpFile, { ...mcp, mcpServers: { ...mcp.mcpServers, arcroom: { type: 'stdio', command: NODE, args: [entry, 'mcp'] } } });
166
+ written.push(hooksFile, mcpFile);
167
+ }
168
+ else {
169
+ const configFile = join(cwd, 'opencode.json');
170
+ const config = readJson(configFile);
171
+ writeJson(configFile, {
172
+ $schema: 'https://opencode.ai/config.json',
173
+ ...config,
174
+ mcp: { ...config.mcp, arcroom: { type: 'local', command: [NODE, entry, 'mcp'], enabled: true } },
175
+ });
176
+ const pluginFile = join(cwd, '.opencode', 'plugins', 'arcroom.js');
177
+ mkdirSync(dirname(pluginFile), { recursive: true });
178
+ writeFileSync(pluginFile, opencodePlugin(entry, project, NODE));
179
+ written.push(configFile, pluginFile);
180
+ }
181
+ const next = {
182
+ codex: 'Trust this project in Codex, then approve the ArcRoom hooks once with /hooks.',
183
+ cursor: 'Restart Cursor. Cursor adds context only after a tool call, so team memory reaches the agent on its first tool use.',
184
+ opencode: "Restart OpenCode. Experimental: per-prompt recall rides OpenCode's experimental system-prompt hook, so it may change with OpenCode.",
185
+ };
186
+ console.log(`ArcRoom is on for ${agent} in this project.
187
+ key ${join(ARCROOM_HOME, 'config.json')}
188
+ ${written.map((f) => ` wrote ${f}`).join('\n')}
189
+ ${next[agent]}`);
190
+ }
package/dist/logo.js ADDED
@@ -0,0 +1,13 @@
1
+ // The ArcRoom shared-wall logo as MCP icons (spec: icons on serverInfo and tools; PNG must be supported, SVG should).
2
+ // `theme` = the background the icon is meant for: dark stroke on light backgrounds, light stroke on dark ones.
3
+ // PNGs are 64x64, rendered from the same paths as the website logo.
4
+ const svg = (stroke) => 'data:image/svg+xml;base64,' +
5
+ Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="8 10 104 104"><path d="M18 104 V58 A21 21 0 0 1 60 58 A21 21 0 0 1 102 58 V104" fill="none" stroke="${stroke}" stroke-width="14" stroke-linejoin="round"/><path d="M60 60 V104" fill="none" stroke="#FF5B2E" stroke-width="14"/></svg>`).toString('base64');
6
+ const PNG_LIGHT_BG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAADqElEQVR4Aexau1LbQBS9K89Qg2Q8KVKYLn0KykCTfEbgSzBfAk3o06Q1+YWkSoXTZQw2LtIx1uYeWZLXQt5dWbKlWKvZa+/jPs+eXTyDPGr54wBoOQHIMcAxoOUIuCPQcgK4S9AdAXcEWo5AqSMQBMcXED/o3aTi9658lsNu9+zw8E2/CnwDjgOf/hbiFAYg4GSOguOhH/SkJHED4SIvUhE0IBZPekOvEz5AN2CbomDAxg96DyxRHPgkosrjWAMQcBE+J4SCBYkzsnwE68IGYATswwQEdPwYXA5hzSAlzjCwiMO+o2YEAAljF1EEW1gnxLqvGnwwEEP4zC5iTomTXS4y7iOO6Mxv4DNrmB1rAcA5RsJAN2tYYtxnnw/wnfhAH3NVxoEv9pkLdhIX32sBiJLic8xKpXad7XObx75xsYGu6OcqlZ9cgK25jHMBAHW2mNSyLL4sQdflxHZ6OibkAoDzs51UavMKJuQeh1cAgJY4PxumOtrQbhdmfc8LP2cDrQAA6sd/b7N6uWNJ8l6QvAzn3sl0MhYsJ6EIzzHHBrcsVbVb+FTiCMQhSQPkYB2Ej1xUo2KwAgCflStlTdtFQs+Tx/PJ5PF2NvuT7vzs6ekecwzGJXTYSbrG/cINhcIXfGbjTKfja+QAINixVZwsC1IAYmTwS4t9adsIO4GEtFq8CB3WPeeuVXKsp7YRFy4AqDqZ1wcQ1nEEDeJaI1cpAFlkotWcD96RS3UnclRWpqBrnZxiGdsoM/ou4jDjrvVai9VOZ57+kk0BWCwZPvnM2exI1guSY5ra3wmIoxyrrL91YzCO4wzWrSfzksSHpL8EQJCR/qAabfjEtjZHYRTrbhTJ0jaXAaZffDbJm5K+NylIklXEMflIa40YoF4KmgSNyWtsrZeEFFXEMfpIao4AsMpOkglVMj18SX036YReaNQx+eB7wDpXewCMUc0Kd+/9v6dHB6ST509vX8yeqtPYKQAfuwcv30590gnJebe68ohMvnYKgCmZOtYdAHWg3qSYjgFN2o06cnEMqAP1JsV0DGjSbtSRi2NAHag3KaZjQJN2o45cHAPqQL1JMfeOAUXBdQAURWzf9B0D9m1Hi9bjGFAUsX3TdwzYtx0tWo9jgIIY/p+2VoSQvxXdMt1fbKwTXq6kra2FvY+idxa4EzEAg+lkjBed1kr08gEblGni7udX8eXHO62wTpkYsMU7AqZ6oAeJAECnreIAaOvOJ3U7BiRI/K/fZfP+BwAA//+UjP8CAAAABklEQVQDAAelm5CWvAzJAAAAAElFTkSuQmCC';
7
+ const PNG_DARK_BG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAADqUlEQVR4AexbTXLaMBR+gk2YIXSc9ADJrrdoOEnDroseoLuQfZfdk0277wlINz1Am00XndADJEzBMyxSUL8nWx4wxpLAxC6WRs+2pPf76ZPDTKBBNW8egJoTgDwDPANqjoA/AjUngH8J+iPgj0DNEdjpCIST8WUkD4NwEsn0z8OVkun4YjYenxWBL8dgnzoG33msZMc4zgBwMuHkcQiRRHIQibgkikQI0Vci5XDelPfQg+740hWMUIH7yPYqDvvUMfjOYyU7xrEGQCcUFUwXZN+gKwcRGGYg4jiqaIRwYZCO4wS4EQDeuRA7HhfukhDyT3cFxJB9pld4bilOetlljBxVnAH7TBumx7kATHG+sHNDGAFdXIvpZ/B5z761O37mOYyLjHMBn5lgI07SNwLASQmcL2gCUVwL7uybX2Ihzjo/F+xeu1Ng5zEhEwA22GNSOjnil1h8tJK5fTzkMSETABjg7b6PVErzyUzIPA5rADAtkea2Z3EE26r2s7+NxZt0cisAKOrj73haKWd8SyR6zbk4b3dOBORcCtHlOVD7hgprEr5W4giOI6XsIwRywNWiC9Q2G69+OFsBYN5cXFn4iVVEDwV3253gphUEyc4fHwe3PNfunPYI4BBRskZbNC6UfbHPdJzjF6fX7c5JV0ZAWMVJsyABYKaQEfhEZ8xyFO14gF3J1+WkoQtGkFVytNpGKE4woKvT6yMGwjaOSLEgASCNzHqYaAY70mst7Xg0u/nKurbJLXuJbZancp85DpG4Jos2b1LyjksAIIsmQTWbHUm7agEw2BoZo+2g22cbPba9M+PY1qy/eK11EgCEMNOfqaYNXe+xrc1RGMW6riGUvp2tyGSA6ROfTfIqic0XafPGLiAOmXwktSoGzNQLkAzNKnmDD/OylEXEMfuYxTUrAMxpEUlJJlTJ3BpfjToNCx2DE5dcrQEwxLRaPvr4Pmz+uqM8ab/tPlk5K0jpWQFo3n17OvrwjvKE5PxlQbUpN6bLswJgSqaMdQ9AGahXKaZnQJV2o4xcPAPKQL1KMT0DqrQbZeTiGVAG6lWK6RlQpd0oIxfPgDJQr1LMg2OAK7geAFfEDk3fM+DQdtS1Hs8AV8QOTd8z4NB21LUez4AlxPh/fxtFiMbvJd1dHn/COE+wXEjfWAu8j1pBwOvRL0Z40O6c8BedciSw/oIDAmR28fnHF/Hp+6tcgU6mscMkf0fAVI9254+ARqKud8+Auu68rvu/Z4AuZNv7PwAAAP//+WPCTQAAAAZJREFUAwCt5dOQ/FC9ZQAAAABJRU5ErkJggg==';
8
+ export const ICONS = [
9
+ { src: PNG_LIGHT_BG, mimeType: 'image/png', sizes: ['64x64'], theme: 'light' },
10
+ { src: PNG_DARK_BG, mimeType: 'image/png', sizes: ['64x64'], theme: 'dark' },
11
+ { src: svg('#121316'), mimeType: 'image/svg+xml', sizes: ['any'], theme: 'light' },
12
+ { src: svg('#F4F2EE'), mimeType: 'image/svg+xml', sizes: ['any'], theme: 'dark' },
13
+ ];
package/dist/lookup.js ADDED
@@ -0,0 +1,98 @@
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ import { join } from 'node:path';
5
+ import { codeContext } from './code.js';
6
+ import { ARCROOM_HOME, loadConfig } from './home.js';
7
+ import { recall } from './recall.js';
8
+ import { projectOf } from './session.js';
9
+ const PORT_FILE = join(ARCROOM_HOME, 'daemon.port'); // "<port> <secret>"
10
+ // A hung API costs the full wait on every prompt. After a failure, prompts skip the lookup for a minute:
11
+ // an outage then costs that wait once a minute, not every prompt.
12
+ const DOWN = join(ARCROOM_HOME, 'api-down');
13
+ const COOL_MS = 60_000;
14
+ const OFFLINE = { recall: {}, offline: true };
15
+ const markDown = () => {
16
+ try {
17
+ writeFileSync(DOWN, new Date().toISOString(), { mode: 0o600 });
18
+ }
19
+ catch {
20
+ // no cool-down this time: the next prompt waits again
21
+ }
22
+ };
23
+ const recentlyDown = () => {
24
+ try {
25
+ return Date.now() - statSync(DOWN).mtimeMs < COOL_MS;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ };
31
+ // Inside the hook's 3 s wait (lookupFast): the lookup itself must finish well before it, or its answer arrives after
32
+ // the hook gave up and every prompt reads "memory offline" (seen from India to the Ashburn API: 3.1 s each time).
33
+ const RECALL_MS = 2200;
34
+ const CODE_MS = 1200; // the code map is the extra: it never holds recall up
35
+ /** Recall and the code map for one prompt, at the same time; a failure of either is an empty answer, never an error. */
36
+ export async function lookup(prompt, cwd, session, given) {
37
+ const [r, code] = await Promise.all([
38
+ recall(prompt, cwd, projectOf(cwd), given, RECALL_MS).catch(() => undefined),
39
+ codeContext(loadConfig(), cwd, session, prompt, CODE_MS).catch(() => undefined),
40
+ ]);
41
+ if (!r)
42
+ markDown();
43
+ return { recall: r ?? {}, ...(code ? { code } : {}), ...(r ? {} : { offline: true }) };
44
+ }
45
+ /** The warm daemon's lookup; the hook's own when no daemon answers. A daemon that is there but slow is not asked twice. */
46
+ export async function lookupFast(prompt, cwd, session, given, timeoutMs = 3000) {
47
+ if (recentlyDown())
48
+ return OFFLINE;
49
+ const [port, secret] = existsSync(PORT_FILE) ? readFileSync(PORT_FILE, 'utf8').trim().split(' ') : [];
50
+ if (!port)
51
+ return lookup(prompt, cwd, session, given);
52
+ try {
53
+ const res = await fetch(`http://127.0.0.1:${port}/lookup`, {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json', 'x-arcroom': secret },
56
+ body: JSON.stringify({ prompt, cwd, session, given: [...given] }),
57
+ signal: AbortSignal.timeout(timeoutMs),
58
+ });
59
+ if (!res.ok)
60
+ throw new Error(`daemon ${res.status}`);
61
+ // only our daemon knows the secret: an answer without its signature (whatever took the port after the daemon
62
+ // stopped) is never handed to the agent (L8)
63
+ const text = await res.text();
64
+ if (res.headers.get('x-arcroom-proof') !== proof(secret, text))
65
+ throw new Error('daemon answer not signed');
66
+ return JSON.parse(text);
67
+ }
68
+ catch (err) {
69
+ if (err.name === 'TimeoutError') {
70
+ markDown();
71
+ return OFFLINE; // the time is spent: no hand-over this prompt
72
+ }
73
+ return lookup(prompt, cwd, session, given); // no daemon on that port (it stopped): do it here
74
+ }
75
+ }
76
+ /** The daemon's side: serves lookups on a free port of 127.0.0.1 and writes the port and a fresh secret. */
77
+ export function serveLookups() {
78
+ const secret = randomBytes(16).toString('hex');
79
+ const server = createServer(async (req, res) => {
80
+ try {
81
+ if (req.method !== 'POST' || req.url !== '/lookup' || req.headers['x-arcroom'] !== secret)
82
+ return void res.writeHead(404).end();
83
+ let body = '';
84
+ for await (const chunk of req)
85
+ body += chunk;
86
+ const a = JSON.parse(body);
87
+ const out = JSON.stringify(await lookup(String(a.prompt ?? ''), String(a.cwd ?? ''), String(a.session ?? ''), new Set(a.given ?? [])));
88
+ res.writeHead(200, { 'content-type': 'application/json', 'x-arcroom-proof': proof(secret, out) }).end(out);
89
+ }
90
+ catch (err) {
91
+ res.writeHead(500).end(String(err));
92
+ }
93
+ });
94
+ server.listen(0, '127.0.0.1', () => writeFileSync(PORT_FILE, `${server.address().port} ${secret}`, { mode: 0o600 }));
95
+ process.on('exit', () => rmSync(PORT_FILE, { force: true })); // a stopped daemon leaves no port to take over
96
+ }
97
+ /** The daemon's signature on an answer: an HMAC with the secret only it and this user can read. */
98
+ export const proof = (secret, body) => createHmac('sha256', secret).update(body).digest('hex');
package/dist/loop.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ARCROOM_HOME } from './home.js';
5
+ export const LOOP_LIMIT = 3; // the 3rd identical call in a row is blocked
6
+ const stable = (v) => Array.isArray(v)
7
+ ? `[${v.map(stable).join(',')}]`
8
+ : v && typeof v === 'object'
9
+ ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(',')}}`
10
+ : JSON.stringify(v) ?? 'null';
11
+ /** Same tool + same input (in any key order) = same signature. */
12
+ export function signature(tool, input) {
13
+ return createHash('sha256').update(`${tool}\0${stable(input)}`).digest('hex').slice(0, 16);
14
+ }
15
+ /** True when the last LOOP_LIMIT-1 calls were all this same call. */
16
+ export function isLoop(recent, sig) {
17
+ return recent.length >= LOOP_LIMIT - 1 && recent.slice(-(LOOP_LIMIT - 1)).every((s) => s === sig);
18
+ }
19
+ /** Path of a per-session state file under ~/.arcroom/sessions. */
20
+ export function sessionFile(session, suffix = '') {
21
+ const dir = join(ARCROOM_HOME, 'sessions'); // ponytail: tiny files per session, never cleaned up; prune old ones if it matters
22
+ mkdirSync(dir, { recursive: true });
23
+ return join(dir, `${session.replace(/[^\w-]/g, '_')}${suffix}.json`);
24
+ }
25
+ /** Returns a block reason for a looping call; otherwise records the call and returns undefined. */
26
+ export function checkLoop(session, tool, input) {
27
+ const file = sessionFile(session);
28
+ const recent = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
29
+ const sig = signature(tool, input);
30
+ if (isLoop(recent, sig)) {
31
+ return `ArcRoom loop guard: this exact ${tool} call already ran ${LOOP_LIMIT - 1} times in a row. Don't repeat it; try a different approach.`;
32
+ }
33
+ writeFileSync(file, JSON.stringify([...recent, sig].slice(-(LOOP_LIMIT - 1))));
34
+ return undefined;
35
+ }