@pellux/goodvibes-tui 0.24.1 → 0.26.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 +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- 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/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- 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/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -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/smtp-connector.ts +557 -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/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -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/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import initSqlJs from 'sql.js';
|
|
6
|
+
|
|
7
|
+
type SqlJsStatic = Awaited<ReturnType<typeof initSqlJs>>;
|
|
8
|
+
type SqlDatabase = InstanceType<SqlJsStatic['Database']>;
|
|
9
|
+
|
|
10
|
+
export interface SqliteStoreOptions {
|
|
11
|
+
workingDirectory: string;
|
|
12
|
+
/** e.g. 'channel-routes.sqlite', 'drafts.sqlite', 'inbox-cursors.sqlite' */
|
|
13
|
+
fileName: string;
|
|
14
|
+
/** CREATE TABLE / INDEX statements run once on init (idempotent: IF NOT EXISTS). */
|
|
15
|
+
schema: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let sqlJsStaticPromise: Promise<SqlJsStatic> | null = null;
|
|
19
|
+
|
|
20
|
+
async function loadSqlJs(): Promise<SqlJsStatic> {
|
|
21
|
+
if (!sqlJsStaticPromise) {
|
|
22
|
+
sqlJsStaticPromise = initSqlJs();
|
|
23
|
+
}
|
|
24
|
+
return sqlJsStaticPromise;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Daemon sqlite helper following the project MemoryStore lifecycle
|
|
29
|
+
* (sql.js WASM: init → run/exec → save → close). One file per concern under
|
|
30
|
+
* {workingDirectory}/.goodvibes/tui/operator/{fileName}.
|
|
31
|
+
*/
|
|
32
|
+
export class HandlerSqliteStore {
|
|
33
|
+
private readonly options: SqliteStoreOptions;
|
|
34
|
+
private readonly resolvedPath: string;
|
|
35
|
+
private db: SqlDatabase | null = null;
|
|
36
|
+
|
|
37
|
+
constructor(options: SqliteStoreOptions) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
this.resolvedPath = join(
|
|
40
|
+
options.workingDirectory,
|
|
41
|
+
'.goodvibes',
|
|
42
|
+
'tui',
|
|
43
|
+
'operator',
|
|
44
|
+
options.fileName,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get dbPath(): string {
|
|
49
|
+
return this.resolvedPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private requireDb(): SqlDatabase {
|
|
53
|
+
if (!this.db) {
|
|
54
|
+
throw new Error(`HandlerSqliteStore not initialized: ${this.resolvedPath}`);
|
|
55
|
+
}
|
|
56
|
+
return this.db;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async init(): Promise<void> {
|
|
60
|
+
if (this.db) return;
|
|
61
|
+
await mkdir(dirname(this.resolvedPath), { recursive: true });
|
|
62
|
+
const SQL = await loadSqlJs();
|
|
63
|
+
const existing = existsSync(this.resolvedPath) ? readFileSync(this.resolvedPath) : undefined;
|
|
64
|
+
this.db = existing ? new SQL.Database(existing) : new SQL.Database();
|
|
65
|
+
for (const statement of this.options.schema) {
|
|
66
|
+
this.db.run(statement);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Execute a write (INSERT/UPDATE/DELETE/CREATE). */
|
|
71
|
+
run(sql: string, params?: (string | number | Uint8Array | null)[]): void {
|
|
72
|
+
this.requireDb().run(sql, params);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** SELECT → array of row objects (columns mapped to values). */
|
|
76
|
+
all<T = Record<string, unknown>>(sql: string, params?: (string | number)[]): T[] {
|
|
77
|
+
const result = this.requireDb().exec(sql, params);
|
|
78
|
+
if (result.length === 0) return [];
|
|
79
|
+
const { columns, values } = result[0]!;
|
|
80
|
+
return values.map((row) => {
|
|
81
|
+
const obj: Record<string, unknown> = {};
|
|
82
|
+
for (let i = 0; i < columns.length; i += 1) {
|
|
83
|
+
obj[columns[i]!] = row[i];
|
|
84
|
+
}
|
|
85
|
+
return obj as T;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** First row or null. */
|
|
90
|
+
get<T = Record<string, unknown>>(sql: string, params?: (string | number)[]): T | null {
|
|
91
|
+
const rows = this.all<T>(sql, params);
|
|
92
|
+
return rows.length > 0 ? rows[0]! : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Serialize and atomically persist to dbPath (tmp + rename). */
|
|
96
|
+
async save(): Promise<void> {
|
|
97
|
+
const db = this.requireDb();
|
|
98
|
+
await mkdir(dirname(this.resolvedPath), { recursive: true });
|
|
99
|
+
const data = db.export();
|
|
100
|
+
const tmpPath = `${this.resolvedPath}.${process.pid}.${Date.now()}.tmp`;
|
|
101
|
+
await writeFile(tmpPath, data);
|
|
102
|
+
await rename(tmpPath, this.resolvedPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
close(): void {
|
|
106
|
+
if (this.db) {
|
|
107
|
+
this.db.close();
|
|
108
|
+
this.db = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** BEGIN/COMMIT around fn; ROLLBACK on throw (synchronous sql.js). */
|
|
113
|
+
transaction(fn: () => void): void {
|
|
114
|
+
const db = this.requireDb();
|
|
115
|
+
db.run('BEGIN');
|
|
116
|
+
try {
|
|
117
|
+
fn();
|
|
118
|
+
db.run('COMMIT');
|
|
119
|
+
} catch (error) {
|
|
120
|
+
db.run('ROLLBACK');
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -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,212 @@
|
|
|
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
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Canonical id of the inbox list method whose handler we decorate. This is the
|
|
53
|
+
* SDK's published id — referenced as a plain string for matching during
|
|
54
|
+
* registration; no descriptor or schema is authored here.
|
|
55
|
+
*/
|
|
56
|
+
export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
|
|
57
|
+
|
|
58
|
+
/** Minimal shape of the inbox.list result the decorator overlays triage onto. */
|
|
59
|
+
interface InboxListResult {
|
|
60
|
+
items?: unknown;
|
|
61
|
+
[key: string]: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RegisterTriagedInboxOptions {
|
|
65
|
+
pipeline?: RunInboxTriageOptions;
|
|
66
|
+
tagger?: TriageTaggerOptions;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The inbox surface provider. The runtime supplies the inbox module's register
|
|
71
|
+
* function; we hand it a decorating catalog proxy so its `channels.inbox.list`
|
|
72
|
+
* handler is wrapped with triage enrichment.
|
|
73
|
+
*/
|
|
74
|
+
export type RegisterInbox = (ctx: HandlerContext) => Unregister;
|
|
75
|
+
|
|
76
|
+
/** Handle returned to the runtime: teardown + the poller-facing pipeline/tagger. */
|
|
77
|
+
export interface TriagedInboxRegistration {
|
|
78
|
+
readonly unregister: Unregister;
|
|
79
|
+
/** Score (+persist) a batch of polled items. Used by the inbox poller. */
|
|
80
|
+
runInboxTriage(
|
|
81
|
+
items: readonly InboundChannelItem[],
|
|
82
|
+
options?: RunInboxTriageOptions,
|
|
83
|
+
): Promise<RunInboxTriageResult>;
|
|
84
|
+
/** Provider-side tagger (IMAP flag / Slack or Discord reaction/tag). */
|
|
85
|
+
readonly tagger: TriageTagger;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type StoredHandler = GatewayMethodHandler;
|
|
89
|
+
|
|
90
|
+
interface EnrichmentProxy {
|
|
91
|
+
readonly ctx: HandlerContext;
|
|
92
|
+
/** Close the shared triage store handle (if one was ever opened). */
|
|
93
|
+
dispose(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build a HandlerContext whose catalog decorates `channels.inbox.list`
|
|
98
|
+
* registration. The triage store is opened lazily ONCE on the first list
|
|
99
|
+
* invocation and reused for every subsequent call (hot read path); the caller
|
|
100
|
+
* disposes the handle on teardown.
|
|
101
|
+
*/
|
|
102
|
+
function withInboxEnrichment(ctx: HandlerContext): EnrichmentProxy {
|
|
103
|
+
const original = ctx.catalog;
|
|
104
|
+
|
|
105
|
+
let store: HandlerSqliteStore | null = null;
|
|
106
|
+
let initPromise: Promise<HandlerSqliteStore> | null = null;
|
|
107
|
+
const getStore = async (): Promise<HandlerSqliteStore> => {
|
|
108
|
+
if (store) return store;
|
|
109
|
+
if (!initPromise) {
|
|
110
|
+
const pending = createTriageStore(ctx.workingDirectory);
|
|
111
|
+
initPromise = pending
|
|
112
|
+
.init()
|
|
113
|
+
.then(() => {
|
|
114
|
+
store = pending;
|
|
115
|
+
return pending;
|
|
116
|
+
})
|
|
117
|
+
.catch((error) => {
|
|
118
|
+
initPromise = null; // allow a later call to retry opening the store
|
|
119
|
+
throw error;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return initPromise;
|
|
123
|
+
};
|
|
124
|
+
const dispose = (): void => {
|
|
125
|
+
if (store) {
|
|
126
|
+
store.close();
|
|
127
|
+
store = null;
|
|
128
|
+
}
|
|
129
|
+
initPromise = null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const decoratedRegister: GatewayMethodCatalog['register'] = (
|
|
133
|
+
descriptor: GatewayMethodDescriptor,
|
|
134
|
+
handler?: StoredHandler,
|
|
135
|
+
options?: { replace?: boolean },
|
|
136
|
+
): Unregister => {
|
|
137
|
+
if (descriptor.id !== INBOX_LIST_METHOD_ID || !handler) {
|
|
138
|
+
return original.register(descriptor, handler, options);
|
|
139
|
+
}
|
|
140
|
+
const innerHandler = handler;
|
|
141
|
+
const wrapped: StoredHandler = async (invocation) => {
|
|
142
|
+
const result = (await innerHandler(invocation)) as InboxListResult;
|
|
143
|
+
if (!result || !Array.isArray(result.items) || result.items.length === 0) {
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
const items = result.items as Array<{ id: string }>;
|
|
147
|
+
try {
|
|
148
|
+
const handle = await getStore();
|
|
149
|
+
return { ...result, items: enrichItemsWithTriage(handle, items) };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Triage is best-effort: a missing/locked store must never break the
|
|
152
|
+
// read-only inbox feed. Log and return the un-enriched result.
|
|
153
|
+
ctx.logger.warn('triage: inbox enrichment skipped', {
|
|
154
|
+
message: error instanceof Error ? error.message : String(error),
|
|
155
|
+
});
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return original.register(descriptor, wrapped, options);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Clone the context with only `catalog.register` swapped. Every other catalog
|
|
163
|
+
// method (invoke/list/get/...) keeps pointing at the original instance.
|
|
164
|
+
const proxiedCatalog = new Proxy(original, {
|
|
165
|
+
get(target, prop, receiver) {
|
|
166
|
+
if (prop === 'register') return decoratedRegister;
|
|
167
|
+
return Reflect.get(target, prop, receiver);
|
|
168
|
+
},
|
|
169
|
+
}) as GatewayMethodCatalog;
|
|
170
|
+
|
|
171
|
+
return { ctx: { ...ctx, catalog: proxiedCatalog }, dispose };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Compose the triage pipeline with the inbox surface so channels.inbox.list
|
|
176
|
+
* returns pre-scored items. Returns the teardown plus the poller-facing
|
|
177
|
+
* pipeline/tagger handle. The inbox surface's registration is wrapped; inbox
|
|
178
|
+
* teardown runs first, then the shared store handle is disposed.
|
|
179
|
+
*/
|
|
180
|
+
export function registerTriagedInbox(
|
|
181
|
+
ctx: HandlerContext,
|
|
182
|
+
registerInbox: RegisterInbox,
|
|
183
|
+
options: RegisterTriagedInboxOptions = {},
|
|
184
|
+
): TriagedInboxRegistration {
|
|
185
|
+
const tagger = createTriageTagger(ctx, options.tagger);
|
|
186
|
+
const enriched = withInboxEnrichment(ctx);
|
|
187
|
+
|
|
188
|
+
let unregisterInbox: Unregister;
|
|
189
|
+
try {
|
|
190
|
+
unregisterInbox = registerInbox(enriched.ctx);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
enriched.dispose();
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const unregister: Unregister = () => {
|
|
197
|
+
try {
|
|
198
|
+
unregisterInbox();
|
|
199
|
+
} finally {
|
|
200
|
+
enriched.dispose();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
unregister,
|
|
206
|
+
runInboxTriage: (items, runOptions) =>
|
|
207
|
+
runInboxTriage(items, ctx, { ...options.pipeline, ...runOptions }),
|
|
208
|
+
tagger,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type { ApplyTagsRequest, ApplyTagsResult };
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal triage PIPELINE.
|
|
3
|
+
//
|
|
4
|
+
// runInboxTriage(items): scores each inbound item (scorer.ts) and writes the
|
|
5
|
+
// resulting triageScore/triageTags back into the inbox triage store so that a
|
|
6
|
+
// later channels.inbox.list response can surface pre-scored items.
|
|
7
|
+
//
|
|
8
|
+
// The inbox surface owns the authoritative cursor-store and exposes
|
|
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).
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
18
|
+
import type { HandlerContext } from '../context.ts';
|
|
19
|
+
import type { InboundChannelItem, TriageLabel } from './types.ts';
|
|
20
|
+
import {
|
|
21
|
+
labelToTag,
|
|
22
|
+
scoreInboundItem,
|
|
23
|
+
type TriageScore,
|
|
24
|
+
type TriageScorerOptions,
|
|
25
|
+
} from './scorer.ts';
|
|
26
|
+
|
|
27
|
+
export const TRIAGE_STORE_FILE = 'inbox-triage.sqlite';
|
|
28
|
+
|
|
29
|
+
const SCHEMA: string[] = [
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS inbox_triage (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
surface TEXT NOT NULL,
|
|
33
|
+
triageScore REAL NOT NULL,
|
|
34
|
+
triageLabel TEXT NOT NULL,
|
|
35
|
+
triageTags TEXT NOT NULL,
|
|
36
|
+
spamSignal REAL NOT NULL,
|
|
37
|
+
prioritySignal REAL NOT NULL,
|
|
38
|
+
updatedAt TEXT NOT NULL
|
|
39
|
+
)`,
|
|
40
|
+
`CREATE INDEX IF NOT EXISTS idx_inbox_triage_label ON inbox_triage (triageLabel)`,
|
|
41
|
+
`CREATE INDEX IF NOT EXISTS idx_inbox_triage_surface ON inbox_triage (surface)`,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export interface TriageMetadata {
|
|
45
|
+
triageScore: number;
|
|
46
|
+
triageLabel: TriageLabel;
|
|
47
|
+
triageTags: string[];
|
|
48
|
+
signals: TriageScore['signals'];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TriagedItem extends InboundChannelItem {
|
|
52
|
+
triage: TriageMetadata;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RunInboxTriageOptions {
|
|
56
|
+
scorer?: TriageScorerOptions;
|
|
57
|
+
/** Inject a store (tests). When omitted, a triage store is opened/closed. */
|
|
58
|
+
store?: HandlerSqliteStore;
|
|
59
|
+
/** When true, do not persist — only compute (used by inbox.triage.list). */
|
|
60
|
+
dryRun?: boolean;
|
|
61
|
+
/** Clock injection for deterministic updatedAt in tests. */
|
|
62
|
+
now?: () => Date;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RunInboxTriageResult {
|
|
66
|
+
items: TriagedItem[];
|
|
67
|
+
scored: number;
|
|
68
|
+
persisted: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toMetadata(score: TriageScore): TriageMetadata {
|
|
72
|
+
return {
|
|
73
|
+
triageScore: score.score,
|
|
74
|
+
triageLabel: score.label,
|
|
75
|
+
triageTags: [labelToTag(score.label)],
|
|
76
|
+
signals: score.signals,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Open the triage store for this working directory. Caller owns close()/save()
|
|
82
|
+
* when they pass their own store; otherwise runInboxTriage manages lifecycle.
|
|
83
|
+
*/
|
|
84
|
+
export function createTriageStore(workingDirectory: string): HandlerSqliteStore {
|
|
85
|
+
return new HandlerSqliteStore({
|
|
86
|
+
workingDirectory,
|
|
87
|
+
fileName: TRIAGE_STORE_FILE,
|
|
88
|
+
schema: SCHEMA,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function persistRow(
|
|
93
|
+
store: HandlerSqliteStore,
|
|
94
|
+
item: InboundChannelItem,
|
|
95
|
+
meta: TriageMetadata,
|
|
96
|
+
updatedAt: string,
|
|
97
|
+
): void {
|
|
98
|
+
store.run(
|
|
99
|
+
`INSERT INTO inbox_triage
|
|
100
|
+
(id, surface, triageScore, triageLabel, triageTags, spamSignal, prioritySignal, updatedAt)
|
|
101
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
102
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
103
|
+
surface = excluded.surface,
|
|
104
|
+
triageScore = excluded.triageScore,
|
|
105
|
+
triageLabel = excluded.triageLabel,
|
|
106
|
+
triageTags = excluded.triageTags,
|
|
107
|
+
spamSignal = excluded.spamSignal,
|
|
108
|
+
prioritySignal = excluded.prioritySignal,
|
|
109
|
+
updatedAt = excluded.updatedAt`,
|
|
110
|
+
[
|
|
111
|
+
item.id,
|
|
112
|
+
item.surface,
|
|
113
|
+
meta.triageScore,
|
|
114
|
+
meta.triageLabel,
|
|
115
|
+
JSON.stringify(meta.triageTags),
|
|
116
|
+
meta.signals.spam,
|
|
117
|
+
meta.signals.priority,
|
|
118
|
+
updatedAt,
|
|
119
|
+
],
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Score every item and (unless dryRun) persist triageScore/triageTags back into
|
|
125
|
+
* the inbox triage store. Returns each item enriched with triage metadata so
|
|
126
|
+
* the caller can surface it without a second read.
|
|
127
|
+
*
|
|
128
|
+
* Called by the inbox poller (Responsibility 1) after each poll.
|
|
129
|
+
*/
|
|
130
|
+
export async function runInboxTriage(
|
|
131
|
+
items: readonly InboundChannelItem[],
|
|
132
|
+
ctx: HandlerContext,
|
|
133
|
+
options: RunInboxTriageOptions = {},
|
|
134
|
+
): Promise<RunInboxTriageResult> {
|
|
135
|
+
const now = options.now ?? (() => new Date());
|
|
136
|
+
const updatedAt = now().toISOString();
|
|
137
|
+
|
|
138
|
+
const enriched: TriagedItem[] = items.map((item) => {
|
|
139
|
+
const score = scoreInboundItem(item, options.scorer);
|
|
140
|
+
return { ...item, triage: toMetadata(score) };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (options.dryRun || enriched.length === 0) {
|
|
144
|
+
return { items: enriched, scored: enriched.length, persisted: 0 };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ownsStore = !options.store;
|
|
148
|
+
const store = options.store ?? createTriageStore(ctx.workingDirectory);
|
|
149
|
+
let persisted = 0;
|
|
150
|
+
try {
|
|
151
|
+
if (ownsStore) await store.init();
|
|
152
|
+
store.transaction(() => {
|
|
153
|
+
for (const item of enriched) {
|
|
154
|
+
persistRow(store, item, item.triage, updatedAt);
|
|
155
|
+
persisted += 1;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
if (ownsStore) await store.save();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
ctx.logger.error('triage: failed to persist scores', {
|
|
161
|
+
message: error instanceof Error ? error.message : String(error),
|
|
162
|
+
});
|
|
163
|
+
throw error;
|
|
164
|
+
} finally {
|
|
165
|
+
if (ownsStore) store.close();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { items: enriched, scored: enriched.length, persisted };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Optional persisted triage metadata overlaid onto an inbound item. */
|
|
172
|
+
export interface TriageOverlay {
|
|
173
|
+
triageScore?: number;
|
|
174
|
+
triageTags?: string[];
|
|
175
|
+
triageLabel?: TriageLabel;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** An inbound item overlaid with optional persisted triage metadata. */
|
|
179
|
+
export type TriageEnrichedItem = InboundChannelItem & TriageOverlay;
|
|
180
|
+
|
|
181
|
+
function rowToMetadata(row: Omit<TriageRow, 'id'>): TriageMetadata {
|
|
182
|
+
let tags: string[] = [];
|
|
183
|
+
try {
|
|
184
|
+
const parsed = JSON.parse(row.triageTags) as unknown;
|
|
185
|
+
if (Array.isArray(parsed)) tags = parsed.filter((t): t is string => typeof t === 'string');
|
|
186
|
+
} catch {
|
|
187
|
+
tags = [];
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
triageScore: row.triageScore,
|
|
191
|
+
triageLabel: row.triageLabel as TriageLabel,
|
|
192
|
+
triageTags: tags,
|
|
193
|
+
signals: { spam: row.spamSignal, priority: row.prioritySignal },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface TriageRow {
|
|
198
|
+
id: string;
|
|
199
|
+
triageScore: number;
|
|
200
|
+
triageLabel: string;
|
|
201
|
+
triageTags: string;
|
|
202
|
+
spamSignal: number;
|
|
203
|
+
prioritySignal: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read persisted triage rows for many item ids in a single `WHERE id IN (...)`
|
|
208
|
+
* query. Returns a Map keyed by item id; ids without a stored row are absent.
|
|
209
|
+
* De-duplicates ids so the placeholder list stays minimal.
|
|
210
|
+
*/
|
|
211
|
+
export function readTriageMetadataBatch(
|
|
212
|
+
store: HandlerSqliteStore,
|
|
213
|
+
itemIds: readonly string[],
|
|
214
|
+
): Map<string, TriageMetadata> {
|
|
215
|
+
const out = new Map<string, TriageMetadata>();
|
|
216
|
+
const uniqueIds = [...new Set(itemIds)];
|
|
217
|
+
if (uniqueIds.length === 0) return out;
|
|
218
|
+
const placeholders = uniqueIds.map(() => '?').join(', ');
|
|
219
|
+
const rows = store.all<TriageRow>(
|
|
220
|
+
`SELECT id, triageScore, triageLabel, triageTags, spamSignal, prioritySignal
|
|
221
|
+
FROM inbox_triage WHERE id IN (${placeholders})`,
|
|
222
|
+
uniqueIds,
|
|
223
|
+
);
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
out.set(row.id, rowToMetadata(row));
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Read a single persisted triage row by item id (used by the inbox surface). */
|
|
231
|
+
export function readTriageMetadata(
|
|
232
|
+
store: HandlerSqliteStore,
|
|
233
|
+
itemId: string,
|
|
234
|
+
): TriageMetadata | null {
|
|
235
|
+
const row = store.get<Omit<TriageRow, 'id'>>(
|
|
236
|
+
`SELECT triageScore, triageLabel, triageTags, spamSignal, prioritySignal
|
|
237
|
+
FROM inbox_triage WHERE id = ?`,
|
|
238
|
+
[itemId],
|
|
239
|
+
);
|
|
240
|
+
if (!row) return null;
|
|
241
|
+
return rowToMetadata(row);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Merge persisted triage metadata onto a batch of inbound items by id. This is
|
|
246
|
+
* the exact glue `channels.inbox.list` invokes to surface pre-scored items: the
|
|
247
|
+
* inbox surface lists from its cursor store, then calls this to overlay the
|
|
248
|
+
* triageScore/triageTags columns the contract promises. Items without a stored
|
|
249
|
+
* triage row pass through untouched, so an un-scored feed degrades gracefully.
|
|
250
|
+
*/
|
|
251
|
+
export function enrichItemsWithTriage<T extends { id: string }>(
|
|
252
|
+
store: HandlerSqliteStore,
|
|
253
|
+
items: readonly T[],
|
|
254
|
+
): Array<T & TriageOverlay> {
|
|
255
|
+
if (items.length === 0) return [];
|
|
256
|
+
// Single batched read (`WHERE id IN (...)`) instead of one SELECT per item —
|
|
257
|
+
// this is a hot read path (every channels.inbox.list call), so the N+1 is
|
|
258
|
+
// collapsed to one query keyed by id.
|
|
259
|
+
const byId = readTriageMetadataBatch(
|
|
260
|
+
store,
|
|
261
|
+
items.map((item) => item.id),
|
|
262
|
+
);
|
|
263
|
+
return items.map((item) => {
|
|
264
|
+
const meta = byId.get(item.id);
|
|
265
|
+
if (!meta) return { ...item };
|
|
266
|
+
return {
|
|
267
|
+
...item,
|
|
268
|
+
triageScore: meta.triageScore,
|
|
269
|
+
triageLabel: meta.triageLabel,
|
|
270
|
+
triageTags: meta.triageTags,
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|