@pellux/goodvibes-agent 1.5.2 → 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 +7 -0
- package/README.md +1 -1
- package/dist/package/main.js +3436 -286
- package/package.json +2 -3
- 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,378 @@
|
|
|
1
|
+
import type { CommandContext } from '../input/command-registry.ts';
|
|
2
|
+
import {
|
|
3
|
+
aggregateUnifiedInbox,
|
|
4
|
+
formatUnifiedInbox,
|
|
5
|
+
} from '../agent/unified-inbox.ts';
|
|
6
|
+
import {
|
|
7
|
+
listDrafts,
|
|
8
|
+
getDraft,
|
|
9
|
+
saveDraft,
|
|
10
|
+
queueDraftToSend,
|
|
11
|
+
markDraftSent,
|
|
12
|
+
markDraftFailed,
|
|
13
|
+
formatChannelDraftList,
|
|
14
|
+
formatChannelDraft,
|
|
15
|
+
} from '../agent/channel-draft.ts';
|
|
16
|
+
import {
|
|
17
|
+
listChannelProfileRoutes,
|
|
18
|
+
getProfileForChannel,
|
|
19
|
+
assignChannelToProfile,
|
|
20
|
+
removeChannelProfileRoute,
|
|
21
|
+
formatChannelProfileRoutes,
|
|
22
|
+
} from '../agent/channel-profile-routing.ts';
|
|
23
|
+
import { buildAgentWorkspaceChannelTriage } from '../input/agent-workspace-channel-triage.ts';
|
|
24
|
+
import { deliverAgentChannelMessage } from '../agent/channel-delivery.ts';
|
|
25
|
+
import { requireConfirmedAction } from './agent-harness-tool-utils.ts';
|
|
26
|
+
|
|
27
|
+
/** Redact a draft's webhook (which can embed a token) in any structured display payload. */
|
|
28
|
+
function redactDraftWebhook<T extends { readonly webhook?: string }>(draft: T): T {
|
|
29
|
+
return draft.webhook ? { ...draft, webhook: '[redacted]' } : draft;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentHarnessCommsArgs {
|
|
33
|
+
readonly draftId?: unknown;
|
|
34
|
+
readonly draftStatus?: unknown;
|
|
35
|
+
readonly draftTitle?: unknown;
|
|
36
|
+
readonly draftMessage?: unknown;
|
|
37
|
+
readonly draftChannel?: unknown;
|
|
38
|
+
readonly draftRoute?: unknown;
|
|
39
|
+
readonly draftWebhook?: unknown;
|
|
40
|
+
readonly draftLink?: unknown;
|
|
41
|
+
readonly draftTags?: unknown;
|
|
42
|
+
readonly surfaceKind?: unknown;
|
|
43
|
+
readonly profileId?: unknown;
|
|
44
|
+
readonly routeLabel?: unknown;
|
|
45
|
+
readonly limit?: unknown;
|
|
46
|
+
readonly confirm?: unknown;
|
|
47
|
+
readonly explicitUserRequest?: unknown;
|
|
48
|
+
readonly target?: unknown;
|
|
49
|
+
readonly query?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readString(value: unknown): string {
|
|
53
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readOptString(value: unknown): string | undefined {
|
|
57
|
+
const s = readString(value);
|
|
58
|
+
return s || undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readLimit(value: unknown, fallback: number): number {
|
|
62
|
+
const parsed = typeof value === 'string' && value.trim() ? Number(value) : value;
|
|
63
|
+
if (typeof parsed !== 'number' || !Number.isFinite(parsed)) return fallback;
|
|
64
|
+
return Math.max(1, Math.min(500, Math.trunc(parsed)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readStringArray(value: unknown): string[] | undefined {
|
|
68
|
+
if (!Array.isArray(value)) return undefined;
|
|
69
|
+
const arr = value.filter((v) => typeof v === 'string').map((v) => (v as string).trim()).filter(Boolean);
|
|
70
|
+
return arr.length > 0 ? arr : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// INBOX — unified_inbox
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export async function unifiedInboxSummary(
|
|
78
|
+
context: CommandContext,
|
|
79
|
+
args: AgentHarnessCommsArgs,
|
|
80
|
+
): Promise<Record<string, unknown>> {
|
|
81
|
+
const triage = await buildAgentWorkspaceChannelTriage(context, {});
|
|
82
|
+
const limit = readLimit(args.limit, 50);
|
|
83
|
+
const inbox = aggregateUnifiedInbox(triage, { limit });
|
|
84
|
+
return {
|
|
85
|
+
mode: 'unified_inbox',
|
|
86
|
+
status: inbox.status,
|
|
87
|
+
summary: inbox.summary,
|
|
88
|
+
items: inbox.items,
|
|
89
|
+
sources: inbox.sources,
|
|
90
|
+
deliveryItems: inbox.deliveryItems,
|
|
91
|
+
surfaceMessageItems: inbox.surfaceMessageItems,
|
|
92
|
+
routeBindingItems: inbox.routeBindingItems,
|
|
93
|
+
inboundChannelFeed: inbox.inboundChannelFeed,
|
|
94
|
+
formatted: formatUnifiedInbox(inbox),
|
|
95
|
+
policy: inbox.policy,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// DRAFTS — channel_drafts (read)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
export function channelDraftsSummary(
|
|
104
|
+
context: CommandContext,
|
|
105
|
+
args: AgentHarnessCommsArgs,
|
|
106
|
+
): Record<string, unknown> {
|
|
107
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
108
|
+
if (!shellPaths) {
|
|
109
|
+
return {
|
|
110
|
+
mode: 'channel_drafts',
|
|
111
|
+
status: 'unavailable',
|
|
112
|
+
drafts: [],
|
|
113
|
+
policy: 'Draft management requires an active Agent workspace with shell path context.',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const draftId = readOptString(args.draftId || args.target || args.query);
|
|
117
|
+
if (draftId) {
|
|
118
|
+
const draft = getDraft(shellPaths, draftId);
|
|
119
|
+
if (!draft) {
|
|
120
|
+
return {
|
|
121
|
+
mode: 'channel_drafts',
|
|
122
|
+
status: 'not_found',
|
|
123
|
+
draftId,
|
|
124
|
+
policy: 'Draft not found. Use channel_drafts without draftId to list all drafts.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
mode: 'channel_drafts',
|
|
129
|
+
status: 'found',
|
|
130
|
+
draft: redactDraftWebhook(draft),
|
|
131
|
+
formatted: formatChannelDraft(draft),
|
|
132
|
+
routes: {
|
|
133
|
+
send: 'agent_harness mode:"channel_draft_send" draftId:"' + draft.id + '"',
|
|
134
|
+
},
|
|
135
|
+
policy: 'Read-only draft inspection. Use channel_draft_send to queue and deliver.',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const statusFilter = readOptString(args.draftStatus) as 'draft' | 'queued' | 'sent' | 'failed' | undefined;
|
|
139
|
+
const limit = readLimit(args.limit, 50);
|
|
140
|
+
const snapshot = listDrafts(shellPaths, { status: statusFilter, limit });
|
|
141
|
+
return {
|
|
142
|
+
mode: 'channel_drafts',
|
|
143
|
+
status: snapshot.exists ? 'ready' : 'empty',
|
|
144
|
+
path: snapshot.path,
|
|
145
|
+
total: snapshot.drafts.length,
|
|
146
|
+
drafts: snapshot.drafts.map(redactDraftWebhook),
|
|
147
|
+
formatted: formatChannelDraftList(snapshot),
|
|
148
|
+
routes: {
|
|
149
|
+
save: 'agent_harness mode:"channel_draft_save"',
|
|
150
|
+
send: 'agent_harness mode:"channel_draft_send" draftId:"<id>"',
|
|
151
|
+
},
|
|
152
|
+
...(snapshot.parseError ? { parseError: snapshot.parseError } : {}),
|
|
153
|
+
policy: 'Read-only draft list. Drafts are local-only. Use channel_draft_save to create or update. Use channel_draft_send to deliver.',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// DRAFTS — channel_draft_save (effect)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export function channelDraftSaveHandoff(
|
|
162
|
+
context: CommandContext,
|
|
163
|
+
args: AgentHarnessCommsArgs,
|
|
164
|
+
): Record<string, unknown> | string {
|
|
165
|
+
const confirmationError = requireConfirmedAction(args, 'Channel draft save');
|
|
166
|
+
if (confirmationError) return confirmationError;
|
|
167
|
+
|
|
168
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
169
|
+
if (!shellPaths) return 'Channel draft save requires an active workspace.';
|
|
170
|
+
|
|
171
|
+
const message = readString(args.draftMessage);
|
|
172
|
+
if (!message) return 'channel_draft_save requires draftMessage.';
|
|
173
|
+
|
|
174
|
+
const result = saveDraft(shellPaths, {
|
|
175
|
+
id: readOptString(args.draftId),
|
|
176
|
+
message,
|
|
177
|
+
title: readOptString(args.draftTitle),
|
|
178
|
+
channel: readOptString(args.draftChannel),
|
|
179
|
+
route: readOptString(args.draftRoute),
|
|
180
|
+
webhook: readOptString(args.draftWebhook),
|
|
181
|
+
link: readOptString(args.draftLink),
|
|
182
|
+
tags: readStringArray(args.draftTags),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
mode: 'channel_draft_save',
|
|
187
|
+
draft: redactDraftWebhook(result.draft),
|
|
188
|
+
path: result.path,
|
|
189
|
+
routes: {
|
|
190
|
+
send: 'agent_harness mode:"channel_draft_send" draftId:"' + result.draft.id + '"',
|
|
191
|
+
list: 'agent_harness mode:"channel_drafts"',
|
|
192
|
+
},
|
|
193
|
+
policy: 'Draft saved locally. Use channel_draft_send with confirm:true and explicitUserRequest to deliver.',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// DRAFTS — channel_draft_send (effect)
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
export async function channelDraftSendHandoff(
|
|
202
|
+
context: CommandContext,
|
|
203
|
+
args: AgentHarnessCommsArgs,
|
|
204
|
+
): Promise<Record<string, unknown> | string> {
|
|
205
|
+
const confirmationError = requireConfirmedAction(args, 'Channel draft send');
|
|
206
|
+
if (confirmationError) return confirmationError;
|
|
207
|
+
|
|
208
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
209
|
+
if (!shellPaths) return 'Channel draft send requires an active workspace.';
|
|
210
|
+
|
|
211
|
+
const draftId = readString(args.draftId || args.target);
|
|
212
|
+
if (!draftId) return 'channel_draft_send requires draftId.';
|
|
213
|
+
|
|
214
|
+
const router = context.platform?.channelDeliveryRouter;
|
|
215
|
+
if (!router) {
|
|
216
|
+
return 'Channel delivery router is not available. Ensure the Agent is connected to a channel delivery service.';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let queueResult: ReturnType<typeof queueDraftToSend>;
|
|
220
|
+
try {
|
|
221
|
+
queueResult = queueDraftToSend(shellPaths, draftId);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return err instanceof Error ? err.message : String(err);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const result = await deliverAgentChannelMessage(router, queueResult.deliveryInput);
|
|
228
|
+
const sentDraft = markDraftSent(shellPaths, draftId, result.responseId);
|
|
229
|
+
return {
|
|
230
|
+
mode: 'channel_draft_send',
|
|
231
|
+
status: 'sent',
|
|
232
|
+
draftId,
|
|
233
|
+
responseId: result.responseId ?? null,
|
|
234
|
+
draft: sentDraft ? redactDraftWebhook(sentDraft) : sentDraft,
|
|
235
|
+
delivery: {
|
|
236
|
+
message: result.message,
|
|
237
|
+
title: result.title,
|
|
238
|
+
target: result.target,
|
|
239
|
+
strategyCount: result.strategyCount,
|
|
240
|
+
},
|
|
241
|
+
policy: 'Draft delivered and marked sent. Receipt recorded via delivery path.',
|
|
242
|
+
};
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
245
|
+
try {
|
|
246
|
+
markDraftFailed(shellPaths, draftId, errorMessage);
|
|
247
|
+
} catch {
|
|
248
|
+
// best-effort
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
mode: 'channel_draft_send',
|
|
252
|
+
status: 'failed',
|
|
253
|
+
draftId,
|
|
254
|
+
error: errorMessage,
|
|
255
|
+
policy: 'Delivery failed. Draft marked failed. Retry with channel_draft_send or use agent_channel_send directly.',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// ROUTING — channel_routing (read)
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export function channelRoutingSummary(
|
|
265
|
+
context: CommandContext,
|
|
266
|
+
args: AgentHarnessCommsArgs,
|
|
267
|
+
): Record<string, unknown> {
|
|
268
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
269
|
+
if (!shellPaths) {
|
|
270
|
+
return {
|
|
271
|
+
mode: 'channel_routing',
|
|
272
|
+
status: 'unavailable',
|
|
273
|
+
routes: [],
|
|
274
|
+
policy: 'Channel routing requires an active Agent workspace with shell path context.',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const surfaceKind = readOptString(args.surfaceKind || args.target || args.query);
|
|
278
|
+
if (surfaceKind) {
|
|
279
|
+
const profileId = getProfileForChannel(shellPaths, surfaceKind, readOptString(args.draftRoute));
|
|
280
|
+
const snapshot = listChannelProfileRoutes(shellPaths, { surfaceKind });
|
|
281
|
+
return {
|
|
282
|
+
mode: 'channel_routing',
|
|
283
|
+
status: snapshot.exists ? 'ready' : 'empty',
|
|
284
|
+
surfaceKind,
|
|
285
|
+
resolvedProfileId: profileId,
|
|
286
|
+
routes: snapshot.routes,
|
|
287
|
+
formatted: formatChannelProfileRoutes(snapshot),
|
|
288
|
+
policy: 'Read-only routing inspection. Use channel_routing_assign to add or update a profile assignment.',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const limit = readLimit(args.limit, 50);
|
|
292
|
+
const snapshot = listChannelProfileRoutes(shellPaths);
|
|
293
|
+
const limited = { ...snapshot, routes: snapshot.routes.slice(0, limit) };
|
|
294
|
+
return {
|
|
295
|
+
mode: 'channel_routing',
|
|
296
|
+
status: snapshot.exists ? 'ready' : 'empty',
|
|
297
|
+
path: snapshot.path,
|
|
298
|
+
total: snapshot.routes.length,
|
|
299
|
+
returned: limited.routes.length,
|
|
300
|
+
routes: limited.routes,
|
|
301
|
+
formatted: formatChannelProfileRoutes(limited),
|
|
302
|
+
assignRoute: 'agent_harness mode:"channel_routing_assign"',
|
|
303
|
+
removeRoute: 'agent_harness mode:"channel_routing_remove"',
|
|
304
|
+
...(snapshot.parseError ? { parseError: snapshot.parseError } : {}),
|
|
305
|
+
policy: 'Read-only routing list. Assignments are local-only until daemon publishes channels.routing.assign.',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// ROUTING — channel_routing_assign (effect)
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
export function channelRoutingAssignHandoff(
|
|
314
|
+
context: CommandContext,
|
|
315
|
+
args: AgentHarnessCommsArgs,
|
|
316
|
+
): Record<string, unknown> | string {
|
|
317
|
+
const confirmationError = requireConfirmedAction(args, 'Channel routing assignment');
|
|
318
|
+
if (confirmationError) return confirmationError;
|
|
319
|
+
|
|
320
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
321
|
+
if (!shellPaths) return 'Channel routing assign requires an active workspace.';
|
|
322
|
+
|
|
323
|
+
const surfaceKind = readString(args.surfaceKind);
|
|
324
|
+
if (!surfaceKind) return 'channel_routing_assign requires surfaceKind.';
|
|
325
|
+
const profileId = readString(args.profileId);
|
|
326
|
+
if (!profileId) return 'channel_routing_assign requires profileId.';
|
|
327
|
+
|
|
328
|
+
const result = assignChannelToProfile(shellPaths, {
|
|
329
|
+
surfaceKind,
|
|
330
|
+
routeId: readOptString(args.draftRoute),
|
|
331
|
+
profileId,
|
|
332
|
+
label: readOptString(args.routeLabel),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
mode: 'channel_routing_assign',
|
|
337
|
+
created: result.created,
|
|
338
|
+
route: result.route,
|
|
339
|
+
path: result.path,
|
|
340
|
+
daemonMethodNeeded: result.route.daemonMethodNeeded,
|
|
341
|
+
routes: {
|
|
342
|
+
list: 'agent_harness mode:"channel_routing"',
|
|
343
|
+
remove: 'agent_harness mode:"channel_routing_remove" draftRoute:"' + (result.route.routeId ?? '') + '" surfaceKind:"' + surfaceKind + '"',
|
|
344
|
+
},
|
|
345
|
+
policy: 'Assignment saved locally (daemonSyncState: local_only). Daemon sync pending channels.routing.assign method publication.',
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// ROUTING — channel_routing_remove (effect)
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
export function channelRoutingRemoveHandoff(
|
|
354
|
+
context: CommandContext,
|
|
355
|
+
args: AgentHarnessCommsArgs,
|
|
356
|
+
): Record<string, unknown> | string {
|
|
357
|
+
const confirmationError = requireConfirmedAction(args, 'Channel routing removal');
|
|
358
|
+
if (confirmationError) return confirmationError;
|
|
359
|
+
|
|
360
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
361
|
+
if (!shellPaths) return 'Channel routing remove requires an active workspace.';
|
|
362
|
+
|
|
363
|
+
const routeId = readString(args.draftRoute || args.target);
|
|
364
|
+
if (!routeId) return 'channel_routing_remove requires draftRoute (the route id to remove).';
|
|
365
|
+
|
|
366
|
+
const removed = removeChannelProfileRoute(shellPaths, routeId);
|
|
367
|
+
return {
|
|
368
|
+
mode: 'channel_routing_remove',
|
|
369
|
+
removed,
|
|
370
|
+
routeId,
|
|
371
|
+
routes: {
|
|
372
|
+
list: 'agent_harness mode:"channel_routing"',
|
|
373
|
+
},
|
|
374
|
+
policy: removed
|
|
375
|
+
? 'Route removed locally. Daemon sync state is local_only until daemon contract is published.'
|
|
376
|
+
: 'No matching route found with that routeId.',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { CommandContext } from '../input/command-registry.ts';
|
|
2
|
+
import type { MemoryApi } from '@pellux/goodvibes-sdk/platform/knowledge';
|
|
3
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
4
|
+
import { AgentPersonaRegistry } from '../agent/persona-registry.ts';
|
|
5
|
+
import { AgentRoutineRegistry } from '../agent/routine-registry.ts';
|
|
6
|
+
import { AgentSkillRegistry } from '../agent/skill-registry.ts';
|
|
7
|
+
import { runSkillDraftProposer } from '../agent/skill-draft-runner.ts';
|
|
8
|
+
import { buildLearningCandidates } from './agent-harness-learning-curator-proposals.ts';
|
|
9
|
+
import type { LearningCandidate } from './agent-harness-learning-curator-types.ts';
|
|
10
|
+
import {
|
|
11
|
+
deleteDuplicate,
|
|
12
|
+
domainForCandidate,
|
|
13
|
+
markDuplicateStale,
|
|
14
|
+
updateSurvivor,
|
|
15
|
+
} from './agent-learning-consolidation-core.ts';
|
|
16
|
+
import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
|
|
17
|
+
|
|
18
|
+
const AUTO_PROMOTE_PROVENANCE = 'learning-curator-auto-promote';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Result type
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface AutoPromoteResult {
|
|
25
|
+
/** Total candidates seen as ready-to-promote or needs-consolidation. */
|
|
26
|
+
readonly eligible: number;
|
|
27
|
+
/** Records successfully promoted this pass. */
|
|
28
|
+
readonly promoted: number;
|
|
29
|
+
/** Candidates skipped due to errors, missing data, or secret-scan rejection. */
|
|
30
|
+
readonly skipped: number;
|
|
31
|
+
/** Duplicate records consolidated (staled + deleted). */
|
|
32
|
+
readonly consolidated: number;
|
|
33
|
+
/** Per-domain breakdown. */
|
|
34
|
+
readonly domains: Record<string, number>;
|
|
35
|
+
/** Brief log lines for each promoted or consolidated item. */
|
|
36
|
+
readonly log: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function extractProposalFields(
|
|
44
|
+
candidate: LearningCandidate,
|
|
45
|
+
): Record<string, string> {
|
|
46
|
+
return candidate.proposalFields ?? {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function tryPromoteMemory(
|
|
50
|
+
memoryApi: MemoryApi,
|
|
51
|
+
candidate: LearningCandidate,
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const fields = extractProposalFields(candidate);
|
|
54
|
+
const summary = (fields.summary ?? fields.detail ?? candidate.label).trim();
|
|
55
|
+
if (!summary) throw new Error('Missing summary for memory candidate.');
|
|
56
|
+
const detail = (fields.detail ?? '').trim();
|
|
57
|
+
const cls = (fields.cls ?? 'fact').trim() as Parameters<typeof memoryApi.add>[0]['cls'];
|
|
58
|
+
const scope = (fields.scope ?? 'project').trim() as Parameters<typeof memoryApi.add>[0]['scope'];
|
|
59
|
+
const tags = fields.tags
|
|
60
|
+
? fields.tags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean)
|
|
61
|
+
: [];
|
|
62
|
+
// assertNoSecretLikeText is called inside memoryApi.add
|
|
63
|
+
const record = await memoryApi.add({
|
|
64
|
+
scope,
|
|
65
|
+
cls,
|
|
66
|
+
summary,
|
|
67
|
+
detail,
|
|
68
|
+
tags,
|
|
69
|
+
provenance: [{ kind: 'event', ref: AUTO_PROMOTE_PROVENANCE }],
|
|
70
|
+
});
|
|
71
|
+
return `memory:${record.id} ${summary.slice(0, 60)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tryPromotePersona(
|
|
75
|
+
shellPaths: ShellPathService,
|
|
76
|
+
candidate: LearningCandidate,
|
|
77
|
+
): string {
|
|
78
|
+
const fields = extractProposalFields(candidate);
|
|
79
|
+
const name = (fields.name ?? candidate.label).trim();
|
|
80
|
+
if (!name) throw new Error('Missing name for persona candidate.');
|
|
81
|
+
const description = (fields.description ?? name).trim();
|
|
82
|
+
const body = (fields.body ?? fields.detail ?? '').trim();
|
|
83
|
+
if (!body) throw new Error('Missing body for persona candidate.');
|
|
84
|
+
const triggers = fields.triggers
|
|
85
|
+
? fields.triggers.split(/[,;]+/).map((t) => t.trim()).filter(Boolean)
|
|
86
|
+
: [];
|
|
87
|
+
const tags = fields.tags
|
|
88
|
+
? fields.tags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean)
|
|
89
|
+
: [];
|
|
90
|
+
// assertNoSecretLikeText is called inside AgentPersonaRegistry.create
|
|
91
|
+
const record = AgentPersonaRegistry.fromShellPaths(shellPaths).create({
|
|
92
|
+
name,
|
|
93
|
+
description,
|
|
94
|
+
body,
|
|
95
|
+
triggers,
|
|
96
|
+
tags,
|
|
97
|
+
source: 'agent',
|
|
98
|
+
provenance: AUTO_PROMOTE_PROVENANCE,
|
|
99
|
+
});
|
|
100
|
+
return `persona:${record.id} ${name.slice(0, 60)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tryPromoteRoutine(
|
|
104
|
+
shellPaths: ShellPathService,
|
|
105
|
+
candidate: LearningCandidate,
|
|
106
|
+
): string {
|
|
107
|
+
const fields = extractProposalFields(candidate);
|
|
108
|
+
const name = (fields.name ?? candidate.label).trim();
|
|
109
|
+
if (!name) throw new Error('Missing name for routine candidate.');
|
|
110
|
+
const description = (fields.description ?? name).trim();
|
|
111
|
+
const steps = (fields.steps ?? fields.notes ?? fields.detail ?? '').trim();
|
|
112
|
+
if (!steps) throw new Error('Missing steps for routine candidate.');
|
|
113
|
+
const triggers = fields.triggers
|
|
114
|
+
? fields.triggers.split(/[,;]+/).map((t) => t.trim()).filter(Boolean)
|
|
115
|
+
: [];
|
|
116
|
+
const tags = fields.tags
|
|
117
|
+
? fields.tags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean)
|
|
118
|
+
: [];
|
|
119
|
+
// assertNoSecretLikeText is called inside AgentRoutineRegistry.create
|
|
120
|
+
const record = AgentRoutineRegistry.fromShellPaths(shellPaths).create({
|
|
121
|
+
name,
|
|
122
|
+
description,
|
|
123
|
+
steps,
|
|
124
|
+
triggers,
|
|
125
|
+
tags,
|
|
126
|
+
enabled: true,
|
|
127
|
+
source: 'agent',
|
|
128
|
+
provenance: AUTO_PROMOTE_PROVENANCE,
|
|
129
|
+
});
|
|
130
|
+
return `routine:${record.id} ${name.slice(0, 60)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Consolidate a duplicate candidate for non-memory domains (persona/skill/routine).
|
|
135
|
+
* Memory domain consolidation is skipped here because it requires MemoryRegistry,
|
|
136
|
+
* which is not accessible from CommandContext (it is a service-level dependency).
|
|
137
|
+
*/
|
|
138
|
+
function tryConsolidateNonMemoryCandidate(
|
|
139
|
+
shellPaths: ShellPathService,
|
|
140
|
+
candidate: LearningCandidate,
|
|
141
|
+
): readonly string[] {
|
|
142
|
+
const plan = candidate.consolidation;
|
|
143
|
+
if (!plan) throw new Error('No consolidation plan on candidate.');
|
|
144
|
+
const domain = domainForCandidate(candidate);
|
|
145
|
+
|
|
146
|
+
// Use a no-op MemoryRegistry sentinel — it is never called because domain
|
|
147
|
+
// is already checked above (domainForCandidate throws for non-consolidatable domains).
|
|
148
|
+
const nullMemory = null as unknown as MemoryRegistry;
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
|
|
151
|
+
// Merge survivor fields when present
|
|
152
|
+
if (plan.updateFields) {
|
|
153
|
+
updateSurvivor(shellPaths, nullMemory, domain, plan.survivorId, plan.updateFields);
|
|
154
|
+
lines.push(`merged survivor ${domain}:${plan.survivorId}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Mark duplicates stale then delete
|
|
158
|
+
for (const dupId of plan.duplicateIds) {
|
|
159
|
+
try {
|
|
160
|
+
markDuplicateStale(shellPaths, nullMemory, domain, dupId, plan.survivorId);
|
|
161
|
+
deleteDuplicate(shellPaths, nullMemory, domain, dupId);
|
|
162
|
+
lines.push(`consolidated duplicate ${domain}:${dupId} -> ${plan.survivorId}`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
lines.push(`skipped duplicate ${domain}:${dupId}: ${String(err)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Main executor
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run the autonomous promotion pass. Promotes `ready-to-promote` candidates
|
|
177
|
+
* and resolves `needs-consolidation` duplicates (non-memory domains) without
|
|
178
|
+
* requiring confirm:true / explicitUserRequest.
|
|
179
|
+
*
|
|
180
|
+
* Secret scanning is enforced by each registry's create() method — this
|
|
181
|
+
* function does NOT bypass it.
|
|
182
|
+
*
|
|
183
|
+
* Memory domain consolidation is intentionally skipped: MemoryRegistry is a
|
|
184
|
+
* service-level dependency not available in CommandContext. Memory promotions
|
|
185
|
+
* (new records from proposal candidates) ARE supported via MemoryApi.
|
|
186
|
+
*/
|
|
187
|
+
export async function runAutoPromoter(
|
|
188
|
+
context: CommandContext,
|
|
189
|
+
skillRegistry: AgentSkillRegistry,
|
|
190
|
+
memoryApi: MemoryApi,
|
|
191
|
+
): Promise<AutoPromoteResult> {
|
|
192
|
+
const shellPaths = context.workspace?.shellPaths;
|
|
193
|
+
if (!shellPaths) {
|
|
194
|
+
return {
|
|
195
|
+
eligible: 0,
|
|
196
|
+
promoted: 0,
|
|
197
|
+
skipped: 0,
|
|
198
|
+
consolidated: 0,
|
|
199
|
+
domains: {},
|
|
200
|
+
log: ['No active workspace; promotion skipped.'],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const all = buildLearningCandidates(context);
|
|
205
|
+
const promotionCandidates = all.filter((c) => c.status === 'ready-to-promote');
|
|
206
|
+
// Only consolidate non-memory domains (MemoryRegistry not available here)
|
|
207
|
+
const consolidationCandidates = all.filter(
|
|
208
|
+
(c) => c.status === 'needs-consolidation' && c.consolidation !== undefined && c.domain !== 'memory',
|
|
209
|
+
);
|
|
210
|
+
const eligible = promotionCandidates.length + consolidationCandidates.length;
|
|
211
|
+
|
|
212
|
+
const log: string[] = [];
|
|
213
|
+
const domains: Record<string, number> = {};
|
|
214
|
+
let promoted = 0;
|
|
215
|
+
let skipped = 0;
|
|
216
|
+
let consolidated = 0;
|
|
217
|
+
|
|
218
|
+
function bump(domain: string): void {
|
|
219
|
+
domains[domain] = (domains[domain] ?? 0) + 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Skill promotions via runSkillDraftProposer (handles dedup + ledger) ---
|
|
223
|
+
const skillResult = runSkillDraftProposer(context, skillRegistry);
|
|
224
|
+
if (skillResult.proposed > 0) {
|
|
225
|
+
for (const entry of skillResult.entries) {
|
|
226
|
+
log.push(`promoted skill:${entry.skillId} ${entry.name}`);
|
|
227
|
+
bump('skill');
|
|
228
|
+
}
|
|
229
|
+
promoted += skillResult.proposed;
|
|
230
|
+
}
|
|
231
|
+
skipped += skillResult.skipped;
|
|
232
|
+
|
|
233
|
+
// --- Non-skill ready-to-promote candidates ---
|
|
234
|
+
for (const candidate of promotionCandidates) {
|
|
235
|
+
if (candidate.proposalTarget === 'skill') continue; // handled above
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
let line: string;
|
|
239
|
+
switch (candidate.proposalTarget) {
|
|
240
|
+
case 'memory': {
|
|
241
|
+
line = await tryPromoteMemory(memoryApi, candidate);
|
|
242
|
+
promoted += 1;
|
|
243
|
+
bump('memory');
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'persona': {
|
|
247
|
+
line = tryPromotePersona(shellPaths, candidate);
|
|
248
|
+
promoted += 1;
|
|
249
|
+
bump('persona');
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'routine': {
|
|
253
|
+
line = tryPromoteRoutine(shellPaths, candidate);
|
|
254
|
+
promoted += 1;
|
|
255
|
+
bump('routine');
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
default: {
|
|
259
|
+
// No create path for this target (e.g. notes-to-knowledge is a
|
|
260
|
+
// workspace action that requires a browser session — skip it).
|
|
261
|
+
skipped += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
log.push(`promoted ${line}`);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
log.push(`skipped ${candidate.label}: ${String(err)}`);
|
|
268
|
+
skipped += 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Non-memory consolidation candidates ---
|
|
273
|
+
for (const candidate of consolidationCandidates) {
|
|
274
|
+
try {
|
|
275
|
+
const lines = tryConsolidateNonMemoryCandidate(shellPaths, candidate);
|
|
276
|
+
const dupeCount = candidate.consolidation?.duplicateIds.length ?? 0;
|
|
277
|
+
consolidated += dupeCount;
|
|
278
|
+
promoted += dupeCount;
|
|
279
|
+
bump(candidate.domain);
|
|
280
|
+
log.push(...lines);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
log.push(`skipped ${candidate.label}: ${String(err)}`);
|
|
283
|
+
skipped += 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { eligible, promoted, skipped, consolidated, domains, log };
|
|
288
|
+
}
|