@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +63 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +20 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +64 -1
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +149 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// agents-hub lib/git.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Which branch a checkout is on, and whether it is a worktree of another repo, read from git's own files (no git
|
|
3
|
+
// process): `<dir>/.git` is a directory for a main checkout, or a file `gitdir: <main>/.git/worktrees/<name>` for a
|
|
4
|
+
// worktree. Cached by the HEAD file's mtime.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
const readSmall = (file) => { try { return fs.readFileSync(file, 'utf8').slice(0, 4096).trim(); } catch { return null; } };
|
|
10
|
+
|
|
11
|
+
/** { path, branch, sha, worktree, mainRepo } for a checkout directory, or null when it is not a git checkout. */
|
|
12
|
+
export function gitInfo(dir) {
|
|
13
|
+
let gitDir = null;
|
|
14
|
+
let worktree = false;
|
|
15
|
+
try {
|
|
16
|
+
const st = fs.lstatSync(path.join(dir, '.git'));
|
|
17
|
+
if (st.isDirectory()) gitDir = path.join(dir, '.git');
|
|
18
|
+
else if (st.isFile()) {
|
|
19
|
+
const m = (readSmall(path.join(dir, '.git')) || '').match(/^gitdir:\s*(.+)$/m);
|
|
20
|
+
if (m) { gitDir = path.resolve(dir, m[1].trim()); worktree = true; }
|
|
21
|
+
}
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
if (!gitDir) return null;
|
|
24
|
+
const headFile = path.join(gitDir, 'HEAD');
|
|
25
|
+
let mtime = 0;
|
|
26
|
+
try { mtime = fs.statSync(headFile).mtimeMs; } catch { return null; }
|
|
27
|
+
const hit = cache.get(dir);
|
|
28
|
+
if (hit && hit.mtime === mtime && hit.gitDir === gitDir) return hit.info;
|
|
29
|
+
const head = readSmall(headFile) || '';
|
|
30
|
+
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
31
|
+
const common = worktree ? readSmall(path.join(gitDir, 'commondir')) : null;
|
|
32
|
+
const mainGit = worktree ? path.resolve(gitDir, common || '../..') : gitDir;
|
|
33
|
+
const info = {
|
|
34
|
+
path: dir,
|
|
35
|
+
branch: ref ? ref[1] : null,
|
|
36
|
+
sha: ref ? null : (head.match(/^[0-9a-f]{7,40}$/) || [])[0]?.slice(0, 10) || null,
|
|
37
|
+
worktree,
|
|
38
|
+
mainRepo: worktree ? path.dirname(mainGit) : null,
|
|
39
|
+
};
|
|
40
|
+
if (cache.size > 500) cache.clear();
|
|
41
|
+
cache.set(dir, { mtime, gitDir, info });
|
|
42
|
+
return info;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function branchExists(mainGit, branch) {
|
|
46
|
+
if (fs.existsSync(path.join(mainGit, 'refs', 'heads', ...branch.split('/')))) return true;
|
|
47
|
+
return (readSmallBig(path.join(mainGit, 'packed-refs')) || '').includes(` refs/heads/${branch}\n`);
|
|
48
|
+
}
|
|
49
|
+
function readSmallBig(file) { try { return `${fs.readFileSync(file, 'utf8').slice(0, 2e6)}\n`; } catch { return null; } }
|
|
50
|
+
|
|
51
|
+
/** Where a branch is checked out: the main checkout or one of its worktrees (from `.git/worktrees/<n>/{HEAD,gitdir}`). */
|
|
52
|
+
function checkedOutAt(mainGit, branch) {
|
|
53
|
+
const want = `ref: refs/heads/${branch}`;
|
|
54
|
+
if (readSmall(path.join(mainGit, 'HEAD')) === want) return path.dirname(mainGit);
|
|
55
|
+
let names = [];
|
|
56
|
+
try { names = fs.readdirSync(path.join(mainGit, 'worktrees')); } catch { return null; }
|
|
57
|
+
for (const n of names) {
|
|
58
|
+
if (readSmall(path.join(mainGit, 'worktrees', n, 'HEAD')) !== want) continue;
|
|
59
|
+
const gitFile = readSmall(path.join(mainGit, 'worktrees', n, 'gitdir'));
|
|
60
|
+
if (gitFile) return path.dirname(gitFile);
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A task's branch and worktree: the branch its KICKOFF named (else the one its channel's checkout is on), where that
|
|
67
|
+
* branch is checked out now, and whether the branch still exists.
|
|
68
|
+
*/
|
|
69
|
+
export function taskCheckout(checkout, planned) {
|
|
70
|
+
if (!checkout) return null;
|
|
71
|
+
const branch = (planned && planned.branch) || checkout.branch;
|
|
72
|
+
if (!branch) return { branch: null, sha: checkout.sha, worktreePath: checkout.path, isWorktree: checkout.worktree, mainRepo: checkout.mainRepo, channelCheckout: checkout.path, exists: null, here: true };
|
|
73
|
+
const mainGit = path.join(checkout.mainRepo || checkout.path, '.git');
|
|
74
|
+
const at = checkout.branch === branch ? checkout.path : checkedOutAt(mainGit, branch);
|
|
75
|
+
return {
|
|
76
|
+
branch,
|
|
77
|
+
worktreePath: at,
|
|
78
|
+
isWorktree: !!at && at !== (checkout.mainRepo || (checkout.worktree ? null : checkout.path)),
|
|
79
|
+
mainRepo: checkout.mainRepo || checkout.path,
|
|
80
|
+
channelCheckout: checkout.path,
|
|
81
|
+
channelBranch: checkout.branch,
|
|
82
|
+
exists: branchExists(mainGit, branch),
|
|
83
|
+
here: checkout.branch === branch,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The branch and worktree a KICKOFF post asked for ("Worktree <path>, branch <name>"). */
|
|
88
|
+
export function plannedCheckout(text) {
|
|
89
|
+
const t = String(text || '');
|
|
90
|
+
const branch = (t.match(/\bbranch\s+`?([A-Za-z0-9._/-]+?)`?(?=[\s,.;)]|$)/i) || [])[1] || null;
|
|
91
|
+
const wt = (t.match(/\bworktree\s+`?([^\s,`]+)`?/i) || [])[1] || null;
|
|
92
|
+
return branch || wt ? { branch, worktree: wt ? wt.replace(/[).,]+$/, '') : null } : null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// agents-hub lib/me.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Who "@you" is: the AgentPulse CLI's signed-in user. The CLI config holds no name, so the server asks the platform's
|
|
3
|
+
// OIDC userinfo with the CLI's access token (as `agentpulse login` does) and keeps the answer in memory. The browser
|
|
4
|
+
// only ever gets { name, handle, aliases }; the token goes only to the profile's own platform and is never logged.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { USER_ALIAS } from './channels.mjs';
|
|
9
|
+
|
|
10
|
+
export const NOBODY = Object.freeze({ name: 'You', handle: null, aliases: Object.freeze([]) });
|
|
11
|
+
/** Why the name is "You". Only these words are ever logged or kept, never an error message or a header. */
|
|
12
|
+
export const REASONS = Object.freeze(['no-config', 'unauthorized', 'timeout', 'network', 'blank']);
|
|
13
|
+
const TIMEOUT_MS = 3000;
|
|
14
|
+
const FRESH_MS = 3600e3;
|
|
15
|
+
const RETRY_MS = 5 * 60e3;
|
|
16
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
17
|
+
|
|
18
|
+
export const cliConfigPath = (env = process.env, home = os.homedir()) => path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'agentpulse', 'config.json');
|
|
19
|
+
|
|
20
|
+
/** The CLI's active profile: its default, else its only one. */
|
|
21
|
+
export function activeProfile(cfg) {
|
|
22
|
+
const profiles = (cfg && typeof cfg.profiles === 'object' && cfg.profiles) || {};
|
|
23
|
+
if (cfg && cfg.defaultProfile && profiles[cfg.defaultProfile]) return profiles[cfg.defaultProfile];
|
|
24
|
+
const names = Object.keys(profiles);
|
|
25
|
+
return names.length === 1 ? profiles[names[0]] : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The profile's platform origin, when the bearer may go there: https, or http on this machine. */
|
|
29
|
+
export function platformOrigin(baseUrl) {
|
|
30
|
+
let u;
|
|
31
|
+
try { u = new URL(String(baseUrl)); } catch { return null; }
|
|
32
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && LOCAL.has(u.hostname)) ? u.origin : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tidy = (s) => (typeof s === 'string' ? s.replace(/\s+/g, ' ').trim().slice(0, 60) : '');
|
|
36
|
+
const localPart = (s) => tidy(s).split('@')[0];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* OIDC claims or a profile → { name, handle, aliases }: "First Last", else `name`, else the username; NOBODY when blank.
|
|
40
|
+
* Aliases are the handle and the name without spaces.
|
|
41
|
+
*/
|
|
42
|
+
export function identityOf(u) {
|
|
43
|
+
if (!u || typeof u !== 'object') return NOBODY;
|
|
44
|
+
const first = tidy(u.given_name || u.firstName || u.first_name);
|
|
45
|
+
const last = tidy(u.family_name || u.lastName || u.last_name);
|
|
46
|
+
const username = localPart(u.preferred_username || u.username || u.email);
|
|
47
|
+
const name = tidy([first, last].filter(Boolean).join(' ')) || tidy(u.name) || username;
|
|
48
|
+
if (!name) return NOBODY;
|
|
49
|
+
const handle = USER_ALIAS.test(username) ? username : null;
|
|
50
|
+
const aliases = [...new Set([handle, name.replace(/\s+/g, '')]
|
|
51
|
+
.filter((a) => a && USER_ALIAS.test(a) && !/^(you|user)$/i.test(a)).map((a) => a.toLowerCase()))];
|
|
52
|
+
return { name, handle, aliases };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The aliases no agent persona has: when the user's handle is also an agent's username, "@" it means the agent. */
|
|
56
|
+
export const aliasesFor = (me, taken) => me.aliases.filter((a) => !taken.has(a.toLowerCase()));
|
|
57
|
+
|
|
58
|
+
async function lookup(configPath, fetchImpl) {
|
|
59
|
+
let cfg;
|
|
60
|
+
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return { reason: 'no-config' }; }
|
|
61
|
+
const profile = activeProfile(cfg);
|
|
62
|
+
if (!profile) return { reason: 'no-config' };
|
|
63
|
+
const own = identityOf(profile);
|
|
64
|
+
if (own !== NOBODY) return { me: own };
|
|
65
|
+
const origin = platformOrigin(profile.baseUrl);
|
|
66
|
+
if (!origin || typeof profile.accessToken !== 'string' || !profile.accessToken) return { reason: 'no-config' };
|
|
67
|
+
const ctl = new AbortController();
|
|
68
|
+
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
|
|
69
|
+
try {
|
|
70
|
+
const r = await fetchImpl(`${origin}/api/oauth/userinfo`, {
|
|
71
|
+
headers: { authorization: `Bearer ${profile.accessToken}`, accept: 'application/json' }, redirect: 'error', signal: ctl.signal,
|
|
72
|
+
});
|
|
73
|
+
if (r.status === 401 || r.status === 403) return { reason: 'unauthorized' };
|
|
74
|
+
if (!r.ok) return { reason: 'network' };
|
|
75
|
+
const got = identityOf(await r.json().catch(() => null));
|
|
76
|
+
return got === NOBODY ? { reason: 'blank' } : { me: got };
|
|
77
|
+
} catch {
|
|
78
|
+
return { reason: ctl.signal.aborted ? 'timeout' : 'network' };
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The CLI user, resolved in the background: `current()` never waits, and starts a lookup when the answer is stale (hourly,
|
|
86
|
+
* or every few minutes while it is still "You"). A failed lookup keeps the last good answer; `listen(fn)` hears changes,
|
|
87
|
+
* and `reason()` says why the name is "You".
|
|
88
|
+
*/
|
|
89
|
+
export function cliIdentity({ configPath = cliConfigPath(), fetchImpl = globalThis.fetch, now = Date.now, log = console.error } = {}) {
|
|
90
|
+
let me = NOBODY;
|
|
91
|
+
let why = null;
|
|
92
|
+
let checkedAt = -Infinity;
|
|
93
|
+
let running = null;
|
|
94
|
+
const listeners = new Set();
|
|
95
|
+
function refresh() {
|
|
96
|
+
if (running) return running;
|
|
97
|
+
checkedAt = now();
|
|
98
|
+
running = lookup(configPath, fetchImpl).then((got) => {
|
|
99
|
+
running = null;
|
|
100
|
+
if (got.reason) {
|
|
101
|
+
if (me === NOBODY && got.reason !== why) log(`@you shows "You": the CLI user could not be read (${got.reason})`);
|
|
102
|
+
why = got.reason;
|
|
103
|
+
} else {
|
|
104
|
+
why = null;
|
|
105
|
+
if (got.me.name !== me.name || got.me.aliases.join('|') !== me.aliases.join('|')) {
|
|
106
|
+
me = got.me;
|
|
107
|
+
for (const fn of listeners) fn(me);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return me;
|
|
111
|
+
});
|
|
112
|
+
return running;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
current() {
|
|
116
|
+
if (now() - checkedAt >= (me === NOBODY ? RETRY_MS : FRESH_MS)) refresh();
|
|
117
|
+
return me;
|
|
118
|
+
},
|
|
119
|
+
refresh,
|
|
120
|
+
reason: () => why,
|
|
121
|
+
listen(fn) { listeners.add(fn); },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// agents-hub lib/personas.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Every agent gets a persona the first time the hub sees it: a first name, a surname, a username that is never reused,
|
|
3
|
+
// and a seed for its illustrated face. The pairing is stored, so an agent keeps its identity for good.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { HUB } from './store.mjs';
|
|
7
|
+
|
|
8
|
+
const FILE = path.join(HUB, 'state', 'personas.json');
|
|
9
|
+
const RECENT_MS = 7 * 24 * 3600e3;
|
|
10
|
+
const FIRST = [
|
|
11
|
+
'Maya', 'Arjun', 'Lena', 'Kofi', 'Sofia', 'Ravi', 'Noor', 'Mateo', 'Aiko', 'Elias', 'Priya', 'Tomas', 'Zara', 'Kenji', 'Amara', 'Luca',
|
|
12
|
+
'Ines', 'Dev', 'Hana', 'Omar', 'Clara', 'Jin', 'Asha', 'Felix', 'Leila', 'Nikhil', 'Mira', 'Diego', 'Yara', 'Sami', 'Ingrid', 'Tariq',
|
|
13
|
+
'Nora', 'Kai', 'Ananya', 'Bruno', 'Selin', 'Ade', 'Freya', 'Hugo', 'Meera', 'Rafael', 'Suki', 'Idris', 'Lina', 'Oskar', 'Tara', 'Malik',
|
|
14
|
+
'Esme', 'Rohan', 'Ayla', 'Theo', 'Kavya', 'Pablo', 'Rin', 'Yusuf', 'Alma', 'Ishaan', 'Greta', 'Emeka', 'Lea', 'Vikram', 'Saoirse', 'Ren',
|
|
15
|
+
];
|
|
16
|
+
const LAST = [
|
|
17
|
+
'Okafor', 'Sharma', 'Lindqvist', 'Mensah', 'Moreno', 'Iyer', 'Haddad', 'Silva', 'Tanaka', 'Novak', 'Reddy', 'Kowalski', 'Ahmed', 'Sato',
|
|
18
|
+
'Adeyemi', 'Rossi', 'Costa', 'Patel', 'Kim', 'Farouk', 'Weber', 'Chen', 'Nair', 'Fischer', 'Rahimi', 'Menon', 'Aydin', 'Garcia', 'Nkosi',
|
|
19
|
+
'Hassan', 'Berg', 'Qureshi', 'Larsen', 'Mori', 'Kapoor', 'Duarte', 'Yilmaz', 'Balogun', 'Olsen', 'Laurent', 'Pillai', 'Ortega', 'Ito',
|
|
20
|
+
'Abara', 'Vidal', 'Lund', 'Desai', 'Karimi', 'Brandt', 'Rao', 'Demir', 'Herrera', 'Watanabe', 'Eze', 'Nyberg', 'Joshi', 'Santos', 'Park',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A personality shapes how an agent talks to you in the hub: temperament, how it writes, and the emoji it reaches for.
|
|
25
|
+
* It is tone only. The role's duties are restated next to it every time, so a reviewer stays exacting and a coder
|
|
26
|
+
* stays precise, whatever its temperament.
|
|
27
|
+
*/
|
|
28
|
+
const TEMPERAMENT = ['warm and encouraging', 'dry and witty', 'calm and methodical', 'upbeat and energetic', 'thoughtful and curious',
|
|
29
|
+
'direct and no-nonsense', 'playful but sharp', 'quietly confident', 'friendly and plain-spoken', 'meticulous and a little wry'];
|
|
30
|
+
const VOICE = ['writes short sentences and gets to the point', 'explains the why in one line', 'likes a quick checklist', 'asks one sharp question back when something is unclear',
|
|
31
|
+
'sums up first, then adds a light remark', 'speaks plainly, like a colleague at the next desk', 'leads with the answer and keeps the detail for when you ask'];
|
|
32
|
+
const EMOJI_SETS = [['👍', '✅', '🚀'], ['🙂', '🙌', '✨'], ['👀', '🤔', '📝'], ['🔥', '💪', '🎉'], ['✅', '🧪', '🔍'], ['😄', '🎉', '☕'], ['👌', '🛠️', '📌'], ['🙏', '💡', '🌱']];
|
|
33
|
+
const EMOJI_HABIT = ['rarely uses emoji', 'uses an emoji now and then', 'often answers with just an emoji when no words are needed'];
|
|
34
|
+
const ROLE_DUTY = {
|
|
35
|
+
CODER: 'the coder: precise about what you built, tested and committed, and you never claim a check you did not run',
|
|
36
|
+
REVIEWER: 'the reviewer: exacting and honest; personality never softens a finding, a FAIL or a missing check',
|
|
37
|
+
ARCHITECT: 'the architect: you own the plan, the deploys and the evidence, and you keep every stream to its gates',
|
|
38
|
+
AUDITOR: 'the platform auditor: strict about alignment with the platform, and you flag drift plainly',
|
|
39
|
+
MANAGER: 'the hub manager: you coordinate across deliveries, deduplicate shared work and preserve every specialist role and gate',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function personalityFor(id) {
|
|
43
|
+
return {
|
|
44
|
+
temperament: TEMPERAMENT[hash(`temper:${id}`) % TEMPERAMENT.length],
|
|
45
|
+
voice: VOICE[hash(`voice:${id}`) % VOICE.length],
|
|
46
|
+
emoji: EMOJI_SETS[hash(`emoji:${id}`) % EMOJI_SETS.length],
|
|
47
|
+
habit: EMOJI_HABIT[hash(`habit:${id}`) % EMOJI_HABIT.length],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How an agent should sound when it answers you in the hub, with its role's duties restated so tone never changes the work. */
|
|
52
|
+
export function voiceFor(id, role) {
|
|
53
|
+
let p = null;
|
|
54
|
+
try { p = (JSON.parse(fs.readFileSync(FILE, 'utf8')) || {})[id] || null; } catch { p = null; }
|
|
55
|
+
if (!p) return null;
|
|
56
|
+
const k = p.personality || personalityFor(id);
|
|
57
|
+
const r = String(role || p.role || '').toUpperCase().replace(/[^A-Z]/g, '');
|
|
58
|
+
const duty = ROLE_DUTY[r] || `the ${r ? r.toLowerCase() : 'agent'} on this task, doing that job exactly as before`;
|
|
59
|
+
return `In the hub you are ${p.name} (@${p.username}). Personality: ${k.temperament}; ${k.voice}; ${k.habit} (favourites: ${k.emoji.join(' ')}). `
|
|
60
|
+
+ 'Answer like a human teammate with that personality: words, an emoji reaction, or both, whatever a person like you would send. '
|
|
61
|
+
+ `It is tone only. You are still ${duty}. It changes nothing in your work or your standards. `
|
|
62
|
+
+ 'Whatever the tone, keep the structure readable: a clear first sentence, short paragraphs, bullets, code blocks and tables where they help.';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let cache = null;
|
|
66
|
+
let dirty = false;
|
|
67
|
+
|
|
68
|
+
function hash(s) {
|
|
69
|
+
let x = 2166136261;
|
|
70
|
+
for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = Math.imul(x, 16777619); }
|
|
71
|
+
return x >>> 0;
|
|
72
|
+
}
|
|
73
|
+
const slug = (s) => s.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '');
|
|
74
|
+
|
|
75
|
+
/** A username nobody has had: first.last, then first.last2, first.last3… */
|
|
76
|
+
function uniqueUsername(first, last, all) {
|
|
77
|
+
const used = new Set(Object.values(all).map((p) => p.username).filter(Boolean));
|
|
78
|
+
const base = `${slug(first)}.${slug(last)}`;
|
|
79
|
+
if (!used.has(base)) return base;
|
|
80
|
+
let n = 2;
|
|
81
|
+
while (used.has(`${base}${n}`)) n++;
|
|
82
|
+
return `${base}${n}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Older personas had a first name only: give them a surname and a username, keeping name and face. */
|
|
86
|
+
function upgrade(all) {
|
|
87
|
+
for (const [id, p] of Object.entries(all)) {
|
|
88
|
+
if (p && !p.personality) { p.personality = personalityFor(id); dirty = true; }
|
|
89
|
+
if (!p || p.username) continue;
|
|
90
|
+
const first = String(p.name || FIRST[hash(id) % FIRST.length]).split(' ')[0];
|
|
91
|
+
const last = LAST[hash(`last:${id}`) % LAST.length];
|
|
92
|
+
Object.assign(p, { first, last, name: `${first} ${last}`, username: uniqueUsername(first, last, all) });
|
|
93
|
+
dirty = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function load() {
|
|
98
|
+
if (cache) return cache;
|
|
99
|
+
try { cache = JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch { cache = {}; }
|
|
100
|
+
upgrade(cache);
|
|
101
|
+
return cache;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The persona of an agent id, creating it on first sight. First names are not reused among recent agents. */
|
|
105
|
+
export function personaFor(id, { role = null, channel = null, now = Date.now() } = {}) {
|
|
106
|
+
const all = load();
|
|
107
|
+
if (all[id]) return all[id];
|
|
108
|
+
const recent = new Set(Object.values(all).filter((p) => now - p.createdAt < RECENT_MS).map((p) => p.first));
|
|
109
|
+
const start = hash(id) % FIRST.length;
|
|
110
|
+
let first = FIRST[start];
|
|
111
|
+
for (let i = 0; i < FIRST.length; i++) if (!recent.has(FIRST[(start + i) % FIRST.length])) { first = FIRST[(start + i) % FIRST.length]; break; }
|
|
112
|
+
const last = LAST[hash(`last:${id}`) % LAST.length];
|
|
113
|
+
all[id] = { first, last, name: `${first} ${last}`, username: uniqueUsername(first, last, all), seed: hash(`face:${id}`), role, channel, createdAt: now, personality: personalityFor(id) };
|
|
114
|
+
dirty = true;
|
|
115
|
+
return all[id];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write new or upgraded personas to disk (once per pass). */
|
|
119
|
+
export function savePersonas() {
|
|
120
|
+
if (!dirty || !cache) return;
|
|
121
|
+
fs.writeFileSync(FILE, JSON.stringify(cache, null, 2));
|
|
122
|
+
dirty = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let usernames = { mtime: null, set: new Set() };
|
|
126
|
+
/** Every username an agent persona has had, re-read when the file changes (the collector writes it in its own thread). */
|
|
127
|
+
export function personaUsernames() {
|
|
128
|
+
let mtime;
|
|
129
|
+
try { mtime = fs.statSync(FILE).mtimeMs; } catch { return new Set(); }
|
|
130
|
+
if (mtime !== usernames.mtime) {
|
|
131
|
+
let all = {};
|
|
132
|
+
try { all = JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch { /* half-written: keep the last set */ return usernames.set; }
|
|
133
|
+
usernames = { mtime, set: new Set(Object.values(all).map((p) => p && typeof p.username === 'string' && p.username.toLowerCase()).filter(Boolean)) };
|
|
134
|
+
}
|
|
135
|
+
return usernames.set;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Personas created since sinceMs, for "joined" lines in project channels. Read fresh: the collector writes them in its own thread. */
|
|
139
|
+
export function personasSince(sinceMs) {
|
|
140
|
+
let all = {};
|
|
141
|
+
try { all = JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch { return []; }
|
|
142
|
+
return Object.entries(all).filter(([, p]) => p && p.createdAt >= sinceMs).map(([id, p]) => ({ id, ...p }));
|
|
143
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// agents-hub lib/status.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// A status you set by hand on a delivery or an agent: stored once as the registry fact `status_override`, applied over
|
|
3
|
+
// the derived status when the hub projects its rows, and recorded in the task channels as a USER post. Pure.
|
|
4
|
+
|
|
5
|
+
export const USER_STATUSES = ['done', 'blocked', 'stopped', 'handed-over', 'working'];
|
|
6
|
+
export const STATUS_LIMITS = Object.freeze({ id: 100, reason: 500, to: 120, key: 240 });
|
|
7
|
+
/** The Board's step columns (channels.STEPS), and the ids a move names them by. */
|
|
8
|
+
const STEP_NAMES = ['Research', 'Design', 'Mockup', 'Build', 'Review', 'Checks', 'Proof'];
|
|
9
|
+
export const STEP_IDS = STEP_NAMES.map((name) => name.toLowerCase());
|
|
10
|
+
const STREAM_KEY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
11
|
+
/** Done and Stopped hold until you change them; the others give way to the next agent header in the channel. */
|
|
12
|
+
const STICKY = new Set(['done', 'stopped']);
|
|
13
|
+
const HEADER_TYPE = { done: 'DONE', blocked: 'BLOCKER', stopped: 'STOPPED', 'handed-over': 'HANDED OVER', working: 'STATUS' };
|
|
14
|
+
const LABEL = { done: 'Done', blocked: 'Blocked', stopped: 'Stopped', 'handed-over': 'Handed over', working: 'Working' };
|
|
15
|
+
const DELIVERY_STATUS = { done: 'done', stopped: 'done', blocked: 'blocked', 'handed-over': 'on-track', working: 'on-track' };
|
|
16
|
+
const AGENT_STATUS = { done: 'done', stopped: 'done', blocked: 'stuck', 'handed-over': 'waiting', working: 'working' };
|
|
17
|
+
const LOCAL_ID = /^[^\0-\x1f\x7f]+$/;
|
|
18
|
+
|
|
19
|
+
/** One line, no control or bidi characters: the reason goes into a channel header that editors also render. */
|
|
20
|
+
const oneLine = (s) => s.replace(/[\0-\x1f\x7f-\x9f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
21
|
+
|
|
22
|
+
function text(v, name, max, required) {
|
|
23
|
+
if (v === undefined || v === null) return required ? { error: `${name} is required` } : { value: null };
|
|
24
|
+
if (typeof v !== 'string') return { error: `${name} must be text` };
|
|
25
|
+
const value = oneLine(v);
|
|
26
|
+
if (!value) return required ? { error: `${name} is required` } : { value: null };
|
|
27
|
+
if (value.length > max) return { error: `${name} must be at most ${max} characters` };
|
|
28
|
+
return { value };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A POST /api/status body → { target, status, reason, to }, or for a stream (a card moved to a step column)
|
|
33
|
+
* { target, step }; or { error }. The hub works out the rest of a move itself, so a move carries nothing else.
|
|
34
|
+
*/
|
|
35
|
+
export function statusRequest(body) {
|
|
36
|
+
const t = body && body.target;
|
|
37
|
+
if (!t || typeof t !== 'object' || !['delivery', 'agent', 'stream'].includes(t.kind)) return { error: 'target must be a delivery, an agent or a stream' };
|
|
38
|
+
if (t.kind === 'stream') {
|
|
39
|
+
if (typeof t.id !== 'string' || t.id.length > STATUS_LIMITS.key || !STREAM_KEY.test(t.id)) return { error: 'target id must be a <repo>/<channel> stream key' };
|
|
40
|
+
if (!STEP_IDS.includes(body.step)) return { error: `step must be one of ${STEP_IDS.join(', ')}` };
|
|
41
|
+
const extra = Object.keys(body).find((k) => !['target', 'step'].includes(k));
|
|
42
|
+
if (extra) return { error: `${extra} is not part of a move` };
|
|
43
|
+
return { target: { kind: 'stream', id: t.id }, step: body.step };
|
|
44
|
+
}
|
|
45
|
+
if (body.step !== undefined) return { error: 'step is only for a stream' };
|
|
46
|
+
if (typeof t.id !== 'string' || !t.id || t.id.length > STATUS_LIMITS.id || !LOCAL_ID.test(t.id)) return { error: 'invalid target id' };
|
|
47
|
+
if (!USER_STATUSES.includes(body.status)) return { error: `status must be one of ${USER_STATUSES.join(', ')}` };
|
|
48
|
+
const reason = text(body.reason, 'reason', STATUS_LIMITS.reason, body.status === 'blocked');
|
|
49
|
+
if (reason.error) return { error: reason.error };
|
|
50
|
+
if (body.status !== 'handed-over' && body.to !== undefined && body.to !== null) return { error: 'to is only for handed-over' };
|
|
51
|
+
const to = body.status === 'handed-over' ? text(body.to, 'to', STATUS_LIMITS.to, true) : { value: null };
|
|
52
|
+
if (to.error) return { error: to.error };
|
|
53
|
+
return { target: { kind: t.kind, id: t.id }, status: body.status, reason: reason.value, to: to.value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const overrideKey = (target) => `${target.kind}:${target.id}`;
|
|
57
|
+
|
|
58
|
+
/** Header text without `#`: the parser reads "#12" in any header as a finding id that a closing word would close. */
|
|
59
|
+
const headerSafe = (s) => oneLine(s.replace(/#/g, ''));
|
|
60
|
+
|
|
61
|
+
/** The channel record of a status you set: one USER header, in the time format decisionPost writes. */
|
|
62
|
+
const clock = (at) => [at.getHours(), at.getMinutes(), at.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
|
|
63
|
+
|
|
64
|
+
export function auditPost({ status, reason = null, to = null }, { at = new Date(), who = null } = {}) {
|
|
65
|
+
const hhmmss = clock(at);
|
|
66
|
+
const what = [to ? `to ${headerSafe(to)}` : null, reason ? headerSafe(reason) : null].filter(Boolean).join(': ');
|
|
67
|
+
return `\n## [USER] ${hhmmss} ${HEADER_TYPE[status]} — set from the Board${who ? ` for ${headerSafe(who)}` : ''}${what ? `: ${what}` : ''}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** "Blocked, set by you: org down" — the words a row shows for a status you set. */
|
|
71
|
+
export function overrideText(o) {
|
|
72
|
+
const by = !o.by || o.by === 'user' ? 'you' : o.by;
|
|
73
|
+
return `${LABEL[o.status]}${o.to ? ` to ${o.to}` : ''}, set by ${by}${o.reason ? `: ${o.reason}` : ''}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether an agent has posted a header that moves the work (channels.movesWork: not USER posts, so not the audit post,
|
|
78
|
+
* and not check-ins) since status `o` was set. Headers carry agents' clocks, not the hub's, so this goes by place in
|
|
79
|
+
* the channel: `o.seqs` holds the audit post's position in each stream it went to, `seqOf(key)` the newest moving
|
|
80
|
+
* header's. A status set before the hub recorded positions falls back to `agentAt`, the newest such header's time.
|
|
81
|
+
*/
|
|
82
|
+
export function movedSince(o, { agentAt = 0, seqOf = () => 0 } = {}) {
|
|
83
|
+
const seqs = Object.entries(o.seqs || {});
|
|
84
|
+
return seqs.length ? seqs.some(([key, seq]) => seqOf(key) > seq) : agentAt > o.at;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** For movedSince: where the work under a delivery fact (its streams) or an agent-row fact (its own stream) last moved. */
|
|
88
|
+
export function workMoved(kind, row) {
|
|
89
|
+
if (kind === 'delivery') {
|
|
90
|
+
const streams = row.streams || [];
|
|
91
|
+
const at = new Map(streams.map((s) => [s.key, s.agentSeq || 0]));
|
|
92
|
+
return { agentAt: Math.max(0, ...streams.map((s) => s.agentAt || 0)), seqOf: (key) => at.get(key) || 0 };
|
|
93
|
+
}
|
|
94
|
+
return { agentAt: row.movedAt || 0, seqOf: (key) => (key === row.streamKey ? row.movedSeq || 0 : 0) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether a status you set still holds: Done and Stopped stick, the soft ones last until the work moves. */
|
|
98
|
+
export const overrideHolds = (o, moved = {}) => !!o && USER_STATUSES.includes(o.status) && (STICKY.has(o.status) || !movedSince(o, moved));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A delivery or agent row with the status you set over its derived one. An open ask stays in front: only Stopped, or
|
|
102
|
+
* Done on anything but a result waiting for your Accept, takes a delivery out of needs-you.
|
|
103
|
+
*/
|
|
104
|
+
export function withOverride(row, o, { kind, ...moved }) {
|
|
105
|
+
if (!overrideHolds(o, moved)) return row;
|
|
106
|
+
const override = { status: o.status, reason: o.reason || null, to: o.to || null, by: o.by || 'user', at: o.at };
|
|
107
|
+
const asking = kind === 'delivery' && row.status === 'needs-you';
|
|
108
|
+
if (asking && !(o.status === 'stopped' || (o.status === 'done' && !(row.ask && row.ask.kind === 'result')))) return { ...row, override };
|
|
109
|
+
if (kind === 'agent') {
|
|
110
|
+
return { ...row, status: AGENT_STATUS[o.status], waitingOn: o.status === 'handed-over' ? override.to : row.waitingOn, override };
|
|
111
|
+
}
|
|
112
|
+
return { ...row, status: DELIVERY_STATUS[o.status], reason: overrideText(override), override };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The channel record of a card you moved: a USER NOTE, which is chatter, so it never moves the derived step itself. */
|
|
116
|
+
export const movePost = ({ step }, { at = new Date() } = {}) => `\n## [USER] ${clock(at)} NOTE — moved to ${STEP_NAMES[STEP_IDS.indexOf(step)]} from the Board\n`;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a step you moved a card to still holds on stream `s` (a streamView). It ends at the first header that moves
|
|
120
|
+
* the work, comes after the move's NOTE in the channel (`o.seq`, its position), and either leaves the derived step
|
|
121
|
+
* where it was not when you moved the card (`fromStep`) or implies on its own a step beyond yours (`stepSeq[i]`: the
|
|
122
|
+
* position of the newest such header implying step i or later). So commits in Build keep a move to Review, and the
|
|
123
|
+
* next findings end a move back to Build.
|
|
124
|
+
*/
|
|
125
|
+
export function stepHolds(o, s) {
|
|
126
|
+
if (!o || !STEP_IDS.includes(o.step) || !Number.isInteger(o.seq) || s.done) return false;
|
|
127
|
+
const mine = STEP_IDS.indexOf(o.step);
|
|
128
|
+
if ((s.agentSeq || 0) > o.seq && s.step !== o.fromStep) return false;
|
|
129
|
+
return !(((s.stepSeq || [])[mine + 1] || 0) > o.seq);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A stream row in the column you moved it to, with `override` saying who moved it and when. */
|
|
133
|
+
export function withStep(s, o) {
|
|
134
|
+
if (!stepHolds(o, s)) return s;
|
|
135
|
+
const step = STEP_IDS.indexOf(o.step);
|
|
136
|
+
return { ...s, step, stepName: STEP_NAMES[step], override: { step: o.step, by: o.by || 'user', at: o.at } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A delivery row with its streams' moves applied; its own step and counts follow them, as buildDeliveries counts. */
|
|
140
|
+
export function withSteps(d, overrideOf) {
|
|
141
|
+
const streams = (d.streams || []).map((s) => withStep(s, overrideOf(s.key)));
|
|
142
|
+
if (streams.every((s, i) => s === d.streams[i])) return d;
|
|
143
|
+
const open = streams.filter((s) => !s.done);
|
|
144
|
+
const stepCounts = STEP_NAMES.map((_, i) => open.filter((s) => s.step === i).length);
|
|
145
|
+
const step = open.length ? stepCounts.lastIndexOf(Math.max(...stepCounts)) : d.step;
|
|
146
|
+
return { ...d, streams, stepCounts, step, stepName: step == null ? null : STEP_NAMES[step] };
|
|
147
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// agents-hub lib/store.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// The hub's files. Everything is append-only JSONL or one small JSON file, so any sandboxed session can write it;
|
|
3
|
+
// for the same reason everything read back is shape-checked before use.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { hubHome } from '../home.mjs';
|
|
7
|
+
|
|
8
|
+
export const HUB = hubHome();
|
|
9
|
+
const STATE = path.join(HUB, 'state');
|
|
10
|
+
const EVENTS = path.join(STATE, 'events.jsonl');
|
|
11
|
+
const REQUESTS = path.join(STATE, 'requests');
|
|
12
|
+
const SETTINGS = path.join(STATE, 'settings.json');
|
|
13
|
+
export const KEEPAWAKE_STATUS = path.join(STATE, 'keepawake-status.json');
|
|
14
|
+
const EVENTS_TAIL_BYTES = 1024 * 1024;
|
|
15
|
+
const NUDGE_CHOICES = [0, 5, 10, 15, 30];
|
|
16
|
+
const KEEP_AWAKE_MODES = ['off', 'agents', 'always'];
|
|
17
|
+
|
|
18
|
+
const DEFAULT_SETTINGS = { keepAwake: { mode: 'off' }, wake: { nudgeEveryMin: 0, macWakeAt: null } };
|
|
19
|
+
|
|
20
|
+
fs.mkdirSync(REQUESTS, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const readJson = (f, fallback) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fallback; } };
|
|
23
|
+
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
|
24
|
+
const isEvent = (e) => e && typeof e === 'object' && Number.isFinite(e.at) && typeof e.from === 'string' && typeof e.type === 'string';
|
|
25
|
+
|
|
26
|
+
export function appendEvent(e) {
|
|
27
|
+
const event = { at: Date.now(), ...e };
|
|
28
|
+
fs.appendFileSync(EVENTS, JSON.stringify(event) + '\n');
|
|
29
|
+
return event;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Events since sinceMs, from the last megabyte of the log (older ones are history the views never show). */
|
|
33
|
+
export function readEvents(sinceMs = 0) {
|
|
34
|
+
let text = '';
|
|
35
|
+
try {
|
|
36
|
+
const st = fs.statSync(EVENTS);
|
|
37
|
+
const len = Math.min(EVENTS_TAIL_BYTES, st.size);
|
|
38
|
+
const fd = fs.openSync(EVENTS, 'r');
|
|
39
|
+
const buf = Buffer.alloc(len);
|
|
40
|
+
fs.readSync(fd, buf, 0, len, st.size - len);
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
text = buf.toString('utf8');
|
|
43
|
+
if (len < st.size) text = text.slice(text.indexOf('\n') + 1);
|
|
44
|
+
} catch { return []; }
|
|
45
|
+
return text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
46
|
+
.filter((e) => isEvent(e) && e.at >= sinceMs)
|
|
47
|
+
.map((e) => ({ ...e, to: typeof e.to === 'string' ? e.to : '*', text: str(e.text, 500) }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function addRequest({ from, kind, title, files = [] }) {
|
|
51
|
+
const id = `r-${Date.now().toString(36)}`;
|
|
52
|
+
const req = { id, from: [String(from)], kind: String(kind), title: String(title), files, status: 'queued', at: Date.now() };
|
|
53
|
+
fs.writeFileSync(path.join(REQUESTS, `${id}.json`), JSON.stringify(req, null, 2));
|
|
54
|
+
appendEvent({ from: String(from), to: 'manager', type: 'REQUEST', text: `${kind}: ${title}`, ref: id });
|
|
55
|
+
return req;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readRequests() {
|
|
59
|
+
let files = [];
|
|
60
|
+
try { files = fs.readdirSync(REQUESTS).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
61
|
+
return files.map((f) => readJson(path.join(REQUESTS, f), null))
|
|
62
|
+
.filter((r) => r && typeof r === 'object' && Number.isFinite(r.at) && typeof r.title === 'string')
|
|
63
|
+
.map((r) => ({ id: str(r.id, 60), title: str(r.title, 200), kind: str(r.kind, 30), status: str(r.status, 20) || 'queued', at: r.at,
|
|
64
|
+
from: (Array.isArray(r.from) ? r.from : [r.from]).filter((x) => typeof x === 'string').slice(0, 10) }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ACCEPTED = path.join(STATE, 'accepted.json');
|
|
68
|
+
|
|
69
|
+
/** Deliveries whose result you accepted, by delivery id → when. Kept in its own file so it never rolls off a log. */
|
|
70
|
+
export function readAccepted() {
|
|
71
|
+
const a = readJson(ACCEPTED, {}) || {};
|
|
72
|
+
return new Map(Object.entries(a).filter(([k, v]) => typeof k === 'string' && Number.isFinite(v)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function acceptDelivery(id) {
|
|
76
|
+
const a = Object.fromEntries(readAccepted());
|
|
77
|
+
a[id] = Date.now();
|
|
78
|
+
fs.writeFileSync(ACCEPTED, JSON.stringify(a, null, 2));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readSettings() {
|
|
82
|
+
const s = readJson(SETTINGS, {}) || {};
|
|
83
|
+
return validSettings({ keepAwake: { ...DEFAULT_SETTINGS.keepAwake, ...(s.keepAwake || {}) }, wake: { ...DEFAULT_SETTINGS.wake, ...(s.wake || {}) } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Only known keys and values survive: a nudge interval from the list, a keep-awake mode, a wake time as HH:MM. */
|
|
87
|
+
export function validSettings(s) {
|
|
88
|
+
const mode = KEEP_AWAKE_MODES.includes(s.keepAwake && s.keepAwake.mode) ? s.keepAwake.mode : 'off';
|
|
89
|
+
const every = NUDGE_CHOICES.includes(Number(s.wake && s.wake.nudgeEveryMin)) ? Number(s.wake.nudgeEveryMin) : 0;
|
|
90
|
+
const at = s.wake && typeof s.wake.macWakeAt === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(s.wake.macWakeAt) ? s.wake.macWakeAt : null;
|
|
91
|
+
return { keepAwake: { mode }, wake: { nudgeEveryMin: every, macWakeAt: at } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function writeSettings(patch) {
|
|
95
|
+
const cur = readSettings();
|
|
96
|
+
const next = validSettings({ keepAwake: { ...cur.keepAwake, ...((patch && patch.keepAwake) || {}) }, wake: { ...cur.wake, ...((patch && patch.wake) || {}) } });
|
|
97
|
+
fs.writeFileSync(SETTINGS, JSON.stringify(next, null, 2));
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What the keep-awake helper last reported; `installed` is false when it has not checked in for 90 s. */
|
|
102
|
+
export function keepAwakeStatus() {
|
|
103
|
+
const s = readJson(KEEPAWAKE_STATUS, null);
|
|
104
|
+
if (!s || !Number.isFinite(s.heartbeat)) return { installed: false, running: false };
|
|
105
|
+
return { running: !!s.running, since: Number.isFinite(s.since) ? s.since : null, installed: Date.now() - s.heartbeat < 90e3 };
|
|
106
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// agents-hub lib/titles.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// The title Cursor gives each chat (the one in its chat list), read from Cursor's own store so a delivery is named
|
|
3
|
+
// the way you already know it. Read-only; any failure just means no title, and the caller falls back to the prompt.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const DB = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
|
9
|
+
const SQLITE = '/usr/bin/sqlite3';
|
|
10
|
+
const ID = /^[0-9a-f-]{36}$/;
|
|
11
|
+
const TTL_MS = 60e3;
|
|
12
|
+
const cache = new Map();
|
|
13
|
+
|
|
14
|
+
/** Cursor's chat titles for these conversation ids, as a Map id → title (ids it has no title for are left out). */
|
|
15
|
+
export function chatTitles(ids) {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const want = [...new Set(ids)].filter((id) => ID.test(id) && !(cache.has(id) && now - cache.get(id).at < TTL_MS));
|
|
18
|
+
if (want.length) {
|
|
19
|
+
let rows = [];
|
|
20
|
+
try {
|
|
21
|
+
const sql = `SELECT substr(key, 14) AS id, json_extract(value, '$.name') AS name FROM cursorDiskKV WHERE key IN (${want.map((id) => `'composerData:${id}'`).join(',')});`;
|
|
22
|
+
const out = execFileSync(SQLITE, ['-readonly', '-json', `file:${DB}?mode=ro`, sql], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
23
|
+
rows = out.trim() ? JSON.parse(out) : [];
|
|
24
|
+
} catch { rows = []; }
|
|
25
|
+
const got = new Map(rows.filter((r) => typeof r.name === 'string' && r.name.trim()).map((r) => [r.id, r.name.trim().slice(0, 80)]));
|
|
26
|
+
for (const id of want) cache.set(id, { at: now, name: got.get(id) || null });
|
|
27
|
+
}
|
|
28
|
+
return new Map(ids.map((id) => [id, cache.get(id) && cache.get(id).name]).filter(([, name]) => name));
|
|
29
|
+
}
|