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/redact.js ADDED
@@ -0,0 +1,113 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // Secret redaction: runs on every event before it is written to the local queue and again before it leaves the machine,
4
+ // and on every request body sent to the API.
5
+ // Two layers: this machine's own secret VALUES (sensitive environment variables, values in a project's .env files),
6
+ // removed wherever they appear whatever their shape; then patterns for common key formats and key=value secrets.
7
+ // ponytail: patterns don't catch every secret shape. Add them as we meet them; a generic high-entropy rule would also
8
+ // hit commit hashes and ids, so there is none.
9
+ const R = '[REDACTED]';
10
+ // --- exact values -------------------------------------------------------------------------------------------------
11
+ // auth but not GIT_AUTHOR_NAME; *_SCOPES lists permissions, not a secret
12
+ const SECRET_NAME = /^(?!.*_SCOPES?$).*(?:key|token|secret|passw(?:or)?d|pass\b|_pass|pwd|credential|auth(?!or)|dsn|private|cookie)/i;
13
+ // ids are not secrets, and events carry them (a session id redacted would split a run)
14
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^[0-9a-f]{40}$/i;
15
+ const known = new Set();
16
+ let longestFirst = []; // so a value that contains another known value goes whole
17
+ const read = new Set();
18
+ /** Keeps a value to redact: under a secret-looking name, or (in .env files, `loose`) any long value mixing letters and digits. */
19
+ function keep(name, raw, loose) {
20
+ const v = raw?.trim().replace(/^(['"])(.*)\1$/, '$2');
21
+ if (!v || v.length < 8 || /^(?:true|false|null|\d+)$/i.test(v) || ID.test(v) || known.has(v))
22
+ return;
23
+ // a path or a URL without a login is config, not a secret (GIT_ASKPASS, KEYCHAIN_PATH, AUTH_URL)
24
+ if (/^(?:[.~/\\]|[a-z]:[\\/]|[a-z][\w+.-]*:\/\/[^@\s]*$)/i.test(v))
25
+ return;
26
+ const mixed = v.length >= 16 && /\d/.test(v) && /[a-z]/i.test(v);
27
+ if (!SECRET_NAME.test(name) && !(loose && mixed))
28
+ return;
29
+ known.add(v);
30
+ longestFirst = [...known].sort((a, b) => b.length - a.length);
31
+ }
32
+ /** This process's sensitive environment variables (the agent passes the user's shell environment to its hooks). */
33
+ export function rememberEnv(env = process.env) {
34
+ for (const [k, v] of Object.entries(env))
35
+ keep(k, v, false);
36
+ }
37
+ rememberEnv();
38
+ /** The values in a project's .env files (not .env.example and friends); each folder is read once per process. */
39
+ export function rememberSecrets(dir) {
40
+ if (typeof dir !== 'string' || !dir || read.has(dir))
41
+ return;
42
+ read.add(dir);
43
+ try {
44
+ for (const f of readdirSync(dir)) {
45
+ if (!/^\.env(?:\.|$)/.test(f) || /\.(?:example|sample|template|dist)$/.test(f))
46
+ continue;
47
+ for (const line of readFileSync(join(dir, f), 'utf8').split(/\r?\n/)) {
48
+ const m = line.match(/^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/);
49
+ if (m)
50
+ keep(m[1], m[2].replace(/\s+#.*$/, ''), true);
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // no folder or no access: patterns still apply
56
+ }
57
+ }
58
+ // --- patterns -----------------------------------------------------------------------------------------------------
59
+ const TOKENS = [
60
+ // private keys, also when a cut-off preview lost the END line
61
+ /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|$)/g,
62
+ /\b(?:sk-ant-[\w-]{10,}|sk-[\w-]{20,}|ar[ksi]_[\w-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|xox[abprs]-[\w-]{10,}|(?:AKIA|ASIA)[0-9A-Z]{16}|AIza[\w-]{35})/g,
63
+ /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}|\bglpat-[\w-]{20,}|\bnpm_[A-Za-z0-9]{30,}|\bhf_[A-Za-z0-9]{30,}|\bya29\.[\w-]{20,}|\bSG\.[\w-]{16,}\.[\w-]{16,}/g,
64
+ /https:\/\/hooks\.slack\.com\/services\/[\w/]+/g,
65
+ // more provider formats (after gitleaks' rules): Shopify, Twilio, Mailgun, DigitalOcean, Linear, PyPI, Doppler, Postman, Databricks
66
+ /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b|\bSK[0-9a-f]{32}\b|\bkey-[0-9a-f]{32}\b|\bdo[opr]_v1_[a-f0-9]{64}\b|\blin_api_[A-Za-z0-9]{40}\b/g,
67
+ /\bpypi-AgEIcHlwaS5vcmc[\w-]{50,}|\bdp\.(?:pt|st|sa|ct|scim|audit)\.[A-Za-z0-9]{40,}|\bPMAK-[a-f0-9]{24}-[a-f0-9]{34}\b|\bdapi[a-f0-9]{32}\b/g,
68
+ /\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, // JWTs, including Supabase keys
69
+ ];
70
+ const URL_PASSWORD = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@"']+:)[^\s@"']+(@)/gi;
71
+ // NAME=value, "name": "value", also inside escaped JSON (\"password\": \"x\")
72
+ const KEY_VALUE = /\b((?:[\w-]*(?:password|passwd|pwd|_pass|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|_key|credentials?|accountkey|signature))(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?)(?:Bearer\s+)?[^\s"'\\,;&]{6,}/gi;
73
+ const BEARER = /\b(Bearer\s+)[\w.~+/-]{10,}=*/g;
74
+ const BASIC = /\b(Basic\s+)[A-Za-z0-9+/]{8,}=*/g;
75
+ const COOKIE = /(\b(?:Set-)?Cookie:\s*)[^\n"'\\]+/gi;
76
+ // secrets passed as command flags: mysql -pX, --password X, curl -u user:pass, X-Amz-Signature=…
77
+ const FLAGS = [
78
+ /(\b(?:mysql|mysqldump|mariadb)\b[^\n]*?\s-p)(?!\s)[^\s"']+/g,
79
+ /(--(?:password|pass|token|secret|api-key)[=\s]+)[^\s"']+/gi,
80
+ /(\s(?:-u|--user)\s+[^\s:"']+:)[^\s"']+/g,
81
+ /((?:X-Amz-Signature|X-Amz-Credential|sig)=)[^&\s"']+/g,
82
+ ];
83
+ const SENSITIVE_KEY = /(?:^|[_-])(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|cookie|credentials?)$/i;
84
+ /** Files whose contents are secrets: their reads, writes and command output are not kept at all. */
85
+ const SENSITIVE_FILE = /(?:^|[\\/\s"'=])(?:\.env(?:\.[\w.-]+)?|[^\\/\s"']*\.(?:pem|key|p12|pfx|keystore|jks)|id_(?:rsa|dsa|ecdsa|ed25519)|\.npmrc|\.netrc|\.pgpass|credentials(?:\.json)?|secrets?\.(?:json|ya?ml|toml))(?:$|[\s"'|;&)])/i;
86
+ export const sensitiveFile = (text) => typeof text === 'string' && SENSITIVE_FILE.test(text) && !/\.env\.example\b/i.test(text);
87
+ export function redactText(text) {
88
+ let out = text;
89
+ for (const v of longestFirst)
90
+ if (out.includes(v))
91
+ out = out.split(v).join(R);
92
+ for (const re of TOKENS)
93
+ out = out.replace(re, R);
94
+ for (const re of FLAGS)
95
+ out = out.replace(re, `$1${R}`);
96
+ return out
97
+ .replace(URL_PASSWORD, `$1${R}$2`)
98
+ .replace(KEY_VALUE, `$1${R}`)
99
+ .replace(BEARER, `$1${R}`)
100
+ .replace(BASIC, `$1${R}`)
101
+ .replace(COOKIE, `$1${R}`);
102
+ }
103
+ /** Redacts every string in a JSON-like value; a string under a sensitive key (or in a list under one) is dropped whole. */
104
+ export function redact(value, key = '') {
105
+ if (typeof value === 'string')
106
+ return (SENSITIVE_KEY.test(key) ? R : redactText(value));
107
+ if (Array.isArray(value))
108
+ return value.map((v) => redact(v, key));
109
+ if (value && typeof value === 'object') {
110
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
111
+ }
112
+ return value;
113
+ }
package/dist/route.js ADDED
@@ -0,0 +1,26 @@
1
+ // Model router: which Claude model a (sub)task should run on.
2
+ // A replay uses the cheapest model that already passed with these steps; a new task is routed by the kind of work.
3
+ // ponytail: verb-based tiers; learn from outcomes (cost + done-check results per model) once enough runs exist.
4
+ const TIERS = [
5
+ [/^(list|find|look ?up|fetch|read|summari[sz]e|count|check|search|scan|extract|classify|translate|format|compare)\b/, 'haiku', 'lookup or summary'],
6
+ [/^(write|implement|fix|patch|refactor|add|update|create|test|migrate|convert|rename|remove)\b/, 'sonnet', 'writing or changing code'],
7
+ [/^(plan|design|architect|decide|review|debug|investigate|split|evaluate|choose)\b/, 'opus', 'planning or judgment'],
8
+ ];
9
+ const alias = (model) => model?.includes('haiku') ? 'haiku' : model?.includes('sonnet') ? 'sonnet' : model?.includes('opus') ? 'opus' : undefined;
10
+ export function routeFor(intent, provenModel) {
11
+ const proven = alias(provenModel);
12
+ if (proven)
13
+ return { model: proven, why: 'cheapest model that did these steps before' };
14
+ const verb = intent.trim().toLowerCase();
15
+ for (const [re, model, why] of TIERS)
16
+ if (re.test(verb))
17
+ return { model, why };
18
+ return undefined;
19
+ }
20
+ /** Adds `route` to lookups that still need work (replay or new). Reuse needs no model: the answer is already there. */
21
+ export function withRoute(r, intent) {
22
+ if (r?.match !== 'replay' && r?.match !== 'none')
23
+ return r;
24
+ const route = routeFor(r.intent ?? intent, r.procedure?.model);
25
+ return route ? { ...r, route } : r;
26
+ }
@@ -0,0 +1,43 @@
1
+ import { git } from './git.js';
2
+ import { api, loadConfig } from './home.js';
3
+ /** Project id shared by everyone on the repo: its git remote without protocol/credentials/.git, else the folder path. */
4
+ export function projectOf(cwd) {
5
+ const remote = git(['config', '--get', 'remote.origin.url'], { cwd }).stdout?.trim();
6
+ if (!remote)
7
+ return cwd.replaceAll('\\', '/').toLowerCase();
8
+ return remote
9
+ .replace(/^[a-z+]+:\/\/(?:[^@/]+@)?/i, '') // https://user:token@host/... -> host/...
10
+ .replace(/^[^@]+@([^:]+):/, '$1/') // git@host:owner/repo -> host/owner/repo
11
+ .replace(/\.git$/, '')
12
+ .toLowerCase();
13
+ }
14
+ const ago = (from, now) => {
15
+ const min = Math.max(0, Math.round((now.getTime() - Date.parse(from)) / 60_000));
16
+ return min < 60 ? `${min} min ago` : min < 48 * 60 ? `${Math.round(min / 60)} h ago` : `${Math.round(min / 1440)} days ago`;
17
+ };
18
+ /** The note a new session starts with: what the last run in this project asked, did, and left open. */
19
+ export function lastRunContext(run, now = new Date()) {
20
+ const r = run.report ?? {};
21
+ const edited = (r.files ?? []).filter((f) => f.edited).map((f) => f.path.split(/[\\/]/).pop());
22
+ const memory = r.memory ?? {};
23
+ const lines = [
24
+ `ArcRoom: the last session in this project (${ago(run.started_at, now)}, on ${run.machine ?? 'another machine'}) ` +
25
+ `ended ${run.status === 'partial' ? 'PARTIAL: its done-checks were still failing' : run.status}.`,
26
+ `Asked: "${String(r.first_prompt ?? run.prompt ?? '').slice(0, 200)}"`,
27
+ edited.length ? `Edited: ${edited.slice(0, 10).join(', ')}${edited.length > 10 ? ` (+${edited.length - 10} more)` : ''}` : '',
28
+ `${r.tool_calls ?? 0} tool calls` +
29
+ (r.blocked ? `, ${r.blocked} blocked by the loop guard` : '') +
30
+ (memory.find ? `, memory: ${memory.find} lookups, ${memory.complete ?? 0} stored` : ''),
31
+ 'Use this to pick up where it left off if the user continues that work; ignore it otherwise.',
32
+ ];
33
+ return lines.filter(Boolean).join('\n');
34
+ }
35
+ /** Fetches the last run of this project (skipping the current session); undefined when there is none or the API is slow. */
36
+ export async function lastRunNote(session, project) {
37
+ const cfg = loadConfig();
38
+ if (!cfg.apiKey)
39
+ return undefined;
40
+ const qs = new URLSearchParams({ project, exclude: session });
41
+ const run = await api(cfg, `/v1/runs/last?${qs}`, undefined, 1500); // never hold up session start for long
42
+ return run ? lastRunContext(run) : undefined;
43
+ }
package/dist/signs.js ADDED
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { sessionFile } from './loop.js';
3
+ // What the user sees of ArcRoom: one short line per action (hook systemMessage) and a live status line.
4
+ // Plain name, no glyph: hooks and the status line are text only, and a text-drawn logo did not read as the logo.
5
+ export const MARK = 'ArcRoom';
6
+ const EMPTY = { reused: 0, replayed: 0, saved: 0, blocked: 0, evicted: 0, tokens_saved: 0 };
7
+ const COUNTS = ['reused', 'replayed', 'saved', 'blocked', 'evicted'];
8
+ /** 48213 -> "48.2k" */
9
+ export const compact = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
10
+ const short = (s, n = 60) => {
11
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
12
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
13
+ };
14
+ /** The JSON an ArcRoom MCP tool returned, from Claude Code's tool_response (content blocks or text); an error string otherwise. */
15
+ export function toolResult(response) {
16
+ const texts = [];
17
+ const walk = (v) => {
18
+ if (typeof v === 'string')
19
+ texts.push(v);
20
+ else if (Array.isArray(v))
21
+ v.forEach(walk);
22
+ else if (v && typeof v === 'object')
23
+ typeof v.text === 'string' ? texts.push(v.text) : walk(v.content);
24
+ };
25
+ walk(response);
26
+ for (const t of texts) {
27
+ try {
28
+ return JSON.parse(t);
29
+ }
30
+ catch {
31
+ if (t.startsWith('ArcRoom error'))
32
+ return { error: t.replace(/^ArcRoom error:\s*/, '') };
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+ /** The line to show after an ArcRoom tool call, and which counter it bumps. */
38
+ export function signFor(tool, input, r) {
39
+ if (!r)
40
+ return undefined;
41
+ if (r.error)
42
+ return { message: `${MARK} · ${short(r.error, 90)}` };
43
+ const intent = short(r.intent ?? input?.intent);
44
+ switch (tool) {
45
+ case 'find_task': {
46
+ const changed = r.evicted_stale?.length ? `file changed (${r.evicted_stale.join(', ')}), old answer dropped · ` : '';
47
+ if (r.match === 'reuse')
48
+ return { message: `${MARK} · ${changed}reused a team answer${r.version?.verified ? ' (verified)' : ''}: ${intent}`, stat: 'reused' };
49
+ if (r.match === 'replay')
50
+ return { message: `${MARK} · ${changed}replaying stored steps: ${intent}`, stat: 'replayed' };
51
+ if (r.match === 'in_flight')
52
+ return { message: `${MARK} · a teammate is on it (${r.claimed_by ?? 'another session'}): ${intent}` };
53
+ if (r.broken_procedure)
54
+ return { message: `${MARK} · ${changed}stored steps broke last time, re-planning: ${intent}` };
55
+ if (r.candidates?.length)
56
+ return { message: `${MARK} · ${changed}no exact match, ${r.candidates.length} similar to check` };
57
+ return { message: `${MARK} · ${changed}new task, nothing stored yet: ${intent}` };
58
+ }
59
+ case 'split_task': {
60
+ const c = r.counts ?? {};
61
+ const done = [c.reuse && `${c.reuse} already done`, c.replay && `${c.replay} to replay`, c.in_flight && `${c.in_flight} in progress elsewhere`, c.none && `${c.none} new`];
62
+ return { message: `${MARK} · split into ${r.parts?.length ?? 0} parts: ${done.filter(Boolean).join(', ')}` };
63
+ }
64
+ case 'claim_task':
65
+ return { message: r.claimed ? `${MARK} · claimed: ${intent}` : `${MARK} · already taken by ${r.claimed_by ?? 'someone else'}` };
66
+ case 'complete_task':
67
+ return { message: `${MARK} · saved to team memory (v${r.version})`, stat: 'saved' };
68
+ case 'evict_task':
69
+ return { message: `${MARK} · threw away ${r.evicted} answer(s)${r.cascaded ? ` + ${r.cascaded} built on them` : ''}`, stat: 'evicted' };
70
+ case 'fail_task':
71
+ return { message: `${MARK} · marked the stored steps broken (v${r.procedure_version})` };
72
+ case 'rollback_task':
73
+ return { message: `${MARK} · back to stored steps v${r.procedure_version}` };
74
+ case 'pin_task':
75
+ return { message: `${MARK} · pinned v${r.version} for ${r.for}` };
76
+ case 'unpin_task':
77
+ return { message: `${MARK} · ${r.unpinned ? 'unpinned' : 'nothing was pinned'}` };
78
+ }
79
+ return undefined;
80
+ }
81
+ /** The status line: ArcRoom is live, the tokens this session used and saved, then what it did. */
82
+ export function statusText(s, used = 0) {
83
+ const tokens = [used && `${compact(used)} tokens used`, s.tokens_saved && `${compact(s.tokens_saved)} saved`].filter(Boolean);
84
+ const parts = COUNTS.filter((k) => s[k]).map((k) => `${s[k]} ${k}`);
85
+ return [`${MARK} · live`, ...tokens, ...parts].join(' · ');
86
+ }
87
+ const statsFile = (session) => sessionFile(session, '.signs');
88
+ export function readStats(session) {
89
+ const file = statsFile(session);
90
+ return existsSync(file) ? { ...EMPTY, ...JSON.parse(readFileSync(file, 'utf8')) } : { ...EMPTY };
91
+ }
92
+ export function bump(session, stat, by = 1) {
93
+ const s = readStats(session);
94
+ s[stat] += by;
95
+ writeFileSync(statsFile(session), JSON.stringify(s));
96
+ }
package/dist/tree.js ADDED
@@ -0,0 +1,148 @@
1
+ import { git as runGit } from './git.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { branchKey, hashKey, nodeKey, repoKey } from './blind.js';
6
+ import { blobHash } from './fresh.js';
7
+ import { api, ARCROOM_HOME } from './home.js';
8
+ const MAX_NODES = 20_000; // ponytail: past this only folders are sent; send files per folder on demand if big repos need them
9
+ const git = (root, args) => {
10
+ const r = runGit(args, { cwd: root, timeout: 5000, maxBuffer: 64 * 1024 * 1024 });
11
+ return r.status === 0 ? r.stdout : undefined;
12
+ };
13
+ const parentOf = (p) => (p === '.' ? undefined : p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '.');
14
+ /** Parses `git ls-tree -r -t --full-tree HEAD` output: "<mode> <type> <hash>\t<path>". */
15
+ export function parseLsTree(out, rootHash) {
16
+ const tree = new Map();
17
+ if (rootHash)
18
+ tree.set('.', { kind: 'dir', hash: rootHash });
19
+ for (const line of out.split('\n')) {
20
+ const m = /^\d+ (tree|blob) ([0-9a-f]{40,64})\t(.+)$/.exec(line);
21
+ if (m)
22
+ tree.set(m[3], { kind: m[1] === 'tree' ? 'dir' : 'file', hash: m[2] });
23
+ }
24
+ return tree;
25
+ }
26
+ /** Applies uncommitted changes: edited files get their working hash, deleted ones go, their folders get a trailing *. */
27
+ export function withDirty(tree, changes) {
28
+ const out = new Map(tree);
29
+ for (const c of changes) {
30
+ if (c.hash)
31
+ out.set(c.path, { kind: 'file', hash: c.hash });
32
+ else
33
+ out.delete(c.path);
34
+ for (let d = parentOf(c.path); d; d = parentOf(d)) {
35
+ const cur = out.get(d) ?? { kind: 'dir', hash: 'new' };
36
+ if (!cur.hash.endsWith('*'))
37
+ out.set(d, { kind: 'dir', hash: `${cur.hash}*` });
38
+ if (d === '.')
39
+ break;
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /** This checkout's tree: HEAD from git, plus what is edited but not committed. */
45
+ export function readTree(root) {
46
+ const head = git(root, ['rev-parse', 'HEAD^{tree}'])?.trim();
47
+ const tree = head ? parseLsTree(git(root, ['ls-tree', '-r', '-t', '--full-tree', 'HEAD']) ?? '', head) : new Map([['.', { kind: 'dir', hash: 'empty' }]]);
48
+ const changes = (git(root, ['status', '--porcelain', '-uall']) ?? '')
49
+ .split('\n')
50
+ .filter(Boolean)
51
+ .map((l) => l.slice(3).split(' -> ').pop().replace(/^"|"$/g, ''))
52
+ .map((path) => {
53
+ const file = join(root, path);
54
+ return { path, hash: existsSync(file) ? safeBlob(file) : undefined };
55
+ });
56
+ return withDirty(tree, changes);
57
+ }
58
+ const safeBlob = (file) => {
59
+ try {
60
+ return blobHash(readFileSync(file));
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ };
66
+ const short = (refs) => [...new Set((refs ?? '').split('\n').map((r) => r.trim().replace(/^origin\//, '')).filter((r) => r && r !== 'HEAD' && r !== 'origin'))];
67
+ /** The branch this checkout is on, the team's default branch, and which branches exist and are merged into it. */
68
+ export function branches(root) {
69
+ const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])?.trim() || 'main';
70
+ const remoteHead = git(root, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.trim().replace(/^origin\//, '');
71
+ const has = (b) => git(root, ['rev-parse', '--verify', '--quiet', b]) !== undefined;
72
+ const def = remoteHead || (has('main') ? 'main' : has('master') ? 'master' : branch);
73
+ const ahead = branch === def ? 0 : Number(git(root, ['rev-list', '--count', `${def}..HEAD`])?.trim() ?? 0) || 0;
74
+ const fmt = ['--format=%(refname:short)', 'refs/heads', 'refs/remotes'];
75
+ return { branch, def, ahead, all: short(git(root, ['for-each-ref', ...fmt])), merged: has(def) ? short(git(root, ['for-each-ref', '--merged', def, ...fmt])) : [] };
76
+ }
77
+ /** The paths a branch changed since it left the default branch (plus uncommitted ones), with every folder above. */
78
+ export function branchPaths(root, def, tree) {
79
+ const base = git(root, ['merge-base', 'HEAD', def])?.trim();
80
+ const changed = base ? (git(root, ['diff', '--name-only', base, 'HEAD']) ?? '').split('\n').filter(Boolean) : [];
81
+ const out = new Set();
82
+ const add = (p) => {
83
+ for (let d = p; d && !out.has(d); d = parentOf(d))
84
+ out.add(d);
85
+ };
86
+ for (const p of changed)
87
+ if (tree.has(p))
88
+ add(p);
89
+ // uncommitted: their folders carry a *, and the files in those folders go along
90
+ for (const [p, e] of tree)
91
+ if (e.hash.endsWith('*') || (e.kind === 'file' && tree.get(parentOf(p))?.hash.endsWith('*')))
92
+ add(p);
93
+ return out;
94
+ }
95
+ /** What to send: on the default branch only what changed since the last sync (all of it the first time). */
96
+ export function syncBody(s, tree, b, last, reset = false) {
97
+ const onDefault = b.branch === b.def;
98
+ const paths = onDefault ? [...tree.keys()] : [...branchPaths(s.root, b.def, tree)];
99
+ const keep = paths.length > MAX_NODES ? paths.filter((p) => tree.get(p)?.kind === 'dir') : paths;
100
+ const sent = {};
101
+ const nodes = [];
102
+ for (const p of keep) {
103
+ const e = tree.get(p);
104
+ if (!e)
105
+ continue;
106
+ const k = nodeKey(s, p);
107
+ const h = hashKey(s, e.hash);
108
+ sent[k] = h;
109
+ if (onDefault && last && !reset && last[k] === h)
110
+ continue;
111
+ const parent = parentOf(p);
112
+ nodes.push({ k, ...(parent ? { p: nodeKey(s, parent) } : {}), kind: e.kind, h });
113
+ }
114
+ const full = !onDefault || !last || reset;
115
+ const deleted = full ? [] : Object.keys(last).filter((k) => !(k in sent));
116
+ return {
117
+ sent,
118
+ body: {
119
+ repo: repoKey(s),
120
+ mode: s.mode,
121
+ ...(s.mode === 'blind' ? { fingerprint: s.keys.fp } : {}),
122
+ branch: branchKey(s, b.branch),
123
+ default_branch: branchKey(s, b.def),
124
+ ahead: b.ahead,
125
+ branches: b.all.map((x) => branchKey(s, x)),
126
+ merged: b.merged.map((x) => branchKey(s, x)),
127
+ full,
128
+ ...(reset ? { reset: true } : {}),
129
+ nodes,
130
+ deleted,
131
+ },
132
+ };
133
+ }
134
+ const cacheFile = (s, branch) => join(ARCROOM_HOME, 'tree', `${createHash('sha1').update(`${s.root}|${s.mode}|${s.fp ?? ''}|${branch}`).digest('hex').slice(0, 16)}.json`);
135
+ /** Sends this checkout's tree to the Room. Off, or Blind without the key: nothing is sent. */
136
+ export async function syncTree(cfg, s, reset = false) {
137
+ if (s.mode === 'off' || (s.mode === 'blind' && !s.keys))
138
+ return undefined;
139
+ const tree = readTree(s.root);
140
+ const b = branches(s.root);
141
+ const file = cacheFile(s, b.branch);
142
+ const last = existsSync(file) && !reset ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
143
+ const { body, sent } = syncBody(s, tree, b, last, reset);
144
+ const r = await api(cfg, '/v1/code/sync', body, 30_000);
145
+ mkdirSync(join(ARCROOM_HOME, 'tree'), { recursive: true });
146
+ writeFileSync(file, JSON.stringify(sent));
147
+ return r;
148
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,93 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
2
+ import { sessionFile } from './loop.js';
3
+ const zero = () => ({ input: 0, output: 0, cache_read: 0, cache_write: 0 });
4
+ export const tokensOf = (u) => u.input + u.output + u.cache_read + u.cache_write;
5
+ const plus = (a, b) => ({
6
+ input: a.input + b.input,
7
+ output: a.output + b.output,
8
+ cache_read: a.cache_read + b.cache_read,
9
+ cache_write: a.cache_write + b.cache_write,
10
+ model: b.model ?? a.model,
11
+ });
12
+ const minus = (a, b) => ({
13
+ input: a.input - b.input,
14
+ output: a.output - b.output,
15
+ cache_read: a.cache_read - b.cache_read,
16
+ cache_write: a.cache_write - b.cache_write,
17
+ model: a.model,
18
+ });
19
+ /** Usage of the assistant messages in complete transcript lines, skipping message ids already counted. */
20
+ export function sumUsage(text, seen) {
21
+ const usage = zero();
22
+ const consumed = text.lastIndexOf('\n') + 1; // a half-written last line waits for next time
23
+ const latest = new Map(); // ponytail: a message split across two reads keeps its first counts; fine since usage repeats per line
24
+ for (const line of text.slice(0, consumed).split('\n')) {
25
+ let e;
26
+ try {
27
+ e = JSON.parse(line);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ const m = e?.type === 'assistant' ? e.message : undefined;
33
+ if (m?.usage && m.id && !seen.has(m.id))
34
+ latest.set(m.id, m);
35
+ }
36
+ for (const [id, m] of latest) {
37
+ seen.add(id);
38
+ usage.input += m.usage.input_tokens ?? 0;
39
+ usage.output += m.usage.output_tokens ?? 0;
40
+ usage.cache_read += m.usage.cache_read_input_tokens ?? 0;
41
+ usage.cache_write += m.usage.cache_creation_input_tokens ?? 0;
42
+ usage.model = m.model ?? usage.model;
43
+ }
44
+ return { usage, consumed };
45
+ }
46
+ const file = (session) => sessionFile(session, '.usage');
47
+ /** Reads new transcript lines into the session's running total. */
48
+ function advance(session, transcript) {
49
+ const f = file(session);
50
+ const st = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : { offset: 0, total: zero(), reported: zero(), ids: [], claims: {} };
51
+ if (transcript && existsSync(transcript) && statSync(transcript).size > st.offset) {
52
+ const fd = openSync(transcript, 'r');
53
+ const buf = Buffer.alloc(statSync(transcript).size - st.offset);
54
+ readSync(fd, buf, 0, buf.length, st.offset);
55
+ closeSync(fd);
56
+ const text = buf.toString('utf8');
57
+ const seen = new Set(st.ids);
58
+ const { usage, consumed } = sumUsage(text, seen);
59
+ st.offset += Buffer.byteLength(text.slice(0, consumed));
60
+ st.total = plus(st.total, usage);
61
+ st.ids = [...seen].slice(-500);
62
+ }
63
+ return st;
64
+ }
65
+ const save = (session, st) => writeFileSync(file(session), JSON.stringify(st));
66
+ /** Everything this session has used so far (the status line). */
67
+ export function sessionUsage(session, transcript) {
68
+ const st = advance(session, transcript);
69
+ save(session, st);
70
+ return st.total;
71
+ }
72
+ /** Turn end: the tokens used since the last report, or undefined when nothing new. */
73
+ export function turnUsage(session, transcript) {
74
+ const st = advance(session, transcript);
75
+ const delta = minus(st.total, st.reported);
76
+ st.reported = st.total;
77
+ save(session, st);
78
+ return tokensOf(delta) > 0 ? delta : undefined;
79
+ }
80
+ /** claim_task: remember where the token count stood. */
81
+ export function markClaim(session, transcript, memoId) {
82
+ const st = advance(session, transcript);
83
+ st.claims[memoId] = tokensOf(st.total);
84
+ save(session, st);
85
+ }
86
+ /** complete_task: tokens spent on this memo since its claim (since session start if it was never claimed here). */
87
+ export function costSinceClaim(session, transcript, memoId) {
88
+ const st = advance(session, transcript);
89
+ const tokens = tokensOf(st.total) - (st.claims[memoId] ?? 0);
90
+ delete st.claims[memoId];
91
+ save(session, st);
92
+ return { tokens: Math.max(tokens, 0), model: st.total.model };
93
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "arcroom",
3
+ "version": "0.0.1-beta",
4
+ "description": "Shared team memory and run control for coding agents: Claude Code, Codex, Cursor, OpenCode",
5
+ "license": "Apache-2.0",
6
+ "author": "ArcRoom AI",
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "bin": {
10
+ "arcroom": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "!dist/**/*.test.js",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "homepage": "https://www.arcroomai.com",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/ArcROOM-AI/arcroom.git"
24
+ },
25
+ "bugs": "https://github.com/ArcROOM-AI/arcroom/issues",
26
+ "keywords": [
27
+ "claude-code",
28
+ "codex",
29
+ "cursor",
30
+ "opencode",
31
+ "mcp",
32
+ "agent-memory"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p .",
36
+ "test": "node test.mjs",
37
+ "prepublishOnly": "npm run build && npm test"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.11.0",
41
+ "typescript": "^5.6.0"
42
+ }
43
+ }