@pellux/goodvibes-agent 1.5.2 → 1.5.5

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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * channel-profile-routing.ts
3
+ *
4
+ * Agent-side channel-to-profile routing scaffold.
5
+ *
6
+ * Assigns a channel (surface kind, optional route id) to an active GoodVibes
7
+ * profile so that incoming channel interactions can be routed to the correct
8
+ * agent persona/profile context.
9
+ *
10
+ * Persistence: JSON file at {agentRoot}/channels/profile-routes.json via
11
+ * ShellPathService. Assignments are agent-local and survive sessions.
12
+ *
13
+ * SEAM — Daemon contract needed for full runtime routing:
14
+ *
15
+ * Daemon method: channels.routing.assign
16
+ * Input: { channelId: string; profileId: string; routeId?: string }
17
+ * Output: { assignmentId: string; channelId: string; profileId: string }
18
+ *
19
+ * Until that method is published, this module persists assignments locally.
20
+ * The `daemonMethodNeeded` field on each assignment makes the gap machine-
21
+ * readable so tooling can detect and surface it.
22
+ *
23
+ * When the daemon method ships:
24
+ * 1. Add an adapter that calls operator.invoke('channels.routing.assign', ...)
25
+ * 2. Mirror the local store for offline fallback
26
+ * 3. Remove the `daemonSyncState: 'local_only'` flag from existing records
27
+ */
28
+
29
+ import { randomUUID } from 'node:crypto';
30
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
31
+ import { dirname } from 'node:path';
32
+ import type { ShellPathService } from '@/runtime/index.ts';
33
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Represents a channel-to-profile assignment.
41
+ *
42
+ * `surfaceKind` maps to the existing ChannelDeliverySurfaceKind values
43
+ * (slack, discord, telegram, etc.) plus 'any' as a wildcard.
44
+ */
45
+ export interface ChannelProfileRoute {
46
+ readonly version: 1;
47
+ readonly id: string;
48
+ readonly createdAt: string;
49
+ readonly updatedAt: string;
50
+ /** The channel surface kind (e.g. 'slack', 'discord', 'telegram'). Use 'any' as wildcard. */
51
+ readonly surfaceKind: string;
52
+ /** Optional route id to narrow beyond surface kind. */
53
+ readonly routeId?: string;
54
+ /** The profile id to route this channel to. */
55
+ readonly profileId: string;
56
+ /** Optional human-readable label. */
57
+ readonly label?: string;
58
+ /**
59
+ * Indicates this assignment lives only in the local agent store.
60
+ * Remains 'local_only' until daemon publishes channels.routing.assign.
61
+ */
62
+ readonly daemonSyncState: 'local_only';
63
+ /**
64
+ * SEAM: the daemon operator method needed for runtime-aware routing.
65
+ * Remove this field once the contract is published and synced.
66
+ */
67
+ readonly daemonMethodNeeded: 'channels.routing.assign';
68
+ }
69
+
70
+ export interface ChannelProfileRouteSnapshot {
71
+ readonly path: string;
72
+ readonly exists: boolean;
73
+ readonly routes: readonly ChannelProfileRoute[];
74
+ readonly parseError?: string;
75
+ }
76
+
77
+ export interface ChannelProfileRouteAssignResult {
78
+ readonly route: ChannelProfileRoute;
79
+ readonly path: string;
80
+ readonly created: boolean;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Internal file model
85
+ // ---------------------------------------------------------------------------
86
+
87
+ interface ChannelProfileRouteFile {
88
+ readonly version: 1;
89
+ readonly routes: readonly ChannelProfileRoute[];
90
+ }
91
+
92
+ type ShellPaths = Pick<ShellPathService, 'resolveUserPath'>;
93
+
94
+ const ROUTE_VERSION = 1 as const;
95
+ const ROUTE_FILE_VERSION = 1 as const;
96
+ const ROUTE_LIMIT = 500;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Path
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export function channelProfileRouteFilePath(shellPaths: ShellPaths): string {
103
+ return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'channels', 'profile-routes.json');
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Parsing
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
112
+ }
113
+
114
+ function readString(value: unknown): string {
115
+ return typeof value === 'string' ? value.trim() : '';
116
+ }
117
+
118
+ function readOptString(value: unknown): string | undefined {
119
+ const s = readString(value);
120
+ return s || undefined;
121
+ }
122
+
123
+ function parseRoute(value: unknown): ChannelProfileRoute | null {
124
+ if (!isRecord(value) || value.version !== ROUTE_VERSION) return null;
125
+ const id = readString(value.id);
126
+ const createdAt = readString(value.createdAt);
127
+ const updatedAt = readString(value.updatedAt);
128
+ const surfaceKind = readString(value.surfaceKind);
129
+ const profileId = readString(value.profileId);
130
+ if (!id || !createdAt || !updatedAt || !surfaceKind || !profileId) return null;
131
+ if (Number.isNaN(Date.parse(createdAt)) || Number.isNaN(Date.parse(updatedAt))) return null;
132
+ return {
133
+ version: ROUTE_VERSION,
134
+ id,
135
+ createdAt,
136
+ updatedAt,
137
+ surfaceKind,
138
+ profileId,
139
+ ...( readOptString(value.routeId) ? { routeId: readOptString(value.routeId) } : {}),
140
+ ...( readOptString(value.label) ? { label: readOptString(value.label) } : {}),
141
+ daemonSyncState: 'local_only',
142
+ daemonMethodNeeded: 'channels.routing.assign',
143
+ };
144
+ }
145
+
146
+ function parseRouteFile(raw: unknown): ChannelProfileRouteFile {
147
+ if (!isRecord(raw)) return { version: ROUTE_FILE_VERSION, routes: [] };
148
+ const routes = Array.isArray(raw.routes)
149
+ ? raw.routes.map(parseRoute).filter((r): r is ChannelProfileRoute => r !== null)
150
+ : [];
151
+ return { version: ROUTE_FILE_VERSION, routes };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Read / write
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export function readChannelProfileRoutes(shellPaths: ShellPaths): ChannelProfileRouteSnapshot {
159
+ const path = channelProfileRouteFilePath(shellPaths);
160
+ if (!existsSync(path)) return { path, exists: false, routes: [] };
161
+ try {
162
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
163
+ return { path, exists: true, routes: parseRouteFile(parsed).routes };
164
+ } catch (error) {
165
+ return {
166
+ path,
167
+ exists: true,
168
+ routes: [],
169
+ parseError: error instanceof Error ? error.message : String(error),
170
+ };
171
+ }
172
+ }
173
+
174
+ function writeRoutes(path: string, routes: readonly ChannelProfileRoute[]): void {
175
+ const file: ChannelProfileRouteFile = {
176
+ version: ROUTE_FILE_VERSION,
177
+ routes: routes.slice(0, ROUTE_LIMIT),
178
+ };
179
+ mkdirSync(dirname(path), { recursive: true });
180
+ const tempPath = `${path}.tmp`;
181
+ writeFileSync(tempPath, `${JSON.stringify(file, null, 2)}\n`, 'utf-8');
182
+ renameSync(tempPath, path);
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Public API
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Assign a channel (identified by surfaceKind + optional routeId) to a profile.
191
+ *
192
+ * If an assignment for the same (surfaceKind, routeId) pair already exists,
193
+ * it is updated in-place. Otherwise a new assignment is created.
194
+ *
195
+ * Returns the assignment record and whether it was newly created.
196
+ *
197
+ * SEAM: when daemon publishes `channels.routing.assign`, add a call here and
198
+ * set `daemonSyncState` based on the daemon response.
199
+ */
200
+ export function assignChannelToProfile(
201
+ shellPaths: ShellPaths,
202
+ input: {
203
+ readonly surfaceKind: string;
204
+ readonly routeId?: string;
205
+ readonly profileId: string;
206
+ readonly label?: string;
207
+ },
208
+ ): ChannelProfileRouteAssignResult {
209
+ const surfaceKind = input.surfaceKind.trim();
210
+ const profileId = input.profileId.trim();
211
+ if (!surfaceKind) throw new Error('surfaceKind is required for channel-to-profile assignment.');
212
+ if (!profileId) throw new Error('profileId is required for channel-to-profile assignment.');
213
+
214
+ const snapshot = readChannelProfileRoutes(shellPaths);
215
+ const path = channelProfileRouteFilePath(shellPaths);
216
+ const now = new Date().toISOString();
217
+
218
+ // Find existing assignment for the same (surfaceKind, routeId) pair
219
+ const routeId = input.routeId?.trim() || undefined;
220
+ const existingIndex = snapshot.routes.findIndex(
221
+ (r) => r.surfaceKind === surfaceKind && r.routeId === routeId,
222
+ );
223
+ const existing = existingIndex >= 0 ? snapshot.routes[existingIndex] : null;
224
+ const created = !existing;
225
+
226
+ const route: ChannelProfileRoute = {
227
+ version: ROUTE_VERSION,
228
+ id: existing?.id ?? `cpr-${new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)}-${randomUUID().slice(0, 8)}`,
229
+ createdAt: existing?.createdAt ?? now,
230
+ updatedAt: now,
231
+ surfaceKind,
232
+ profileId,
233
+ ...(routeId ? { routeId } : {}),
234
+ ...(input.label?.trim() ? { label: input.label.trim() } : {}),
235
+ daemonSyncState: 'local_only',
236
+ daemonMethodNeeded: 'channels.routing.assign',
237
+ };
238
+
239
+ let updatedRoutes: readonly ChannelProfileRoute[];
240
+ if (existingIndex >= 0) {
241
+ updatedRoutes = snapshot.routes.map((r, i) => (i === existingIndex ? route : r));
242
+ } else {
243
+ updatedRoutes = [route, ...snapshot.routes];
244
+ }
245
+
246
+ writeRoutes(path, updatedRoutes);
247
+ return { route, path, created };
248
+ }
249
+
250
+ /** List all channel-to-profile assignments, optionally filtered by profileId. */
251
+ export function listChannelProfileRoutes(
252
+ shellPaths: ShellPaths,
253
+ options: { readonly profileId?: string; readonly surfaceKind?: string } = {},
254
+ ): ChannelProfileRouteSnapshot {
255
+ const snapshot = readChannelProfileRoutes(shellPaths);
256
+ let routes = snapshot.routes;
257
+ if (options.profileId) {
258
+ routes = routes.filter((r) => r.profileId === options.profileId);
259
+ }
260
+ if (options.surfaceKind) {
261
+ routes = routes.filter((r) => r.surfaceKind === options.surfaceKind || r.surfaceKind === 'any');
262
+ }
263
+ return { ...snapshot, routes };
264
+ }
265
+
266
+ /**
267
+ * Get the profile id for an incoming channel message.
268
+ *
269
+ * Resolution order: exact (surfaceKind + routeId) → surface wildcard (surfaceKind only) → any wildcard.
270
+ * Returns null if no assignment exists.
271
+ */
272
+ export function getProfileForChannel(
273
+ shellPaths: ShellPaths,
274
+ surfaceKind: string,
275
+ routeId?: string,
276
+ ): string | null {
277
+ const snapshot = readChannelProfileRoutes(shellPaths);
278
+ const { routes } = snapshot;
279
+
280
+ // Exact match: surfaceKind + routeId
281
+ if (routeId) {
282
+ const exact = routes.find((r) => r.surfaceKind === surfaceKind && r.routeId === routeId);
283
+ if (exact) return exact.profileId;
284
+ }
285
+
286
+ // Surface match: surfaceKind only (no routeId on the assignment)
287
+ const surfaceMatch = routes.find((r) => r.surfaceKind === surfaceKind && !r.routeId);
288
+ if (surfaceMatch) return surfaceMatch.profileId;
289
+
290
+ // Wildcard match
291
+ const wildcard = routes.find((r) => r.surfaceKind === 'any');
292
+ if (wildcard) return wildcard.profileId;
293
+
294
+ return null;
295
+ }
296
+
297
+ /** Remove a channel-to-profile assignment by id. Returns true if removed. */
298
+ export function removeChannelProfileRoute(
299
+ shellPaths: ShellPaths,
300
+ routeId: string,
301
+ ): boolean {
302
+ const snapshot = readChannelProfileRoutes(shellPaths);
303
+ const before = snapshot.routes.length;
304
+ const updated = snapshot.routes.filter((r) => r.id !== routeId);
305
+ if (updated.length === before) return false;
306
+ writeRoutes(channelProfileRouteFilePath(shellPaths), updated);
307
+ return true;
308
+ }
309
+
310
+ /** Format the routing table for human-readable output. */
311
+ export function formatChannelProfileRoutes(snapshot: ChannelProfileRouteSnapshot): string {
312
+ const lines = [
313
+ 'Channel-to-Profile Routing',
314
+ ` path: ${snapshot.path}`,
315
+ ` total: ${snapshot.routes.length}`,
316
+ ` sync: local_only — daemon method needed: channels.routing.assign`,
317
+ ` status: ${snapshot.parseError ? 'attention' : snapshot.exists ? 'ready' : 'empty'}`,
318
+ ...(snapshot.parseError ? [` parse error: ${snapshot.parseError}`] : []),
319
+ '',
320
+ ];
321
+ if (snapshot.routes.length === 0) {
322
+ lines.push(' no channel-to-profile assignments');
323
+ } else {
324
+ for (const route of snapshot.routes) {
325
+ const channelDesc = route.routeId ? `${route.surfaceKind}:${route.routeId}` : route.surfaceKind;
326
+ lines.push(` ${route.id} channel=${channelDesc} → profile=${route.profileId}${route.label ? ` (${route.label})` : ''} sync=${route.daemonSyncState}`);
327
+ }
328
+ }
329
+ return lines.join('\n');
330
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Personal Ops lane descriptor for the writing-style-matched draft-reply flow.
3
+ *
4
+ * This module defines the PersonalOpsWorkflow and PersonalOpsLiveRecord objects
5
+ * that surface the style-reply capability inside the existing Personal Ops
6
+ * 'inbox' lane. It is integrated into buildLanes() in
7
+ * agent-harness-personal-ops-lanes.ts by appending the exported items to the
8
+ * inbox lane's workflows and liveRecords arrays.
9
+ *
10
+ * BEFORE-SEND REVIEW BOUNDARY (enforced here and in style-reply.ts)
11
+ * ──────────────────────────────────────────────────────────────────────
12
+ * The style-reply lane ONLY produces a DRAFT. Composition is local-only in
13
+ * nature (the live record's typed `effect` is 'read-only' — the closest value
14
+ * in the PersonalOpsLiveRecord.effect union — and it carries no `freshness`, so
15
+ * it is never counted as a provider read). Sending requires the confirmed send path
16
+ * (EmailService.sendMail with confirm:true, or an MCP connector action) with
17
+ * explicit user review and confirmation before any provider effect is executed.
18
+ *
19
+ * The confirmationRequired flag on the send follow-up route is always true.
20
+ * Auto-send is architecturally impossible from this module.
21
+ *
22
+ * INTEGRATION
23
+ * ────────────
24
+ * This module is consumed by src/tools/agent-harness-personal-ops-lanes.ts
25
+ * only (no new top-level harness mode is registered here).
26
+ *
27
+ * // INTEGRATION: if a new top-level mode 'inbox_style_reply' is desired in
28
+ * // agent_harness, register it in agent-harness-mode-catalog.ts (that file
29
+ * // is owned by another agent — leave this comment for the orchestrator).
30
+ */
31
+
32
+ import type {
33
+ PersonalOpsLiveRecord,
34
+ PersonalOpsWorkflow,
35
+ PersonalOpsWorkflowStatus,
36
+ } from '../../tools/agent-harness-personal-ops-types.ts';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Workflow status helper
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Determine the workflow status for the style-reply lane.
44
+ *
45
+ * The lane is 'ready' when at least one email connector or daemon method is
46
+ * available (signalled by hasEmailCapability = true). Otherwise 'needs-setup'.
47
+ */
48
+ export function styleReplyWorkflowStatus(hasEmailCapability: boolean): PersonalOpsWorkflowStatus {
49
+ return hasEmailCapability ? 'ready' : 'needs-setup';
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Workflow descriptor
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Personal Ops workflow card for the writing-style-matched draft-reply flow.
58
+ *
59
+ * Presence in inbox lane's `workflows` array makes it discoverable via
60
+ * `personal_ops action:"intake" query:"draft reply in my style"` and similar.
61
+ */
62
+ export function styleReplyWorkflow(hasEmailCapability: boolean): PersonalOpsWorkflow {
63
+ const status = styleReplyWorkflowStatus(hasEmailCapability);
64
+ return {
65
+ id: 'inbox-style-matched-draft-reply',
66
+ label: 'Writing-style-matched draft reply',
67
+ status,
68
+ summary:
69
+ 'Compose a draft email reply that mirrors the user\'s own writing style '
70
+ + '(tone, length, greeting, sign-off) inferred from prior sent messages. '
71
+ + 'The draft is produced locally and NEVER auto-sent.',
72
+ next: status === 'ready'
73
+ ? 'Call personal_ops action:"read" laneId:"inbox" recordId:"inbox-style-reply-draft" '
74
+ + 'with the inbound message and optional key-points context. '
75
+ + 'Review the composed draft in the Agent transcript before sending through the confirmed send route.'
76
+ : 'Set up an email connector or IMAP/SMTP config (email.enabled = true) before drafting in the user\'s style.',
77
+ modelRoute: 'personal_ops action:"read" laneId:"inbox" recordId:"inbox-style-reply-draft" '
78
+ + 'fields:{inboundFrom:"...",inboundSubject:"...",inboundBodyPreview:"...",context:"key points to include"} '
79
+ + 'confirm:true explicitUserRequest:"draft a reply in my style"',
80
+ inspectRoutes: [
81
+ 'personal_ops action:"lane" laneId:"inbox" includeParameters:true',
82
+ 'personal_ops action:"intake" query:"draft reply in my style" includeParameters:true',
83
+ ],
84
+ prerequisites: status === 'needs-setup'
85
+ ? ['Configure email (email.enabled=true, IMAP+SMTP or an MCP inbox connector) before using style-matched drafts.']
86
+ : [
87
+ 'The user must identify the inbound message (from, subject, bodyPreview).',
88
+ 'Optionally supply key-points context to weave into the draft.',
89
+ 'Review the draft in the Agent transcript before any send route.',
90
+ ],
91
+ runBoundary:
92
+ 'Composing stays local (local-only effect). '
93
+ + 'Sending requires the confirmed SMTP/connector route with explicit user review of recipients and body.',
94
+ };
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Live record descriptor
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Live record for the inbox lane’s liveRecords array that exposes the
103
+ * style-reply composer as a Personal Ops read route.
104
+ *
105
+ * The `effect` is 'read-only' (the local composer performs no provider write);
106
+ * it carries no `freshness` so it is not counted as a provider read. Send is a
107
+ * separate confirmed route.
108
+ */
109
+ export function styleReplyLiveRecord(hasEmailCapability: boolean): PersonalOpsLiveRecord {
110
+ const status = hasEmailCapability ? 'ready' : 'needs-setup';
111
+ return {
112
+ id: 'inbox-style-reply-draft',
113
+ label: 'Draft reply in my writing style',
114
+ status,
115
+ summary:
116
+ 'Locally compose a draft email reply that mirrors the user\'s tone, '
117
+ + 'length, greeting, and sign-off. The draft is shown for review; '
118
+ + 'no send occurs until the user explicitly confirms through the send route.',
119
+ userRoute: 'Agent Workspace → Personal Ops → Inbox → Draft reply in my style',
120
+ modelRoute:
121
+ 'personal_ops action:"read" laneId:"inbox" recordId:"inbox-style-reply-draft" '
122
+ + 'fields:{inboundFrom:"<from>",inboundSubject:"<subject>",inboundBodyPreview:"<preview>",context:"<key points>"} '
123
+ + 'confirm:true explicitUserRequest:"draft a reply in my style"',
124
+ tags: ['style-reply', 'draft', 'inbox', 'personal-ops', 'writing-style'],
125
+ // Note: PersonalOpsLiveRecord.effect only supports 'read-only' | 'confirmed-effect'.
126
+ // Style-reply composition has no provider effect, so 'read-only' is the correct value.
127
+ // The local-only nature is communicated via the followUpRoutes policy and reviewBoundary.
128
+ effect: 'read-only' as const,
129
+ capability: 'inbox-style-reply-draft',
130
+ confirmationRequired: false, // local composition; no provider effect
131
+ requiredFields: ['inboundFrom', 'inboundSubject'],
132
+ optionalFields: ['inboundBodyPreview', 'context'],
133
+ sampleInput: {
134
+ inboundFrom: 'Alice Smith <alice@example.com>',
135
+ inboundSubject: 'Project update',
136
+ inboundBodyPreview: 'Hi, just checking in on the project status...',
137
+ context: 'Mention the milestone is on track and you will share a detailed update by Friday.',
138
+ },
139
+ followUpRoutes: [
140
+ {
141
+ id: 'send-after-review',
142
+ label: 'Send reviewed draft (confirmed)',
143
+ effect: 'confirmed-effect',
144
+ modelRoute:
145
+ 'email action:"send" to:"<recipient>" subject:"Re: <subject>" body:"<reviewed draft>" '
146
+ + 'confirm:true explicitUserRequest:"send this reply"',
147
+ requiresConfirmation: true,
148
+ policy:
149
+ 'Sending requires confirm:true, explicit user review of the exact '
150
+ + 'recipients and body, and must go through the confirmed SMTP or '
151
+ + 'connector send path. Auto-send is not permitted.',
152
+ },
153
+ ],
154
+ // No `freshness`: this record is a LOCAL composition, not a provider-backed
155
+ // read, so it must not be classified as a fresh provider record (which would
156
+ // inflate the Personal Ops queue's freshProviderReads count). Drafts are
157
+ // composed locally from supplied fields and are session-local.
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Aggregated export for easy import in buildLanes()
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export interface StyleReplyLaneAdditions {
166
+ readonly workflow: PersonalOpsWorkflow;
167
+ readonly liveRecord: PersonalOpsLiveRecord;
168
+ }
169
+
170
+ /**
171
+ * Returns both the workflow and live record for the style-reply lane.
172
+ * Call once per buildLanes() invocation; result is deterministic.
173
+ */
174
+ export function buildStyleReplyLaneAdditions(hasEmailCapability: boolean): StyleReplyLaneAdditions {
175
+ return {
176
+ workflow: styleReplyWorkflow(hasEmailCapability),
177
+ liveRecord: styleReplyLiveRecord(hasEmailCapability),
178
+ };
179
+ }