@pleri/olam-cli 0.1.7
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/dist/__tests__/auth-status.test.d.ts +2 -0
- package/dist/__tests__/auth-status.test.d.ts.map +1 -0
- package/dist/__tests__/auth-status.test.js +290 -0
- package/dist/__tests__/auth-status.test.js.map +1 -0
- package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
- package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/auth-upgrade.test.js +161 -0
- package/dist/__tests__/auth-upgrade.test.js.map +1 -0
- package/dist/__tests__/create-app-urls.test.d.ts +2 -0
- package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/create-app-urls.test.js +102 -0
- package/dist/__tests__/create-app-urls.test.js.map +1 -0
- package/dist/__tests__/enter.test.d.ts +2 -0
- package/dist/__tests__/enter.test.d.ts.map +1 -0
- package/dist/__tests__/enter.test.js +90 -0
- package/dist/__tests__/enter.test.js.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
- package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp-gh-token.test.js +119 -0
- package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
- package/dist/__tests__/host-cp.test.d.ts +9 -0
- package/dist/__tests__/host-cp.test.d.ts.map +1 -0
- package/dist/__tests__/host-cp.test.js +254 -0
- package/dist/__tests__/host-cp.test.js.map +1 -0
- package/dist/__tests__/keys.test.d.ts +9 -0
- package/dist/__tests__/keys.test.d.ts.map +1 -0
- package/dist/__tests__/keys.test.js +145 -0
- package/dist/__tests__/keys.test.js.map +1 -0
- package/dist/__tests__/logs.test.d.ts +9 -0
- package/dist/__tests__/logs.test.d.ts.map +1 -0
- package/dist/__tests__/logs.test.js +124 -0
- package/dist/__tests__/logs.test.js.map +1 -0
- package/dist/__tests__/ps.test.d.ts +2 -0
- package/dist/__tests__/ps.test.d.ts.map +1 -0
- package/dist/__tests__/ps.test.js +172 -0
- package/dist/__tests__/ps.test.js.map +1 -0
- package/dist/__tests__/status-app-urls.test.d.ts +2 -0
- package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
- package/dist/__tests__/status-app-urls.test.js +125 -0
- package/dist/__tests__/status-app-urls.test.js.map +1 -0
- package/dist/__tests__/upgrade.test.d.ts +9 -0
- package/dist/__tests__/upgrade.test.d.ts.map +1 -0
- package/dist/__tests__/upgrade.test.js +262 -0
- package/dist/__tests__/upgrade.test.js.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
- package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
- package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
- package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
- package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
- package/dist/commands/__tests__/refresh.test.d.ts +13 -0
- package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
- package/dist/commands/__tests__/refresh.test.js +170 -0
- package/dist/commands/__tests__/refresh.test.js.map +1 -0
- package/dist/commands/auth-status.d.ts +43 -0
- package/dist/commands/auth-status.d.ts.map +1 -0
- package/dist/commands/auth-status.js +208 -0
- package/dist/commands/auth-status.js.map +1 -0
- package/dist/commands/auth-upgrade.d.ts +47 -0
- package/dist/commands/auth-upgrade.d.ts.map +1 -0
- package/dist/commands/auth-upgrade.js +277 -0
- package/dist/commands/auth-upgrade.js.map +1 -0
- package/dist/commands/auth.d.ts +16 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +283 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/create.d.ts +8 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +512 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/crystallize.d.ts +8 -0
- package/dist/commands/crystallize.d.ts.map +1 -0
- package/dist/commands/crystallize.js +101 -0
- package/dist/commands/crystallize.js.map +1 -0
- package/dist/commands/destroy.d.ts +6 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/destroy.js +54 -0
- package/dist/commands/destroy.js.map +1 -0
- package/dist/commands/dispatch.d.ts +9 -0
- package/dist/commands/dispatch.d.ts.map +1 -0
- package/dist/commands/dispatch.js +94 -0
- package/dist/commands/dispatch.js.map +1 -0
- package/dist/commands/enter.d.ts +63 -0
- package/dist/commands/enter.d.ts.map +1 -0
- package/dist/commands/enter.js +206 -0
- package/dist/commands/enter.js.map +1 -0
- package/dist/commands/host-cp.d.ts +191 -0
- package/dist/commands/host-cp.d.ts.map +1 -0
- package/dist/commands/host-cp.js +797 -0
- package/dist/commands/host-cp.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +143 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install.d.ts +22 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +203 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/keys.d.ts +26 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +151 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/lanes.d.ts +18 -0
- package/dist/commands/lanes.d.ts.map +1 -0
- package/dist/commands/lanes.js +122 -0
- package/dist/commands/lanes.js.map +1 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +39 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/logs.d.ts +38 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +177 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/observe.d.ts +9 -0
- package/dist/commands/observe.d.ts.map +1 -0
- package/dist/commands/observe.js +34 -0
- package/dist/commands/observe.js.map +1 -0
- package/dist/commands/policy-check.d.ts +14 -0
- package/dist/commands/policy-check.d.ts.map +1 -0
- package/dist/commands/policy-check.js +76 -0
- package/dist/commands/policy-check.js.map +1 -0
- package/dist/commands/pr.d.ts +17 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +148 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/ps.d.ts +25 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +164 -0
- package/dist/commands/ps.js.map +1 -0
- package/dist/commands/refresh-helpers.d.ts +25 -0
- package/dist/commands/refresh-helpers.d.ts.map +1 -0
- package/dist/commands/refresh-helpers.js +56 -0
- package/dist/commands/refresh-helpers.js.map +1 -0
- package/dist/commands/refresh.d.ts +23 -0
- package/dist/commands/refresh.d.ts.map +1 -0
- package/dist/commands/refresh.js +237 -0
- package/dist/commands/refresh.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/upgrade.d.ts +67 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +358 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/workspace.d.ts +23 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +198 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/commands/world-snapshot.d.ts +18 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -0
- package/dist/commands/world-snapshot.js +327 -0
- package/dist/commands/world-snapshot.js.map +1 -0
- package/dist/context.d.ts +26 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +51 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18007 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.js +32236 -0
- package/dist/output.d.ts +10 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +31 -0
- package/dist/output.js.map +1 -0
- package/host-cp/compose.yaml +126 -0
- package/host-cp/src/auth-secret-hint.mjs +45 -0
- package/host-cp/src/auth.mjs +155 -0
- package/host-cp/src/compose-worlds-sources.mjs +170 -0
- package/host-cp/src/container-secret-fetcher.mjs +163 -0
- package/host-cp/src/docker-events.mjs +184 -0
- package/host-cp/src/local-worlds-source.mjs +83 -0
- package/host-cp/src/plan-orchestrator.mjs +829 -0
- package/host-cp/src/plan-progress.mjs +282 -0
- package/host-cp/src/pr-cache.mjs +201 -0
- package/host-cp/src/pr-merge-poller.mjs +154 -0
- package/host-cp/src/process-poller.mjs +250 -0
- package/host-cp/src/proxy.mjs +245 -0
- package/host-cp/src/pylon-worlds-source.mjs +68 -0
- package/host-cp/src/redact.mjs +67 -0
- package/host-cp/src/secret-cache.mjs +104 -0
- package/host-cp/src/server.mjs +2215 -0
- package/host-cp/src/sse-gate.mjs +117 -0
- package/host-cp/src/version-status.mjs +209 -0
- package/host-cp/src/workspace-catalog.mjs +149 -0
- package/host-cp/src/world-names-store.mjs +176 -0
- package/host-cp/src/world-pr-state.mjs +97 -0
- package/host-cp/src/world-progress.mjs +322 -0
- package/host-cp/src/world-tunnel-manager.mjs +288 -0
- package/host-cp/src/worlds-db-source.mjs +191 -0
- package/host-cp/src/worlds-source.mjs +59 -0
- package/package.json +38 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
// plan-orchestrator.mjs — Phase 2: multi-persona conversation coordinator.
|
|
2
|
+
//
|
|
3
|
+
// Architecture:
|
|
4
|
+
// - AgentRegistry holds one pi AgentSession per (conversationId, personaId).
|
|
5
|
+
// - HandoffEngine forks the session tree when the active persona changes.
|
|
6
|
+
// - All persona turns share one session.jsonl per conversation.
|
|
7
|
+
// - SSE sinks are an in-process Set<ServerResponse> per conversationId.
|
|
8
|
+
//
|
|
9
|
+
// Credentials:
|
|
10
|
+
// - Uses the Olam auth-service vault (same as the rest of host-cp).
|
|
11
|
+
// - No ANTHROPIC_API_KEY required; tokens fetched on demand via auth-service.
|
|
12
|
+
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import Database from 'better-sqlite3';
|
|
18
|
+
import { SessionManager } from '@mariozechner/pi-coding-agent';
|
|
19
|
+
import { PERSONAS, DEFAULT_PERSONA_ID, getPersona } from './plan/personas.mjs';
|
|
20
|
+
import { AgentRegistry } from './plan/agent-registry.mjs';
|
|
21
|
+
import { HandoffEngine } from './plan/handoff-engine.mjs';
|
|
22
|
+
|
|
23
|
+
// ── Paths ─────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const PLAN_DB_PATH = path.join(os.homedir(), '.olam', 'plan.db');
|
|
26
|
+
const PLAN_DIR = path.join(os.homedir(), '.olam', 'plan');
|
|
27
|
+
|
|
28
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function initSessionFile(sessionFile, sessionId) {
|
|
31
|
+
const header = {
|
|
32
|
+
type: 'session',
|
|
33
|
+
version: 3,
|
|
34
|
+
id: sessionId,
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
cwd: os.homedir(),
|
|
37
|
+
};
|
|
38
|
+
fs.writeFileSync(sessionFile, JSON.stringify(header) + '\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive a short title from the first user message content.
|
|
43
|
+
* Truncates at a word boundary to at most maxLen characters.
|
|
44
|
+
* @param {string} content
|
|
45
|
+
* @param {number} [maxLen=40]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function deriveTitle(content, maxLen = 40) {
|
|
49
|
+
const trimmed = content.trim().replace(/\s+/g, ' ');
|
|
50
|
+
if (!trimmed) return '(empty)';
|
|
51
|
+
if (trimmed.length <= maxLen) return trimmed;
|
|
52
|
+
const cut = trimmed.slice(0, maxLen);
|
|
53
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
54
|
+
return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut) + '…';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── PlanOrchestrator ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export class PlanOrchestrator {
|
|
60
|
+
#db;
|
|
61
|
+
#authServiceUrl;
|
|
62
|
+
#authServiceSecret;
|
|
63
|
+
#registry;
|
|
64
|
+
#handoffEngine;
|
|
65
|
+
|
|
66
|
+
/** Tracks the active persona per conversationId: Map<conversationId, personaId> */
|
|
67
|
+
#activePersona = new Map();
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, Set<import('node:http').ServerResponse>>} */
|
|
70
|
+
#sinks = new Map();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ring buffer of in-flight SSE events per conversationId.
|
|
74
|
+
* Populated while a turn is active; cleared on turn_complete.
|
|
75
|
+
* Used by drainReplayBuffer to replay missed events on reconnect.
|
|
76
|
+
* @type {Map<string, Array<{event: string, data: object}>>}
|
|
77
|
+
*/
|
|
78
|
+
#activeTurns = new Map();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mutable current-chunk refs per conversationId.
|
|
82
|
+
* ChunkEmitter updates these; read_sidebar tool reads them.
|
|
83
|
+
* @type {Map<string, { current: string|null }>}
|
|
84
|
+
*/
|
|
85
|
+
#currentChunkRefs = new Map();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{ authServiceUrl: string, authServiceSecret: string }} opts
|
|
89
|
+
*/
|
|
90
|
+
constructor({ authServiceUrl, authServiceSecret }) {
|
|
91
|
+
this.#authServiceUrl = authServiceUrl;
|
|
92
|
+
this.#authServiceSecret = authServiceSecret;
|
|
93
|
+
|
|
94
|
+
this.#registry = new AgentRegistry({ authServiceUrl, authServiceSecret });
|
|
95
|
+
this.#handoffEngine = new HandoffEngine(this.#registry);
|
|
96
|
+
|
|
97
|
+
fs.mkdirSync(path.dirname(PLAN_DB_PATH), { recursive: true });
|
|
98
|
+
this.#db = new Database(PLAN_DB_PATH);
|
|
99
|
+
this.#db.exec(`
|
|
100
|
+
CREATE TABLE IF NOT EXISTS plan_conversations (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
title TEXT,
|
|
103
|
+
persona TEXT NOT NULL DEFAULT 'brainstorm',
|
|
104
|
+
created_at INTEGER NOT NULL,
|
|
105
|
+
last_turn_at INTEGER
|
|
106
|
+
);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS plan_turns (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
conversation_id TEXT NOT NULL REFERENCES plan_conversations(id),
|
|
110
|
+
role TEXT NOT NULL,
|
|
111
|
+
content TEXT NOT NULL DEFAULT '',
|
|
112
|
+
persona TEXT,
|
|
113
|
+
from_persona TEXT,
|
|
114
|
+
to_persona TEXT,
|
|
115
|
+
mode TEXT,
|
|
116
|
+
fork_node_id TEXT,
|
|
117
|
+
created_at INTEGER NOT NULL
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS plan_turns_conv_idx
|
|
120
|
+
ON plan_turns(conversation_id, created_at);
|
|
121
|
+
|
|
122
|
+
-- Phase 4B: lookout agent registry per conversation
|
|
123
|
+
CREATE TABLE IF NOT EXISTS plan_lookout_agents (
|
|
124
|
+
conversation_id TEXT NOT NULL,
|
|
125
|
+
persona_id TEXT NOT NULL,
|
|
126
|
+
muted INTEGER NOT NULL DEFAULT 0,
|
|
127
|
+
mode TEXT NOT NULL DEFAULT 'observe',
|
|
128
|
+
created_at INTEGER NOT NULL,
|
|
129
|
+
PRIMARY KEY (conversation_id, persona_id)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
-- Phase 4B: sidebar signals from lookout agents
|
|
133
|
+
CREATE TABLE IF NOT EXISTS plan_sidebar_signals (
|
|
134
|
+
id TEXT PRIMARY KEY,
|
|
135
|
+
conversation_id TEXT NOT NULL,
|
|
136
|
+
agent_id TEXT NOT NULL,
|
|
137
|
+
urgency TEXT NOT NULL DEFAULT 'p2',
|
|
138
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
139
|
+
content TEXT NOT NULL DEFAULT '',
|
|
140
|
+
chunk_id TEXT NOT NULL,
|
|
141
|
+
created_at INTEGER NOT NULL,
|
|
142
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
143
|
+
tension_subject TEXT,
|
|
144
|
+
parent_signal_id TEXT
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS plan_sidebar_conv_idx
|
|
147
|
+
ON plan_sidebar_signals(conversation_id, created_at);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS plan_sidebar_chunk_idx
|
|
149
|
+
ON plan_sidebar_signals(chunk_id);
|
|
150
|
+
`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Auth-service credential fetching ──────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fetch a live Claude API token from the vault.
|
|
157
|
+
* @returns {Promise<string>}
|
|
158
|
+
*/
|
|
159
|
+
async #fetchToken() {
|
|
160
|
+
return this.#registry.fetchToken('claude');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Lightweight check — returns true if vault has an active credential.
|
|
165
|
+
* @returns {Promise<boolean>}
|
|
166
|
+
*/
|
|
167
|
+
async hasCredential() {
|
|
168
|
+
try {
|
|
169
|
+
await this.#fetchToken();
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Conversation management ───────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {{ title?: string }} [opts]
|
|
180
|
+
* @returns {{ id: string, title: string|null, persona: string, created_at: number }}
|
|
181
|
+
*/
|
|
182
|
+
createConversation({ title } = {}) {
|
|
183
|
+
const id = randomUUID();
|
|
184
|
+
const created_at = Date.now();
|
|
185
|
+
|
|
186
|
+
const sessionDir = path.join(PLAN_DIR, id);
|
|
187
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
188
|
+
initSessionFile(path.join(sessionDir, 'session.jsonl'), id);
|
|
189
|
+
|
|
190
|
+
this.#db
|
|
191
|
+
.prepare(
|
|
192
|
+
`INSERT INTO plan_conversations (id, title, persona, created_at)
|
|
193
|
+
VALUES (?, ?, ?, ?)`,
|
|
194
|
+
)
|
|
195
|
+
.run(id, title ?? null, DEFAULT_PERSONA_ID, created_at);
|
|
196
|
+
|
|
197
|
+
this.#activePersona.set(id, DEFAULT_PERSONA_ID);
|
|
198
|
+
|
|
199
|
+
return { id, title: title ?? null, persona: DEFAULT_PERSONA_ID, created_at };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @returns {Array<{id, title, created_at, last_turn_at, persona}>} */
|
|
203
|
+
listConversations() {
|
|
204
|
+
return this.#db
|
|
205
|
+
.prepare(
|
|
206
|
+
`SELECT id, title, created_at, last_turn_at, persona
|
|
207
|
+
FROM plan_conversations
|
|
208
|
+
ORDER BY created_at DESC`,
|
|
209
|
+
)
|
|
210
|
+
.all();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} id
|
|
215
|
+
* @returns {{ id, title, persona, created_at, last_turn_at, tree } | null}
|
|
216
|
+
*/
|
|
217
|
+
getConversation(id) {
|
|
218
|
+
const row = this.#db
|
|
219
|
+
.prepare(
|
|
220
|
+
`SELECT id, title, persona, created_at, last_turn_at
|
|
221
|
+
FROM plan_conversations WHERE id = ?`,
|
|
222
|
+
)
|
|
223
|
+
.get(id);
|
|
224
|
+
|
|
225
|
+
if (!row) return null;
|
|
226
|
+
|
|
227
|
+
const sessionFile = path.join(PLAN_DIR, id, 'session.jsonl');
|
|
228
|
+
let tree = [];
|
|
229
|
+
try {
|
|
230
|
+
const mgr = SessionManager.open(sessionFile, path.join(PLAN_DIR, id));
|
|
231
|
+
tree = mgr.getTree();
|
|
232
|
+
} catch {
|
|
233
|
+
// Session file missing or corrupt — return empty tree.
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { ...row, tree };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Active persona management ─────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} conversationId
|
|
243
|
+
* @returns {string} Active persona ID.
|
|
244
|
+
*/
|
|
245
|
+
getActivePersona(conversationId) {
|
|
246
|
+
if (this.#activePersona.has(conversationId)) {
|
|
247
|
+
return this.#activePersona.get(conversationId);
|
|
248
|
+
}
|
|
249
|
+
const row = this.#db
|
|
250
|
+
.prepare(`SELECT persona FROM plan_conversations WHERE id = ?`)
|
|
251
|
+
.get(conversationId);
|
|
252
|
+
const personaId = row?.persona ?? DEFAULT_PERSONA_ID;
|
|
253
|
+
this.#activePersona.set(conversationId, personaId);
|
|
254
|
+
return personaId;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Set the active default persona for a conversation (does NOT trigger a handoff).
|
|
259
|
+
* @param {string} conversationId
|
|
260
|
+
* @param {string} personaId
|
|
261
|
+
*/
|
|
262
|
+
setActivePersona(conversationId, personaId) {
|
|
263
|
+
this.#activePersona.set(conversationId, personaId);
|
|
264
|
+
this.#db
|
|
265
|
+
.prepare(`UPDATE plan_conversations SET persona = ? WHERE id = ?`)
|
|
266
|
+
.run(personaId, conversationId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── SSE broadcast ─────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
#broadcast(conversationId, eventName, data) {
|
|
272
|
+
// Buffer event while a turn is active for reconnect replay.
|
|
273
|
+
const buf = this.#activeTurns.get(conversationId);
|
|
274
|
+
if (buf) {
|
|
275
|
+
buf.push({ event: eventName, data });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const sinks = this.#sinks.get(conversationId);
|
|
279
|
+
if (!sinks || sinks.size === 0) return;
|
|
280
|
+
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
281
|
+
for (const res of sinks) {
|
|
282
|
+
try { res.write(chunk); } catch { /* client disconnected */ }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Clear buffer after turn_complete so the next turn starts fresh.
|
|
286
|
+
if (eventName === 'turn_complete') {
|
|
287
|
+
this.#activeTurns.delete(conversationId);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Lookout agent management ──────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Invite a persona as a lookout for a conversation.
|
|
295
|
+
* @param {string} conversationId
|
|
296
|
+
* @param {string} personaId
|
|
297
|
+
* @returns {{ persona_id: string, state: string, muted: boolean, mode: string }}
|
|
298
|
+
*/
|
|
299
|
+
inviteLookout(conversationId, personaId) {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
this.#db
|
|
302
|
+
.prepare(`INSERT OR IGNORE INTO plan_lookout_agents (conversation_id, persona_id, muted, mode, created_at) VALUES (?, ?, 0, 'observe', ?)`)
|
|
303
|
+
.run(conversationId, personaId, now);
|
|
304
|
+
const agent = { persona_id: personaId, state: 'listening', muted: false, mode: 'observe' };
|
|
305
|
+
this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'listening' });
|
|
306
|
+
return agent;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Update muted status (or mode) for a lookout agent.
|
|
311
|
+
* @param {string} conversationId
|
|
312
|
+
* @param {string} personaId
|
|
313
|
+
* @param {{ muted?: boolean, mode?: string }} updates
|
|
314
|
+
* @returns {{ persona_id: string, state: string, muted: boolean, mode: string } | null}
|
|
315
|
+
*/
|
|
316
|
+
updateLookout(conversationId, personaId, { muted, mode } = {}) {
|
|
317
|
+
const row = this.#db
|
|
318
|
+
.prepare(`SELECT * FROM plan_lookout_agents WHERE conversation_id = ? AND persona_id = ?`)
|
|
319
|
+
.get(conversationId, personaId);
|
|
320
|
+
if (!row) return null;
|
|
321
|
+
|
|
322
|
+
const newMuted = muted !== undefined ? (muted ? 1 : 0) : row.muted;
|
|
323
|
+
const newMode = mode ?? row.mode;
|
|
324
|
+
this.#db
|
|
325
|
+
.prepare(`UPDATE plan_lookout_agents SET muted = ?, mode = ? WHERE conversation_id = ? AND persona_id = ?`)
|
|
326
|
+
.run(newMuted, newMode, conversationId, personaId);
|
|
327
|
+
|
|
328
|
+
const newState = newMuted ? 'idle' : 'listening';
|
|
329
|
+
this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: newState });
|
|
330
|
+
return { persona_id: personaId, state: newState, muted: !!newMuted, mode: newMode };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Remove a lookout agent.
|
|
335
|
+
* @param {string} conversationId
|
|
336
|
+
* @param {string} personaId
|
|
337
|
+
*/
|
|
338
|
+
uninviteLookout(conversationId, personaId) {
|
|
339
|
+
this.#db
|
|
340
|
+
.prepare(`DELETE FROM plan_lookout_agents WHERE conversation_id = ? AND persona_id = ?`)
|
|
341
|
+
.run(conversationId, personaId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* List active lookout agents for a conversation.
|
|
346
|
+
* @param {string} conversationId
|
|
347
|
+
* @returns {Array<{ persona_id: string, state: string, muted: boolean, mode: string }>}
|
|
348
|
+
*/
|
|
349
|
+
listLookoutAgents(conversationId) {
|
|
350
|
+
const rows = this.#db
|
|
351
|
+
.prepare(`SELECT persona_id, muted, mode FROM plan_lookout_agents WHERE conversation_id = ?`)
|
|
352
|
+
.all(conversationId);
|
|
353
|
+
return rows.map((r) => ({
|
|
354
|
+
persona_id: r.persona_id,
|
|
355
|
+
state: r.muted ? 'idle' : 'listening',
|
|
356
|
+
muted: !!r.muted,
|
|
357
|
+
mode: r.mode,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Sidebar signal management ─────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Dismiss a sidebar signal.
|
|
365
|
+
* @param {string} conversationId
|
|
366
|
+
* @param {string} signalId
|
|
367
|
+
* @returns {boolean}
|
|
368
|
+
*/
|
|
369
|
+
dismissSignal(conversationId, signalId) {
|
|
370
|
+
const info = this.#db
|
|
371
|
+
.prepare(`UPDATE plan_sidebar_signals SET status = 'dismissed' WHERE id = ? AND conversation_id = ?`)
|
|
372
|
+
.run(signalId, conversationId);
|
|
373
|
+
return info.changes > 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Mark a sidebar signal as used (for next turn context).
|
|
378
|
+
* @param {string} conversationId
|
|
379
|
+
* @param {string} signalId
|
|
380
|
+
* @returns {boolean}
|
|
381
|
+
*/
|
|
382
|
+
useSignal(conversationId, signalId) {
|
|
383
|
+
const info = this.#db
|
|
384
|
+
.prepare(`UPDATE plan_sidebar_signals SET status = 'used' WHERE id = ? AND conversation_id = ?`)
|
|
385
|
+
.run(signalId, conversationId);
|
|
386
|
+
return info.changes > 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* List sidebar signals for a conversation (optionally filtered by chunk_id).
|
|
391
|
+
* @param {string} conversationId
|
|
392
|
+
* @param {string} [chunkId]
|
|
393
|
+
* @returns {Array<object>}
|
|
394
|
+
*/
|
|
395
|
+
listSignals(conversationId, chunkId) {
|
|
396
|
+
if (chunkId) {
|
|
397
|
+
return this.#db
|
|
398
|
+
.prepare(`SELECT * FROM plan_sidebar_signals WHERE conversation_id = ? AND chunk_id = ? ORDER BY created_at ASC`)
|
|
399
|
+
.all(conversationId, chunkId);
|
|
400
|
+
}
|
|
401
|
+
return this.#db
|
|
402
|
+
.prepare(`SELECT * FROM plan_sidebar_signals WHERE conversation_id = ? ORDER BY created_at ASC`)
|
|
403
|
+
.all(conversationId);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Lookout analysis ──────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Persona-specific heuristics for lookout analysis.
|
|
410
|
+
* Returns { shouldComment: boolean, urgency, content, reason, tension_subject? }
|
|
411
|
+
* or null if no comment warranted.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} personaId
|
|
414
|
+
* @param {string} content — chunk content to analyze
|
|
415
|
+
* @returns {{ urgency: string, content: string, reason: string, tension_subject?: string } | null}
|
|
416
|
+
*/
|
|
417
|
+
#analyzeChunkHeuristic(personaId, content) {
|
|
418
|
+
const lower = content.toLowerCase();
|
|
419
|
+
|
|
420
|
+
if (personaId === 'scout') {
|
|
421
|
+
// Scout: flag unsubstantiated claims and factual assertions
|
|
422
|
+
const claimPatterns = [
|
|
423
|
+
/\b(research shows|studies (show|indicate|suggest)|data (shows|indicates|suggests))\b/i,
|
|
424
|
+
/\b\d+(\.\d+)?\s*%\b/,
|
|
425
|
+
/\b(always|never|all|every|none|no one)\b/i,
|
|
426
|
+
/\b(proven|definitive|certain|guaranteed|undeniable)\b/i,
|
|
427
|
+
/\b(industry standard|best practice|widely accepted)\b/i,
|
|
428
|
+
];
|
|
429
|
+
const matched = claimPatterns.find((p) => p.test(content));
|
|
430
|
+
if (matched) {
|
|
431
|
+
return {
|
|
432
|
+
urgency: 'p2',
|
|
433
|
+
reason: 'Factual claim without cited source',
|
|
434
|
+
content: 'This response contains claims that should be verified with evidence. What data or sources back this up?',
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// Scout spark: look for unexplored data angles
|
|
438
|
+
if (lower.includes('option') || lower.includes('approach') || lower.includes('strategy')) {
|
|
439
|
+
if (Math.random() < 0.3) {
|
|
440
|
+
return {
|
|
441
|
+
urgency: 'spark',
|
|
442
|
+
reason: 'Potential evidence gap',
|
|
443
|
+
content: '_What metrics or signals would tell us which option is actually better here?_',
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (personaId === 'pm') {
|
|
450
|
+
// PM: flag scope ambiguity and missing requirements
|
|
451
|
+
const scopePatterns = [
|
|
452
|
+
/\b(could|might|maybe|perhaps|possibly|potentially)\b/i,
|
|
453
|
+
/\b(later|eventually|someday|future)\b/i,
|
|
454
|
+
/\b(depends on|unclear|tbd|to be determined)\b/i,
|
|
455
|
+
];
|
|
456
|
+
const matched = scopePatterns.find((p) => p.test(content));
|
|
457
|
+
if (matched) {
|
|
458
|
+
return {
|
|
459
|
+
urgency: 'p1',
|
|
460
|
+
reason: 'Scope ambiguity detected',
|
|
461
|
+
content: 'Scope boundary needs clarification. What specifically is in vs. out for this iteration?',
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
// PM: flag missing success criteria
|
|
465
|
+
if ((lower.includes('implement') || lower.includes('build') || lower.includes('create')) && !lower.includes('success') && !lower.includes('metric') && !lower.includes('goal')) {
|
|
466
|
+
if (Math.random() < 0.4) {
|
|
467
|
+
return {
|
|
468
|
+
urgency: 'p2',
|
|
469
|
+
reason: 'Missing acceptance criteria',
|
|
470
|
+
content: 'What does done look like here? Define the measurable success criteria before building.',
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (personaId === 'brainstorm') {
|
|
477
|
+
// Brainstorm: flag premature convergence on a single option
|
|
478
|
+
const convergencePatterns = [
|
|
479
|
+
/\b(the (best|right|correct|only) (way|approach|solution|option))\b/i,
|
|
480
|
+
/\b(we should|we must|we need to|the answer is)\b/i,
|
|
481
|
+
/\b(obviously|clearly|simply|just)\b/i,
|
|
482
|
+
];
|
|
483
|
+
const matched = convergencePatterns.find((p) => p.test(content));
|
|
484
|
+
if (matched) {
|
|
485
|
+
return {
|
|
486
|
+
urgency: 'spark',
|
|
487
|
+
reason: 'Early convergence on one path',
|
|
488
|
+
content: '_Before narrowing: what\'s the alternative that explicitly rejects this approach? What would it look like?_',
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Run lookout analysis for all active lookout agents after a turn completes.
|
|
498
|
+
* Emits sidebar_entry SSE events for any signals generated.
|
|
499
|
+
*
|
|
500
|
+
* @param {string} conversationId
|
|
501
|
+
* @param {string} chunkId — the turn ID used as chunk reference
|
|
502
|
+
* @param {string} chunkContent — the assistant's response text
|
|
503
|
+
* @param {string} chunkPersona — which persona produced the chunk
|
|
504
|
+
*/
|
|
505
|
+
async #runLookoutAnalysis(conversationId, chunkId, chunkContent, chunkPersona) {
|
|
506
|
+
const lookouts = this.#db
|
|
507
|
+
.prepare(`SELECT persona_id, muted FROM plan_lookout_agents WHERE conversation_id = ? AND muted = 0`)
|
|
508
|
+
.all(conversationId);
|
|
509
|
+
|
|
510
|
+
for (const lookout of lookouts) {
|
|
511
|
+
const { persona_id: personaId } = lookout;
|
|
512
|
+
|
|
513
|
+
// Skip if this is the persona that produced the chunk
|
|
514
|
+
if (personaId === chunkPersona) continue;
|
|
515
|
+
|
|
516
|
+
// Emit thinking state
|
|
517
|
+
this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'thinking' });
|
|
518
|
+
|
|
519
|
+
// Small async gap to let the SSE event reach the client before analysis
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 300 + Math.random() * 700));
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const analysis = this.#analyzeChunkHeuristic(personaId, chunkContent);
|
|
524
|
+
|
|
525
|
+
if (analysis) {
|
|
526
|
+
const signalId = randomUUID();
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
this.#db
|
|
529
|
+
.prepare(
|
|
530
|
+
`INSERT INTO plan_sidebar_signals (id, conversation_id, agent_id, urgency, reason, content, chunk_id, created_at, status, tension_subject)
|
|
531
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
|
|
532
|
+
)
|
|
533
|
+
.run(signalId, conversationId, personaId, analysis.urgency, analysis.reason, analysis.content, chunkId, now, analysis.tension_subject ?? null);
|
|
534
|
+
|
|
535
|
+
const signal = {
|
|
536
|
+
id: signalId,
|
|
537
|
+
agent_id: personaId,
|
|
538
|
+
urgency: analysis.urgency,
|
|
539
|
+
reason: analysis.reason,
|
|
540
|
+
content: analysis.content,
|
|
541
|
+
chunk_id: chunkId,
|
|
542
|
+
created_at: now,
|
|
543
|
+
status: 'active',
|
|
544
|
+
tension_subject: analysis.tension_subject ?? null,
|
|
545
|
+
parent_signal_id: null,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
this.#broadcast(
|
|
549
|
+
conversationId,
|
|
550
|
+
analysis.urgency === 'p0' ? 'interrupt' : 'sidebar_entry',
|
|
551
|
+
{ signal },
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
} catch (err) {
|
|
555
|
+
console.error(`[plan] lookout analysis error ${conversationId}/${personaId}:`, err.message);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Return to listening state
|
|
559
|
+
this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'listening' });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Persona subscription setup ────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Wire pi event listeners for a session so tokens + turn_complete events are
|
|
567
|
+
* forwarded to SSE clients.
|
|
568
|
+
*
|
|
569
|
+
* @param {string} conversationId
|
|
570
|
+
* @param {string} personaId
|
|
571
|
+
* @param {import('@mariozechner/pi-coding-agent').AgentSession} session
|
|
572
|
+
*/
|
|
573
|
+
#wireSessionEvents(conversationId, personaId, session) {
|
|
574
|
+
session.subscribe((event) => {
|
|
575
|
+
if (event.type === 'message_update') {
|
|
576
|
+
const ae = event.assistantMessageEvent;
|
|
577
|
+
if (ae.type === 'text_delta') {
|
|
578
|
+
this.#broadcast(conversationId, 'token', { delta: ae.delta, persona: personaId });
|
|
579
|
+
}
|
|
580
|
+
} else if (event.type === 'agent_end') {
|
|
581
|
+
const msgs = event.messages;
|
|
582
|
+
const last = msgs[msgs.length - 1];
|
|
583
|
+
|
|
584
|
+
let persistedText = '';
|
|
585
|
+
let turnId = last?.id ?? randomUUID();
|
|
586
|
+
|
|
587
|
+
// Persist the assistant turn so history loads correctly.
|
|
588
|
+
if (last) {
|
|
589
|
+
const text = (last.content ?? [])
|
|
590
|
+
.filter((c) => c.type === 'text')
|
|
591
|
+
.map((c) => c.text ?? '')
|
|
592
|
+
.join('');
|
|
593
|
+
if (text) {
|
|
594
|
+
persistedText = text;
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
this.#db
|
|
597
|
+
.prepare(
|
|
598
|
+
`INSERT OR IGNORE INTO plan_turns
|
|
599
|
+
(id, conversation_id, role, content, persona, created_at)
|
|
600
|
+
VALUES (?, ?, 'assistant', ?, ?, ?)`,
|
|
601
|
+
)
|
|
602
|
+
.run(turnId, conversationId, text, personaId, now);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
this.#broadcast(conversationId, 'turn_complete', {
|
|
607
|
+
turnId,
|
|
608
|
+
persona: personaId,
|
|
609
|
+
finishReason: last?.stopReason ?? 'end_turn',
|
|
610
|
+
});
|
|
611
|
+
this.#db
|
|
612
|
+
.prepare(`UPDATE plan_conversations SET last_turn_at = ? WHERE id = ?`)
|
|
613
|
+
.run(Date.now(), conversationId);
|
|
614
|
+
|
|
615
|
+
// Trigger lookout analysis asynchronously — does not block the turn.
|
|
616
|
+
if (persistedText) {
|
|
617
|
+
this.#runLookoutAnalysis(conversationId, turnId, persistedText, personaId)
|
|
618
|
+
.catch((err) => console.error('[plan] lookout run error:', err.message));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Submit a user turn to the active (or overridden) persona.
|
|
628
|
+
* Returns immediately; tokens stream over SSE.
|
|
629
|
+
*
|
|
630
|
+
* @param {{
|
|
631
|
+
* conversationId: string,
|
|
632
|
+
* content: string,
|
|
633
|
+
* personaOverride?: string,
|
|
634
|
+
* }} params
|
|
635
|
+
* @returns {Promise<{ turnId: string, persona: string }>}
|
|
636
|
+
*/
|
|
637
|
+
async submitTurn({ conversationId, content, personaOverride }) {
|
|
638
|
+
const row = this.#db
|
|
639
|
+
.prepare(`SELECT id, title FROM plan_conversations WHERE id = ?`)
|
|
640
|
+
.get(conversationId);
|
|
641
|
+
|
|
642
|
+
if (!row) {
|
|
643
|
+
const err = new Error('conversation not found');
|
|
644
|
+
err.code = 'NOT_FOUND';
|
|
645
|
+
throw err;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const now = Date.now();
|
|
649
|
+
|
|
650
|
+
// Open (or reset) the replay buffer for this turn.
|
|
651
|
+
this.#activeTurns.set(conversationId, []);
|
|
652
|
+
|
|
653
|
+
// Set title from first user message if still null.
|
|
654
|
+
if (row.title === null) {
|
|
655
|
+
this.#db
|
|
656
|
+
.prepare(`UPDATE plan_conversations SET title = ? WHERE id = ?`)
|
|
657
|
+
.run(deriveTitle(content), conversationId);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Persist the user turn for history replay.
|
|
661
|
+
this.#db
|
|
662
|
+
.prepare(
|
|
663
|
+
`INSERT INTO plan_turns (id, conversation_id, role, content, created_at)
|
|
664
|
+
VALUES (?, ?, 'user', ?, ?)`,
|
|
665
|
+
)
|
|
666
|
+
.run(randomUUID(), conversationId, content, now);
|
|
667
|
+
|
|
668
|
+
const personaId = personaOverride ?? this.getActivePersona(conversationId);
|
|
669
|
+
const onStubCall = (event) => {
|
|
670
|
+
this.#broadcast(conversationId, 'tool_stub_call', { persona: personaId, ...event });
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const { session, authStorage } = await this.#registry.getAgent(conversationId, personaId, { onStubCall });
|
|
674
|
+
|
|
675
|
+
// Wire events on first use (idempotent because pi de-duplicates subscribers).
|
|
676
|
+
this.#wireSessionEvents(conversationId, personaId, session);
|
|
677
|
+
|
|
678
|
+
// Refresh credential before each turn.
|
|
679
|
+
const token = await this.#fetchToken();
|
|
680
|
+
authStorage.setRuntimeApiKey('anthropic', token);
|
|
681
|
+
|
|
682
|
+
const turnId = randomUUID();
|
|
683
|
+
|
|
684
|
+
session.prompt(content).catch((err) => {
|
|
685
|
+
console.error(`[plan] prompt error ${conversationId}/${personaId}:`, err.message);
|
|
686
|
+
this.#broadcast(conversationId, 'error', {
|
|
687
|
+
message: err.message,
|
|
688
|
+
code: err.code ?? 'PROMPT_ERROR',
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
return { turnId, persona: personaId };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Execute a handoff, switching the default active persona.
|
|
697
|
+
*
|
|
698
|
+
* @param {{
|
|
699
|
+
* conversationId: string,
|
|
700
|
+
* toPersona: string,
|
|
701
|
+
* mode?: 'full' | 'distilled' | 'quoted',
|
|
702
|
+
* selectedTurnIds?: string[],
|
|
703
|
+
* }} params
|
|
704
|
+
* @returns {Promise<{ handoffId: string, forkNodeId: string | null, seededTurnCount: number }>}
|
|
705
|
+
*/
|
|
706
|
+
async handoff({ conversationId, toPersona, mode = 'full', selectedTurnIds = [] }) {
|
|
707
|
+
const row = this.#db
|
|
708
|
+
.prepare(`SELECT id FROM plan_conversations WHERE id = ?`)
|
|
709
|
+
.get(conversationId);
|
|
710
|
+
if (!row) {
|
|
711
|
+
const err = new Error('conversation not found');
|
|
712
|
+
err.code = 'NOT_FOUND';
|
|
713
|
+
throw err;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const fromPersona = this.getActivePersona(conversationId);
|
|
717
|
+
const onStubCall = (event) => {
|
|
718
|
+
this.#broadcast(conversationId, 'tool_stub_call', { persona: toPersona, ...event });
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const result = await this.#handoffEngine.handoff({
|
|
722
|
+
conversationId,
|
|
723
|
+
fromPersona,
|
|
724
|
+
toPersona,
|
|
725
|
+
mode,
|
|
726
|
+
selectedTurnIds,
|
|
727
|
+
fetchToken: () => this.#fetchToken(),
|
|
728
|
+
onStubCall,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Update the active persona for this conversation.
|
|
732
|
+
this.setActivePersona(conversationId, toPersona);
|
|
733
|
+
|
|
734
|
+
// Persist handoff marker so history replay can reconstruct it.
|
|
735
|
+
this.#db
|
|
736
|
+
.prepare(
|
|
737
|
+
`INSERT OR IGNORE INTO plan_turns
|
|
738
|
+
(id, conversation_id, role, content, from_persona, to_persona, mode, fork_node_id, created_at)
|
|
739
|
+
VALUES (?, ?, 'handoff', '', ?, ?, ?, ?, ?)`,
|
|
740
|
+
)
|
|
741
|
+
.run(result.handoffId, conversationId, fromPersona, toPersona, mode, result.forkNodeId ?? null, Date.now());
|
|
742
|
+
|
|
743
|
+
// Broadcast the handoff event to SSE clients.
|
|
744
|
+
this.#broadcast(conversationId, 'handoff', {
|
|
745
|
+
handoffId: result.handoffId,
|
|
746
|
+
fromPersona,
|
|
747
|
+
toPersona,
|
|
748
|
+
mode,
|
|
749
|
+
forkNodeId: result.forkNodeId,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// Wire events for the new persona's session.
|
|
753
|
+
try {
|
|
754
|
+
const { session } = await this.#registry.getAgent(conversationId, toPersona, { onStubCall });
|
|
755
|
+
this.#wireSessionEvents(conversationId, toPersona, session);
|
|
756
|
+
} catch {
|
|
757
|
+
// Best-effort — events will be wired on first turn if this fails.
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Replay buffered in-flight SSE events to a reconnecting client.
|
|
765
|
+
* Call this before addEventSink so the client gets events it missed.
|
|
766
|
+
* No-op if no turn is active.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} conversationId
|
|
769
|
+
* @param {import('node:http').ServerResponse} res
|
|
770
|
+
*/
|
|
771
|
+
drainReplayBuffer(conversationId, res) {
|
|
772
|
+
const buf = this.#activeTurns.get(conversationId);
|
|
773
|
+
if (!buf || buf.length === 0) return;
|
|
774
|
+
for (const { event, data } of buf) {
|
|
775
|
+
try {
|
|
776
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
777
|
+
} catch { /* client closed before drain completed */ }
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Register an SSE sink for a conversation. Returns a cleanup function.
|
|
783
|
+
* @param {string} conversationId
|
|
784
|
+
* @param {import('node:http').ServerResponse} res
|
|
785
|
+
* @returns {() => void}
|
|
786
|
+
*/
|
|
787
|
+
addEventSink(conversationId, res) {
|
|
788
|
+
if (!this.#sinks.has(conversationId)) {
|
|
789
|
+
this.#sinks.set(conversationId, new Set());
|
|
790
|
+
}
|
|
791
|
+
this.#sinks.get(conversationId).add(res);
|
|
792
|
+
return () => {
|
|
793
|
+
const s = this.#sinks.get(conversationId);
|
|
794
|
+
if (s) s.delete(res);
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Return the ordered turn list for a conversation (for history replay).
|
|
800
|
+
* Each turn is one of:
|
|
801
|
+
* { role:'user'|'assistant', content, persona?, created_at }
|
|
802
|
+
* { role:'handoff', from_persona, to_persona, mode, fork_node_id, created_at }
|
|
803
|
+
* @param {string} conversationId
|
|
804
|
+
* @returns {Array<object>}
|
|
805
|
+
*/
|
|
806
|
+
getTurns(conversationId) {
|
|
807
|
+
return this.#db
|
|
808
|
+
.prepare(
|
|
809
|
+
`SELECT id, role, content, persona, from_persona, to_persona, mode, fork_node_id, created_at
|
|
810
|
+
FROM plan_turns
|
|
811
|
+
WHERE conversation_id = ?
|
|
812
|
+
ORDER BY created_at ASC`,
|
|
813
|
+
)
|
|
814
|
+
.all(conversationId);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Expose persona list for the /api/plan/personas endpoint. */
|
|
818
|
+
listPersonas() {
|
|
819
|
+
return PERSONAS.map((p) => ({
|
|
820
|
+
id: p.id,
|
|
821
|
+
displayName: p.displayName,
|
|
822
|
+
model: p.model,
|
|
823
|
+
toolNames: p.toolNames,
|
|
824
|
+
systemPromptPreview: p.systemPrompt.length > 120
|
|
825
|
+
? p.systemPrompt.slice(0, 117) + '...'
|
|
826
|
+
: p.systemPrompt,
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
}
|