@pellux/goodvibes-agent 1.5.1 → 1.5.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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/dist/package/main.js +5324 -1089
- package/package.json +2 -3
- package/release/release-notes.md +5 -5
- package/release/release-readiness.json +7 -7
- package/src/agent/channel-draft.ts +431 -0
- package/src/agent/channel-profile-routing.ts +330 -0
- package/src/agent/email/style-reply-lane.ts +179 -0
- package/src/agent/email/style-reply.ts +358 -0
- package/src/agent/memory-prompt.ts +2 -1
- package/src/agent/skill-draft-proposer.ts +3 -3
- package/src/agent/unified-inbox.ts +393 -0
- package/src/tools/agent-harness-comms.ts +378 -0
- package/src/tools/agent-harness-learning-auto-promote.ts +288 -0
- package/src/tools/agent-harness-learning-curator.ts +9 -9
- package/src/tools/agent-harness-mode-catalog.ts +16 -0
- package/src/tools/agent-harness-personal-ops-discovery.ts +2 -1
- package/src/tools/agent-harness-personal-ops-lanes.ts +9 -5
- package/src/tools/agent-harness-remote.ts +220 -0
- package/src/tools/agent-harness-setup-smoke.ts +2 -4
- package/src/tools/agent-harness-tool-schema.ts +80 -1
- package/src/tools/agent-harness-tool-types.ts +18 -0
- package/src/tools/agent-harness-tool.ts +61 -0
- package/src/version.ts +1 -1
|
@@ -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
|
+
}
|