hive-am 1.4.0 → 1.5.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/CHANGELOG.md +28 -1
- package/README.md +1 -5
- package/bin/hive-am.mjs +242 -31
- package/bin/run.mjs +135 -0
- package/package.json +1 -1
- package/server/dist/connections/manager.js +15 -3
- package/server/dist/connections/router.js +2 -2
- package/server/dist/connections/secrets.js +8 -0
- package/server/dist/db/migrations.js +68 -0
- package/server/dist/db.js +11 -10
- package/server/dist/efforts.js +105 -0
- package/server/dist/http/routes/agents.js +4 -3
- package/server/dist/http/routes/meta.js +12 -0
- package/server/dist/http/routes/notebook.js +10 -2
- package/server/dist/http/routes/resources.js +3 -0
- package/server/dist/http/routes/types.js +18 -1
- package/server/dist/http/validate.js +5 -2
- package/server/dist/index.js +4 -1
- package/server/dist/instructions.js +10 -2
- package/server/dist/objects/agent-db.js +258 -0
- package/server/dist/objects/database.js +496 -34
- package/server/dist/objects/index.js +16 -8
- package/server/dist/providers/kiro.js +7 -1
- package/server/dist/providers/opencode-server.js +11 -1
- package/server/dist/providers/opencode.js +4 -3
- package/server/dist/providers/spawn.js +38 -4
- package/server/dist/retention.js +50 -0
- package/server/dist/runtime.js +52 -21
- package/server/dist/services/agents.js +53 -1
- package/server/dist/services/notebook.js +7 -3
- package/server/dist/skills/defaults.js +35 -1
- package/server/dist/skills/notebook.js +57 -13
- package/server/mcp/dispatch.mjs +68 -0
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +60 -60
- package/web/.next/app-path-routes-manifest.json +7 -7
- package/web/.next/build-manifest.json +2 -2
- package/web/.next/prerender-manifest.json +58 -58
- package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/server/app/_not-found.rsc +3 -3
- package/web/.next/server/app/agents/[id]/page.js +4 -4
- package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents/page.js +2 -2
- package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents.html +1 -1
- package/web/.next/server/app/agents.rsc +4 -4
- package/web/.next/server/app/chat/page.js +1 -1
- package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/chat.html +1 -1
- package/web/.next/server/app/chat.rsc +4 -4
- package/web/.next/server/app/connections/page.js +1 -1
- package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/connections.html +1 -1
- package/web/.next/server/app/connections.rsc +4 -4
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +4 -4
- package/web/.next/server/app/page.js +1 -1
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page.js +1 -1
- package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations.html +1 -1
- package/web/.next/server/app/relations.rsc +4 -4
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources.html +1 -1
- package/web/.next/server/app/resources.rsc +4 -4
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/schedules.html +1 -1
- package/web/.next/server/app/schedules.rsc +4 -4
- package/web/.next/server/app/sessions/page.js +1 -1
- package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/sessions.html +1 -1
- package/web/.next/server/app/sessions.rsc +4 -4
- package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/skills.html +1 -1
- package/web/.next/server/app/skills.rsc +4 -4
- package/web/.next/server/app/types/page.js +1 -1
- package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/types.html +1 -1
- package/web/.next/server/app/types.rsc +4 -4
- package/web/.next/server/app-paths-manifest.json +7 -7
- package/web/.next/server/chunks/143.js +1 -1
- package/web/.next/server/chunks/213.js +1 -1
- package/web/.next/server/chunks/274.js +1 -1
- package/web/.next/server/chunks/368.js +1 -1
- package/web/.next/server/chunks/403.js +1 -1
- package/web/.next/server/chunks/612.js +1 -1
- package/web/.next/server/pages/404.html +1 -1
- package/web/.next/server/pages/500.html +1 -1
- package/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/static/chunks/{316-26da9cc233ba7557.js → 107-77dd87cc2ca4f941.js} +1 -1
- package/web/.next/static/chunks/281-30e32d932b8b5a75.js +1 -0
- package/web/.next/static/chunks/517-b3b1c9044d2ef23c.js +1 -0
- package/web/.next/static/chunks/587-5b511f5f93910180.js +1 -0
- package/web/.next/static/chunks/606-14fb5aaa4d0ff22d.js +1 -0
- package/web/.next/static/chunks/656-f3cc78328603241a.js +1 -0
- package/web/.next/static/chunks/{705-f81face97b1bb1af.js → 705-ce86175dea00e6a9.js} +1 -1
- package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-638e12ba3cb813c3.js +1 -0
- package/web/.next/static/chunks/app/agents/page-9da499db6d22731f.js +1 -0
- package/web/.next/static/chunks/app/chat/{page-5261b12b7d50f5d1.js → page-383bc24e72dab7fa.js} +1 -1
- package/web/.next/static/chunks/app/connections/{page-eb11cfb1ffeab3ca.js → page-06bd2d7d9495126a.js} +1 -1
- package/web/.next/static/chunks/app/page-0062ebc30322b1ae.js +1 -0
- package/web/.next/static/chunks/app/relations/page-02710fe1f8a670a9.js +1 -0
- package/web/.next/static/chunks/app/sessions/page-85638705fdc18174.js +1 -0
- package/web/.next/static/chunks/app/types/page-8e25f503bdcd72ad.js +1 -0
- package/web/.next/static/css/dfcaa19a8d46e60d.css +1 -0
- package/web/.next/trace +30 -30
- package/web/.next/static/chunks/281-113274e705940a55.js +0 -1
- package/web/.next/static/chunks/517-373a80c8ba432bdc.js +0 -1
- package/web/.next/static/chunks/562-7c2e33d838e869bf.js +0 -1
- package/web/.next/static/chunks/587-8e4d2be01ba7d48f.js +0 -1
- package/web/.next/static/chunks/606-c9a19918b02a98d0.js +0 -1
- package/web/.next/static/chunks/656-f537149fe28f79aa.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-6ed6edb3e74b0ebe.js +0 -1
- package/web/.next/static/chunks/app/agents/page-9bb8cba69776c719.js +0 -1
- package/web/.next/static/chunks/app/page-99e0351e6ccaa384.js +0 -1
- package/web/.next/static/chunks/app/relations/page-a44fd515a3b0525e.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-a7534ebd90e982d6.js +0 -1
- package/web/.next/static/chunks/app/types/page-ebd37e220f77c1f3.js +0 -1
- package/web/.next/static/css/582723fcea1f9887.css +0 -1
- /package/web/.next/static/{YT6HM6qmegMJaBMX5QvuP → UDA414PFPu9ZROltNmjbX}/_buildManifest.js +0 -0
- /package/web/.next/static/{YT6HM6qmegMJaBMX5QvuP → UDA414PFPu9ZROltNmjbX}/_ssgManifest.js +0 -0
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { DATA_DIR } from '../db.js';
|
|
5
5
|
import { createHash, randomBytes } from 'node:crypto';
|
|
6
6
|
import { learnWindows } from '../context.js';
|
|
7
|
+
import { learnEfforts } from '../efforts.js';
|
|
7
8
|
/**
|
|
8
9
|
* One long-lived `opencode serve` per agent. A private server per turn (`opencode run --standalone`) boots from zero every time and its
|
|
9
10
|
* hive MCP server connects only after the model has already taken its first step, so the first `tools.hive.*` call always fails
|
|
@@ -63,7 +64,14 @@ let windowsAt = 0;
|
|
|
63
64
|
* Reads the context window of every model from OpenCode's catalog, using this turn's server, any running one or a short-lived one
|
|
64
65
|
* (about 4 s and 150 MB, once). At most once an hour, and a failure waits ten minutes: OpenCode 1.x has no catalog endpoint at all.
|
|
65
66
|
*/
|
|
66
|
-
export
|
|
67
|
+
export function learnOpencodeWindows(cwd, access) {
|
|
68
|
+
if (learning)
|
|
69
|
+
return learning; // a read is under way (a turn and the agent form can ask at the same time): both wait for it
|
|
70
|
+
learning = readCatalog(cwd, access).finally(() => { learning = null; });
|
|
71
|
+
return learning;
|
|
72
|
+
}
|
|
73
|
+
let learning = null;
|
|
74
|
+
async function readCatalog(cwd, access) {
|
|
67
75
|
if (Date.now() - windowsAt < 60 * 60_000)
|
|
68
76
|
return;
|
|
69
77
|
windowsAt = Date.now();
|
|
@@ -107,6 +115,8 @@ export async function learnOpencodeWindows(cwd, access) {
|
|
|
107
115
|
if (!list.length)
|
|
108
116
|
throw new Error('the model catalog stayed empty');
|
|
109
117
|
learnWindows('opencode', Object.fromEntries(list.map((m) => [`${m.providerID}/${m.id}`, Number(m.limit?.context)])));
|
|
118
|
+
// Every model carries the variants it takes (`id` goes after `#`, `settings.reasoningEffort` is the level it stands for); a model with none takes no effort.
|
|
119
|
+
learnEfforts('opencode', Object.fromEntries(list.map((m) => [`${m.providerID}/${m.id}`, (Array.isArray(m.variants) ? m.variants : []).map((v) => ({ id: String(v?.id), level: typeof v?.settings?.reasoningEffort === 'string' ? v.settings.reasoningEffort : undefined }))])));
|
|
110
120
|
}
|
|
111
121
|
catch {
|
|
112
122
|
windowsAt = Date.now() - 50 * 60_000; /* try again in ten minutes */
|
|
@@ -2,7 +2,7 @@ import { DATA_DIR } from '../db.js';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
const INBOX_ROOT = join(DATA_DIR, 'inbox');
|
|
5
|
-
import { spawnJson } from './spawn.js';
|
|
5
|
+
import { spawnJson, StalledError } from './spawn.js';
|
|
6
6
|
import { dispatchMcpConfigOpencode } from '../mcp-config.js';
|
|
7
7
|
import { opencodeSessionDir } from '../history/opencode.js';
|
|
8
8
|
import { withInstructions } from './preamble.js';
|
|
@@ -93,7 +93,8 @@ export async function* runOpencode(o) {
|
|
|
93
93
|
}
|
|
94
94
|
else if (has('--standalone'))
|
|
95
95
|
args.splice(1, 0, '--standalone'); // 1.x always runs its own server
|
|
96
|
-
|
|
96
|
+
// After `--` so a message that begins with a dash is the prompt and never an option; nothing may be pushed after it.
|
|
97
|
+
args.push('--', prompt);
|
|
97
98
|
// Stopping a turn must really stop the model: a client that is killed would leave the server working on, so the server goes down with it.
|
|
98
99
|
if (access)
|
|
99
100
|
o.signal.addEventListener('abort', () => stopOpencodeServer(agent.id), { once: true });
|
|
@@ -144,7 +145,7 @@ export async function* runOpencode(o) {
|
|
|
144
145
|
throw new Error(failure);
|
|
145
146
|
}
|
|
146
147
|
catch (e) {
|
|
147
|
-
if (failure && !o.signal.aborted)
|
|
148
|
+
if (failure && !o.signal.aborted && !(e instanceof StalledError))
|
|
148
149
|
throw new Error(failure); // OpenCode's own reason says more than "exited with code 1"
|
|
149
150
|
throw e;
|
|
150
151
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
|
+
/** A CLI that prints nothing for this long has hung (a long turn is fine as long as it keeps emitting). */
|
|
4
|
+
const STALL_MS = () => Number(process.env.HIVE_AM_TURN_STALL_MS) || 10 * 60_000;
|
|
5
|
+
const quietFor = (ms) => ms >= 60_000 ? `${Math.round(ms / 6000) / 10} minutes` : `${Math.round(ms / 100) / 10} seconds`;
|
|
3
6
|
const KILL_GRACE_MS = Number(process.env.HIVE_AM_KILL_GRACE_MS) || 5000;
|
|
7
|
+
/** The CLI hung and was cut off; adapters must not replace this reason with an error the dying CLI printed. */
|
|
8
|
+
export class StalledError extends Error {
|
|
9
|
+
}
|
|
4
10
|
/** Spawns a CLI and yields its stdout lines. Throws with stderr tail if it exits non-zero and nothing aborted it. */
|
|
5
11
|
export async function* spawnLines(o) {
|
|
6
12
|
const child = spawn(o.cmd, o.args, {
|
|
@@ -21,6 +27,15 @@ export async function* spawnLines(o) {
|
|
|
21
27
|
hardKill.unref?.();
|
|
22
28
|
};
|
|
23
29
|
o.signal.addEventListener('abort', kill, { once: true });
|
|
30
|
+
// Silence, not length: the timer restarts on every stdout line and, when it fires, ends the CLI like a Stop would, but the turn fails with its own text.
|
|
31
|
+
let stalled = false;
|
|
32
|
+
let stallTimer;
|
|
33
|
+
const armStall = () => {
|
|
34
|
+
clearTimeout(stallTimer);
|
|
35
|
+
stallTimer = setTimeout(() => { stalled = true; kill(); }, STALL_MS());
|
|
36
|
+
stallTimer.unref?.();
|
|
37
|
+
};
|
|
38
|
+
armStall();
|
|
24
39
|
let stderr = '';
|
|
25
40
|
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
|
|
26
41
|
const exit = new Promise((res) => {
|
|
@@ -28,7 +43,7 @@ export async function* spawnLines(o) {
|
|
|
28
43
|
// 'exit' (not 'close'): a daemon the CLI forks (e.g. opencode's background service) can keep
|
|
29
44
|
// inheriting our stdout pipe forever, so 'close' would never fire. Drain briefly, then cut the pipe.
|
|
30
45
|
child.on('exit', (c) => { if (hardKill)
|
|
31
|
-
clearTimeout(hardKill); setTimeout(() => child.stdout.destroy(), 250); res(c); });
|
|
46
|
+
clearTimeout(hardKill); setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, 250); res(c); });
|
|
32
47
|
});
|
|
33
48
|
child.stdin.on('error', () => { });
|
|
34
49
|
if (o.stdin !== undefined)
|
|
@@ -36,15 +51,23 @@ export async function* spawnLines(o) {
|
|
|
36
51
|
else
|
|
37
52
|
child.stdin.end();
|
|
38
53
|
try {
|
|
39
|
-
|
|
54
|
+
// readline only ends on the stream's 'end'; a stream we destroy (the exit handler above) emits 'close' alone, so close it ourselves.
|
|
55
|
+
const rl = createInterface({ input: child.stdout });
|
|
56
|
+
child.stdout.on('close', () => rl.close());
|
|
57
|
+
for await (const line of rl) {
|
|
58
|
+
armStall();
|
|
40
59
|
if (line.trim())
|
|
41
60
|
yield line;
|
|
61
|
+
}
|
|
42
62
|
}
|
|
43
63
|
finally {
|
|
64
|
+
clearTimeout(stallTimer);
|
|
44
65
|
o.signal.removeEventListener('abort', kill);
|
|
45
66
|
kill();
|
|
46
67
|
}
|
|
47
68
|
const code = await exit;
|
|
69
|
+
if (stalled && !o.signal.aborted)
|
|
70
|
+
throw new StalledError(`${o.cmd} stopped answering (nothing on stdout for ${quietFor(STALL_MS())})`);
|
|
48
71
|
if (code !== 0 && !o.signal.aborted)
|
|
49
72
|
throw new Error(`${o.cmd} exited with code ${code}${stderr ? `: ${stderr.trim().slice(-600)}` : ''}`);
|
|
50
73
|
}
|
|
@@ -56,13 +79,24 @@ export function safeJson(line) {
|
|
|
56
79
|
return null;
|
|
57
80
|
}
|
|
58
81
|
}
|
|
59
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* The JSON objects a CLI prints, one per line; lines that are not JSON (banners, warnings) are skipped.
|
|
84
|
+
* A CLI that exits cleanly having printed only prose (its usage text, say) has not answered: that is an error carrying its first line.
|
|
85
|
+
*/
|
|
60
86
|
export async function* spawnJson(o) {
|
|
87
|
+
let events = 0;
|
|
88
|
+
let firstNoise = '';
|
|
61
89
|
for await (const line of spawnLines(o)) {
|
|
62
90
|
const ev = safeJson(line);
|
|
63
|
-
if (ev)
|
|
91
|
+
if (ev) {
|
|
92
|
+
events++;
|
|
64
93
|
yield ev;
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
firstNoise ||= line.trim();
|
|
65
97
|
}
|
|
98
|
+
if (!events && firstNoise && !o.signal.aborted)
|
|
99
|
+
throw new Error(`${o.cmd} produced no events, it printed: ${firstNoise.slice(0, 300)}`);
|
|
66
100
|
}
|
|
67
101
|
/** The text of a tool result, which CLIs send as a string, a list of blocks or any other JSON. */
|
|
68
102
|
export function flattenText(c) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { db } from './db.js';
|
|
2
|
+
import { historySig } from './history/index.js';
|
|
3
|
+
import { RETENTION_DAYS } from './connections/files.js';
|
|
4
|
+
/**
|
|
5
|
+
* What hive-am keeps of its own record, and for how long. Three tables only ever grew: the messages of channel conversations, the log of
|
|
6
|
+
* delegations and the list of known conversations. The conversations themselves live in each CLI's own store and are never touched here:
|
|
7
|
+
* only hive-am's copy (the row that lists them) goes. A row is removed only when it is past its cap AND old enough, so nothing recent is lost.
|
|
8
|
+
*/
|
|
9
|
+
export const KEEP_THREAD_MESSAGES = 500; // the newest of each thread...
|
|
10
|
+
export const MESSAGE_DAYS = 30; // ...and anything younger than this, whatever the count
|
|
11
|
+
export const KEEP_DISPATCHES = 200; // the screen shows 50
|
|
12
|
+
export const KEEP_DELEGATIONS = 50; // delegated conversations per agent (a read-only log), kept while younger than SESSION_FORGET_DAYS
|
|
13
|
+
export const SESSION_FORGET_DAYS = 90; // a conversation not used for this long, and that the CLI no longer has, is forgotten
|
|
14
|
+
// Received files live RETENTION_DAYS (connections/files.ts, 14): the record of a file on a message goes with the file.
|
|
15
|
+
const DAY = 86_400_000;
|
|
16
|
+
export function retentionPass({ now = Date.now() } = {}) {
|
|
17
|
+
const forgetBefore = now - SESSION_FORGET_DAYS * DAY;
|
|
18
|
+
// A direct conversation goes only when it is old AND its transcript is gone. `historySig` is null for a failed read too: the age is what makes that harmless.
|
|
19
|
+
const gone = [];
|
|
20
|
+
const old = db.prepare("SELECT s.agent_id, s.session_id, s.provider, s.cwd FROM agent_sessions s WHERE s.kind='direct' AND s.last_seen < ? AND s.session_id IS NOT (SELECT session_id FROM agents WHERE id=s.agent_id)")
|
|
21
|
+
.all(forgetBefore);
|
|
22
|
+
for (const s of old)
|
|
23
|
+
if (historySig({ provider: s.provider, cwd: s.cwd }, s.session_id) === null)
|
|
24
|
+
gone.push(s);
|
|
25
|
+
return db.transaction(() => {
|
|
26
|
+
const out = { messages: 0, dispatches: 0, sessions: 0, files: 0 };
|
|
27
|
+
// Messages: beyond the newest KEEP_THREAD_MESSAGES of their thread and older than the window.
|
|
28
|
+
const cut = db.prepare('SELECT id FROM thread_messages WHERE thread_id=? ORDER BY id DESC LIMIT 1 OFFSET ?');
|
|
29
|
+
const drop = db.prepare('DELETE FROM thread_messages WHERE thread_id=? AND id<? AND ts<?');
|
|
30
|
+
for (const { thread_id } of db.prepare('SELECT DISTINCT thread_id FROM thread_messages').all()) {
|
|
31
|
+
const edge = cut.get(thread_id, KEEP_THREAD_MESSAGES - 1);
|
|
32
|
+
if (edge)
|
|
33
|
+
out.messages += drop.run(thread_id, edge.id, now - MESSAGE_DAYS * DAY).changes;
|
|
34
|
+
}
|
|
35
|
+
// The record of a received file goes with the file.
|
|
36
|
+
out.files = db.prepare('UPDATE thread_messages SET files=NULL WHERE files IS NOT NULL AND ts<?').run(now - RETENTION_DAYS * DAY).changes;
|
|
37
|
+
// Delegations: all but the newest KEEP_DISPATCHES (one that is still running is never one of them).
|
|
38
|
+
out.dispatches = db.prepare("DELETE FROM dispatches WHERE status<>'running' AND id IN (SELECT id FROM dispatches ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)").run(KEEP_DISPATCHES).changes;
|
|
39
|
+
// Delegated conversations: past the newest KEEP_DELEGATIONS of their agent and not used for SESSION_FORGET_DAYS.
|
|
40
|
+
out.sessions += db.prepare(`DELETE FROM agent_sessions WHERE kind='delegation' AND last_seen<? AND (agent_id, session_id) IN (
|
|
41
|
+
SELECT agent_id, session_id FROM (SELECT agent_id, session_id, ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY last_seen DESC, session_id) AS n FROM agent_sessions WHERE kind='delegation') WHERE n>?)`)
|
|
42
|
+
.run(forgetBefore, KEEP_DELEGATIONS).changes;
|
|
43
|
+
// Direct conversations the CLI no longer has (the current one of an agent was left out above, and is checked again here).
|
|
44
|
+
const del = db.prepare("DELETE FROM agent_sessions WHERE agent_id=? AND session_id=? AND kind='direct' AND session_id IS NOT (SELECT session_id FROM agents WHERE id=?)");
|
|
45
|
+
for (const g of gone)
|
|
46
|
+
out.sessions += del.run(g.agent_id, g.session_id, g.agent_id).changes;
|
|
47
|
+
return out;
|
|
48
|
+
})();
|
|
49
|
+
}
|
|
50
|
+
export const retentionTouched = (r) => r.messages + r.dispatches + r.sessions + r.files > 0;
|
package/server/dist/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import { agents, dispatches, resolved } from './db.js';
|
|
|
4
4
|
import { composeInstructions, mcpCaps, notebookBlock } from './instructions.js';
|
|
5
5
|
import { runners } from './providers/index.js';
|
|
6
6
|
import { notebooks } from './skills/notebook.js';
|
|
7
|
+
import { resolveEffort } from './efforts.js';
|
|
7
8
|
export const bus = new EventEmitter();
|
|
8
9
|
bus.setMaxListeners(0);
|
|
9
10
|
const emit = (m) => bus.emit('msg', m);
|
|
@@ -22,6 +23,19 @@ export const liveOrigin = (id) => states.get(id)?.live?.origin;
|
|
|
22
23
|
export const liveSource = (id) => states.get(id)?.live?.source;
|
|
23
24
|
/** Whether the turn running now is the agent's own conversation (not work delegated to it by an orchestrator). */
|
|
24
25
|
export const liveIsDirect = (id) => states.get(id)?.live?.source === 'user';
|
|
26
|
+
/**
|
|
27
|
+
* The reasoning effort a turn asks for: the one a connection sets for its own turns, else the agent's. One that does not apply to the agent's
|
|
28
|
+
* model (it changed, or the model never had that level) falls back to the CLI's own default, with a line in the log, instead of failing the turn.
|
|
29
|
+
*/
|
|
30
|
+
function effortOf(agent, fromOrigin) {
|
|
31
|
+
const asked = fromOrigin || agent.effort;
|
|
32
|
+
if (!asked)
|
|
33
|
+
return undefined;
|
|
34
|
+
const use = resolveEffort(agent.provider, agent.model, asked);
|
|
35
|
+
if (!use)
|
|
36
|
+
console.warn(`[effort] ${agent.name}: "${asked}" does not apply to ${agent.model || 'the default model'} (${agent.provider}): the turn runs at the CLI's own default`);
|
|
37
|
+
return use || undefined;
|
|
38
|
+
}
|
|
25
39
|
/** Queue a prompt for an agent. Turns for one agent run strictly one at a time. */
|
|
26
40
|
export function sendTurn(agentId, prompt, source = 'user', from, dispatchId, origin) {
|
|
27
41
|
const s = st(agentId);
|
|
@@ -34,36 +48,46 @@ export function sendTurn(agentId, prompt, source = 'user', from, dispatchId, ori
|
|
|
34
48
|
async function execute(agentId, prompt, source, from, dispatchId, origin) {
|
|
35
49
|
const s = st(agentId);
|
|
36
50
|
s.queued--;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
let raw;
|
|
52
|
+
let agent;
|
|
53
|
+
let delegated = false;
|
|
54
|
+
try {
|
|
55
|
+
raw = agents.get(agentId);
|
|
56
|
+
if (!raw)
|
|
57
|
+
return { ok: false, text: '', error: 'Agent no longer exists' };
|
|
58
|
+
agent = resolved(raw);
|
|
59
|
+
if (!agent.cwd)
|
|
60
|
+
return { ok: false, text: '', error: 'This agent has no working folder. Set one on the agent or on its colony.' };
|
|
61
|
+
delegated = source === 'dispatch';
|
|
62
|
+
if (delegated) {
|
|
63
|
+
// Every delegation gets its own native session, so the agent's direct conversation never fills up with delegated work.
|
|
64
|
+
agent.session_id = null;
|
|
65
|
+
}
|
|
66
|
+
else if (raw.session_id && raw.session_cwd && raw.session_cwd !== agent.cwd) {
|
|
67
|
+
// Native sessions are tied to the folder they started in; if the folder changed, start a fresh one.
|
|
68
|
+
agents.setSession(raw, null);
|
|
69
|
+
agent.session_id = null;
|
|
70
|
+
emit({ kind: 'agents_changed' });
|
|
71
|
+
}
|
|
47
72
|
}
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
agent.session_id = null;
|
|
52
|
-
emit({ kind: 'agents_changed' });
|
|
73
|
+
catch (e) {
|
|
74
|
+
// Preparing a turn can fail too (a damaged row, a database write): that is this turn's error, not a rejected promise nobody waits for.
|
|
75
|
+
return { ok: false, text: '', error: e instanceof Error ? e.message : String(e) };
|
|
53
76
|
}
|
|
54
77
|
const turnId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
55
78
|
const controller = new AbortController();
|
|
56
|
-
s.controller = controller;
|
|
57
|
-
s.live = { turnId, prompt, events: [], source, origin };
|
|
58
|
-
agents.setStatus(agentId, 'running');
|
|
59
|
-
emit({ kind: 'turn_start', agentId, turnId, prompt, source, from, channel: origin && { platform: origin.platform, place: origin.place, user: origin.userName } });
|
|
60
|
-
emit({ kind: 'status', agentId, status: 'running', queued: s.queued });
|
|
61
79
|
let text = '';
|
|
62
80
|
let summary;
|
|
63
81
|
let error;
|
|
82
|
+
let events = 0;
|
|
64
83
|
let hash = '', nbVersion = 0;
|
|
65
84
|
const push = (event) => { s.live?.events.push(event); emit({ kind: 'event', agentId, turnId, event }); };
|
|
66
85
|
try {
|
|
86
|
+
s.controller = controller;
|
|
87
|
+
s.live = { turnId, prompt, events: [], source, origin };
|
|
88
|
+
agents.setStatus(agentId, 'running');
|
|
89
|
+
emit({ kind: 'turn_start', agentId, turnId, prompt, source, from, channel: origin && { platform: origin.platform, place: origin.place, user: origin.userName } });
|
|
90
|
+
emit({ kind: 'status', agentId, status: 'running', queued: s.queued });
|
|
67
91
|
// Composing the instructions can fail too (a damaged skill row...): that is this turn's error, and it must not leave the agent "running" forever.
|
|
68
92
|
const base = composeInstructions(agent, delegated);
|
|
69
93
|
nbVersion = notebooks.get(agentId).version;
|
|
@@ -74,9 +98,11 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
|
|
|
74
98
|
const stream = runners[agent.provider]({
|
|
75
99
|
agent, prompt, signal: controller.signal,
|
|
76
100
|
instructions, refreshInstructions,
|
|
77
|
-
mcpCaps: mcpCaps(agent), effort: origin?.effort,
|
|
101
|
+
mcpCaps: mcpCaps(agent), effort: effortOf(agent, origin?.effort),
|
|
78
102
|
});
|
|
79
103
|
for await (const ev of stream) {
|
|
104
|
+
if (ev.t !== 'done')
|
|
105
|
+
events++;
|
|
80
106
|
if (ev.t === 'session') {
|
|
81
107
|
if (delegated) {
|
|
82
108
|
agents.recordSession(agent, ev.sessionId, 'delegation', from, prompt);
|
|
@@ -107,6 +133,11 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
|
|
|
107
133
|
push({ t: 'error', message: error });
|
|
108
134
|
}
|
|
109
135
|
}
|
|
136
|
+
// A turn with no event of any kind (no session, text, tool or usage) never reached the model: it is not an empty answer.
|
|
137
|
+
if (!events && !error && !controller.signal.aborted) {
|
|
138
|
+
error = 'The agent produced no events, so the turn did not run';
|
|
139
|
+
push({ t: 'error', message: error });
|
|
140
|
+
}
|
|
110
141
|
if (controller.signal.aborted && !error)
|
|
111
142
|
push({ t: 'done', ok: false, summary: 'Stopped' });
|
|
112
143
|
s.controller = null;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { agents, db, resolved } from '../db.js';
|
|
1
|
+
import { agents, colonies, db, resolved } from '../db.js';
|
|
2
|
+
import { notebooks } from '../skills/notebook.js';
|
|
3
|
+
import { checkEffort } from '../efforts.js';
|
|
2
4
|
import { channelPermissionError } from '../connections/rules.js';
|
|
3
5
|
import { liveTurn, notifyAgentsChanged, stopAgent } from '../runtime.js';
|
|
4
6
|
import { findRepo } from '../git/repo.js';
|
|
@@ -20,9 +22,14 @@ function create(p) {
|
|
|
20
22
|
notifyAgentsChanged();
|
|
21
23
|
return a;
|
|
22
24
|
}
|
|
25
|
+
/** An effort that does not apply to the provider and model is refused (an empty one always applies). */
|
|
26
|
+
const checkAgentEffort = (provider, model, effort) => { const why = checkEffort(provider, model, effort); if (why)
|
|
27
|
+
throw bad(why); };
|
|
23
28
|
export function createAgent(p) {
|
|
24
29
|
const name = trimmed(p.name);
|
|
25
30
|
uniqueName(name);
|
|
31
|
+
if (p.effort !== undefined && p.provider)
|
|
32
|
+
checkAgentEffort(p.provider, p.model ?? '', p.effort);
|
|
26
33
|
return create({ ...p, name, cwd: p.cwd ?? '' });
|
|
27
34
|
}
|
|
28
35
|
/** A new agent that starts from a type: the type lends everything but name, description, folder and colony. */
|
|
@@ -31,13 +38,51 @@ export function spawnFromType(t, p) {
|
|
|
31
38
|
uniqueName(name);
|
|
32
39
|
return create({
|
|
33
40
|
name, description: p.description ?? t.description, role: t.role, type_id: t.id, provider: t.provider, model: t.model,
|
|
41
|
+
effort: checkEffort(t.provider, t.model, t.effort) === null ? t.effort : '', // a type whose level the model no longer takes starts at the default
|
|
34
42
|
system_prompt: t.system_prompt, permission: t.permission, cwd: p.cwd ?? '', skill_ids: t.skill_ids, skill_loads: t.skill_loads, colony_id: p.colony_id ?? null,
|
|
35
43
|
});
|
|
36
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* A separate agent with the same configuration as `sourceId`: it runs in parallel with it (the turns of one agent are serialized, so conversations at the
|
|
47
|
+
* same time need separate agents) and READS THE SAME NOTEBOOK. It copies what the agent is (description, role, type, provider, model, prompt, permission,
|
|
48
|
+
* folder, overrides, browser, skills and, for an orchestrator, its team) and nothing it has done or is attached to: not its conversation or sessions,
|
|
49
|
+
* connections, wake-ups, schedules, watches, delegations or managed resources. Its colony is the source's unless another is given.
|
|
50
|
+
*/
|
|
51
|
+
export function cloneAgent(sourceId, p) {
|
|
52
|
+
const src = agentOrFail(sourceId);
|
|
53
|
+
const name = trimmed(typeof p.name === 'string' ? p.name : '');
|
|
54
|
+
if (!name)
|
|
55
|
+
throw bad('Give the clone a name');
|
|
56
|
+
uniqueName(name);
|
|
57
|
+
const colony = p.colony_id === undefined ? src.colony_id : p.colony_id ? String(p.colony_id) : null;
|
|
58
|
+
if (colony && !colonies.get(colony))
|
|
59
|
+
throw bad('That colony no longer exists');
|
|
60
|
+
const clone = create({
|
|
61
|
+
name, description: src.description, role: src.role, type_id: src.type_id, provider: src.provider, model: src.model, effort: src.effort, system_prompt: src.system_prompt,
|
|
62
|
+
permission: src.permission, cwd: src.cwd, overrides: src.overrides, browser: src.browser, skill_ids: src.skill_ids, skill_loads: src.skill_loads,
|
|
63
|
+
worker_ids: src.role === 'orchestrator' ? src.worker_ids : [], colony_id: colony, cloned_from: src.id,
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
notebooks.share(src.id, clone.id);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
agents.remove(clone.id);
|
|
70
|
+
throw e;
|
|
71
|
+
} // never leave a clone that does not read the notebook of its source
|
|
72
|
+
notifyAgentsChanged();
|
|
73
|
+
return agents.get(clone.id);
|
|
74
|
+
}
|
|
37
75
|
export function updateAgent(id, p) {
|
|
38
76
|
const cur = agentOrFail(id);
|
|
39
77
|
if (p.name && p.name.toLowerCase() !== cur.name.toLowerCase())
|
|
40
78
|
uniqueName(p.name, id);
|
|
79
|
+
// The effort has to apply to the provider and model the agent will have. One that is sent is checked; one that was saved and no longer applies
|
|
80
|
+
// (the provider or the model changed) falls back to the CLI's default instead of failing every turn.
|
|
81
|
+
const provider = p.provider ?? cur.provider, model = p.model ?? cur.model;
|
|
82
|
+
if (p.effort !== undefined)
|
|
83
|
+
checkAgentEffort(provider, model, p.effort);
|
|
84
|
+
else if (cur.effort && checkEffort(provider, model, cur.effort) !== null)
|
|
85
|
+
p = { ...p, effort: '' };
|
|
41
86
|
// Changing provider invalidates the native session pointer. (A changed folder is detected when the next turn starts.)
|
|
42
87
|
// All-or-nothing: a change that would leave a linked agent in Plan mode is rolled back.
|
|
43
88
|
const a = db.transaction(() => {
|
|
@@ -68,6 +113,13 @@ export function sessionToRead(a, requested) {
|
|
|
68
113
|
return sid;
|
|
69
114
|
}
|
|
70
115
|
/** Only the agent's own conversations can be made current; delegated ones are read-only. */
|
|
116
|
+
/** True when an OpenCode conversation started under a different permission than the agent has now: OpenCode keeps the tools a session started with. */
|
|
117
|
+
export function permissionStale(a) {
|
|
118
|
+
if (a.provider !== 'opencode' || !a.session_id)
|
|
119
|
+
return false;
|
|
120
|
+
const row = agents.sessions(a.id).find((s) => s.session_id === a.session_id);
|
|
121
|
+
return !!row && row.permission !== null && row.permission !== a.effective.permission;
|
|
122
|
+
}
|
|
71
123
|
export function resumeSession(a, sessionId) {
|
|
72
124
|
if (!agents.sessions(a.id).some((s) => s.session_id === sessionId && s.kind !== 'delegation'))
|
|
73
125
|
throw bad('Only the agent’s own conversations can be made current. Delegated sessions are read-only.');
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { agents, resolved } from '../db.js';
|
|
2
2
|
import { mcpCaps } from '../instructions.js';
|
|
3
3
|
import { liveOrigin } from '../runtime.js';
|
|
4
|
-
import { NOTEBOOK_MAX } from '../skills/notebook.js';
|
|
4
|
+
import { NOTEBOOK_MAX, notebooks } from '../skills/notebook.js';
|
|
5
5
|
import { NOTEBOOK_SKILL_ID } from '../skills/defaults.js';
|
|
6
6
|
import { bad } from '../http/errors.js';
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Who a note came from, so it can be audited: the date plus, for chat turns, where and who. In a notebook that several agents share it also
|
|
9
|
+
* says which agent wrote it: "who wrote this" is what tells a useful note from a mystery.
|
|
10
|
+
*/
|
|
8
11
|
export function noteSource(agentId) {
|
|
9
12
|
const day = new Date().toISOString().slice(0, 10), o = liveOrigin(agentId);
|
|
10
|
-
|
|
13
|
+
const who = notebooks.sharedWith(agentId).length ? agents.get(agentId)?.name : undefined;
|
|
14
|
+
return [day, who, o ? `${o.platform}: ${o.userName}` : ''].filter(Boolean).join(' · ');
|
|
11
15
|
}
|
|
12
16
|
/** The agent behind a notebook tool call: it must exist and have the Notebook skill. */
|
|
13
17
|
export function notebookAgent(from) {
|
|
@@ -5,6 +5,7 @@ export const CHANNELS_SKILL_ID = 'default-channels';
|
|
|
5
5
|
export const WAKEUPS_SKILL_ID = 'default-wakeups';
|
|
6
6
|
/** Running the servers and containers of the colony. Its tools (`object_list`, `object_action`, `object_logs`) exist only for agents that have this skill. */
|
|
7
7
|
export const OBJECTS_SKILL_ID = 'default-objects';
|
|
8
|
+
export const DATABASES_SKILL_ID = 'default-databases';
|
|
8
9
|
/**
|
|
9
10
|
* How to react to messages in a chat. It is part of the "Chat channels" skill; agents that already had that skill get it added once
|
|
10
11
|
* (see `migrateChannelSkill`). Telegram's list is the one in `connections/telegram.ts` (`TELEGRAM_REACTIONS`): keep them in step.
|
|
@@ -150,7 +151,40 @@ The resources of your own colony, or the ones that belong to no colony if you ar
|
|
|
150
151
|
## Care
|
|
151
152
|
- Stopping or restarting a server or a container interrupts whoever is using it. If the person did not ask for it and the resource is running fine, ask first.
|
|
152
153
|
- Logs can contain secrets (tokens, passwords, connection strings). Never repeat them in a chat; replace them with \`***\`.
|
|
153
|
-
- Do not run the resource's command yourself from a terminal as a way around \`object_action\`: use the tool, so hive-am knows what is running
|
|
154
|
+
- Do not run the resource's command yourself from a terminal as a way around \`object_action\`: use the tool, so hive-am knows what is running.
|
|
155
|
+
|
|
156
|
+
## Databases
|
|
157
|
+
A **database** resource is not started or stopped: it is used. \`object_list\` shows its state, but to look inside it or run SQL you need the "Database management" skill.`,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
slug: 'databases',
|
|
161
|
+
load: 'always',
|
|
162
|
+
name: 'Database management',
|
|
163
|
+
description: 'Look inside the database resources of the colony and use them: structure, queries, writes, saved queries, history, and (with Full access) their connection.',
|
|
164
|
+
content: `The colony you belong to can have **database resources** (MySQL or MariaDB, PostgreSQL, SQL Server, SQLite). You can look at their structure, run SQL, keep saved queries and, with Full access, change how they connect. hive-am opens a connection when you ask something and closes it right after.
|
|
165
|
+
|
|
166
|
+
## Which databases are yours
|
|
167
|
+
The ones of your own colony, or the ones that belong to no colony if you are in none, unless a person chose one agent to look after a database (then only that agent). Another colony's databases are not visible to you. Use the exact name that \`object_db_list\` shows. \`object_db_list\` answers even when you do not have the Colony resources skill.
|
|
168
|
+
|
|
169
|
+
## How to work
|
|
170
|
+
1. **Look before you run.** \`object_db_list\`, then \`object_db_schema\` (databases, tables and views), \`object_db_columns\` and \`object_db_describe\` (keys, indexes, foreign keys, row count), \`object_db_search\` (find a table or column by name), \`object_db_ddl\` (the CREATE statement) and \`object_db_sample\` (the first rows without writing SQL). \`object_db_explain\` shows the plan of a statement without running it.
|
|
171
|
+
2. **Run SQL** with \`object_db_query\` (one statement: a read, a write or a DDL) or \`object_db_exec\` (several statements in ONE transaction: all are applied or none; MySQL commits by itself on a CREATE, ALTER or DROP, so those cannot be undone there). Another database of the same server: pass \`database\`.
|
|
172
|
+
3. **Keep what is useful**: \`object_db_saved\` (list, add, update, remove named queries) and \`object_db_history\` (the last 200 queries, including the ones people ran). \`object_db_export\` writes a result as CSV or JSON to a file inside your own working folder (it never overwrites unless you say so).
|
|
173
|
+
4. **A result is cut at the resource's row limit and a query at its time limit**: ask for fewer rows or columns instead of working around it. Never \`SELECT *\` from a big table without a \`LIMIT\` or a \`WHERE\`: it fills your context.
|
|
174
|
+
|
|
175
|
+
## What you may do
|
|
176
|
+
Your permission decides it (the \`readOnly\` setting of the resource is for people using its panel and does not apply to you):
|
|
177
|
+
- **Read-only (plan)**: everything that looks and reads. The connection is read-only and only SELECT, WITH, SHOW, DESCRIBE and EXPLAIN pass.
|
|
178
|
+
- **Edit files (acceptEdits)**: also INSERT, UPDATE, DELETE, CREATE and ALTER, scripts, saved queries, clearing the history, exports and \`object_db_sessions\`. **DROP and TRUNCATE are refused**, also inside a script (nothing of it runs).
|
|
179
|
+
- **Full access (bypassPermissions)**: also DROP and TRUNCATE, \`object_db_config_set\` (host, port, user, password, database, SSL, driver properties, limits, name, colony, manager), \`object_db_create\`, \`object_db_delete\` and \`object_db_kill\`.
|
|
180
|
+
|
|
181
|
+
## Care
|
|
182
|
+
- **Writes and DDL are real and irreversible.** Think first: say what you are going to change and on which database, prefer a transaction (\`object_db_exec\`), try a \`SELECT\` with the same \`WHERE\` before an UPDATE or DELETE, and suggest a backup before anything that cannot be undone. A DELETE or UPDATE without a WHERE is the commonest mistake.
|
|
183
|
+
- hive-am refuses DROP and TRUNCATE without Full access, but that is a guardrail, not a sandbox: it does not see a DELETE without a WHERE, a CALL, a PostgreSQL DO block or a stored procedure. The real boundary is what the database user is allowed to do: recommend a user with limited grants.
|
|
184
|
+
- **Results can contain secrets** (password hashes, tokens, personal data). Never repeat them in a chat; replace them with \`***\`.
|
|
185
|
+
- **Passwords are write-only.** You can set or replace one with \`object_db_create\` or \`object_db_config_set\`, but no tool will ever show you a stored one (\`object_db_config\` only says whether one is set). Never repeat a password, write it in a file, a note or a chat, or put it in a query that is not needed. If a person types one in a chat, use it once in the tool without echoing it, and prefer asking them to type it in the resource's panel. When you write SQL that sets a password (\`CREATE USER … IDENTIFIED BY\`), hive-am replaces the literal with \`***\` in the history and the logs.
|
|
186
|
+
- Changing a connection (\`object_db_config_set\`) points hive-am at that server with those credentials. Do it only when asked.
|
|
187
|
+
- \`object_db_kill\` cancels what another session is running: look at \`object_db_sessions\` first and do it only when asked.`,
|
|
154
188
|
},
|
|
155
189
|
{
|
|
156
190
|
slug: 'git-workflow',
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { db } from '../db.js';
|
|
2
3
|
import { DomainError } from '../errors.js';
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* A notebook: Markdown an agent keeps for itself across conversations (see the «Notebook» default skill). It is an object of its own with
|
|
6
|
+
* readers (`notebooks` and `notebook_readers`, created by db/migrations.ts): an agent and the clones made from it read and write the same one, and
|
|
7
|
+
* it goes away only with its last reader. It lives in its own table so it never travels with the agent list. Every change bumps `version`;
|
|
8
|
+
* `seen` belongs to the reader: it is the version that agent's live conversation already contains, so the runtime knows when the text must be
|
|
9
|
+
* re-sent to it (e.g. after the user, or another agent that shares the notebook, changed it).
|
|
7
10
|
*/
|
|
8
|
-
// The table (agent_notebooks) is created by db/migrations.ts.
|
|
9
11
|
/** It is prompt overhead on every turn, so it has a hard cap. */
|
|
10
12
|
export const NOTEBOOK_MAX = 8000;
|
|
11
13
|
const NOTE_MAX = 500;
|
|
@@ -37,19 +39,61 @@ const refuseSecret = (text) => {
|
|
|
37
39
|
throw new NotebookError(`That looks like ${what}. Credentials are never stored in the notebook: leave it out (or describe where it lives, not its value).`);
|
|
38
40
|
};
|
|
39
41
|
const empty = () => ({ content: '', version: 0, seen: 0, updated_at: 0, updated_by: '' });
|
|
42
|
+
/** The notebook an agent reads, with that agent's own `seen`. */
|
|
43
|
+
const readerRow = (agentId) => db.prepare(`SELECT n.id, n.content, n.version, r.seen, n.updated_at, n.updated_by FROM notebook_readers r JOIN notebooks n ON n.id = r.notebook_id WHERE r.agent_id = ?`).get(agentId);
|
|
44
|
+
/** The notebook the agent reads, created empty (version 0) when it has none yet. */
|
|
45
|
+
function ensure(agentId) {
|
|
46
|
+
let row = readerRow(agentId);
|
|
47
|
+
if (!row) {
|
|
48
|
+
const id = randomUUID();
|
|
49
|
+
db.prepare('INSERT INTO notebooks (id) VALUES (?)').run(id);
|
|
50
|
+
db.prepare('INSERT INTO notebook_readers (agent_id, notebook_id, seen) VALUES (?,?,0)').run(agentId, id);
|
|
51
|
+
row = readerRow(agentId);
|
|
52
|
+
}
|
|
53
|
+
return row;
|
|
54
|
+
}
|
|
40
55
|
export const notebooks = {
|
|
41
|
-
get: (agentId) =>
|
|
56
|
+
get: (agentId) => { const r = readerRow(agentId); return r ? { content: r.content, version: r.version, seen: r.seen, updated_at: r.updated_at, updated_by: r.updated_by } : empty(); },
|
|
57
|
+
/** The other agents that read the same notebook (empty for an ordinary notebook). */
|
|
58
|
+
sharedWith(agentId) {
|
|
59
|
+
const r = readerRow(agentId);
|
|
60
|
+
if (!r)
|
|
61
|
+
return [];
|
|
62
|
+
return db.prepare('SELECT a.id, a.name FROM notebook_readers x JOIN agents a ON a.id = x.agent_id WHERE x.notebook_id = ? AND x.agent_id <> ? ORDER BY a.name').all(r.id, agentId);
|
|
63
|
+
},
|
|
42
64
|
/** The agent (or the user) replaced the text. `live`: the change happened inside the conversation the agent is running now. */
|
|
43
|
-
save(agentId, content, by, live) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
db.prepare(
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
save: db.transaction((agentId, content, by, live) => {
|
|
66
|
+
const row = ensure(agentId); // an agent that never had one gets its own
|
|
67
|
+
const version = row.version + 1;
|
|
68
|
+
db.prepare('UPDATE notebooks SET content=?, version=?, updated_at=?, updated_by=? WHERE id=?').run(content, version, Date.now(), by, row.id);
|
|
69
|
+
// Only the writer's own conversation already contains what it just wrote; the other readers are sent it on their next turn.
|
|
70
|
+
if (live)
|
|
71
|
+
db.prepare('UPDATE notebook_readers SET seen=? WHERE agent_id=?').run(version, agentId);
|
|
49
72
|
return notebooks.get(agentId);
|
|
50
|
-
},
|
|
73
|
+
}),
|
|
51
74
|
/** The runtime sent version `v` to the agent's conversation. */
|
|
52
|
-
markSeen: (agentId, v) => { db.prepare('UPDATE
|
|
75
|
+
markSeen: (agentId, v) => { db.prepare('UPDATE notebook_readers SET seen=MAX(seen, ?) WHERE agent_id=?').run(v, agentId); },
|
|
76
|
+
/**
|
|
77
|
+
* `to` starts reading the notebook of `from` (a clone sharing its source's notes): `from` gets one if it had none, and what `to` read before
|
|
78
|
+
* is dropped with its last reader. Its `seen` starts at 0, so its first turn is sent the text.
|
|
79
|
+
*/
|
|
80
|
+
share: db.transaction((fromId, toId) => {
|
|
81
|
+
if (fromId === toId)
|
|
82
|
+
return;
|
|
83
|
+
const nb = ensure(fromId);
|
|
84
|
+
db.prepare('INSERT INTO notebook_readers (agent_id, notebook_id, seen) VALUES (?,?,0) ON CONFLICT(agent_id) DO UPDATE SET notebook_id=excluded.notebook_id, seen=0').run(toId, nb.id);
|
|
85
|
+
db.prepare('DELETE FROM notebooks WHERE id NOT IN (SELECT notebook_id FROM notebook_readers)').run(); // what `to` read before, if nobody else does
|
|
86
|
+
}),
|
|
87
|
+
/** Gives the agent its own copy of the notebook it shares, and leaves the shared one with the others. Does nothing for an agent that shares nothing. */
|
|
88
|
+
separate: db.transaction((agentId) => {
|
|
89
|
+
const row = readerRow(agentId);
|
|
90
|
+
if (!row || !notebooks.sharedWith(agentId).length)
|
|
91
|
+
return notebooks.get(agentId);
|
|
92
|
+
const id = randomUUID();
|
|
93
|
+
db.prepare('INSERT INTO notebooks (id, content, version, updated_at, updated_by) VALUES (?,?,?,?,?)').run(id, row.content, row.version, row.updated_at, row.updated_by);
|
|
94
|
+
db.prepare('UPDATE notebook_readers SET notebook_id=? WHERE agent_id=?').run(id, agentId); // `seen` stays: it still describes what this agent's conversation holds
|
|
95
|
+
return notebooks.get(agentId);
|
|
96
|
+
}),
|
|
53
97
|
/** Edit from the UI. `version` (when given) must be the one the user was looking at. */
|
|
54
98
|
edit(agentId, content, version) {
|
|
55
99
|
const text = content.replace(/\r\n/g, '\n').trimEnd();
|