@pellux/goodvibes-tui 0.25.0 → 0.27.0
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 +22 -3
- package/README.md +12 -7
- package/docs/foundation-artifacts/operator-contract.json +2422 -1040
- package/package.json +2 -2
- package/src/cli/bundle-command.ts +2 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/core/conversation-rendering.ts +2 -2
- package/src/core/conversation.ts +49 -2
- package/src/core/session-recovery.ts +24 -18
- package/src/core/system-message-router.ts +42 -26
- package/src/core/turn-event-wiring.ts +12 -16
- package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +29 -24
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
- package/src/daemon/handlers/inbox/index.ts +211 -0
- package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
- package/src/daemon/{channels → handlers}/inbox/poller.ts +6 -5
- package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
- package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +10 -11
- package/src/daemon/{channels → handlers}/inbox/providers/email.ts +2 -1
- package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +4 -3
- package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +11 -12
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
- package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
- package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
- package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
- package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/{triage → handlers/triage}/pipeline.ts +60 -71
- package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/export/gist-uploader.ts +3 -1
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/cloudflare-runtime.ts +2 -1
- package/src/input/commands/guidance-runtime.ts +2 -4
- package/src/input/commands/health-runtime.ts +13 -7
- package/src/input/commands/knowledge.ts +2 -1
- package/src/input/commands/runtime-services.ts +40 -10
- package/src/input/handler-command-route.ts +1 -1
- package/src/input/handler-interactions.ts +2 -1
- package/src/input/handler-modal-routes.ts +2 -1
- package/src/input/handler-onboarding-cloudflare.ts +2 -1
- package/src/input/handler-onboarding.ts +8 -8
- package/src/input/handler-ui-state.ts +1 -1
- package/src/input/tts-settings-actions.ts +2 -1
- package/src/main.ts +52 -59
- package/src/panels/agent-inspector-panel.ts +72 -2
- package/src/panels/agent-logs-panel.ts +127 -19
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/builtin/agent.ts +3 -1
- package/src/panels/builtin/development.ts +1 -0
- package/src/panels/builtin/session.ts +1 -1
- package/src/panels/context-visualizer-panel.ts +24 -14
- package/src/panels/cost-tracker-panel.ts +7 -13
- package/src/panels/debug-panel.ts +11 -22
- package/src/panels/docs-panel.ts +14 -24
- package/src/panels/file-explorer-panel.ts +2 -1
- package/src/panels/file-preview-panel.ts +2 -1
- package/src/panels/git-panel.ts +10 -17
- package/src/panels/marketplace-panel.ts +2 -1
- package/src/panels/memory-panel.ts +2 -1
- package/src/panels/project-planning-panel.ts +5 -4
- package/src/panels/provider-health-panel.ts +56 -67
- package/src/panels/schedule-panel.ts +4 -7
- package/src/panels/session-browser-panel.ts +13 -21
- package/src/panels/skills-panel.ts +2 -1
- package/src/panels/tasks-panel.ts +2 -7
- package/src/panels/thinking-panel.ts +6 -11
- package/src/panels/token-budget-panel.ts +31 -15
- package/src/panels/tool-inspector-panel.ts +10 -18
- package/src/panels/work-plan-panel.ts +2 -1
- package/src/panels/wrfc-panel.ts +37 -35
- package/src/renderer/agent-detail-modal.ts +2 -2
- package/src/renderer/modal-utils.ts +0 -10
- package/src/renderer/process-modal.ts +2 -2
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +31 -31
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/runtime/onboarding/apply.ts +4 -3
- package/src/runtime/onboarding/snapshot.ts +4 -3
- package/src/runtime/process-lifecycle.ts +195 -0
- package/src/runtime/services.ts +52 -36
- package/src/runtime/wrfc-persistence.ts +20 -5
- package/src/shell/ui-openers.ts +3 -2
- package/src/verification/live-verifier.ts +4 -3
- package/src/version.ts +1 -1
- package/src/core/context-auto-compact.ts +0 -110
- package/src/daemon/calendar/index.ts +0 -52
- package/src/daemon/calendar/register.ts +0 -527
- package/src/daemon/channels/drafts/index.ts +0 -22
- package/src/daemon/channels/drafts/register.ts +0 -449
- package/src/daemon/channels/inbox/index.ts +0 -58
- package/src/daemon/channels/inbox/register.ts +0 -247
- package/src/daemon/channels/routing/index.ts +0 -39
- package/src/daemon/channels/routing/register.ts +0 -296
- package/src/daemon/email/index.ts +0 -68
- package/src/daemon/email/register.ts +0 -715
- package/src/daemon/operator/index.ts +0 -43
- package/src/daemon/operator/register-helper.ts +0 -150
- package/src/daemon/operator/surfaces.ts +0 -137
- package/src/daemon/operator/types.ts +0 -207
- package/src/daemon/remote/backends/index.ts +0 -34
- package/src/daemon/remote/index.ts +0 -74
- package/src/daemon/remote/register.ts +0 -411
- package/src/daemon/triage/index.ts +0 -59
- package/src/daemon/triage/integration.ts +0 -179
- package/src/daemon/triage/register.ts +0 -231
- package/src/daemon/triage/tagger.ts +0 -777
- /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
- /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
- /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
- /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
- /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
- /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
3
|
+
import { HandlerError } from '../errors.ts';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* A persisted channel-to-profile routing assignment, in the exact shape the
|
|
7
|
-
* `channels.routing.*`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `channels.routing.*` methods serve. Keyed on a composite `channelId`
|
|
8
|
+
* (`surfaceKind` or `surfaceKind:routeId`). The SDK output schemas require
|
|
9
|
+
* `surfaceKind`, `profileId`, `createdAt`, and `updatedAt`; `channelId`,
|
|
10
|
+
* `routeId`, and `label` are carried through as optional refinements.
|
|
11
11
|
*/
|
|
12
12
|
export interface RoutingChannelRoute {
|
|
13
13
|
assignmentId: string;
|
|
@@ -21,6 +21,47 @@ export interface RoutingChannelRoute {
|
|
|
21
21
|
updatedAt: string; // ISO-8601
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Per-route item shape emitted by `channels.routing.list`, matching the SDK
|
|
26
|
+
* `CHANNEL_ROUTING_RULE_SCHEMA` exactly: properties
|
|
27
|
+
* {id, createdAt, updatedAt, surfaceKind, routeId, profileId, label},
|
|
28
|
+
* required [id, createdAt, updatedAt, surfaceKind, profileId], and
|
|
29
|
+
* additionalProperties:false. The store's `assignmentId` is projected to `id`,
|
|
30
|
+
* and the daemon-internal `channelId` is dropped (it is forbidden by the
|
|
31
|
+
* schema's additionalProperties:false).
|
|
32
|
+
*/
|
|
33
|
+
export interface RoutingRouteListItem {
|
|
34
|
+
id: string;
|
|
35
|
+
createdAt: string; // ISO-8601
|
|
36
|
+
updatedAt: string; // ISO-8601
|
|
37
|
+
surfaceKind: string;
|
|
38
|
+
routeId?: string;
|
|
39
|
+
profileId: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Project a stored route to the SDK `channels.routing.list` item shape. Pure;
|
|
45
|
+
* only includes optional `routeId`/`label` when present so the payload never
|
|
46
|
+
* carries `undefined`-valued or schema-forbidden keys.
|
|
47
|
+
*/
|
|
48
|
+
export function toRouteListItem(route: RoutingChannelRoute): RoutingRouteListItem {
|
|
49
|
+
const item: RoutingRouteListItem = {
|
|
50
|
+
id: route.assignmentId,
|
|
51
|
+
createdAt: route.createdAt,
|
|
52
|
+
updatedAt: route.updatedAt,
|
|
53
|
+
surfaceKind: route.surfaceKind,
|
|
54
|
+
profileId: route.profileId,
|
|
55
|
+
};
|
|
56
|
+
if (route.routeId !== undefined) {
|
|
57
|
+
item.routeId = route.routeId;
|
|
58
|
+
}
|
|
59
|
+
if (route.label !== undefined) {
|
|
60
|
+
item.label = route.label;
|
|
61
|
+
}
|
|
62
|
+
return item;
|
|
63
|
+
}
|
|
64
|
+
|
|
24
65
|
/** Parsed components of a composite channelId. */
|
|
25
66
|
export interface ParsedChannelId {
|
|
26
67
|
surfaceKind: string;
|
|
@@ -80,7 +121,7 @@ interface RouteRow {
|
|
|
80
121
|
export function parseChannelId(channelId: string): ParsedChannelId {
|
|
81
122
|
const raw = typeof channelId === 'string' ? channelId.trim() : '';
|
|
82
123
|
if (raw.length === 0) {
|
|
83
|
-
throw new
|
|
124
|
+
throw new HandlerError('channelId is required', 'ROUTING_INVALID_CHANNEL_ID', 400);
|
|
84
125
|
}
|
|
85
126
|
const separator = raw.indexOf(':');
|
|
86
127
|
if (separator === -1) {
|
|
@@ -89,7 +130,7 @@ export function parseChannelId(channelId: string): ParsedChannelId {
|
|
|
89
130
|
const surfaceKind = raw.slice(0, separator).trim();
|
|
90
131
|
const routeId = raw.slice(separator + 1).trim();
|
|
91
132
|
if (surfaceKind.length === 0) {
|
|
92
|
-
throw new
|
|
133
|
+
throw new HandlerError('channelId is missing a surfaceKind', 'ROUTING_INVALID_CHANNEL_ID', 400);
|
|
93
134
|
}
|
|
94
135
|
// A trailing colon with no routeId collapses to a surface-only assignment.
|
|
95
136
|
return routeId.length === 0 ? { surfaceKind } : { surfaceKind, routeId };
|
|
@@ -117,16 +158,16 @@ function rowToRoute(row: RouteRow): RoutingChannelRoute {
|
|
|
117
158
|
/**
|
|
118
159
|
* SQLite-backed store for channel-to-profile routing assignments.
|
|
119
160
|
*
|
|
120
|
-
* Wraps {@link
|
|
161
|
+
* Wraps {@link HandlerSqliteStore} (file `channel-routes.sqlite`) and persists
|
|
121
162
|
* after every mutation. The store owns the composite-key parsing so callers only
|
|
122
163
|
* ever deal with `channelId` strings.
|
|
123
164
|
*/
|
|
124
165
|
export class RouteStore {
|
|
125
|
-
private readonly store:
|
|
166
|
+
private readonly store: HandlerSqliteStore;
|
|
126
167
|
private initialized = false;
|
|
127
168
|
|
|
128
169
|
constructor(options: { workingDirectory: string }) {
|
|
129
|
-
this.store = new
|
|
170
|
+
this.store = new HandlerSqliteStore({
|
|
130
171
|
workingDirectory: options.workingDirectory,
|
|
131
172
|
fileName: 'channel-routes.sqlite',
|
|
132
173
|
schema: SCHEMA,
|
|
@@ -150,7 +191,7 @@ export class RouteStore {
|
|
|
150
191
|
|
|
151
192
|
private requireInit(): void {
|
|
152
193
|
if (!this.initialized) {
|
|
153
|
-
throw new
|
|
194
|
+
throw new HandlerError('RouteStore not initialized', 'ROUTING_STORE_UNINITIALIZED', 500);
|
|
154
195
|
}
|
|
155
196
|
}
|
|
156
197
|
|
|
@@ -219,7 +260,7 @@ export class RouteStore {
|
|
|
219
260
|
this.requireInit();
|
|
220
261
|
const profileId = typeof input.profileId === 'string' ? input.profileId.trim() : '';
|
|
221
262
|
if (profileId.length === 0) {
|
|
222
|
-
throw new
|
|
263
|
+
throw new HandlerError('profileId is required', 'ROUTING_INVALID_PROFILE_ID', 400);
|
|
223
264
|
}
|
|
224
265
|
const { surfaceKind, routeId } = parseChannelId(input.channelId);
|
|
225
266
|
const channelId = buildChannelId(surfaceKind, routeId);
|
|
@@ -239,7 +280,7 @@ export class RouteStore {
|
|
|
239
280
|
await this.store.save();
|
|
240
281
|
const route = this.findById(existing.assignmentId);
|
|
241
282
|
if (!route) {
|
|
242
|
-
throw new
|
|
283
|
+
throw new HandlerError('Failed to read back updated route', 'ROUTING_WRITE_FAILED', 500);
|
|
243
284
|
}
|
|
244
285
|
return { route, created: false };
|
|
245
286
|
}
|
|
@@ -254,7 +295,7 @@ export class RouteStore {
|
|
|
254
295
|
await this.store.save();
|
|
255
296
|
const route = this.findById(assignmentId);
|
|
256
297
|
if (!route) {
|
|
257
|
-
throw new
|
|
298
|
+
throw new HandlerError('Failed to read back inserted route', 'ROUTING_WRITE_FAILED', 500);
|
|
258
299
|
}
|
|
259
300
|
return { route, created: true };
|
|
260
301
|
}
|
|
@@ -267,7 +308,7 @@ export class RouteStore {
|
|
|
267
308
|
this.requireInit();
|
|
268
309
|
const id = typeof assignmentId === 'string' ? assignmentId.trim() : '';
|
|
269
310
|
if (id.length === 0) {
|
|
270
|
-
throw new
|
|
311
|
+
throw new HandlerError('assignmentId is required', 'ROUTING_INVALID_ASSIGNMENT_ID', 400);
|
|
271
312
|
}
|
|
272
313
|
const existing = this.findById(id);
|
|
273
314
|
if (!existing) return false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RoutingChannelRoute } from './route-store.ts';
|
|
2
|
-
import { RouteStore } from './route-store.ts';
|
|
2
|
+
import type { RouteStore } from './route-store.ts';
|
|
3
3
|
|
|
4
4
|
/** Wildcard surfaceKind that matches any surface as a last resort. */
|
|
5
5
|
export const WILDCARD_SURFACE = 'any';
|
|
@@ -7,7 +7,7 @@ import initSqlJs from 'sql.js';
|
|
|
7
7
|
type SqlJsStatic = Awaited<ReturnType<typeof initSqlJs>>;
|
|
8
8
|
type SqlDatabase = InstanceType<SqlJsStatic['Database']>;
|
|
9
9
|
|
|
10
|
-
export interface
|
|
10
|
+
export interface SqliteStoreOptions {
|
|
11
11
|
workingDirectory: string;
|
|
12
12
|
/** e.g. 'channel-routes.sqlite', 'drafts.sqlite', 'inbox-cursors.sqlite' */
|
|
13
13
|
fileName: string;
|
|
@@ -29,12 +29,12 @@ async function loadSqlJs(): Promise<SqlJsStatic> {
|
|
|
29
29
|
* (sql.js WASM: init → run/exec → save → close). One file per concern under
|
|
30
30
|
* {workingDirectory}/.goodvibes/tui/operator/{fileName}.
|
|
31
31
|
*/
|
|
32
|
-
export class
|
|
33
|
-
private readonly options:
|
|
32
|
+
export class HandlerSqliteStore {
|
|
33
|
+
private readonly options: SqliteStoreOptions;
|
|
34
34
|
private readonly resolvedPath: string;
|
|
35
35
|
private db: SqlDatabase | null = null;
|
|
36
36
|
|
|
37
|
-
constructor(options:
|
|
37
|
+
constructor(options: SqliteStoreOptions) {
|
|
38
38
|
this.options = options;
|
|
39
39
|
this.resolvedPath = join(
|
|
40
40
|
options.workingDirectory,
|
|
@@ -51,7 +51,7 @@ export class OperatorSqliteStore {
|
|
|
51
51
|
|
|
52
52
|
private requireDb(): SqlDatabase {
|
|
53
53
|
if (!this.db) {
|
|
54
|
-
throw new Error(`
|
|
54
|
+
throw new Error(`HandlerSqliteStore not initialized: ${this.resolvedPath}`);
|
|
55
55
|
}
|
|
56
56
|
return this.db;
|
|
57
57
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Public surface for the daemon-internal Email Auto-Tag / Spam Triage handler.
|
|
3
|
+
//
|
|
4
|
+
// `registerTriagedInbox` (from ./integration.ts) is the single entry the
|
|
5
|
+
// runtime composition root calls. It DECORATES the inbox surface's
|
|
6
|
+
// `channels.inbox.list` handler (overlaying persisted triage metadata) and
|
|
7
|
+
// returns the poller-facing pipeline + tagger. inbox.triage.* are NOT published
|
|
8
|
+
// catalog methods — this surface registers no triage method id.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
scoreInboundItem,
|
|
13
|
+
labelToTag,
|
|
14
|
+
type TriageScore,
|
|
15
|
+
type TriageScorerOptions,
|
|
16
|
+
} from './scorer.ts';
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
InboundChannelItem,
|
|
20
|
+
TriageLabel,
|
|
21
|
+
ConversationKind,
|
|
22
|
+
} from './types.ts';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
runInboxTriage,
|
|
26
|
+
createTriageStore,
|
|
27
|
+
readTriageMetadata,
|
|
28
|
+
readTriageMetadataBatch,
|
|
29
|
+
enrichItemsWithTriage,
|
|
30
|
+
TRIAGE_STORE_FILE,
|
|
31
|
+
type TriageMetadata,
|
|
32
|
+
type TriagedItem,
|
|
33
|
+
type TriageOverlay,
|
|
34
|
+
type TriageEnrichedItem,
|
|
35
|
+
type RunInboxTriageOptions,
|
|
36
|
+
type RunInboxTriageResult,
|
|
37
|
+
} from './pipeline.ts';
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
createTriageTagger,
|
|
41
|
+
TRIAGE_AUTOTAG_FLAG,
|
|
42
|
+
type TriageTagger,
|
|
43
|
+
type TriageTaggerOptions,
|
|
44
|
+
type TaggerProviderConfig,
|
|
45
|
+
type ApplyTagsRequest,
|
|
46
|
+
type ApplyTagsResult,
|
|
47
|
+
type ImapStoreArgs,
|
|
48
|
+
type ImapRetryOptions,
|
|
49
|
+
} from './tagger/index.ts';
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
registerTriagedInbox,
|
|
53
|
+
INBOX_LIST_METHOD_ID,
|
|
54
|
+
type RegisterInbox,
|
|
55
|
+
type RegisterTriagedInboxOptions,
|
|
56
|
+
type TriagedInboxRegistration,
|
|
57
|
+
} from './integration.ts';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal triage INTEGRATION (the single entry the runtime calls).
|
|
3
|
+
//
|
|
4
|
+
// `registerTriagedInbox(ctx, registerInbox, options)` closes the contract loop:
|
|
5
|
+
//
|
|
6
|
+
// 1. It DECORATES the inbox surface's `channels.inbox.list` handler. The
|
|
7
|
+
// inbox surface (a separate handler module) attaches its handler to the
|
|
8
|
+
// SDK-auto-registered descriptor via `ctx.catalog.register(descriptor,
|
|
9
|
+
// handler, { replace: true })`. We pass the inbox `registerInbox` a
|
|
10
|
+
// catalog PROXY whose `register` intercepts exactly the
|
|
11
|
+
// `channels.inbox.list` id and wraps the handler so every returned item is
|
|
12
|
+
// overlaid with the persisted triageScore/triageTags via
|
|
13
|
+
// enrichItemsWithTriage(). Every other registration passes straight
|
|
14
|
+
// through to the real catalog. The inbox descriptor/schema/id is never
|
|
15
|
+
// re-authored — only its handler is wrapped.
|
|
16
|
+
// 2. It exposes the triage pipeline + tagger (`runInboxTriage`, `tagger`) for
|
|
17
|
+
// the daemon-internal poller, which scores items and persists them to the
|
|
18
|
+
// co-located inbox-triage.sqlite store the decorator reads from.
|
|
19
|
+
// 3. inbox.triage.* are intentionally NOT registered on the catalog (they are
|
|
20
|
+
// a daemon-internal pipeline, not published methods) — so this module
|
|
21
|
+
// makes ZERO catalog.register call for any triage id.
|
|
22
|
+
//
|
|
23
|
+
// Reads in the decorator are best-effort and degrade to the raw item when no
|
|
24
|
+
// triage row exists yet, so the read-only inbox feed never breaks.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import type { HandlerContext } from '../context.ts';
|
|
28
|
+
import type {
|
|
29
|
+
GatewayMethodCatalog,
|
|
30
|
+
GatewayMethodDescriptor,
|
|
31
|
+
GatewayMethodHandler,
|
|
32
|
+
} from '../contracts.ts';
|
|
33
|
+
import type { Unregister } from '../register.ts';
|
|
34
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
35
|
+
import {
|
|
36
|
+
createTriageStore,
|
|
37
|
+
enrichItemsWithTriage,
|
|
38
|
+
runInboxTriage,
|
|
39
|
+
type RunInboxTriageOptions,
|
|
40
|
+
type RunInboxTriageResult,
|
|
41
|
+
} from './pipeline.ts';
|
|
42
|
+
import type { InboundChannelItem } from './types.ts';
|
|
43
|
+
import {
|
|
44
|
+
createTriageTagger,
|
|
45
|
+
type ApplyTagsRequest,
|
|
46
|
+
type ApplyTagsResult,
|
|
47
|
+
type TriageTagger,
|
|
48
|
+
type TriageTaggerOptions,
|
|
49
|
+
} from './tagger/index.ts';
|
|
50
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Canonical id of the inbox list method whose handler we decorate. This is the
|
|
54
|
+
* SDK's published id — referenced as a plain string for matching during
|
|
55
|
+
* registration; no descriptor or schema is authored here.
|
|
56
|
+
*/
|
|
57
|
+
export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
|
|
58
|
+
|
|
59
|
+
/** Minimal shape of the inbox.list result the decorator overlays triage onto. */
|
|
60
|
+
interface InboxListResult {
|
|
61
|
+
items?: unknown;
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RegisterTriagedInboxOptions {
|
|
66
|
+
pipeline?: RunInboxTriageOptions;
|
|
67
|
+
tagger?: TriageTaggerOptions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The inbox surface provider. The runtime supplies the inbox module's register
|
|
72
|
+
* function; we hand it a decorating catalog proxy so its `channels.inbox.list`
|
|
73
|
+
* handler is wrapped with triage enrichment.
|
|
74
|
+
*/
|
|
75
|
+
export type RegisterInbox = (ctx: HandlerContext) => Unregister;
|
|
76
|
+
|
|
77
|
+
/** Handle returned to the runtime: teardown + the poller-facing pipeline/tagger. */
|
|
78
|
+
export interface TriagedInboxRegistration {
|
|
79
|
+
readonly unregister: Unregister;
|
|
80
|
+
/** Score (+persist) a batch of polled items. Used by the inbox poller. */
|
|
81
|
+
runInboxTriage(
|
|
82
|
+
items: readonly InboundChannelItem[],
|
|
83
|
+
options?: RunInboxTriageOptions,
|
|
84
|
+
): Promise<RunInboxTriageResult>;
|
|
85
|
+
/** Provider-side tagger (IMAP flag / Slack or Discord reaction/tag). */
|
|
86
|
+
readonly tagger: TriageTagger;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type StoredHandler = GatewayMethodHandler;
|
|
90
|
+
|
|
91
|
+
interface EnrichmentProxy {
|
|
92
|
+
readonly ctx: HandlerContext;
|
|
93
|
+
/** Close the shared triage store handle (if one was ever opened). */
|
|
94
|
+
dispose(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a HandlerContext whose catalog decorates `channels.inbox.list`
|
|
99
|
+
* registration. The triage store is opened lazily ONCE on the first list
|
|
100
|
+
* invocation and reused for every subsequent call (hot read path); the caller
|
|
101
|
+
* disposes the handle on teardown.
|
|
102
|
+
*/
|
|
103
|
+
function withInboxEnrichment(ctx: HandlerContext): EnrichmentProxy {
|
|
104
|
+
const original = ctx.catalog;
|
|
105
|
+
|
|
106
|
+
let store: HandlerSqliteStore | null = null;
|
|
107
|
+
let initPromise: Promise<HandlerSqliteStore> | null = null;
|
|
108
|
+
const getStore = async (): Promise<HandlerSqliteStore> => {
|
|
109
|
+
if (store) return store;
|
|
110
|
+
if (!initPromise) {
|
|
111
|
+
const pending = createTriageStore(ctx.workingDirectory);
|
|
112
|
+
initPromise = pending
|
|
113
|
+
.init()
|
|
114
|
+
.then(() => {
|
|
115
|
+
store = pending;
|
|
116
|
+
return pending;
|
|
117
|
+
})
|
|
118
|
+
.catch((error) => {
|
|
119
|
+
initPromise = null; // allow a later call to retry opening the store
|
|
120
|
+
throw error;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return initPromise;
|
|
124
|
+
};
|
|
125
|
+
const dispose = (): void => {
|
|
126
|
+
if (store) {
|
|
127
|
+
store.close();
|
|
128
|
+
store = null;
|
|
129
|
+
}
|
|
130
|
+
initPromise = null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const decoratedRegister: GatewayMethodCatalog['register'] = (
|
|
134
|
+
descriptor: GatewayMethodDescriptor,
|
|
135
|
+
handler?: StoredHandler,
|
|
136
|
+
options?: { replace?: boolean },
|
|
137
|
+
): Unregister => {
|
|
138
|
+
if (descriptor.id !== INBOX_LIST_METHOD_ID || !handler) {
|
|
139
|
+
return original.register(descriptor, handler, options);
|
|
140
|
+
}
|
|
141
|
+
const innerHandler = handler;
|
|
142
|
+
const wrapped: StoredHandler = async (invocation) => {
|
|
143
|
+
const result = (await innerHandler(invocation)) as InboxListResult;
|
|
144
|
+
if (!result || !Array.isArray(result.items) || result.items.length === 0) {
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
const items = result.items as Array<{ id: string }>;
|
|
148
|
+
try {
|
|
149
|
+
const handle = await getStore();
|
|
150
|
+
return { ...result, items: enrichItemsWithTriage(handle, items) };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
// Triage is best-effort: a missing/locked store must never break the
|
|
153
|
+
// read-only inbox feed. Log and return the un-enriched result.
|
|
154
|
+
ctx.logger.warn('triage: inbox enrichment skipped', {
|
|
155
|
+
message: summarizeError(error),
|
|
156
|
+
});
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
return original.register(descriptor, wrapped, options);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Clone the context with only `catalog.register` swapped. Every other catalog
|
|
164
|
+
// method (invoke/list/get/...) keeps pointing at the original instance.
|
|
165
|
+
const proxiedCatalog = new Proxy(original, {
|
|
166
|
+
get(target, prop, receiver) {
|
|
167
|
+
if (prop === 'register') return decoratedRegister;
|
|
168
|
+
return Reflect.get(target, prop, receiver);
|
|
169
|
+
},
|
|
170
|
+
}) as GatewayMethodCatalog;
|
|
171
|
+
|
|
172
|
+
return { ctx: { ...ctx, catalog: proxiedCatalog }, dispose };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compose the triage pipeline with the inbox surface so channels.inbox.list
|
|
177
|
+
* returns pre-scored items. Returns the teardown plus the poller-facing
|
|
178
|
+
* pipeline/tagger handle. The inbox surface's registration is wrapped; inbox
|
|
179
|
+
* teardown runs first, then the shared store handle is disposed.
|
|
180
|
+
*/
|
|
181
|
+
export function registerTriagedInbox(
|
|
182
|
+
ctx: HandlerContext,
|
|
183
|
+
registerInbox: RegisterInbox,
|
|
184
|
+
options: RegisterTriagedInboxOptions = {},
|
|
185
|
+
): TriagedInboxRegistration {
|
|
186
|
+
const tagger = createTriageTagger(ctx, options.tagger);
|
|
187
|
+
const enriched = withInboxEnrichment(ctx);
|
|
188
|
+
|
|
189
|
+
let unregisterInbox: Unregister;
|
|
190
|
+
try {
|
|
191
|
+
unregisterInbox = registerInbox(enriched.ctx);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
enriched.dispose();
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const unregister: Unregister = () => {
|
|
198
|
+
try {
|
|
199
|
+
unregisterInbox();
|
|
200
|
+
} finally {
|
|
201
|
+
enriched.dispose();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
unregister,
|
|
207
|
+
runInboxTriage: (items, runOptions) =>
|
|
208
|
+
runInboxTriage(items, ctx, { ...options.pipeline, ...runOptions }),
|
|
209
|
+
tagger,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type { ApplyTagsRequest, ApplyTagsResult };
|
|
@@ -6,25 +6,24 @@
|
|
|
6
6
|
// later channels.inbox.list response can surface pre-scored items.
|
|
7
7
|
//
|
|
8
8
|
// The inbox surface owns the authoritative cursor-store and exposes
|
|
9
|
-
// triageScore/triageTags columns. To stay strictly within
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// surface reads these rows by id when assembling its list response
|
|
13
|
-
//
|
|
9
|
+
// triageScore/triageTags columns. To stay strictly within the triage handler
|
|
10
|
+
// surface, this pipeline persists into a dedicated, co-located
|
|
11
|
+
// HandlerSqliteStore ('inbox-triage.sqlite') keyed by item id. The inbox
|
|
12
|
+
// surface reads these rows by id when assembling its list response (the
|
|
13
|
+
// integration decorator does exactly that). The schema mirrors the agreed
|
|
14
|
+
// columns: triageScore REAL, triageTags TEXT (JSON array).
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
type OperatorContext,
|
|
20
|
-
} from '../operator/index.ts';
|
|
17
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
18
|
+
import type { HandlerContext } from '../context.ts';
|
|
19
|
+
import type { InboundChannelItem, TriageLabel } from './types.ts';
|
|
21
20
|
import {
|
|
22
21
|
labelToTag,
|
|
23
22
|
scoreInboundItem,
|
|
24
|
-
type TriageLabel,
|
|
25
23
|
type TriageScore,
|
|
26
24
|
type TriageScorerOptions,
|
|
27
25
|
} from './scorer.ts';
|
|
26
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
28
27
|
|
|
29
28
|
export const TRIAGE_STORE_FILE = 'inbox-triage.sqlite';
|
|
30
29
|
|
|
@@ -57,7 +56,7 @@ export interface TriagedItem extends InboundChannelItem {
|
|
|
57
56
|
export interface RunInboxTriageOptions {
|
|
58
57
|
scorer?: TriageScorerOptions;
|
|
59
58
|
/** Inject a store (tests). When omitted, a triage store is opened/closed. */
|
|
60
|
-
store?:
|
|
59
|
+
store?: HandlerSqliteStore;
|
|
61
60
|
/** When true, do not persist — only compute (used by inbox.triage.list). */
|
|
62
61
|
dryRun?: boolean;
|
|
63
62
|
/** Clock injection for deterministic updatedAt in tests. */
|
|
@@ -83,8 +82,8 @@ function toMetadata(score: TriageScore): TriageMetadata {
|
|
|
83
82
|
* Open the triage store for this working directory. Caller owns close()/save()
|
|
84
83
|
* when they pass their own store; otherwise runInboxTriage manages lifecycle.
|
|
85
84
|
*/
|
|
86
|
-
export function createTriageStore(workingDirectory: string):
|
|
87
|
-
return new
|
|
85
|
+
export function createTriageStore(workingDirectory: string): HandlerSqliteStore {
|
|
86
|
+
return new HandlerSqliteStore({
|
|
88
87
|
workingDirectory,
|
|
89
88
|
fileName: TRIAGE_STORE_FILE,
|
|
90
89
|
schema: SCHEMA,
|
|
@@ -92,7 +91,7 @@ export function createTriageStore(workingDirectory: string): OperatorSqliteStore
|
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
function persistRow(
|
|
95
|
-
store:
|
|
94
|
+
store: HandlerSqliteStore,
|
|
96
95
|
item: InboundChannelItem,
|
|
97
96
|
meta: TriageMetadata,
|
|
98
97
|
updatedAt: string,
|
|
@@ -131,7 +130,7 @@ function persistRow(
|
|
|
131
130
|
*/
|
|
132
131
|
export async function runInboxTriage(
|
|
133
132
|
items: readonly InboundChannelItem[],
|
|
134
|
-
ctx:
|
|
133
|
+
ctx: HandlerContext,
|
|
135
134
|
options: RunInboxTriageOptions = {},
|
|
136
135
|
): Promise<RunInboxTriageResult> {
|
|
137
136
|
const now = options.now ?? (() => new Date());
|
|
@@ -160,7 +159,7 @@ export async function runInboxTriage(
|
|
|
160
159
|
if (ownsStore) await store.save();
|
|
161
160
|
} catch (error) {
|
|
162
161
|
ctx.logger.error('triage: failed to persist scores', {
|
|
163
|
-
message:
|
|
162
|
+
message: summarizeError(error),
|
|
164
163
|
});
|
|
165
164
|
throw error;
|
|
166
165
|
} finally {
|
|
@@ -170,11 +169,7 @@ export async function runInboxTriage(
|
|
|
170
169
|
return { items: enriched, scored: enriched.length, persisted };
|
|
171
170
|
}
|
|
172
171
|
|
|
173
|
-
/**
|
|
174
|
-
* Inbound item enriched with the persisted triage metadata that
|
|
175
|
-
* `channels.inbox.list` advertises (triageScore + triageTags). When no triage
|
|
176
|
-
* row exists yet for an item the original item is returned unchanged.
|
|
177
|
-
*/
|
|
172
|
+
/** Optional persisted triage metadata overlaid onto an inbound item. */
|
|
178
173
|
export interface TriageOverlay {
|
|
179
174
|
triageScore?: number;
|
|
180
175
|
triageTags?: string[];
|
|
@@ -184,46 +179,6 @@ export interface TriageOverlay {
|
|
|
184
179
|
/** An inbound item overlaid with optional persisted triage metadata. */
|
|
185
180
|
export type TriageEnrichedItem = InboundChannelItem & TriageOverlay;
|
|
186
181
|
|
|
187
|
-
/**
|
|
188
|
-
* Merge persisted triage metadata onto a batch of inbound items by id. This is
|
|
189
|
-
* the exact glue `channels.inbox.list` invokes to surface pre-scored items: the
|
|
190
|
-
* inbox surface lists from its cursor store, then calls this to overlay the
|
|
191
|
-
* triageScore/triageTags columns the contract promises. Items without a stored
|
|
192
|
-
* triage row pass through untouched, so an un-scored feed degrades gracefully.
|
|
193
|
-
*/
|
|
194
|
-
export function enrichItemsWithTriage<T extends { id: string }>(
|
|
195
|
-
store: OperatorSqliteStore,
|
|
196
|
-
items: readonly T[],
|
|
197
|
-
): Array<T & TriageOverlay> {
|
|
198
|
-
if (items.length === 0) return [];
|
|
199
|
-
// Single batched read (`WHERE id IN (...)`) instead of one SELECT per item —
|
|
200
|
-
// this is a hot read path (every channels.inbox.list call), so the N+1 is
|
|
201
|
-
// collapsed to one query keyed by id.
|
|
202
|
-
const byId = readTriageMetadataBatch(
|
|
203
|
-
store,
|
|
204
|
-
items.map((item) => item.id),
|
|
205
|
-
);
|
|
206
|
-
return items.map((item) => {
|
|
207
|
-
const meta = byId.get(item.id);
|
|
208
|
-
if (!meta) return { ...item };
|
|
209
|
-
return {
|
|
210
|
-
...item,
|
|
211
|
-
triageScore: meta.triageScore,
|
|
212
|
-
triageLabel: meta.triageLabel,
|
|
213
|
-
triageTags: meta.triageTags,
|
|
214
|
-
};
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
interface TriageRow {
|
|
219
|
-
id: string;
|
|
220
|
-
triageScore: number;
|
|
221
|
-
triageLabel: string;
|
|
222
|
-
triageTags: string;
|
|
223
|
-
spamSignal: number;
|
|
224
|
-
prioritySignal: number;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
182
|
function rowToMetadata(row: Omit<TriageRow, 'id'>): TriageMetadata {
|
|
228
183
|
let tags: string[] = [];
|
|
229
184
|
try {
|
|
@@ -240,13 +195,22 @@ function rowToMetadata(row: Omit<TriageRow, 'id'>): TriageMetadata {
|
|
|
240
195
|
};
|
|
241
196
|
}
|
|
242
197
|
|
|
198
|
+
interface TriageRow {
|
|
199
|
+
id: string;
|
|
200
|
+
triageScore: number;
|
|
201
|
+
triageLabel: string;
|
|
202
|
+
triageTags: string;
|
|
203
|
+
spamSignal: number;
|
|
204
|
+
prioritySignal: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
243
207
|
/**
|
|
244
208
|
* Read persisted triage rows for many item ids in a single `WHERE id IN (...)`
|
|
245
209
|
* query. Returns a Map keyed by item id; ids without a stored row are absent.
|
|
246
210
|
* De-duplicates ids so the placeholder list stays minimal.
|
|
247
211
|
*/
|
|
248
212
|
export function readTriageMetadataBatch(
|
|
249
|
-
store:
|
|
213
|
+
store: HandlerSqliteStore,
|
|
250
214
|
itemIds: readonly string[],
|
|
251
215
|
): Map<string, TriageMetadata> {
|
|
252
216
|
const out = new Map<string, TriageMetadata>();
|
|
@@ -266,16 +230,10 @@ export function readTriageMetadataBatch(
|
|
|
266
230
|
|
|
267
231
|
/** Read a single persisted triage row by item id (used by the inbox surface). */
|
|
268
232
|
export function readTriageMetadata(
|
|
269
|
-
store:
|
|
233
|
+
store: HandlerSqliteStore,
|
|
270
234
|
itemId: string,
|
|
271
235
|
): TriageMetadata | null {
|
|
272
|
-
const row = store.get<
|
|
273
|
-
triageScore: number;
|
|
274
|
-
triageLabel: string;
|
|
275
|
-
triageTags: string;
|
|
276
|
-
spamSignal: number;
|
|
277
|
-
prioritySignal: number;
|
|
278
|
-
}>(
|
|
236
|
+
const row = store.get<Omit<TriageRow, 'id'>>(
|
|
279
237
|
`SELECT triageScore, triageLabel, triageTags, spamSignal, prioritySignal
|
|
280
238
|
FROM inbox_triage WHERE id = ?`,
|
|
281
239
|
[itemId],
|
|
@@ -283,3 +241,34 @@ export function readTriageMetadata(
|
|
|
283
241
|
if (!row) return null;
|
|
284
242
|
return rowToMetadata(row);
|
|
285
243
|
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Merge persisted triage metadata onto a batch of inbound items by id. This is
|
|
247
|
+
* the exact glue `channels.inbox.list` invokes to surface pre-scored items: the
|
|
248
|
+
* inbox surface lists from its cursor store, then calls this to overlay the
|
|
249
|
+
* triageScore/triageTags columns the contract promises. Items without a stored
|
|
250
|
+
* triage row pass through untouched, so an un-scored feed degrades gracefully.
|
|
251
|
+
*/
|
|
252
|
+
export function enrichItemsWithTriage<T extends { id: string }>(
|
|
253
|
+
store: HandlerSqliteStore,
|
|
254
|
+
items: readonly T[],
|
|
255
|
+
): Array<T & TriageOverlay> {
|
|
256
|
+
if (items.length === 0) return [];
|
|
257
|
+
// Single batched read (`WHERE id IN (...)`) instead of one SELECT per item —
|
|
258
|
+
// this is a hot read path (every channels.inbox.list call), so the N+1 is
|
|
259
|
+
// collapsed to one query keyed by id.
|
|
260
|
+
const byId = readTriageMetadataBatch(
|
|
261
|
+
store,
|
|
262
|
+
items.map((item) => item.id),
|
|
263
|
+
);
|
|
264
|
+
return items.map((item) => {
|
|
265
|
+
const meta = byId.get(item.id);
|
|
266
|
+
if (!meta) return { ...item };
|
|
267
|
+
return {
|
|
268
|
+
...item,
|
|
269
|
+
triageScore: meta.triageScore,
|
|
270
|
+
triageLabel: meta.triageLabel,
|
|
271
|
+
triageTags: meta.triageTags,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|