@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,62 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
+
import { defaultConfigDirectory } from '../../lib/application.js';
|
|
4
|
+
import { CloudFirstCommands } from '../../lib/cloudFirstCommands.js';
|
|
5
|
+
|
|
6
|
+
export default class PulseImport extends BaseCommand {
|
|
7
|
+
static description = 'Import the local Agent Hub into the Pulse log from a snapshot of its sources (idempotent: a re-run on the same snapshot writes nothing). --dry-run reads only and prints what it would send; --reconcile rescans the snapshot and compares it with Pulse, exiting 1 on any mismatch.';
|
|
8
|
+
static examples = ['<%= config.bin %> pulse import --dry-run', '<%= config.bin %> pulse import', '<%= config.bin %> pulse import --reconcile', '<%= config.bin %> pulse import --reuse-snapshot'];
|
|
9
|
+
static flags = {
|
|
10
|
+
'dry-run': Flags.boolean({ description: 'Read the local hub and report per source; send and write nothing', default: false }),
|
|
11
|
+
wait: Flags.boolean({ description: 'Follow the import until it has fully drained', default: true, allowNo: true }),
|
|
12
|
+
reconcile: Flags.boolean({ description: 'Rescan the last import\'s snapshot and compare every family, channel and conversation with Pulse', default: false }),
|
|
13
|
+
'reuse-snapshot': Flags.boolean({ description: 'Import the last snapshot again instead of taking a new one', default: false }),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
const { flags } = await this.parse(PulseImport);
|
|
18
|
+
const commands = this.cloudFirst || new CloudFirstCommands({ configDir: defaultConfigDirectory() });
|
|
19
|
+
if (flags['dry-run']) {
|
|
20
|
+
const result = await commands.dryRun();
|
|
21
|
+
for (const row of result.sources) {
|
|
22
|
+
const detail = [...Object.entries(row.skipped).map(([k, v]) => `skipped ${k} ${v}`), ...Object.entries(row.refused).map(([k, v]) => `refused ${k} ${v}`)];
|
|
23
|
+
this.log(`${row.source.padEnd(9)} read ${row.read} planned ${row.planned} unchanged ${row.unchanged} redactions ${row.redactions}${row.cut ? ` cut ${row.cut}` : ''}${detail.length ? ` (${detail.join(', ')})` : ''}`);
|
|
24
|
+
if (Object.keys(row.events).length) this.log(` events ${Object.entries(row.events).map(([type, n]) => `${type} ${n}`).join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
this.log(result.message);
|
|
27
|
+
if (!result.totals.balanced) process.exitCode = 1;
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
let last = '';
|
|
31
|
+
if (flags.reconcile) {
|
|
32
|
+
const result = await commands.reconcile({
|
|
33
|
+
onProgress: (progress) => {
|
|
34
|
+
const line = `Reconcile: ${progress.state}${progress.code ? ` (${progress.code})` : ''}`;
|
|
35
|
+
if (line !== last) this.log(line);
|
|
36
|
+
last = line;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
for (const [family, row] of Object.entries(result.families || {})) {
|
|
40
|
+
this.log(`${family.padEnd(18)} local ${String(row.local).padStart(6)} pulse ${String(row.cloud).padStart(6)} ${row.equal ? 'equal' : 'DIFFERS'}`);
|
|
41
|
+
}
|
|
42
|
+
for (const [source, row] of Object.entries(result.sources || {})) {
|
|
43
|
+
this.log(`source ${source.padEnd(9)} rows ${row.counted ?? '—'} read ${row.read} accounted ${row.accounted}`);
|
|
44
|
+
}
|
|
45
|
+
for (const item of (result.mismatches || []).slice(0, 20)) this.log(`MISMATCH ${item.family}${item.item ? ` ${item.item}` : ''}${item.note ? `: ${item.note}` : ''}`);
|
|
46
|
+
this.log(result.message);
|
|
47
|
+
process.exitCode = result.exitCode;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
const result = await commands.import({
|
|
51
|
+
wait: flags.wait,
|
|
52
|
+
reuseSnapshot: flags['reuse-snapshot'],
|
|
53
|
+
onProgress: (progress) => {
|
|
54
|
+
const line = `Import: ${progress.state}${progress.code ? ` (${progress.code})` : ''}${progress.pending !== null && progress.pending !== undefined ? `, ${progress.pending} commands waiting` : ''}`;
|
|
55
|
+
if (line !== last) this.log(line);
|
|
56
|
+
last = line;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
this.log(result.message);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
+
import { getDefaultApplication } from '../../lib/application.js';
|
|
4
|
+
|
|
5
|
+
export default class PulseLogWrite extends BaseCommand {
|
|
6
|
+
static description = 'Let this machine read and write your Pulse log (hub.sync and log.write), stop it writing, or show whether it can.';
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> pulse log-write status',
|
|
9
|
+
'<%= config.bin %> pulse log-write on',
|
|
10
|
+
'<%= config.bin %> pulse log-write off',
|
|
11
|
+
];
|
|
12
|
+
static args = {
|
|
13
|
+
action: Args.string({
|
|
14
|
+
description: 'on, off or status',
|
|
15
|
+
options: ['on', 'off', 'status'],
|
|
16
|
+
default: 'status',
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async run() {
|
|
21
|
+
const { args } = await this.parse(PulseLogWrite);
|
|
22
|
+
const result = await (this.runtime || getDefaultApplication()).logWrite({ action: args.action });
|
|
23
|
+
this.log(result.message);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
+
import { defaultConfigDirectory } from '../../lib/application.js';
|
|
4
|
+
import { CloudFirstCommands } from '../../lib/cloudFirstCommands.js';
|
|
5
|
+
|
|
6
|
+
export default class PulseParity extends BaseCommand {
|
|
7
|
+
static description = 'Show how this machine\'s Pulse mirror and the Pulse log agree: the last parity check and its history, or a fresh one.';
|
|
8
|
+
static examples = ['<%= config.bin %> pulse parity', '<%= config.bin %> pulse parity --now --hours 6'];
|
|
9
|
+
static flags = {
|
|
10
|
+
hours: Flags.integer({ description: 'Window in hours (1 to 168)', default: 6, min: 1, max: 168 }),
|
|
11
|
+
now: Flags.boolean({ description: 'Ask the daemon for a fresh check and wait for it', default: false }),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
async run() {
|
|
15
|
+
const { flags } = await this.parse(PulseParity);
|
|
16
|
+
const result = await (this.cloudFirst || new CloudFirstCommands({ configDir: defaultConfigDirectory() })).parity({ hours: flags.hours, now: flags.now });
|
|
17
|
+
this.log(result.message);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../lib/BaseCommand.js';
|
|
3
|
+
import { defaultConfigDirectory } from '../../lib/application.js';
|
|
4
|
+
import { CloudFirstCommands } from '../../lib/cloudFirstCommands.js';
|
|
5
|
+
|
|
6
|
+
export default class PulseShadow extends BaseCommand {
|
|
7
|
+
static description = 'Turn the Pulse shadow feed (the local hub, tailed into the Pulse log) on or off, or show its status.';
|
|
8
|
+
static examples = ['<%= config.bin %> pulse shadow status', '<%= config.bin %> pulse shadow on', '<%= config.bin %> pulse shadow off'];
|
|
9
|
+
static args = {
|
|
10
|
+
action: Args.string({ description: 'on, off or status', options: ['on', 'off', 'status'], default: 'status' }),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async run() {
|
|
14
|
+
const { args } = await this.parse(PulseShadow);
|
|
15
|
+
const result = await (this.cloudFirst || new CloudFirstCommands({ configDir: defaultConfigDirectory() })).shadow({ action: args.action });
|
|
16
|
+
this.log(result.message);
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/daemon/main.js
CHANGED
|
@@ -3,12 +3,14 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { defaultConfigDirectory } from '../lib/application.js';
|
|
5
5
|
import { runDaemon } from '../lib/daemonProcess.js';
|
|
6
|
+
import { nodeFloor } from '../lib/nodeFloor.js';
|
|
6
7
|
import { ServiceManager } from '../lib/serviceManager.js';
|
|
7
8
|
|
|
8
9
|
const MIRROR_FAILURES = new Set(['mirror_failed', 'mirror_blocked', 'mirror_stream_failed']);
|
|
9
10
|
const ERROR_EVENTS = new Set([
|
|
10
11
|
'job_failed', 'job_refused', 'job_settle_failed', 'cycle_stalled', 'drain_timed_out', 'daemon_start_failed',
|
|
11
12
|
'daemon_shutdown_failed', 'source_watch_refused', 'source_watch_failed',
|
|
13
|
+
'cutover_failed', 'cutover_request_failed', 'cutover_request_refused', 'cutover_interrupted', 'cutover_service_failed',
|
|
12
14
|
]);
|
|
13
15
|
|
|
14
16
|
/** One redacted line per job, local hub or restart event; failures go to daemon-error.log, the rest to daemon.log. */
|
|
@@ -22,15 +24,21 @@ function configDirectory(argv) {
|
|
|
22
24
|
return index === -1 ? defaultConfigDirectory() : path.resolve(argv[index + 1]);
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}).
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
27
|
+
const floor = nodeFloor();
|
|
28
|
+
if (!floor.ok) {
|
|
29
|
+
// Exit 0: launchd (SuccessfulExit false) and systemd (on-failure) would otherwise restart it in a loop.
|
|
30
|
+
process.stderr.write(`Pulse daemon refused: ${floor.code}: ${floor.message}\n`);
|
|
31
|
+
} else {
|
|
32
|
+
runDaemon({
|
|
33
|
+
directory: configDirectory(process.argv.slice(2)),
|
|
34
|
+
pluginRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
|
|
35
|
+
logDir: new ServiceManager({}).logDir,
|
|
36
|
+
log: logEvent,
|
|
37
|
+
mirrorLog: (event, details) => {
|
|
38
|
+
if (MIRROR_FAILURES.has(event)) process.stderr.write(`Pulse ${event}: ${details.code}\n`);
|
|
39
|
+
},
|
|
40
|
+
}).catch((error) => {
|
|
41
|
+
process.stderr.write(`Pulse daemon failed: ${error.code || 'HUB_DAEMON_FAILED'}: ${error.message}\n`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// agents-hub core/auth.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Local hub auth: a 0600 token file for CLI/hook, an HttpOnly SameSite=Strict cookie for the browser.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const COOKIE = 'hub_auth';
|
|
8
|
+
|
|
9
|
+
export function tokenPath(stateDir) {
|
|
10
|
+
return path.join(stateDir, 'hub.token');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadOrCreateToken(stateDir) {
|
|
14
|
+
const file = tokenPath(stateDir);
|
|
15
|
+
try {
|
|
16
|
+
const existing = fs.readFileSync(file, 'utf8').trim();
|
|
17
|
+
if (/^[a-f0-9]{32,}$/i.test(existing)) return existing;
|
|
18
|
+
} catch { /* create */ }
|
|
19
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
20
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
21
|
+
const fd = fs.openSync(file, 'w', 0o600);
|
|
22
|
+
try { fs.writeSync(fd, token); } finally { fs.closeSync(fd); }
|
|
23
|
+
try { fs.chmodSync(file, 0o600); } catch { /* ignore */ }
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sessionCookie(token) {
|
|
28
|
+
return crypto.createHmac('sha256', String(token)).update('hub-browser-user').digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseCookie(header) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const part of String(header || '').split(';')) {
|
|
34
|
+
const i = part.indexOf('=');
|
|
35
|
+
if (i < 0) continue;
|
|
36
|
+
let value = part.slice(i + 1).trim();
|
|
37
|
+
try { value = decodeURIComponent(value); } catch { /* leave encoded */ }
|
|
38
|
+
out[part.slice(0, i).trim()] = value;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bearer(req) {
|
|
44
|
+
const h = String(req.headers.authorization || '');
|
|
45
|
+
const m = /^Bearer\s+(\S+)/i.exec(h);
|
|
46
|
+
if (m) return m[1];
|
|
47
|
+
return req.headers['x-hub-token'] || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Authenticate a request. Returns { principal, via } or null.
|
|
52
|
+
* cookie → principal `user` (the browser may only act as you)
|
|
53
|
+
* bearer matching hub.token → principal from X-Hub-As, default `user`
|
|
54
|
+
*/
|
|
55
|
+
export function authenticate(req, token, { asHint = null } = {}) {
|
|
56
|
+
if (!token) return null;
|
|
57
|
+
const cookie = parseCookie(req.headers.cookie)[COOKIE];
|
|
58
|
+
if (cookie && cookie === sessionCookie(token)) return { principal: 'user', via: 'cookie' };
|
|
59
|
+
const hdr = bearer(req);
|
|
60
|
+
if (hdr && hdr === token) {
|
|
61
|
+
const as = asHint || req.headers['x-hub-as'] || null;
|
|
62
|
+
return { principal: typeof as === 'string' && as ? as : 'user', via: 'token' };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setAuthCookie(res, token) {
|
|
68
|
+
const cookie = `${COOKIE}=${sessionCookie(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`;
|
|
69
|
+
if (typeof res.setHeader === 'function') res.setHeader('Set-Cookie', cookie);
|
|
70
|
+
return cookie;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function bindPrincipal(auth, requestedAs) {
|
|
74
|
+
if (!auth) return { error: 'unauthorized' };
|
|
75
|
+
if (auth.via === 'cookie') {
|
|
76
|
+
if (requestedAs && requestedAs !== 'user') return { error: 'forbidden principal' };
|
|
77
|
+
return { principal: 'user' };
|
|
78
|
+
}
|
|
79
|
+
return { principal: requestedAs || auth.principal };
|
|
80
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// agents-hub core/changes.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// What counts as news in hub.db for the live change feed. Discovery rewrites the rows it owns on graph reads and the
|
|
3
|
+
// server renews its lease every 10 s; waking the feed for those makes every client read the graph again, which runs
|
|
4
|
+
// discovery again.
|
|
5
|
+
|
|
6
|
+
/** Fact kinds owned by discovery (workers/collector.mjs persistDiscovery); each pass replaces them. */
|
|
7
|
+
export const DISCOVERY_FACTS = ['delivery', 'stream', 'agent-row', 'request', 'totals'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns `changed()`: what news hub.db holds since the last call. `false` for none; `'presence'` when only presence
|
|
11
|
+
* moved (hook heartbeats: projections must refresh, discovery's inputs did not change); `'hub'` for anything else:
|
|
12
|
+
* bus mail and its delivery states, registrations, new actors, events and non-discovery facts. Discovery's inferred
|
|
13
|
+
* actor fields and facts, and lease renewals, are not news.
|
|
14
|
+
*/
|
|
15
|
+
export function changeTracker(db) {
|
|
16
|
+
// Sums, not maxima, so a second update in the same millisecond still moves the signature; cast to text because a
|
|
17
|
+
// sum of epoch milliseconds passes 2^53 and would lose its low digits as a JS number.
|
|
18
|
+
const messages = db.prepare('SELECT COUNT(*) AS n, MAX(rowid) AS last FROM messages');
|
|
19
|
+
const deliveries = db.prepare(`SELECT COUNT(*) AS n, SUM(attempts) AS attempts, SUM(awaiting_reply) AS awaiting,
|
|
20
|
+
CAST(SUM(IFNULL(leased_at, 0) + IFNULL(delivered_at, 0) + IFNULL(acked_at, 0)) AS TEXT) AS at FROM deliveries`);
|
|
21
|
+
const states = db.prepare('SELECT state, COUNT(*) AS n FROM deliveries GROUP BY state ORDER BY state');
|
|
22
|
+
const presence = db.prepare('SELECT COUNT(*) AS n, CAST(SUM(last_seen) AS TEXT) AS seen FROM presence');
|
|
23
|
+
const actors = db.prepare('SELECT COUNT(*) AS n FROM actors');
|
|
24
|
+
const events = db.prepare('SELECT COUNT(*) AS n, MAX(id) AS last FROM events');
|
|
25
|
+
const facts = db.prepare(`SELECT COUNT(*) AS n, CAST(SUM(updated_at) AS TEXT) AS at, SUM(LENGTH(payload)) AS size
|
|
26
|
+
FROM facts WHERE kind NOT IN (${DISCOVERY_FACTS.map(() => '?').join(', ')})`);
|
|
27
|
+
const signature = () => ({
|
|
28
|
+
presence: JSON.stringify(presence.get()),
|
|
29
|
+
rest: JSON.stringify([messages.get(), deliveries.get(), states.all(), actors.get(), events.get(), facts.get(...DISCOVERY_FACTS)]),
|
|
30
|
+
});
|
|
31
|
+
let last = signature();
|
|
32
|
+
return function changed() {
|
|
33
|
+
const next = signature();
|
|
34
|
+
const kind = next.rest !== last.rest ? 'hub' : next.presence !== last.presence ? 'presence' : false;
|
|
35
|
+
last = next;
|
|
36
|
+
return kind;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// agents-hub core/conversations.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// One-to-one threads: only messages between you and one actor. Top-level Cursor chats also include transcript
|
|
3
|
+
// prompts and final replies, de-duplicated by message id and turn id.
|
|
4
|
+
import { ADDRESS } from './ids.mjs';
|
|
5
|
+
import { parseJson } from './db.mjs';
|
|
6
|
+
|
|
7
|
+
const MAX = 300;
|
|
8
|
+
|
|
9
|
+
function shape(m, actorId) {
|
|
10
|
+
const fromUser = m.sender === 'user';
|
|
11
|
+
return {
|
|
12
|
+
id: m.id,
|
|
13
|
+
at: m.created_at,
|
|
14
|
+
from: fromUser ? 'USER' : (m.sender === 'manager' ? 'MANAGER' : 'AGENT'),
|
|
15
|
+
fromId: m.sender,
|
|
16
|
+
type: m.type,
|
|
17
|
+
head: '',
|
|
18
|
+
body: m.body,
|
|
19
|
+
replyTo: m.reply_to,
|
|
20
|
+
reaction: m.reaction,
|
|
21
|
+
via: parseJson(m.meta, {}).source === 'transcript' ? 'transcript' : 'bus',
|
|
22
|
+
turnId: parseJson(m.meta, {}).turnId || null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createConversations(db, messaging, registry) {
|
|
27
|
+
function thread(actorId, { since = 0, limit = MAX } = {}) {
|
|
28
|
+
if (!ADDRESS.test(actorId) && actorId !== 'hub') return null;
|
|
29
|
+
const rows = db.prepare(`SELECT * FROM messages WHERE created_at >= ? AND (
|
|
30
|
+
(sender = 'user' AND id IN (SELECT message_id FROM deliveries WHERE recipient = ?))
|
|
31
|
+
OR (sender = ? AND id IN (SELECT message_id FROM deliveries WHERE recipient = 'user'))
|
|
32
|
+
OR (sender = 'user' AND topic = ? )
|
|
33
|
+
OR (sender = ? AND (topic = 'user' OR topic = 'agent/user'))
|
|
34
|
+
) ORDER BY created_at ASC`).all(since, actorId, actorId, `agent/${actorId}`, actorId);
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const m of rows) {
|
|
38
|
+
if (parseJson(m.meta, {}).source !== 'transcript') continue;
|
|
39
|
+
const turnId = parseJson(m.meta, {}).turnId;
|
|
40
|
+
const key = turnId ? `turn:${turnId}` : m.id;
|
|
41
|
+
if (seen.has(m.id) || seen.has(key)) continue;
|
|
42
|
+
seen.add(m.id);
|
|
43
|
+
if (turnId) seen.add(key);
|
|
44
|
+
out.push(shape(m, actorId));
|
|
45
|
+
}
|
|
46
|
+
const wire = messaging.involving(actorId, since)
|
|
47
|
+
.filter((m) => (m.from === 'user' && m.to.includes(actorId)) || (m.from === actorId && m.to.includes('user')));
|
|
48
|
+
const replyOf = new Map(wire.filter((r) => r.replyTo && r.from === actorId).map((r) => [r.replyTo, r]));
|
|
49
|
+
for (const m of wire) {
|
|
50
|
+
if (seen.has(m.id) || (m.meta && m.meta.source === 'transcript')) continue;
|
|
51
|
+
seen.add(m.id);
|
|
52
|
+
const fromUser = m.from === 'user';
|
|
53
|
+
const reply = fromUser ? replyOf.get(m.id) || null : null;
|
|
54
|
+
const receipt = messaging.statusOf(m.id).find((s) => s.to === (fromUser ? actorId : 'user'));
|
|
55
|
+
const wake = fromUser && receipt && receipt.state === 'queued' ? registry.fact('wake', actorId) : null;
|
|
56
|
+
const wakeReceipt = wake && wake.at >= m.at - 1000 ? messaging.statusOf(wake.messageId).find((s) => s.to === wake.parent) : null;
|
|
57
|
+
out.push({
|
|
58
|
+
id: m.id, at: m.at, from: fromUser ? 'USER' : (m.from === 'manager' ? 'MANAGER' : 'AGENT'),
|
|
59
|
+
fromId: m.from, type: m.type, head: '', body: m.body, replyTo: m.replyTo, reaction: m.reaction, via: 'bus',
|
|
60
|
+
delivery: {
|
|
61
|
+
intoChatAt: receipt ? receipt.deliveredAt : null,
|
|
62
|
+
repliedAt: reply ? reply.at : null,
|
|
63
|
+
busState: receipt ? receipt.state : 'queued',
|
|
64
|
+
wakeVia: wakeReceipt ? wake.parent : null,
|
|
65
|
+
wokenAt: wakeReceipt ? wakeReceipt.deliveredAt : null,
|
|
66
|
+
},
|
|
67
|
+
reactions: [
|
|
68
|
+
receipt && ['delivered', 'acked', 'unacked'].includes(receipt.state) ? { emoji: '👀', at: receipt.deliveredAt, auto: true } : null,
|
|
69
|
+
m.reaction ? { emoji: m.reaction, at: m.at } : null,
|
|
70
|
+
reply && reply.reaction ? { emoji: reply.reaction, at: reply.at } : null,
|
|
71
|
+
].filter(Boolean),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
out.sort((a, b) => a.at - b.at);
|
|
75
|
+
const actor = registry.get(actorId);
|
|
76
|
+
const awaiting = messaging.awaiting(actorId);
|
|
77
|
+
const pres = registry.presence(actorId);
|
|
78
|
+
const typing = awaiting.length && pres && pres.last_event !== 'stop' && Date.now() - pres.last_seen < 5 * 60e3
|
|
79
|
+
? { since: awaiting[0] ? Date.now() : pres.last_seen, at: pres.last_seen, tool: pres.last_tool }
|
|
80
|
+
: null;
|
|
81
|
+
return { messages: out.slice(-limit), actor, typing };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function importTranscriptTurn({ sessionId, turnId, at, prompt, reply }) {
|
|
85
|
+
if (!sessionId || !turnId) return;
|
|
86
|
+
const meta = JSON.stringify({ source: 'transcript', turnId });
|
|
87
|
+
const promptId = `t-${String(turnId).slice(0, 40)}-p`;
|
|
88
|
+
const replyId = `t-${String(turnId).slice(0, 40)}-r`;
|
|
89
|
+
const ins = db.prepare(`INSERT OR IGNORE INTO messages (id, sender, topic, type, body, needs_reply, expires_at, reply_to, reaction, from_name, created_at, meta)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, 0, ?, ?, NULL, ?, ?, ?)`);
|
|
91
|
+
const del = db.prepare(`INSERT OR IGNORE INTO deliveries (message_id, recipient, state, queued_at, delivered_at, delivered_via) VALUES (?, ?, 'acked', ?, ?, 'transcript')`);
|
|
92
|
+
if (prompt) {
|
|
93
|
+
ins.run(promptId, 'user', `agent/${sessionId}`, 'PROMPT', String(prompt).slice(0, 4000), at + 365 * 24 * 3600e3, null, 'You', at, meta);
|
|
94
|
+
del.run(promptId, sessionId, at, at);
|
|
95
|
+
}
|
|
96
|
+
if (reply) {
|
|
97
|
+
ins.run(replyId, sessionId, 'user', 'REPLY', String(reply).slice(0, 4000), at + 365 * 24 * 3600e3, prompt ? promptId : null, null, at + 1, meta);
|
|
98
|
+
del.run(replyId, 'user', at + 1, at + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Remove the opening prompt imported for a chat that turned out to be a subagent (its brief is not your prompt). */
|
|
103
|
+
function forgetOpening(sessionId) {
|
|
104
|
+
const base = `t-${`${sessionId}-open`.slice(0, 40)}`;
|
|
105
|
+
for (const id of [`${base}-p`, `${base}-r`]) {
|
|
106
|
+
db.prepare('DELETE FROM deliveries WHERE message_id = ?').run(id);
|
|
107
|
+
db.prepare('DELETE FROM messages WHERE id = ?').run(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dmPartners(since = Date.now() - 30 * 24 * 3600e3) {
|
|
112
|
+
const map = new Map();
|
|
113
|
+
for (const m of messaging.involving('user', since)) {
|
|
114
|
+
const other = m.from === 'user' ? m.to.find((x) => x !== 'user') : m.from;
|
|
115
|
+
if (other && other !== 'user') map.set(other, Math.max(map.get(other) || 0, m.at));
|
|
116
|
+
}
|
|
117
|
+
return [...map].map(([id, lastAt]) => ({ id, lastAt })).sort((a, b) => b.lastAt - a.lastAt);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { thread, importTranscriptTurn, forgetOpening, dmPartners };
|
|
121
|
+
}
|