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/LICENSE +202 -0
- package/README.md +46 -0
- package/dist/agents.js +158 -0
- package/dist/blind.js +216 -0
- package/dist/code.js +161 -0
- package/dist/daemon.js +208 -0
- package/dist/doctor.js +74 -0
- package/dist/done.js +135 -0
- package/dist/events.js +51 -0
- package/dist/fence.js +12 -0
- package/dist/fresh.js +140 -0
- package/dist/git.js +36 -0
- package/dist/home.js +91 -0
- package/dist/hook.js +247 -0
- package/dist/index.js +154 -0
- package/dist/init.js +190 -0
- package/dist/logo.js +13 -0
- package/dist/lookup.js +98 -0
- package/dist/loop.js +35 -0
- package/dist/mcp.js +358 -0
- package/dist/pack.js +37 -0
- package/dist/recall.js +208 -0
- package/dist/redact.js +113 -0
- package/dist/route.js +26 -0
- package/dist/session.js +43 -0
- package/dist/signs.js +96 -0
- package/dist/tree.js +148 -0
- package/dist/usage.js +93 -0
- package/package.json +43 -0
package/dist/fresh.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { git } from './git.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
// Is a stored memory still true for this checkout? Steps keep the git blob hash of each file they touched (taken on
|
|
6
|
+
// the laptop at the end of the turn; hashes only, no content). Before handing steps over, each file is looked up here:
|
|
7
|
+
// same hash → as it was
|
|
8
|
+
// other hash → changed since: kept, flagged so the agent checks before trusting it
|
|
9
|
+
// missing → renamed if a file here has the saved hash (followed to its new path); otherwise deleted (dropped)
|
|
10
|
+
// when the task read it, or created by the task when it only wrote it (still a target)
|
|
11
|
+
// More than half the files it read deleted: the steps are stale and nothing is handed over. All local, no network.
|
|
12
|
+
/** Git's blob id for a file's bytes (the same id `git hash-object` prints), so it works with or without git. */
|
|
13
|
+
export const blobHash = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
14
|
+
const MAX_SCAN = 2000; // ponytail: rename search hashes candidate files; index blob ids instead if repos get huge
|
|
15
|
+
const MAX_READ = 5_000_000;
|
|
16
|
+
/**
|
|
17
|
+
* A path from stored memory (anyone with a workspace key can write it) as a file inside `cwd` that may be read, or
|
|
18
|
+
* undefined: absolute, UNC (\\host\share would send this machine's login to that host) and ".." paths, links out of the
|
|
19
|
+
* repo, folders, devices and huge files are never opened (M8, N13).
|
|
20
|
+
*/
|
|
21
|
+
export function localFile(cwd, p) {
|
|
22
|
+
if (!cwd || typeof p !== 'string' || !p || isAbsolute(p) || /^[\\/]{2}|^[a-z]:/i.test(p))
|
|
23
|
+
return undefined;
|
|
24
|
+
const full = resolve(cwd, p);
|
|
25
|
+
const rel = relative(resolve(cwd), full);
|
|
26
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel))
|
|
27
|
+
return undefined;
|
|
28
|
+
try {
|
|
29
|
+
const real = realpathSync(full);
|
|
30
|
+
const inRepo = relative(realpathSync(cwd), real);
|
|
31
|
+
const s = statSync(real);
|
|
32
|
+
return !inRepo.startsWith('..') && !isAbsolute(inRepo) && s.isFile() && s.size <= MAX_READ ? real : undefined;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A probe over a real checkout: file hashes, and a rename search over the repo's files (git ls-files when there is git). */
|
|
39
|
+
export function checkout(cwd) {
|
|
40
|
+
const hash = (p) => {
|
|
41
|
+
const file = localFile(cwd, p);
|
|
42
|
+
try {
|
|
43
|
+
return file ? blobHash(readFileSync(file)) : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
let files;
|
|
50
|
+
const list = () => {
|
|
51
|
+
if (files)
|
|
52
|
+
return files;
|
|
53
|
+
const r = git(['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, timeout: 1500 });
|
|
54
|
+
files = r.status === 0 ? (r.stdout ?? '').split('\n').filter(Boolean).slice(0, MAX_SCAN) : [];
|
|
55
|
+
return files;
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
exists: (p) => !!localFile(cwd, p),
|
|
59
|
+
hash,
|
|
60
|
+
find: (want, like) => {
|
|
61
|
+
// same extension first (a move keeps it), then everything; only files that exist and are not huge
|
|
62
|
+
const ext = extname(like);
|
|
63
|
+
const pool = [...list().filter((f) => extname(f) === ext), ...list().filter((f) => extname(f) !== ext)];
|
|
64
|
+
for (const f of pool) {
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(join(cwd, f)).size > 2_000_000)
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (hash(f) === want)
|
|
73
|
+
return f;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** The file a "read x" / "write x" step names. */
|
|
80
|
+
const fileOf = (step) => step.match(/^(?:read|write) (.+)$/)?.[1];
|
|
81
|
+
/**
|
|
82
|
+
* Steps as they apply here: renamed files followed, deleted ones dropped, changed ones listed. A file the task only
|
|
83
|
+
* wrote (never read) and that is not here was created by it: still a target ("new"), not a deleted file.
|
|
84
|
+
*/
|
|
85
|
+
export function freshen(steps, saved = {}, probe) {
|
|
86
|
+
const changed = new Set();
|
|
87
|
+
const deleted = new Set();
|
|
88
|
+
const created = new Set();
|
|
89
|
+
const moved = new Map();
|
|
90
|
+
const named = new Set(steps.map(fileOf).filter((f) => !!f));
|
|
91
|
+
const read = new Set(steps.filter((s) => s.startsWith('read ')).map(fileOf).filter((f) => !!f));
|
|
92
|
+
for (const f of named) {
|
|
93
|
+
if (probe.exists(f)) {
|
|
94
|
+
const was = saved[f];
|
|
95
|
+
if (was && probe.hash && probe.hash(f) !== was)
|
|
96
|
+
changed.add(f);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const to = saved[f] && probe.find ? probe.find(saved[f], f) : undefined;
|
|
100
|
+
if (to && !named.has(to))
|
|
101
|
+
moved.set(f, to);
|
|
102
|
+
else if (read.has(f))
|
|
103
|
+
deleted.add(f);
|
|
104
|
+
else
|
|
105
|
+
created.add(f);
|
|
106
|
+
}
|
|
107
|
+
const out = steps.flatMap((s) => {
|
|
108
|
+
const f = fileOf(s);
|
|
109
|
+
if (!f)
|
|
110
|
+
return [s];
|
|
111
|
+
if (deleted.has(f))
|
|
112
|
+
return [];
|
|
113
|
+
return [moved.has(f) ? s.replace(f, moved.get(f)) : s];
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
steps: out,
|
|
117
|
+
changed: [...changed],
|
|
118
|
+
renamed: [...moved],
|
|
119
|
+
deleted: [...deleted],
|
|
120
|
+
created: [...created],
|
|
121
|
+
// stale: most of the files it worked from are gone (files it created don't count)
|
|
122
|
+
stale: read.size > 0 && deleted.size * 2 > read.size,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** {path: blob hash} for the files a turn touched, relative to the project (paths outside it are skipped). */
|
|
126
|
+
export function hashFiles(cwd, paths) {
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const p of paths) {
|
|
129
|
+
const rel = relative(cwd, isAbsolute(p) ? p : join(cwd, p)).replaceAll('\\', '/');
|
|
130
|
+
if (!rel || rel.startsWith('..'))
|
|
131
|
+
continue;
|
|
132
|
+
try {
|
|
133
|
+
out[rel] = blobHash(readFileSync(join(cwd, rel)));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* deleted during the turn: nothing to remember */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
4
|
+
// Every git call ArcRoom makes runs inside a repo it did not write, often with no one watching (hooks, the daemon).
|
|
5
|
+
// Two ways such a repo could run its own code through those calls, both closed here:
|
|
6
|
+
// - Windows starts a git.exe sitting in the working folder before the one on PATH: git is looked up on PATH once,
|
|
7
|
+
// absolute folders only, and always started by its full path
|
|
8
|
+
// - the repo's .git/config can name a core.fsmonitor command that `git status` / `ls-files` run: it is switched off
|
|
9
|
+
// on every call (with the untracked cache that relies on it)
|
|
10
|
+
const NAMES = process.platform === 'win32' ? ['git.exe'] : ['git'];
|
|
11
|
+
const SAFE = ['-c', 'core.fsmonitor=', '-c', 'core.untrackedCache=false'];
|
|
12
|
+
let found;
|
|
13
|
+
/** The full path of the first of these program names on PATH (absolute folders only, never the working folder). */
|
|
14
|
+
export function onPath(names, path = process.env.PATH ?? '') {
|
|
15
|
+
for (const dir of path.split(delimiter))
|
|
16
|
+
for (const name of names)
|
|
17
|
+
if (dir && isAbsolute(dir) && existsSync(join(dir, name)))
|
|
18
|
+
return join(dir, name);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/** git's full path from PATH, or null when git is not installed. */
|
|
22
|
+
export function gitPath(path = process.env.PATH ?? '') {
|
|
23
|
+
if (path !== process.env.PATH)
|
|
24
|
+
return onPath(NAMES, path);
|
|
25
|
+
if (found === undefined)
|
|
26
|
+
found = onPath(NAMES, path);
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
/** Runs git safely in `cwd`; stdout is undefined when git is missing or fails to start. */
|
|
30
|
+
export function git(args, opts = {}) {
|
|
31
|
+
const bin = gitPath();
|
|
32
|
+
if (!bin)
|
|
33
|
+
return { status: null, stdout: undefined };
|
|
34
|
+
const r = spawnSync(bin, [...SAFE, ...args], { encoding: 'utf8', ...opts });
|
|
35
|
+
return { status: r.status, stdout: r.stdout };
|
|
36
|
+
}
|
package/dist/home.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
4
|
+
import { redact } from './redact.js';
|
|
5
|
+
/**
|
|
6
|
+
* ArcRoom's folder: ~/.arcroom, or ARCROOM_HOME (tests, a second setup). A repo can set environment variables for the
|
|
7
|
+
* hooks (.claude/settings.json "env"), so a relative ARCROOM_HOME, or one inside the folder being worked in, is ignored:
|
|
8
|
+
* otherwise a cloned repo could ship its own config.json and send that repo's traces to its own server (M10).
|
|
9
|
+
*/
|
|
10
|
+
export function homeFrom(custom, cwd) {
|
|
11
|
+
const rel = custom && isAbsolute(custom) ? relative(cwd, custom) : '';
|
|
12
|
+
return custom && isAbsolute(custom) && (rel.startsWith('..') || isAbsolute(rel)) ? custom : join(homedir(), '.arcroom');
|
|
13
|
+
}
|
|
14
|
+
export const ARCROOM_HOME = homeFrom(process.env.ARCROOM_HOME, process.cwd());
|
|
15
|
+
// Repos where this person switched ArcRoom off (arcroom off): no tracing, no lookups, nothing leaves the laptop (I6).
|
|
16
|
+
// Kept on this laptop, not in the repo, so a commit can neither switch it off nor back on for someone.
|
|
17
|
+
const OFF_FILE = join(ARCROOM_HOME, 'off-repos.json');
|
|
18
|
+
export const offRepos = (file = OFF_FILE) => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** Whether `cwd` is inside a repo switched off here. */
|
|
27
|
+
export const isOff = (cwd, repos = offRepos()) => typeof cwd === 'string' && !!cwd && repos.some((r) => {
|
|
28
|
+
const rel = relative(r, cwd);
|
|
29
|
+
return !rel.startsWith('..') && !isAbsolute(rel);
|
|
30
|
+
});
|
|
31
|
+
export function setOff(root, off, file = OFF_FILE) {
|
|
32
|
+
const next = offRepos(file).filter((r) => r !== root);
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(file, JSON.stringify(off ? [...next, root] : next, null, 2) + '\n', { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
// ArcRoom API (the Oracle server behind Caddy). A local API: arcroom init --url http://localhost:8787
|
|
37
|
+
export const DEFAULT_URL = 'https://api.arcroomai.com';
|
|
38
|
+
/** ~/.arcroom/config.json written by `arcroom init`. */
|
|
39
|
+
export function loadConfig() {
|
|
40
|
+
const file = join(ARCROOM_HOME, 'config.json');
|
|
41
|
+
return { url: DEFAULT_URL, apiKey: '', ...(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}) };
|
|
42
|
+
}
|
|
43
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
44
|
+
/** The API URL, refused unless it is https:// or this machine: the key and the traces never cross a network in clear text. */
|
|
45
|
+
export function apiUrl(url) {
|
|
46
|
+
const u = new URL(url);
|
|
47
|
+
if (u.protocol === 'https:' || (u.protocol === 'http:' && LOCAL.has(u.hostname)))
|
|
48
|
+
return u.origin + u.pathname.replace(/\/+$/, '');
|
|
49
|
+
throw new Error(`the ArcRoom API URL must start with https:// (got ${u.protocol}//${u.host}); plain http is only for this machine`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* POST/GET to the ArcRoom API with the saved key; throws with the API's error message. Every body is redacted here,
|
|
53
|
+
* so what an agent stores through the tools (answers, steps, facts, notes) or a prompt sent for lookup never carries a
|
|
54
|
+
* secret to the team.
|
|
55
|
+
*/
|
|
56
|
+
export async function api(cfg, path, body, timeoutMs = 10_000) {
|
|
57
|
+
const res = await fetch(apiUrl(cfg.url) + path, {
|
|
58
|
+
method: body ? 'POST' : 'GET',
|
|
59
|
+
headers: { Authorization: `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' },
|
|
60
|
+
body: body ? JSON.stringify(redact(body)) : undefined,
|
|
61
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
62
|
+
});
|
|
63
|
+
const json = (await res.json());
|
|
64
|
+
// the key was revoked, its person removed or its account deleted: ArcRoom stops on this machine until a new key
|
|
65
|
+
if (res.status === 401 && json.error === 'invalid api key')
|
|
66
|
+
stopForKey(cfg.apiKey);
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
69
|
+
return json;
|
|
70
|
+
}
|
|
71
|
+
// Off because the API refused this machine's key: hooks, the daemon and the tools do nothing until `arcroom init` saves a
|
|
72
|
+
// key that works. The file names the refused key's start, so a new key in config.json switches ArcRoom on again.
|
|
73
|
+
const STOPPED = join(ARCROOM_HOME, 'key-refused');
|
|
74
|
+
export function stopForKey(key) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
|
|
77
|
+
writeFileSync(STOPPED, key.slice(0, 8), { mode: 0o600 });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// nothing to do: the next call is refused again and tries again
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** True while the saved key is the one the API refused. */
|
|
84
|
+
export function keyRefused(cfg = loadConfig()) {
|
|
85
|
+
try {
|
|
86
|
+
return readFileSync(STOPPED, 'utf8') === cfg.apiKey.slice(0, 8);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { git } from './git.js';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { AGENTS, fromAgent, toAgent } from './agents.js';
|
|
5
|
+
import { ensureDaemon } from './daemon.js';
|
|
6
|
+
import { noteEdit, onStop } from './done.js';
|
|
7
|
+
import { toArcroomEvent } from './events.js';
|
|
8
|
+
import { noteDir } from './code.js';
|
|
9
|
+
import { fence } from './fence.js';
|
|
10
|
+
import { redact, rememberSecrets } from './redact.js';
|
|
11
|
+
import { ARCROOM_HOME, isOff, keyRefused } from './home.js';
|
|
12
|
+
import { lookupFast } from './lookup.js';
|
|
13
|
+
import { checkLoop, sessionFile } from './loop.js';
|
|
14
|
+
import { pack } from './pack.js';
|
|
15
|
+
import { lastRunNote, projectOf } from './session.js';
|
|
16
|
+
import { bump, MARK, signFor, toolResult } from './signs.js';
|
|
17
|
+
import { costSinceClaim, markClaim, turnUsage } from './usage.js';
|
|
18
|
+
// Claude Code runs this for every hook event, with the event JSON on stdin.
|
|
19
|
+
// It turns the event into ArcRoom run events and appends them to the local queue (spool.jsonl),
|
|
20
|
+
// which the daemon drains: nothing is lost if the daemon is down or the machine is offline.
|
|
21
|
+
// - PreToolUse: loop guard (can deny); Stop: done-checks (can block) + token usage of the turn
|
|
22
|
+
// - context events log every piece of text ArcRoom injects into the agent (the context ledger)
|
|
23
|
+
// - memory.* events record reuse / replay / cost of ArcRoom tool calls (tokens saved)
|
|
24
|
+
// What ArcRoom did is shown to the user as one short line (systemMessage). Claude Code takes ONE JSON object
|
|
25
|
+
// on stdout, so every part adds to `out` and it is written once at the end.
|
|
26
|
+
// It must never slow down or break a session: any error lets the call through, exit is always 0.
|
|
27
|
+
// Every prompt without a hit gets this short note; a hit gets the stored steps or answer instead.
|
|
28
|
+
// Code changes are recorded from the trace by the hooks, so the agent is told not to save them by hand:
|
|
29
|
+
// find/claim/complete on a code change only added calls (and duplicate step versions) in the benchmark.
|
|
30
|
+
const REMINDER = 'ArcRoom team memory is on and records code changes by itself: for those, do not call the mcp__arcroom__ tools. ' +
|
|
31
|
+
'For a question, lookup or analysis someone could reuse, call mcp__arcroom__find_task first (reuse: answer from it; ' +
|
|
32
|
+
'none: claim_task, do it, complete_task with the answer). A request with several parts: split_task. ' +
|
|
33
|
+
'Learned something lasting (a fact about this project, or a decision and why)? Save it with mcp__arcroom__remember.';
|
|
34
|
+
const OUR_TOOLS = 'mcp__arcroom__';
|
|
35
|
+
export async function hook() {
|
|
36
|
+
try {
|
|
37
|
+
let input = '';
|
|
38
|
+
for await (const chunk of process.stdin)
|
|
39
|
+
input += chunk;
|
|
40
|
+
// Codex, Cursor and OpenCode call this same handler with --agent; their payloads become Claude Code's here
|
|
41
|
+
const flag = process.argv.indexOf('--agent');
|
|
42
|
+
const agent = flag > 0 && AGENTS.includes(process.argv[flag + 1]) ? process.argv[flag + 1] : 'claude';
|
|
43
|
+
const raw = fromAgent(JSON.parse(input), agent);
|
|
44
|
+
if (isOff(raw.cwd))
|
|
45
|
+
return; // switched off for this repo by this person (arcroom off): nothing recorded or sent
|
|
46
|
+
if (keyRefused())
|
|
47
|
+
return; // the key was revoked or its account deleted: nothing runs until a new key (arcroom init)
|
|
48
|
+
rememberSecrets(raw.cwd); // this project's .env values are redacted wherever they show up
|
|
49
|
+
const name = raw.hook_event_name;
|
|
50
|
+
const session = String(raw.session_id);
|
|
51
|
+
const out = {};
|
|
52
|
+
const say = (message) => (out.systemMessage = out.systemMessage ? `${out.systemMessage}\n${message}` : message);
|
|
53
|
+
// the local queue holds redacted events only, in a folder and file only this user can read
|
|
54
|
+
const spool = join(ARCROOM_HOME, 'spool.jsonl');
|
|
55
|
+
mkdirSync(ARCROOM_HOME, { recursive: true, mode: 0o700 });
|
|
56
|
+
const queue = (e) => appendFileSync(spool, JSON.stringify(redact(e)) + '\n', { mode: 0o600 });
|
|
57
|
+
const emit = (type, data) => queue({ ...toArcroomEvent(raw), type, data });
|
|
58
|
+
const injected = (kind, text, keepText = false) => emit('context', { kind, chars: text.length, ...(keepText ? { text: text.slice(0, 2000) } : {}) });
|
|
59
|
+
// Token meter: the turn's usage goes in before its turn.end, so the report built at turn.end includes it.
|
|
60
|
+
// After each tool call too, so the website shows a long turn's tokens while it runs.
|
|
61
|
+
if (name === 'Stop' || name === 'SessionEnd' || name === 'PostToolUse') {
|
|
62
|
+
const usage = turnUsage(session, raw.transcript_path);
|
|
63
|
+
if (usage)
|
|
64
|
+
emit('usage', usage);
|
|
65
|
+
}
|
|
66
|
+
const event = toArcroomEvent(raw);
|
|
67
|
+
const project = name === 'SessionStart' && raw.cwd ? projectOf(raw.cwd) : undefined;
|
|
68
|
+
// lets the next session find this run as "last run here"; project_id puts the run in the website's project
|
|
69
|
+
const projectId = process.argv.includes('--project') ? process.argv[process.argv.indexOf('--project') + 1] : undefined;
|
|
70
|
+
// cwd turns the trace's absolute paths into project paths when steps are built from it
|
|
71
|
+
// the branch lets the Room label memories made on a branch that is not merged yet
|
|
72
|
+
const branch = project ? git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: raw.cwd }).stdout?.trim() : undefined;
|
|
73
|
+
if (project)
|
|
74
|
+
event.data = { ...event.data, project, cwd: raw.cwd, ...(branch ? { branch } : {}), ...(projectId ? { project_id: projectId } : {}) };
|
|
75
|
+
// which agent is editing: other agents are told who is changing a file (the daemon adds the file's repo and folder)
|
|
76
|
+
if (name === 'PreToolUse')
|
|
77
|
+
event.data = { ...event.data, agent };
|
|
78
|
+
if (name === 'PreToolUse' && raw.cwd)
|
|
79
|
+
noteDir(session, raw.cwd, raw.tool_input?.file_path ?? raw.tool_input?.notebook_path);
|
|
80
|
+
const stop = name === 'Stop' ? onStop(raw) : undefined;
|
|
81
|
+
if (stop)
|
|
82
|
+
event.data = stop.data;
|
|
83
|
+
queue(event);
|
|
84
|
+
// a patch that touched several files: one step per file
|
|
85
|
+
if (name === 'PreToolUse')
|
|
86
|
+
for (const f of raw.more_files ?? [])
|
|
87
|
+
emit('tool.start', { tool: raw.tool_name, input: { file_path: f } });
|
|
88
|
+
if (stop?.block) {
|
|
89
|
+
out.decision = 'block';
|
|
90
|
+
out.reason = stop.block;
|
|
91
|
+
injected('block', stop.block);
|
|
92
|
+
say(`${MARK} · asked the agent to run the done-checks (${stop.data.blocked}) before stopping`);
|
|
93
|
+
}
|
|
94
|
+
else if (stop?.unapproved)
|
|
95
|
+
say(`${MARK} · done-checks in .arcroom.json are new or changed, not asked for: read them with arcroom checks`);
|
|
96
|
+
else if (stop?.data.outcome === 'partial')
|
|
97
|
+
say(`${MARK} · stopped as partial: done-checks not run or still failing`);
|
|
98
|
+
else if (stop?.data.outcome === 'done')
|
|
99
|
+
say(`${MARK} · done-checks passed`);
|
|
100
|
+
if (name === 'UserPromptSubmit') {
|
|
101
|
+
// Tool descriptions alone don't change an agent's habits; a per-prompt reminder does.
|
|
102
|
+
// Code answers go stale when the code changes: tie them to the current commit.
|
|
103
|
+
// ponytail: uncommitted edits aren't in the key; add a diff hash if stale code answers show up
|
|
104
|
+
const commit = git(['rev-parse', '--short', 'HEAD'], { cwd: raw.cwd }).stdout?.trim();
|
|
105
|
+
const reminder = commit
|
|
106
|
+
? `${REMINDER} For questions about this repo's code, include "commit": "${commit}" in inputs so the answer is reused only while the code is unchanged.`
|
|
107
|
+
: REMINDER;
|
|
108
|
+
// Recall: was this task done before? The hook asks, so the agent need not remember to.
|
|
109
|
+
// memories already handed over in this session are not repeated
|
|
110
|
+
const givenFile = sessionFile(session, '.recalled');
|
|
111
|
+
const given = new Set(existsSync(givenFile) ? JSON.parse(readFileSync(givenFile, 'utf8')) : []);
|
|
112
|
+
// recall and the code map (folder summaries, who is editing what) in one lookup, by the warm daemon when it runs
|
|
113
|
+
const prompt = String(raw.prompt ?? '');
|
|
114
|
+
const found = raw.cwd ? await lookupFast(prompt, raw.cwd, session, given) : { recall: {} };
|
|
115
|
+
// otherwise a timeout looks the same as "nothing found"
|
|
116
|
+
if (found.offline)
|
|
117
|
+
say(`${MARK} · memory offline, prompt sent without it`);
|
|
118
|
+
const { stale, weights } = found.recall;
|
|
119
|
+
// everything found shares one budget, most useful first (pack.ts); what does not fit is not handed over
|
|
120
|
+
const kept = new Map(pack([
|
|
121
|
+
found.recall.pointer && { kind: 'recall', text: found.recall.pointer.text, value: found.recall.pointer.value },
|
|
122
|
+
found.recall.playbook && { kind: 'playbook', text: found.recall.playbook.text, value: found.recall.playbook.value },
|
|
123
|
+
found.recall.knowledge && { kind: 'knowledge', text: found.recall.knowledge.text, value: found.recall.knowledge.value },
|
|
124
|
+
found.code && { kind: 'code', text: found.code.text, value: found.code.value },
|
|
125
|
+
].filter((p) => !!p), prompt, weights).map((p) => [p.kind, p.text]));
|
|
126
|
+
const pointer = kept.has('recall') ? { ...found.recall.pointer, text: kept.get('recall') } : undefined;
|
|
127
|
+
const playbook = kept.has('playbook') ? { ...found.recall.playbook, text: kept.get('playbook') } : undefined;
|
|
128
|
+
const knowledge = kept.has('knowledge') ? { ...found.recall.knowledge, text: kept.get('knowledge') } : undefined;
|
|
129
|
+
const code = kept.has('code') ? { ...found.code, text: kept.get('code') } : undefined;
|
|
130
|
+
if (code) {
|
|
131
|
+
injected('code', code.text, true);
|
|
132
|
+
say(`${MARK} · code map: ${code.summaries} folder summar${code.summaries === 1 ? 'y' : 'ies'}${code.editing ? `, ${code.editing} agent${code.editing > 1 ? 's' : ''} editing nearby` : ''}`);
|
|
133
|
+
}
|
|
134
|
+
if (stale) {
|
|
135
|
+
// its files are gone from this checkout: two such reports retire the steps
|
|
136
|
+
emit('memory.stale', { memo_id: stale });
|
|
137
|
+
say(`${MARK} · skipped stored steps: their files are not in this checkout`);
|
|
138
|
+
}
|
|
139
|
+
if (knowledge) {
|
|
140
|
+
for (const id of knowledge.ids)
|
|
141
|
+
given.add(id);
|
|
142
|
+
injected('knowledge', knowledge.text, true);
|
|
143
|
+
say(`${MARK} · ${knowledge.ids.length} team fact${knowledge.ids.length > 1 ? 's' : ''} or decision${knowledge.ids.length > 1 ? 's' : ''} for this request`);
|
|
144
|
+
}
|
|
145
|
+
if (playbook) {
|
|
146
|
+
given.add(playbook.memoId);
|
|
147
|
+
injected('playbook', playbook.text, true);
|
|
148
|
+
say(`${MARK} · a playbook from ${playbook.runs} similar runs`);
|
|
149
|
+
}
|
|
150
|
+
if (pointer)
|
|
151
|
+
given.add(pointer.memoId);
|
|
152
|
+
// what `arcroom wrong` reports when the person says the memory just handed over was wrong
|
|
153
|
+
const last = pointer ?? (playbook && { memoId: playbook.memoId, intent: 'playbook' });
|
|
154
|
+
if (last)
|
|
155
|
+
writeFileSync(join(ARCROOM_HOME, 'last-recall.json'), JSON.stringify({ memo_id: last.memoId, intent: last.intent }));
|
|
156
|
+
if (pointer || knowledge || playbook)
|
|
157
|
+
writeFileSync(givenFile, JSON.stringify([...given]));
|
|
158
|
+
if (pointer) {
|
|
159
|
+
injected('recall', pointer.text, true);
|
|
160
|
+
// the turn's outcome credits (or heals) these steps when the turn ends
|
|
161
|
+
if (pointer.kind === 'steps')
|
|
162
|
+
emit('memory.replayed', { memo_id: pointer.memoId });
|
|
163
|
+
else
|
|
164
|
+
emit('memory.reused', { memo_id: pointer.memoId, version: pointer.version });
|
|
165
|
+
bump(session, pointer.kind === 'steps' ? 'replayed' : 'reused');
|
|
166
|
+
say(`${MARK} · ${pointer.kind === 'steps' ? 'steps from a past run' : 'a team answer'}${pointer.similar ? ' for a similar task' : ''}: ${pointer.intent.slice(0, 70)}`);
|
|
167
|
+
}
|
|
168
|
+
// a hit is the lookup already done: the reminder would only send the agent to find_task for it again
|
|
169
|
+
// what teammates' agents wrote goes in one fenced block of data (fence.ts); ArcRoom's own reminder stays outside it
|
|
170
|
+
const memory = [pointer?.text ?? playbook?.text, ...[...kept].filter(([k]) => k === 'knowledge' || k === 'code').map(([, t]) => t)].filter(Boolean);
|
|
171
|
+
const context = [pointer || playbook ? '' : reminder, memory.length ? fence(memory.join('\n\n')) : ''].filter(Boolean).join('\n\n');
|
|
172
|
+
out.hookSpecificOutput = { hookEventName: 'UserPromptSubmit', additionalContext: context };
|
|
173
|
+
if (!pointer && !playbook)
|
|
174
|
+
injected('reminder', reminder);
|
|
175
|
+
}
|
|
176
|
+
if (name === 'PreToolUse') {
|
|
177
|
+
const reason = checkLoop(session, raw.tool_name, raw.tool_input);
|
|
178
|
+
if (reason) {
|
|
179
|
+
const blocked = { ...toArcroomEvent(raw), type: 'tool.blocked' };
|
|
180
|
+
blocked.data = { ...blocked.data, reason: 'loop' };
|
|
181
|
+
queue(blocked);
|
|
182
|
+
out.hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason };
|
|
183
|
+
injected('deny', reason);
|
|
184
|
+
say(`${MARK} · blocked a repeated ${String(raw.tool_name).replace(OUR_TOOLS, '')} call (same input 3 times in a row)`);
|
|
185
|
+
bump(session, 'blocked');
|
|
186
|
+
}
|
|
187
|
+
else
|
|
188
|
+
noteEdit(raw);
|
|
189
|
+
}
|
|
190
|
+
// a done-check the agent ran with Bash (the Stop hook asks for them; it never runs them itself)
|
|
191
|
+
if (name === 'PostToolUse' && raw.tool_name === 'Bash')
|
|
192
|
+
noteEdit(raw);
|
|
193
|
+
if (name === 'PostToolUse' && String(raw.tool_name).startsWith(OUR_TOOLS)) {
|
|
194
|
+
const tool = raw.tool_name.slice(OUR_TOOLS.length);
|
|
195
|
+
const result = toolResult(raw.tool_response);
|
|
196
|
+
const sign = signFor(tool, raw.tool_input, result);
|
|
197
|
+
if (sign) {
|
|
198
|
+
say(sign.message);
|
|
199
|
+
if (sign.stat)
|
|
200
|
+
bump(session, sign.stat);
|
|
201
|
+
}
|
|
202
|
+
// Savings: what an answer cost to make (claim -> complete), and every reuse / replay of it.
|
|
203
|
+
if (tool === 'find_task' && result?.match === 'reuse')
|
|
204
|
+
emit('memory.reused', { memo_id: result.memo_id, version: result.version?.version });
|
|
205
|
+
// what a reused answer cost to make is what this reuse saved (status line)
|
|
206
|
+
if (tool === 'find_task' && result?.match === 'reuse' && result.version?.cost_tokens)
|
|
207
|
+
bump(session, 'tokens_saved', Number(result.version.cost_tokens));
|
|
208
|
+
if (tool === 'find_task' && result?.match === 'replay')
|
|
209
|
+
emit('memory.replayed', { memo_id: result.memo_id });
|
|
210
|
+
if (tool === 'split_task')
|
|
211
|
+
for (const p of result?.parts ?? []) {
|
|
212
|
+
if (p.match === 'reuse')
|
|
213
|
+
emit('memory.reused', { memo_id: p.memo_id, version: p.version?.version });
|
|
214
|
+
if (p.match === 'reuse')
|
|
215
|
+
bump(session, 'reused');
|
|
216
|
+
if (p.match === 'reuse' && p.version?.cost_tokens)
|
|
217
|
+
bump(session, 'tokens_saved', Number(p.version.cost_tokens));
|
|
218
|
+
if (p.match === 'replay')
|
|
219
|
+
emit('memory.replayed', { memo_id: p.memo_id });
|
|
220
|
+
}
|
|
221
|
+
if (tool === 'claim_task' && result?.claimed)
|
|
222
|
+
markClaim(session, raw.transcript_path, result.memo_id);
|
|
223
|
+
if (tool === 'complete_task' && result?.version) {
|
|
224
|
+
const cost = costSinceClaim(session, raw.transcript_path, result.memo_id);
|
|
225
|
+
emit('memory.cost', { memo_id: result.memo_id, version: result.version, tokens: cost.tokens, model: cost.model });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (project) {
|
|
229
|
+
// Session memory: start with what the last run in this project did. Slow or offline API = no note.
|
|
230
|
+
const note = await lastRunNote(session, project).catch(() => undefined);
|
|
231
|
+
if (note) {
|
|
232
|
+
// the last run here may be a teammate's: its ask is their words, so it is fenced like any memory
|
|
233
|
+
out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: fence(note) };
|
|
234
|
+
injected('last_run', note, true);
|
|
235
|
+
}
|
|
236
|
+
// say what leaves the laptop, every session (events.ts keeps it to that)
|
|
237
|
+
say(`${MARK} · on: team memory · sends prompts, file paths and commands (no file contents)${note ? ' · picked up where the last run here left off' : ''}`);
|
|
238
|
+
}
|
|
239
|
+
const reply = toAgent(agent, name, out, session);
|
|
240
|
+
if (Object.keys(reply).length)
|
|
241
|
+
process.stdout.write(JSON.stringify(reply));
|
|
242
|
+
ensureDaemon(process.argv[1]);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// ponytail: errors are swallowed so a broken hook never stops a session; add a log file when debugging needs it
|
|
246
|
+
}
|
|
247
|
+
}
|