@henryz2004/agency 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- package/server.js +370 -0
package/lib/opencode.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { DATA_DIR } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
const DB_PATH = path.join(HOME, '.local', 'share', 'opencode', 'opencode.db');
|
|
9
|
+
|
|
10
|
+
function dbExists() {
|
|
11
|
+
try {
|
|
12
|
+
fs.accessSync(DB_PATH);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function escId(s) {
|
|
20
|
+
return String(s).replace(/'/g, "''");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function query(sql) {
|
|
24
|
+
if (!dbExists()) return [];
|
|
25
|
+
try {
|
|
26
|
+
const out = execFileSync('sqlite3', [DB_PATH, '-cmd', '.timeout 3000', '-json', sql], {
|
|
27
|
+
timeout: 4000,
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
30
|
+
});
|
|
31
|
+
if (!out.trim()) return [];
|
|
32
|
+
return JSON.parse(out);
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const AGENTS_DIR = path.join(HOME, '.local', 'share', 'opencode', 'agents');
|
|
39
|
+
|
|
40
|
+
function isAlive(pid) {
|
|
41
|
+
if (!pid || pid <= 0) return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return e.code === 'EPERM';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getLiveFromHeartbeats(now) {
|
|
51
|
+
let files;
|
|
52
|
+
try {
|
|
53
|
+
files = fs.readdirSync(AGENTS_DIR);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
59
|
+
if (!jsonFiles.length) return null; // no plugin installed yet — fall back to DB
|
|
60
|
+
|
|
61
|
+
// First pass: read every live heartbeat. The opencode plugin writes ONE file
|
|
62
|
+
// per session, INCLUDING subagents a session spawns (each carries a non-null
|
|
63
|
+
// `parentId` pointing at the session that launched it). A subagent shares its
|
|
64
|
+
// parent's pid + cwd, so emitting both as top-level floor agents shows the
|
|
65
|
+
// SAME opencode session as two desks — the duplicate this dedup removes.
|
|
66
|
+
const heartbeats = [];
|
|
67
|
+
const liveIds = new Set();
|
|
68
|
+
for (const f of jsonFiles) {
|
|
69
|
+
let hb;
|
|
70
|
+
try {
|
|
71
|
+
hb = JSON.parse(fs.readFileSync(path.join(AGENTS_DIR, f), 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isAlive(hb.pid)) {
|
|
77
|
+
try { fs.unlinkSync(path.join(AGENTS_DIR, f)); } catch {}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
heartbeats.push(hb);
|
|
82
|
+
if (hb.sessionId) liveIds.add(hb.sessionId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Collect each parent's running subagents, keyed by parentId. We only fold a
|
|
86
|
+
// child into a parent that is ALSO live here — an orphaned subagent (parent
|
|
87
|
+
// heartbeat already reaped) stays a standalone agent rather than vanishing.
|
|
88
|
+
const childrenByParent = new Map();
|
|
89
|
+
for (const hb of heartbeats) {
|
|
90
|
+
const parent = hb.parentId || null;
|
|
91
|
+
if (parent && liveIds.has(parent)) {
|
|
92
|
+
const list = childrenByParent.get(parent) || [];
|
|
93
|
+
list.push({ type: hb.agent || 'agent' }); // matches Claude's subagent shape
|
|
94
|
+
childrenByParent.set(parent, list);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const agents = [];
|
|
99
|
+
for (const hb of heartbeats) {
|
|
100
|
+
// Skip subagents whose parent is live — they fold into the parent below.
|
|
101
|
+
if (hb.parentId && liveIds.has(hb.parentId)) continue;
|
|
102
|
+
|
|
103
|
+
const cwd = hb.cwd || null;
|
|
104
|
+
const activity = hb.status === 'idle' ? 'idle' : 'working';
|
|
105
|
+
const startedAt = hb.startedAt || null;
|
|
106
|
+
|
|
107
|
+
const metrics = hb.sessionId ? metricsFor(hb.sessionId) : { toolCalls30m: 0, tokensOut30m: 0, windowMin: 30 };
|
|
108
|
+
agents.push({
|
|
109
|
+
pid: hb.pid,
|
|
110
|
+
sessionId: hb.sessionId || null,
|
|
111
|
+
source: 'opencode',
|
|
112
|
+
cwd,
|
|
113
|
+
project: cwd ? path.basename(cwd) : 'unknown',
|
|
114
|
+
kind: hb.agent || 'interactive',
|
|
115
|
+
activity,
|
|
116
|
+
status: activity === 'working' ? 'busy' : 'idle',
|
|
117
|
+
state: null,
|
|
118
|
+
needsYou: false,
|
|
119
|
+
task: hb.title || null,
|
|
120
|
+
chatName: hb.title || null,
|
|
121
|
+
lastPrompt: null,
|
|
122
|
+
subagents: hb.sessionId ? (childrenByParent.get(hb.sessionId) || []) : [],
|
|
123
|
+
startedAt,
|
|
124
|
+
uptimeMs: startedAt ? Math.max(0, now - startedAt) : null,
|
|
125
|
+
model: hb.model || null,
|
|
126
|
+
provider: hb.provider || null,
|
|
127
|
+
metrics,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return agents.length ? agents : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function activityFor(sessionId) {
|
|
135
|
+
const rows = query(
|
|
136
|
+
`SELECT json_extract(data, '$.type') as ptype, json_extract(data, '$.state.status') as status ` +
|
|
137
|
+
`FROM part WHERE session_id = '${escId(sessionId)}' ` +
|
|
138
|
+
`ORDER BY time_created DESC LIMIT 20`
|
|
139
|
+
);
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
if (r.ptype === 'tool' && r.status === 'running') return 'shell';
|
|
142
|
+
if (r.ptype === 'step-start') return 'working';
|
|
143
|
+
if (r.ptype === 'step-finish') return 'idle';
|
|
144
|
+
}
|
|
145
|
+
return 'idle';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function metricsFor(sessionId) {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const cutoff = now - 30 * 60 * 1000;
|
|
151
|
+
const sid = escId(sessionId);
|
|
152
|
+
const toolRows = query(
|
|
153
|
+
`SELECT COUNT(*) as cnt FROM part WHERE session_id = '${sid}' ` +
|
|
154
|
+
`AND json_extract(data, '$.type') = 'tool' AND time_created >= ${cutoff}`
|
|
155
|
+
);
|
|
156
|
+
const tokRows = query(
|
|
157
|
+
`SELECT COALESCE(SUM(json_extract(data, '$.tokens.output')), 0) as tok_out ` +
|
|
158
|
+
`FROM part WHERE session_id = '${sid}' ` +
|
|
159
|
+
`AND json_extract(data, '$.type') = 'step-finish' AND time_created >= ${cutoff}`
|
|
160
|
+
);
|
|
161
|
+
return {
|
|
162
|
+
toolCalls30m: (toolRows[0] && toolRows[0].cnt) || 0,
|
|
163
|
+
tokensOut30m: (tokRows[0] && tokRows[0].tok_out) || 0,
|
|
164
|
+
windowMin: 30,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function getOpenCodeLive() {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
|
|
171
|
+
const heartbeatAgents = getLiveFromHeartbeats(now);
|
|
172
|
+
if (heartbeatAgents) {
|
|
173
|
+
return { agents: heartbeatAgents, now };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const staleCutoff = now - 30 * 60 * 1000;
|
|
177
|
+
const sessions = query(
|
|
178
|
+
`SELECT id, title, model, directory, agent, tokens_input, tokens_output, ` +
|
|
179
|
+
`tokens_cache_read, tokens_cache_write, tokens_reasoning, cost, ` +
|
|
180
|
+
`time_created, time_updated ` +
|
|
181
|
+
`FROM session WHERE time_archived IS NULL AND time_updated > ${staleCutoff} ORDER BY time_updated DESC`
|
|
182
|
+
);
|
|
183
|
+
if (!sessions.length) return { agents: [], now };
|
|
184
|
+
|
|
185
|
+
const agents = sessions.map((s) => {
|
|
186
|
+
let modelObj = {};
|
|
187
|
+
try { modelObj = JSON.parse(s.model || '{}'); } catch { /* ignore */ }
|
|
188
|
+
const modelId = modelObj.id || 'unknown';
|
|
189
|
+
const provider = modelObj.providerID || '';
|
|
190
|
+
const startedAt = s.time_created;
|
|
191
|
+
const uptimeMs = startedAt ? Math.max(0, now - startedAt) : null;
|
|
192
|
+
const activity = activityFor(s.id);
|
|
193
|
+
const cwd = s.directory || null;
|
|
194
|
+
|
|
195
|
+
const titleRows = query(
|
|
196
|
+
`SELECT substr(json_extract(data, '$.text'), 1, 80) as txt ` +
|
|
197
|
+
`FROM part WHERE session_id = '${escId(s.id)}' AND json_extract(data, '$.type') = 'text' ` +
|
|
198
|
+
`ORDER BY time_created DESC LIMIT 1`
|
|
199
|
+
);
|
|
200
|
+
const lastPrompt = titleRows.length ? titleRows[0].txt : null;
|
|
201
|
+
|
|
202
|
+
const metrics = metricsFor(s.id);
|
|
203
|
+
return {
|
|
204
|
+
pid: null,
|
|
205
|
+
sessionId: s.id,
|
|
206
|
+
source: 'opencode',
|
|
207
|
+
cwd,
|
|
208
|
+
project: cwd ? path.basename(cwd) : 'unknown',
|
|
209
|
+
kind: s.agent || 'interactive',
|
|
210
|
+
activity,
|
|
211
|
+
status: activity === 'working' ? 'busy' : 'idle',
|
|
212
|
+
state: null,
|
|
213
|
+
needsYou: false,
|
|
214
|
+
task: s.title || null,
|
|
215
|
+
chatName: s.title || null,
|
|
216
|
+
lastPrompt,
|
|
217
|
+
subagents: [],
|
|
218
|
+
startedAt,
|
|
219
|
+
uptimeMs,
|
|
220
|
+
model: modelId,
|
|
221
|
+
provider,
|
|
222
|
+
metrics,
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return { agents, now };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- usage -----------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
const CACHE_PATH = path.join(DATA_DIR, 'opencode-usage-cache.json');
|
|
232
|
+
|
|
233
|
+
let cache = { version: 2, lastDbMtime: 0, sessions: {} };
|
|
234
|
+
let loaded = false;
|
|
235
|
+
|
|
236
|
+
function loadCache() {
|
|
237
|
+
try {
|
|
238
|
+
const raw = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
|
239
|
+
if (raw && raw.version === cache.version) cache = raw;
|
|
240
|
+
} catch { /* no cache yet */ }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function saveCache() {
|
|
244
|
+
try {
|
|
245
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
246
|
+
fs.writeFileSync(CACHE_PATH, JSON.stringify(cache));
|
|
247
|
+
} catch { /* best effort */ }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function localDay(ts) {
|
|
251
|
+
const d = new Date(ts);
|
|
252
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
253
|
+
return d.toLocaleDateString('en-CA');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function emptyDay() {
|
|
257
|
+
return { out: 0, in: 0, cr: 0, cc: 0, tools: 0, msgs: 0, agents: 0 };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseSession(s) {
|
|
261
|
+
let modelObj = {};
|
|
262
|
+
try { modelObj = JSON.parse(s.model || '{}'); } catch { /* ignore */ }
|
|
263
|
+
const modelId = modelObj.id || 'unknown';
|
|
264
|
+
|
|
265
|
+
const parts = query(
|
|
266
|
+
`SELECT json_extract(data, '$.type') as ptype, ` +
|
|
267
|
+
`json_extract(data, '$.tokens.input') as tok_in, ` +
|
|
268
|
+
`json_extract(data, '$.tokens.output') as tok_out, ` +
|
|
269
|
+
`json_extract(data, '$.tokens.cache.read') as tok_cr, ` +
|
|
270
|
+
`json_extract(data, '$.tokens.cache.write') as tok_cc, ` +
|
|
271
|
+
`json_extract(data, '$.tokens.reasoning') as tok_reason, ` +
|
|
272
|
+
`json_extract(data, '$.tool') as tool_name, ` +
|
|
273
|
+
`json_extract(data, '$.text') as txt, ` +
|
|
274
|
+
`time_created ` +
|
|
275
|
+
`FROM part WHERE session_id = '${escId(s.id)}' ` +
|
|
276
|
+
`AND json_extract(data, '$.type') IN ('step-finish', 'tool') ` +
|
|
277
|
+
`ORDER BY time_created ASC`
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const agg = {
|
|
281
|
+
days: {},
|
|
282
|
+
models: {},
|
|
283
|
+
project: s.directory ? path.basename(s.directory) : 'unknown',
|
|
284
|
+
cwd: s.directory || null,
|
|
285
|
+
sessionId: s.id,
|
|
286
|
+
firstTs: s.time_created,
|
|
287
|
+
lastTs: s.time_updated,
|
|
288
|
+
out: 0,
|
|
289
|
+
in: 0,
|
|
290
|
+
cr: 0,
|
|
291
|
+
cc: 0,
|
|
292
|
+
tools: 0,
|
|
293
|
+
msgs: 0,
|
|
294
|
+
agents: 0,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
for (const p of parts) {
|
|
298
|
+
const ts = p.time_created;
|
|
299
|
+
if (!ts) continue;
|
|
300
|
+
|
|
301
|
+
if (p.ptype === 'step-finish') {
|
|
302
|
+
const out = p.tok_out || 0;
|
|
303
|
+
const inp = p.tok_in || 0;
|
|
304
|
+
const cr = p.tok_cr || 0;
|
|
305
|
+
const cc = p.tok_cc || 0;
|
|
306
|
+
|
|
307
|
+
agg.out += out;
|
|
308
|
+
agg.in += inp;
|
|
309
|
+
agg.cr += cr;
|
|
310
|
+
agg.cc += cc;
|
|
311
|
+
agg.msgs += 1;
|
|
312
|
+
|
|
313
|
+
const day = localDay(ts);
|
|
314
|
+
if (day) {
|
|
315
|
+
const d = (agg.days[day] ||= emptyDay());
|
|
316
|
+
d.out += out;
|
|
317
|
+
d.in += inp;
|
|
318
|
+
d.cr += cr;
|
|
319
|
+
d.cc += cc;
|
|
320
|
+
d.msgs += 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!agg.models[modelId]) agg.models[modelId] = { out: 0, in: 0, msgs: 0 };
|
|
324
|
+
agg.models[modelId].out += out;
|
|
325
|
+
agg.models[modelId].in += inp;
|
|
326
|
+
agg.models[modelId].msgs += 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (p.ptype === 'tool') {
|
|
330
|
+
agg.tools += 1;
|
|
331
|
+
const toolName = p.tool_name || '';
|
|
332
|
+
if (toolName === 'task' || toolName === 'agent') agg.agents += 1;
|
|
333
|
+
const day = localDay(ts);
|
|
334
|
+
if (day) {
|
|
335
|
+
const d = (agg.days[day] ||= emptyDay());
|
|
336
|
+
d.tools += 1;
|
|
337
|
+
if (toolName === 'task' || toolName === 'agent') d.agents += 1;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return agg;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function getOpenCodeUsage() {
|
|
346
|
+
if (!dbExists()) return null;
|
|
347
|
+
|
|
348
|
+
if (!loaded) {
|
|
349
|
+
loadCache();
|
|
350
|
+
loaded = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let dbMtime = 0;
|
|
354
|
+
try {
|
|
355
|
+
dbMtime = fs.statSync(DB_PATH).mtimeMs;
|
|
356
|
+
} catch { /* ignore */ }
|
|
357
|
+
|
|
358
|
+
if (dbMtime !== cache.lastDbMtime) {
|
|
359
|
+
const sessions = query(
|
|
360
|
+
`SELECT id, title, model, directory, tokens_input, tokens_output, ` +
|
|
361
|
+
`tokens_cache_read, tokens_cache_write, tokens_reasoning, cost, ` +
|
|
362
|
+
`time_created, time_updated ` +
|
|
363
|
+
`FROM session ORDER BY time_updated ASC`
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
for (const s of sessions) {
|
|
368
|
+
seen.add(s.id);
|
|
369
|
+
const prev = cache.sessions[s.id];
|
|
370
|
+
if (prev && prev.updatedAt === s.time_updated) continue;
|
|
371
|
+
cache.sessions[s.id] = {
|
|
372
|
+
updatedAt: s.time_updated,
|
|
373
|
+
agg: parseSession(s),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
for (const id of Object.keys(cache.sessions)) {
|
|
377
|
+
if (!seen.has(id)) delete cache.sessions[id];
|
|
378
|
+
}
|
|
379
|
+
cache.lastDbMtime = dbMtime;
|
|
380
|
+
saveCache();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return combine();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function combine() {
|
|
387
|
+
const lifetime = { out: 0, in: 0, cr: 0, cc: 0, tools: 0, msgs: 0, agents: 0, sessions: 0 };
|
|
388
|
+
const dayMap = {};
|
|
389
|
+
const byModel = {};
|
|
390
|
+
const byProject = {};
|
|
391
|
+
|
|
392
|
+
for (const { agg } of Object.values(cache.sessions)) {
|
|
393
|
+
if (!agg) continue;
|
|
394
|
+
lifetime.out += agg.out;
|
|
395
|
+
lifetime.in += agg.in;
|
|
396
|
+
lifetime.cr += agg.cr;
|
|
397
|
+
lifetime.cc += agg.cc;
|
|
398
|
+
lifetime.tools += agg.tools;
|
|
399
|
+
lifetime.msgs += agg.msgs;
|
|
400
|
+
lifetime.agents += agg.agents;
|
|
401
|
+
lifetime.sessions += 1;
|
|
402
|
+
|
|
403
|
+
for (const [day, d] of Object.entries(agg.days)) {
|
|
404
|
+
const t = (dayMap[day] ||= emptyDay());
|
|
405
|
+
t.out += d.out;
|
|
406
|
+
t.in += d.in;
|
|
407
|
+
t.cr += d.cr;
|
|
408
|
+
t.cc += d.cc;
|
|
409
|
+
t.tools += d.tools;
|
|
410
|
+
t.agents += d.agents;
|
|
411
|
+
t.msgs += d.msgs;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const [m, v] of Object.entries(agg.models)) {
|
|
415
|
+
const t = (byModel[m] ||= { out: 0, in: 0, msgs: 0 });
|
|
416
|
+
t.out += v.out;
|
|
417
|
+
t.in += v.in;
|
|
418
|
+
t.msgs += v.msgs;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const proj = agg.project || 'unknown';
|
|
422
|
+
const p = (byProject[proj] ||= { out: 0, msgs: 0, tools: 0, agents: 0, sessions: 0, lastTs: null });
|
|
423
|
+
p.out += agg.out;
|
|
424
|
+
p.msgs += agg.msgs;
|
|
425
|
+
p.tools += agg.tools;
|
|
426
|
+
p.agents += agg.agents;
|
|
427
|
+
p.sessions += 1;
|
|
428
|
+
if (agg.lastTs && (!p.lastTs || agg.lastTs > p.lastTs)) p.lastTs = agg.lastTs;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const daily = Object.entries(dayMap)
|
|
432
|
+
.map(([date, v]) => ({ date, ...v }))
|
|
433
|
+
.sort((a, b) => (a.date < b.date ? -1 : 1));
|
|
434
|
+
|
|
435
|
+
const today = new Date().toLocaleDateString('en-CA');
|
|
436
|
+
const todayStats = dayMap[today] ? { date: today, ...dayMap[today] } : { date: today, ...emptyDay() };
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
lifetime,
|
|
440
|
+
today: todayStats,
|
|
441
|
+
daily,
|
|
442
|
+
byModel,
|
|
443
|
+
byProject,
|
|
444
|
+
firstDay: daily.length ? daily[0].date : null,
|
|
445
|
+
activeDays: daily.length,
|
|
446
|
+
};
|
|
447
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// paths.js — where Agency persists its regenerable caches + roster.
|
|
2
|
+
// A published / `npx` install runs from a read-only or ephemeral package dir,
|
|
3
|
+
// so state lives in the user's home (~/.agency) by default. Override with
|
|
4
|
+
// AGENCY_DATA_DIR (e.g. `AGENCY_DATA_DIR=./data` to keep it repo-local in dev).
|
|
5
|
+
// ponytail: one rule, no dev/prod heuristic — set the env var if you want local.
|
|
6
|
+
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
export const DATA_DIR = process.env.AGENCY_DATA_DIR
|
|
11
|
+
? path.resolve(process.env.AGENCY_DATA_DIR)
|
|
12
|
+
: path.join(os.homedir(), '.agency');
|
package/lib/roster.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// roster.js — assign each session a stable identity: a name, a job title keyed
|
|
2
|
+
// to its model tier, an avatar palette, and a hire date. Identities are derived
|
|
3
|
+
// deterministically from the sessionId (so they're stable even before the file
|
|
4
|
+
// is written) and persisted to data/roster.json to record tenure.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { DATA_DIR } from './paths.js';
|
|
9
|
+
|
|
10
|
+
const ROSTER_PATH = path.join(DATA_DIR, 'roster.json');
|
|
11
|
+
|
|
12
|
+
const FIRST = [
|
|
13
|
+
'Ada', 'Ravi', 'Mona', 'Kenji', 'Lena', 'Otis', 'Priya', 'Theo', 'Yara', 'Cole',
|
|
14
|
+
'Nina', 'Dax', 'Ines', 'Bram', 'Suki', 'Ezra', 'Vera', 'Knox', 'Tara', 'Joon',
|
|
15
|
+
'Remy', 'Fynn', 'Wren', 'Iris', 'Beau', 'Sol', 'Hana', 'Milo', 'Faye', 'Ace',
|
|
16
|
+
];
|
|
17
|
+
const LAST = [
|
|
18
|
+
'Vance', 'Okoro', 'Sato', 'Reyes', 'Novak', 'Kapoor', 'Mertz', 'Holt', 'Ferro',
|
|
19
|
+
'Lund', 'Cruz', 'Bose', 'Pike', 'Calder', 'Ono', 'Frost', 'Wells', 'Drake',
|
|
20
|
+
'Marsh', 'Vega', 'Sloan', 'Quill', 'Rhodes', 'Tran', 'Voss', 'Hale', 'Ng', 'Booth',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Job titles by model tier — the office hierarchy.
|
|
24
|
+
const TITLES = {
|
|
25
|
+
opus: ['Principal Engineer', 'Staff Architect', 'Lead Engineer', 'Distinguished Eng'],
|
|
26
|
+
sonnet: ['Senior Engineer', 'Software Engineer II', 'Product Engineer', 'Full-Stack Dev'],
|
|
27
|
+
haiku: ['Junior Engineer', 'Associate Dev', 'Intern', 'Apprentice'],
|
|
28
|
+
unknown: ['Contractor', 'Specialist', 'Generalist'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Avatar shirt/hair palettes — picked deterministically per agent.
|
|
32
|
+
const SHIRTS = [
|
|
33
|
+
'#e05d5d', '#5d9ce0', '#5dc98a', '#e0b05d', '#a87de0', '#e07db0',
|
|
34
|
+
'#5dc9c9', '#9bd45d', '#e0855d', '#7d8fe0',
|
|
35
|
+
];
|
|
36
|
+
const HAIRS = ['#2b2233', '#5a3a26', '#3a2e22', '#1f2a33', '#4a2233', '#332b1f', '#26333a'];
|
|
37
|
+
const SKINS = ['#f0c8a0', '#e8b890', '#d8a070', '#c08858', '#a87048', '#8a5a3a'];
|
|
38
|
+
|
|
39
|
+
function hash(str) {
|
|
40
|
+
let h = 2166136261 >>> 0;
|
|
41
|
+
for (let i = 0; i < str.length; i++) {
|
|
42
|
+
h ^= str.charCodeAt(i);
|
|
43
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
44
|
+
}
|
|
45
|
+
return h >>> 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tierFor(model) {
|
|
49
|
+
if (!model) return 'unknown';
|
|
50
|
+
const m = model.toLowerCase();
|
|
51
|
+
if (m.includes('opus')) return 'opus';
|
|
52
|
+
if (m.includes('sonnet')) return 'sonnet';
|
|
53
|
+
if (m.includes('haiku')) return 'haiku';
|
|
54
|
+
if (m.includes('fable')) return 'opus';
|
|
55
|
+
return 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let roster = null;
|
|
59
|
+
|
|
60
|
+
// Deterministically pick a "First Last" name from the hash, probing the name
|
|
61
|
+
// space until we find one no other chat already owns — so each chat is a
|
|
62
|
+
// distinct, persistent person even when two sessionIds hash near each other.
|
|
63
|
+
function uniqueName(h, id) {
|
|
64
|
+
const taken = new Set(
|
|
65
|
+
Object.entries(roster || {})
|
|
66
|
+
.filter(([k]) => k !== id)
|
|
67
|
+
.map(([, v]) => v.name)
|
|
68
|
+
);
|
|
69
|
+
const total = FIRST.length * LAST.length;
|
|
70
|
+
let fi = h % FIRST.length;
|
|
71
|
+
let li = (h >>> 8) % LAST.length;
|
|
72
|
+
for (let n = 0; n < total; n++) {
|
|
73
|
+
const name = `${FIRST[fi]} ${LAST[li]}`;
|
|
74
|
+
if (!taken.has(name)) return name;
|
|
75
|
+
li = (li + 1) % LAST.length;
|
|
76
|
+
if (li === (h >>> 8) % LAST.length) fi = (fi + 1) % FIRST.length;
|
|
77
|
+
}
|
|
78
|
+
// name space exhausted (>784 chats) — disambiguate with a short suffix
|
|
79
|
+
return `${FIRST[h % FIRST.length]} ${LAST[(h >>> 8) % LAST.length]} ${id.slice(0, 4)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function load() {
|
|
83
|
+
if (roster) return;
|
|
84
|
+
try {
|
|
85
|
+
roster = JSON.parse(fs.readFileSync(ROSTER_PATH, 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
roster = {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function save() {
|
|
92
|
+
try {
|
|
93
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
94
|
+
fs.writeFileSync(ROSTER_PATH, JSON.stringify(roster, null, 2));
|
|
95
|
+
} catch {
|
|
96
|
+
/* best effort */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Tier ranking so a momentarily-null model never demotes an agent's title.
|
|
101
|
+
const TIER_RANK = { unknown: 0, haiku: 1, sonnet: 2, opus: 3 };
|
|
102
|
+
|
|
103
|
+
// Get (or mint) the identity for a session. `model` refines the job title and
|
|
104
|
+
// is allowed to change the tier over the session's life.
|
|
105
|
+
export function identityFor(sessionId, model, firstSeenAt) {
|
|
106
|
+
load();
|
|
107
|
+
const id = sessionId || 'anon';
|
|
108
|
+
const h = hash(id);
|
|
109
|
+
const tier = tierFor(model);
|
|
110
|
+
|
|
111
|
+
let rec = roster[id];
|
|
112
|
+
let dirty = false;
|
|
113
|
+
if (!rec) {
|
|
114
|
+
rec = {
|
|
115
|
+
name: uniqueName(h, id),
|
|
116
|
+
skin: SKINS[(h >>> 3) % SKINS.length],
|
|
117
|
+
hair: HAIRS[(h >>> 11) % HAIRS.length],
|
|
118
|
+
shirt: SHIRTS[(h >>> 5) % SHIRTS.length],
|
|
119
|
+
hiredAt: firstSeenAt || Date.now(),
|
|
120
|
+
tier: 'unknown',
|
|
121
|
+
};
|
|
122
|
+
roster[id] = rec;
|
|
123
|
+
dirty = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Keep the best tier ever seen so a transient null model doesn't demote them.
|
|
127
|
+
if ((TIER_RANK[tier] || 0) > (TIER_RANK[rec.tier] || 0)) {
|
|
128
|
+
rec.tier = tier;
|
|
129
|
+
dirty = true;
|
|
130
|
+
}
|
|
131
|
+
if (dirty) save();
|
|
132
|
+
|
|
133
|
+
const effTier = rec.tier || 'unknown';
|
|
134
|
+
const titles = TITLES[effTier] || TITLES.unknown;
|
|
135
|
+
const title = titles[(h >>> 13) % titles.length];
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
name: rec.customName || rec.name,
|
|
139
|
+
title,
|
|
140
|
+
tier: effTier,
|
|
141
|
+
skin: rec.skin,
|
|
142
|
+
hair: rec.hair,
|
|
143
|
+
shirt: rec.shirt,
|
|
144
|
+
hiredAt: rec.hiredAt,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sanitize a user-supplied custom name: strip control chars, trim, cap length.
|
|
149
|
+
// Returns a non-empty string, or null when the input clears the override.
|
|
150
|
+
// CTRL_RE is built from a plain-ASCII pattern string (no raw control bytes in
|
|
151
|
+
// source) and matches C0 controls (U+0000..U+001F), DEL (U+007F), and C1
|
|
152
|
+
// controls (U+0080..U+009F) so a pasted name cannot smuggle in newlines.
|
|
153
|
+
const NAME_CAP = 60;
|
|
154
|
+
const CTRL_RE = new RegExp('[\\u0000-\\u001F\\u007F-\\u009F]', 'g');
|
|
155
|
+
function cleanName(name) {
|
|
156
|
+
if (name == null) return null;
|
|
157
|
+
const s = String(name).replace(CTRL_RE, '').trim().slice(0, NAME_CAP);
|
|
158
|
+
return s ? s : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Ensure a roster record exists for `sessionId`, minting one via identityFor if
|
|
162
|
+
// missing. Returns the live record (so callers can mutate + save).
|
|
163
|
+
function ensureRec(sessionId) {
|
|
164
|
+
load();
|
|
165
|
+
const id = sessionId || 'anon';
|
|
166
|
+
if (!roster[id]) identityFor(id, null); // mints + persists the base record
|
|
167
|
+
return roster[id];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Apply a user override to a session. `patch` may carry `name` and/or `hidden`;
|
|
171
|
+
// only the keys present are touched (so a hide toggle won't wipe a rename and
|
|
172
|
+
// vice-versa). `name`: a non-empty trimmed string sets the custom name; '' or
|
|
173
|
+
// null clears it. `hidden`: a boolean. Returns the effective { name, hidden }.
|
|
174
|
+
// Fail-soft — never throws.
|
|
175
|
+
export function setOverride(sessionId, patch = {}) {
|
|
176
|
+
try {
|
|
177
|
+
const rec = ensureRec(sessionId);
|
|
178
|
+
if (!rec) return { name: null, hidden: false };
|
|
179
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'name')) {
|
|
180
|
+
const n = cleanName(patch.name);
|
|
181
|
+
if (n) rec.customName = n;
|
|
182
|
+
else delete rec.customName;
|
|
183
|
+
}
|
|
184
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'hidden')) {
|
|
185
|
+
rec.hidden = !!patch.hidden;
|
|
186
|
+
}
|
|
187
|
+
save();
|
|
188
|
+
return { name: rec.customName || null, hidden: !!rec.hidden };
|
|
189
|
+
} catch {
|
|
190
|
+
return { name: null, hidden: false };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Cheap read of a session's override, defaulting when unset. Fail-soft.
|
|
195
|
+
export function overrideFor(sessionId) {
|
|
196
|
+
try {
|
|
197
|
+
load();
|
|
198
|
+
const rec = roster[sessionId || 'anon'];
|
|
199
|
+
if (!rec) return { name: null, hidden: false };
|
|
200
|
+
return { name: rec.customName || null, hidden: !!rec.hidden };
|
|
201
|
+
} catch {
|
|
202
|
+
return { name: null, hidden: false };
|
|
203
|
+
}
|
|
204
|
+
}
|