@pellux/goodvibes-daemon 1.28.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 +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -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/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -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 +126 -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 +120 -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 +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- 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/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
27
|
+
|
|
28
|
+
export const TRIAGE_STORE_FILE = 'inbox-triage.sqlite';
|
|
29
|
+
|
|
30
|
+
const SCHEMA: string[] = [
|
|
31
|
+
`CREATE TABLE IF NOT EXISTS inbox_triage (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
surface TEXT NOT NULL,
|
|
34
|
+
triageScore REAL NOT NULL,
|
|
35
|
+
triageLabel TEXT NOT NULL,
|
|
36
|
+
triageTags TEXT NOT NULL,
|
|
37
|
+
spamSignal REAL NOT NULL,
|
|
38
|
+
prioritySignal REAL NOT NULL,
|
|
39
|
+
updatedAt TEXT NOT NULL
|
|
40
|
+
)`,
|
|
41
|
+
`CREATE INDEX IF NOT EXISTS idx_inbox_triage_label ON inbox_triage (triageLabel)`,
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_inbox_triage_surface ON inbox_triage (surface)`,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export interface TriageMetadata {
|
|
46
|
+
triageScore: number;
|
|
47
|
+
triageLabel: TriageLabel;
|
|
48
|
+
triageTags: string[];
|
|
49
|
+
signals: TriageScore['signals'];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TriagedItem extends InboundChannelItem {
|
|
53
|
+
triage: TriageMetadata;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunInboxTriageOptions {
|
|
57
|
+
scorer?: TriageScorerOptions;
|
|
58
|
+
/** Inject a store (tests). When omitted, a triage store is opened/closed. */
|
|
59
|
+
store?: HandlerSqliteStore;
|
|
60
|
+
/** When true, do not persist — only compute (used by inbox.triage.list). */
|
|
61
|
+
dryRun?: boolean;
|
|
62
|
+
/** Clock injection for deterministic updatedAt in tests. */
|
|
63
|
+
now?: () => Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RunInboxTriageResult {
|
|
67
|
+
items: TriagedItem[];
|
|
68
|
+
scored: number;
|
|
69
|
+
persisted: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toMetadata(score: TriageScore): TriageMetadata {
|
|
73
|
+
return {
|
|
74
|
+
triageScore: score.score,
|
|
75
|
+
triageLabel: score.label,
|
|
76
|
+
triageTags: [labelToTag(score.label)],
|
|
77
|
+
signals: score.signals,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Open the triage store for this working directory. Caller owns close()/save()
|
|
83
|
+
* when they pass their own store; otherwise runInboxTriage manages lifecycle.
|
|
84
|
+
*/
|
|
85
|
+
export function createTriageStore(workingDirectory: string): HandlerSqliteStore {
|
|
86
|
+
return new HandlerSqliteStore({
|
|
87
|
+
workingDirectory,
|
|
88
|
+
fileName: TRIAGE_STORE_FILE,
|
|
89
|
+
schema: SCHEMA,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function persistRow(
|
|
94
|
+
store: HandlerSqliteStore,
|
|
95
|
+
item: InboundChannelItem,
|
|
96
|
+
meta: TriageMetadata,
|
|
97
|
+
updatedAt: string,
|
|
98
|
+
): void {
|
|
99
|
+
store.run(
|
|
100
|
+
`INSERT INTO inbox_triage
|
|
101
|
+
(id, surface, triageScore, triageLabel, triageTags, spamSignal, prioritySignal, updatedAt)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
104
|
+
surface = excluded.surface,
|
|
105
|
+
triageScore = excluded.triageScore,
|
|
106
|
+
triageLabel = excluded.triageLabel,
|
|
107
|
+
triageTags = excluded.triageTags,
|
|
108
|
+
spamSignal = excluded.spamSignal,
|
|
109
|
+
prioritySignal = excluded.prioritySignal,
|
|
110
|
+
updatedAt = excluded.updatedAt`,
|
|
111
|
+
[
|
|
112
|
+
item.id,
|
|
113
|
+
item.surface,
|
|
114
|
+
meta.triageScore,
|
|
115
|
+
meta.triageLabel,
|
|
116
|
+
JSON.stringify(meta.triageTags),
|
|
117
|
+
meta.signals.spam,
|
|
118
|
+
meta.signals.priority,
|
|
119
|
+
updatedAt,
|
|
120
|
+
],
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Score every item and (unless dryRun) persist triageScore/triageTags back into
|
|
126
|
+
* the inbox triage store. Returns each item enriched with triage metadata so
|
|
127
|
+
* the caller can surface it without a second read.
|
|
128
|
+
*
|
|
129
|
+
* Called by the inbox poller (Responsibility 1) after each poll.
|
|
130
|
+
*/
|
|
131
|
+
export async function runInboxTriage(
|
|
132
|
+
items: readonly InboundChannelItem[],
|
|
133
|
+
ctx: HandlerContext,
|
|
134
|
+
options: RunInboxTriageOptions = {},
|
|
135
|
+
): Promise<RunInboxTriageResult> {
|
|
136
|
+
const now = options.now ?? (() => new Date());
|
|
137
|
+
const updatedAt = now().toISOString();
|
|
138
|
+
|
|
139
|
+
const enriched: TriagedItem[] = items.map((item) => {
|
|
140
|
+
const score = scoreInboundItem(item, options.scorer);
|
|
141
|
+
return { ...item, triage: toMetadata(score) };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (options.dryRun || enriched.length === 0) {
|
|
145
|
+
return { items: enriched, scored: enriched.length, persisted: 0 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ownsStore = !options.store;
|
|
149
|
+
const store = options.store ?? createTriageStore(ctx.workingDirectory);
|
|
150
|
+
let persisted = 0;
|
|
151
|
+
try {
|
|
152
|
+
if (ownsStore) await store.init();
|
|
153
|
+
store.transaction(() => {
|
|
154
|
+
for (const item of enriched) {
|
|
155
|
+
persistRow(store, item, item.triage, updatedAt);
|
|
156
|
+
persisted += 1;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (ownsStore) await store.save();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
ctx.logger.error('triage: failed to persist scores', {
|
|
162
|
+
message: summarizeError(error),
|
|
163
|
+
});
|
|
164
|
+
throw error;
|
|
165
|
+
} finally {
|
|
166
|
+
if (ownsStore) store.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { items: enriched, scored: enriched.length, persisted };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Optional persisted triage metadata overlaid onto an inbound item. */
|
|
173
|
+
export interface TriageOverlay {
|
|
174
|
+
triageScore?: number;
|
|
175
|
+
triageTags?: string[];
|
|
176
|
+
triageLabel?: TriageLabel;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** An inbound item overlaid with optional persisted triage metadata. */
|
|
180
|
+
export type TriageEnrichedItem = InboundChannelItem & TriageOverlay;
|
|
181
|
+
|
|
182
|
+
function rowToMetadata(row: Omit<TriageRow, 'id'>): TriageMetadata {
|
|
183
|
+
let tags: string[] = [];
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(row.triageTags) as unknown;
|
|
186
|
+
if (Array.isArray(parsed)) tags = parsed.filter((t): t is string => typeof t === 'string');
|
|
187
|
+
} catch {
|
|
188
|
+
tags = [];
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
triageScore: row.triageScore,
|
|
192
|
+
triageLabel: row.triageLabel as TriageLabel,
|
|
193
|
+
triageTags: tags,
|
|
194
|
+
signals: { spam: row.spamSignal, priority: row.prioritySignal },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface TriageRow {
|
|
199
|
+
id: string;
|
|
200
|
+
triageScore: number;
|
|
201
|
+
triageLabel: string;
|
|
202
|
+
triageTags: string;
|
|
203
|
+
spamSignal: number;
|
|
204
|
+
prioritySignal: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read persisted triage rows for many item ids in a single `WHERE id IN (...)`
|
|
209
|
+
* query. Returns a Map keyed by item id; ids without a stored row are absent.
|
|
210
|
+
* De-duplicates ids so the placeholder list stays minimal.
|
|
211
|
+
*/
|
|
212
|
+
export function readTriageMetadataBatch(
|
|
213
|
+
store: HandlerSqliteStore,
|
|
214
|
+
itemIds: readonly string[],
|
|
215
|
+
): Map<string, TriageMetadata> {
|
|
216
|
+
const out = new Map<string, TriageMetadata>();
|
|
217
|
+
const uniqueIds = [...new Set(itemIds)];
|
|
218
|
+
if (uniqueIds.length === 0) return out;
|
|
219
|
+
const placeholders = uniqueIds.map(() => '?').join(', ');
|
|
220
|
+
const rows = store.all<TriageRow>(
|
|
221
|
+
`SELECT id, triageScore, triageLabel, triageTags, spamSignal, prioritySignal
|
|
222
|
+
FROM inbox_triage WHERE id IN (${placeholders})`,
|
|
223
|
+
uniqueIds,
|
|
224
|
+
);
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
out.set(row.id, rowToMetadata(row));
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Read a single persisted triage row by item id (used by the inbox surface). */
|
|
232
|
+
export function readTriageMetadata(
|
|
233
|
+
store: HandlerSqliteStore,
|
|
234
|
+
itemId: string,
|
|
235
|
+
): TriageMetadata | null {
|
|
236
|
+
const row = store.get<Omit<TriageRow, 'id'>>(
|
|
237
|
+
`SELECT triageScore, triageLabel, triageTags, spamSignal, prioritySignal
|
|
238
|
+
FROM inbox_triage WHERE id = ?`,
|
|
239
|
+
[itemId],
|
|
240
|
+
);
|
|
241
|
+
if (!row) return null;
|
|
242
|
+
return rowToMetadata(row);
|
|
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
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal email auto-tag / spam triage SCORER.
|
|
3
|
+
//
|
|
4
|
+
// Pure, dependency-free heuristic + naive-Bayes-style spam/priority scoring
|
|
5
|
+
// over an InboundChannelItem's textual surface (subject + snippet). No I/O,
|
|
6
|
+
// no credentials, no network — safe to run on every polled item.
|
|
7
|
+
//
|
|
8
|
+
// The score is a normalized 0..1 confidence in the assigned label. The label
|
|
9
|
+
// is one of 'spam' | 'priority' | 'normal'. Scoring is deterministic so the
|
|
10
|
+
// pipeline can persist stable triageScore/triageTags values across polls.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import type { InboundChannelItem, TriageLabel } from './types.ts';
|
|
14
|
+
|
|
15
|
+
export type { TriageLabel } from './types.ts';
|
|
16
|
+
|
|
17
|
+
export interface TriageScore {
|
|
18
|
+
/** Confidence in the assigned label, 0..1 (2-decimal rounded). */
|
|
19
|
+
score: number;
|
|
20
|
+
label: TriageLabel;
|
|
21
|
+
/** Raw component probabilities (for diagnostics / tests). */
|
|
22
|
+
signals: {
|
|
23
|
+
spam: number; // 0..1 P(spam)
|
|
24
|
+
priority: number; // 0..1 P(priority)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TriageScorerOptions {
|
|
29
|
+
/** Minimum P(spam) to label an item 'spam'. Default 0.65. */
|
|
30
|
+
spamThreshold?: number;
|
|
31
|
+
/** Minimum P(priority) to label an item 'priority'. Default 0.6. */
|
|
32
|
+
priorityThreshold?: number;
|
|
33
|
+
/** Extra spam lexicon terms (lowercased) merged with the defaults. */
|
|
34
|
+
extraSpamTerms?: readonly string[];
|
|
35
|
+
/** Extra priority lexicon terms (lowercased) merged with the defaults. */
|
|
36
|
+
extraPriorityTerms?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_SPAM_THRESHOLD = 0.65;
|
|
40
|
+
const DEFAULT_PRIORITY_THRESHOLD = 0.6;
|
|
41
|
+
|
|
42
|
+
// Tokens that, when present, push toward spam. Weighted log-likelihoods.
|
|
43
|
+
const SPAM_TERMS: ReadonlyMap<string, number> = new Map([
|
|
44
|
+
['viagra', 3.2],
|
|
45
|
+
['lottery', 2.8],
|
|
46
|
+
['winner', 2.0],
|
|
47
|
+
['congratulations', 1.4],
|
|
48
|
+
['prize', 2.2],
|
|
49
|
+
['free', 1.1],
|
|
50
|
+
['cash', 1.6],
|
|
51
|
+
['bitcoin', 1.5],
|
|
52
|
+
['crypto', 1.2],
|
|
53
|
+
['investment', 1.0],
|
|
54
|
+
['guarantee', 1.3],
|
|
55
|
+
['guaranteed', 1.5],
|
|
56
|
+
['risk-free', 2.0],
|
|
57
|
+
['click', 1.2],
|
|
58
|
+
['unsubscribe', 0.9],
|
|
59
|
+
['limited', 0.9],
|
|
60
|
+
['offer', 1.0],
|
|
61
|
+
['act now', 2.2],
|
|
62
|
+
['urgent', 0.7],
|
|
63
|
+
['wire transfer', 2.6],
|
|
64
|
+
['nigerian', 2.6],
|
|
65
|
+
['inheritance', 2.4],
|
|
66
|
+
['million', 1.4],
|
|
67
|
+
['pharmacy', 2.2],
|
|
68
|
+
['pills', 2.0],
|
|
69
|
+
['loan', 1.4],
|
|
70
|
+
['debt', 1.2],
|
|
71
|
+
['refinance', 1.6],
|
|
72
|
+
['casino', 2.4],
|
|
73
|
+
['earn money', 2.2],
|
|
74
|
+
['work from home', 1.8],
|
|
75
|
+
['100% free', 2.4],
|
|
76
|
+
['no cost', 1.6],
|
|
77
|
+
['cheap', 1.2],
|
|
78
|
+
['discount', 0.8],
|
|
79
|
+
['weight loss', 2.0],
|
|
80
|
+
['verify your account', 2.6],
|
|
81
|
+
['suspended', 1.4],
|
|
82
|
+
['password', 1.0],
|
|
83
|
+
['confirm your identity', 2.4],
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Tokens that push toward priority/important.
|
|
87
|
+
const PRIORITY_TERMS: ReadonlyMap<string, number> = new Map([
|
|
88
|
+
['urgent', 2.4],
|
|
89
|
+
['asap', 2.4],
|
|
90
|
+
['immediately', 2.0],
|
|
91
|
+
['deadline', 2.0],
|
|
92
|
+
['today', 1.2],
|
|
93
|
+
['eod', 1.6],
|
|
94
|
+
['important', 1.6],
|
|
95
|
+
['action required', 2.2],
|
|
96
|
+
['action needed', 2.2],
|
|
97
|
+
['please respond', 1.8],
|
|
98
|
+
['please reply', 1.8],
|
|
99
|
+
['waiting', 1.0],
|
|
100
|
+
['follow up', 1.2],
|
|
101
|
+
['follow-up', 1.2],
|
|
102
|
+
['reminder', 1.0],
|
|
103
|
+
['overdue', 2.2],
|
|
104
|
+
['blocker', 2.0],
|
|
105
|
+
['blocked', 1.6],
|
|
106
|
+
['escalation', 2.2],
|
|
107
|
+
['escalate', 2.0],
|
|
108
|
+
['critical', 2.4],
|
|
109
|
+
['emergency', 2.6],
|
|
110
|
+
['payment due', 2.0],
|
|
111
|
+
['invoice', 1.2],
|
|
112
|
+
['signature', 1.0],
|
|
113
|
+
['approval', 1.4],
|
|
114
|
+
['approve', 1.4],
|
|
115
|
+
['meeting', 0.8],
|
|
116
|
+
['call me', 1.6],
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const URL_PATTERN = /https?:\/\/[^\s]+/gi;
|
|
120
|
+
const MONEY_PATTERN = /(?:[$£€]\s?\d|(?:\d[\d,]*)\s?(?:usd|eur|gbp|dollars|euros))/i;
|
|
121
|
+
const EXCESSIVE_PUNCT = /[!?]{2,}/;
|
|
122
|
+
|
|
123
|
+
function normalizeText(item: InboundChannelItem): string {
|
|
124
|
+
const subject = typeof item.subject === 'string' ? item.subject : '';
|
|
125
|
+
const snippet = typeof item.snippet === 'string' ? item.snippet : '';
|
|
126
|
+
// Subject is weighted more heavily by duplicating it once.
|
|
127
|
+
return `${subject} ${subject} ${snippet}`.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function escapeRegExp(value: string): string {
|
|
131
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function countLexicon(text: string, lexicon: ReadonlyMap<string, number>): number {
|
|
135
|
+
let total = 0;
|
|
136
|
+
for (const [term, weight] of lexicon) {
|
|
137
|
+
if (term.includes(' ')) {
|
|
138
|
+
// Phrase match: count non-overlapping occurrences.
|
|
139
|
+
let from = 0;
|
|
140
|
+
let idx = text.indexOf(term, from);
|
|
141
|
+
while (idx !== -1) {
|
|
142
|
+
total += weight;
|
|
143
|
+
from = idx + term.length;
|
|
144
|
+
idx = text.indexOf(term, from);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// Word-boundary match for single tokens.
|
|
148
|
+
const re = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g');
|
|
149
|
+
const matches = text.match(re);
|
|
150
|
+
if (matches) total += weight * matches.length;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return total;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function upperCaseRatio(text: string): number {
|
|
157
|
+
const letters = text.replace(/[^a-zA-Z]/g, '');
|
|
158
|
+
if (letters.length < 8) return 0;
|
|
159
|
+
let upper = 0;
|
|
160
|
+
for (const ch of letters) {
|
|
161
|
+
if (ch >= 'A' && ch <= 'Z') upper += 1;
|
|
162
|
+
}
|
|
163
|
+
return upper / letters.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function urlDensity(text: string): number {
|
|
167
|
+
const urls = text.match(URL_PATTERN);
|
|
168
|
+
const urlCount = urls ? urls.length : 0;
|
|
169
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0).length || 1;
|
|
170
|
+
return urlCount / words;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Logistic squash of an accumulated log-likelihood into 0..1. */
|
|
174
|
+
function sigmoid(x: number): number {
|
|
175
|
+
return 1 / (1 + Math.exp(-x));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function round2(value: number): number {
|
|
179
|
+
return Math.round(value * 100) / 100;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clamp01(value: number): number {
|
|
183
|
+
if (Number.isNaN(value)) return 0;
|
|
184
|
+
if (value < 0) return 0;
|
|
185
|
+
if (value > 1) return 1;
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function mergeLexicon(
|
|
190
|
+
base: ReadonlyMap<string, number>,
|
|
191
|
+
extra: readonly string[] | undefined,
|
|
192
|
+
extraWeight: number,
|
|
193
|
+
): ReadonlyMap<string, number> {
|
|
194
|
+
if (!extra || extra.length === 0) return base;
|
|
195
|
+
const merged = new Map(base);
|
|
196
|
+
for (const term of extra) {
|
|
197
|
+
const key = term.trim().toLowerCase();
|
|
198
|
+
if (key.length === 0) continue;
|
|
199
|
+
merged.set(key, Math.max(merged.get(key) ?? 0, extraWeight));
|
|
200
|
+
}
|
|
201
|
+
return merged;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Score a single inbound item for spam likelihood and priority likelihood,
|
|
206
|
+
* then resolve a single label. Deterministic and side-effect free.
|
|
207
|
+
*/
|
|
208
|
+
export function scoreInboundItem(
|
|
209
|
+
item: InboundChannelItem,
|
|
210
|
+
options: TriageScorerOptions = {},
|
|
211
|
+
): TriageScore {
|
|
212
|
+
const spamThreshold = options.spamThreshold ?? DEFAULT_SPAM_THRESHOLD;
|
|
213
|
+
const priorityThreshold = options.priorityThreshold ?? DEFAULT_PRIORITY_THRESHOLD;
|
|
214
|
+
|
|
215
|
+
const spamLexicon = mergeLexicon(SPAM_TERMS, options.extraSpamTerms, 1.5);
|
|
216
|
+
const priorityLexicon = mergeLexicon(PRIORITY_TERMS, options.extraPriorityTerms, 1.5);
|
|
217
|
+
|
|
218
|
+
const raw = normalizeText(item);
|
|
219
|
+
const text = raw.toLowerCase();
|
|
220
|
+
|
|
221
|
+
// ---- Spam evidence -----------------------------------------------------
|
|
222
|
+
// Bias term tuned so a clean message lands well below the threshold.
|
|
223
|
+
let spamLL = -2.4;
|
|
224
|
+
spamLL += countLexicon(text, spamLexicon);
|
|
225
|
+
|
|
226
|
+
const caps = upperCaseRatio(raw);
|
|
227
|
+
if (caps > 0.6) spamLL += 1.8;
|
|
228
|
+
else if (caps > 0.4) spamLL += 0.9;
|
|
229
|
+
|
|
230
|
+
const density = urlDensity(text);
|
|
231
|
+
if (density > 0.25) spamLL += 1.6;
|
|
232
|
+
else if (density > 0.1) spamLL += 0.8;
|
|
233
|
+
|
|
234
|
+
if (MONEY_PATTERN.test(raw)) spamLL += 0.8;
|
|
235
|
+
if (EXCESSIVE_PUNCT.test(raw)) spamLL += 0.6;
|
|
236
|
+
if (raw.length === 0) spamLL -= 1.0; // empty/no-text items are not spam
|
|
237
|
+
|
|
238
|
+
const spam = clamp01(sigmoid(spamLL));
|
|
239
|
+
|
|
240
|
+
// ---- Priority evidence -------------------------------------------------
|
|
241
|
+
let priorityLL = -2.2;
|
|
242
|
+
priorityLL += countLexicon(text, priorityLexicon);
|
|
243
|
+
|
|
244
|
+
// Direct conversations (1:1 DMs) are inherently more likely to be priority.
|
|
245
|
+
if (item.conversationKind === 'direct') priorityLL += 0.8;
|
|
246
|
+
if (item.unread === true) priorityLL += 0.3;
|
|
247
|
+
// A trailing question mark suggests an awaited answer.
|
|
248
|
+
if (/\?\s*$/.test(raw)) priorityLL += 0.5;
|
|
249
|
+
|
|
250
|
+
// Spam strongly suppresses priority — promotional text is rarely urgent.
|
|
251
|
+
priorityLL -= spam * 2.5;
|
|
252
|
+
|
|
253
|
+
const priority = clamp01(sigmoid(priorityLL));
|
|
254
|
+
|
|
255
|
+
// ---- Label resolution --------------------------------------------------
|
|
256
|
+
let label: TriageLabel = 'normal';
|
|
257
|
+
let score: number;
|
|
258
|
+
if (spam >= spamThreshold && spam >= priority) {
|
|
259
|
+
label = 'spam';
|
|
260
|
+
score = spam;
|
|
261
|
+
} else if (priority >= priorityThreshold) {
|
|
262
|
+
label = 'priority';
|
|
263
|
+
score = priority;
|
|
264
|
+
} else {
|
|
265
|
+
label = 'normal';
|
|
266
|
+
// Confidence in 'normal' is how far both signals sit below their gates.
|
|
267
|
+
score = clamp01(1 - Math.max(spam, priority));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
score: round2(score),
|
|
272
|
+
label,
|
|
273
|
+
signals: { spam: round2(spam), priority: round2(priority) },
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Map a label to the canonical provider-side tag string applied by the tagger. */
|
|
278
|
+
export function labelToTag(label: TriageLabel): string {
|
|
279
|
+
switch (label) {
|
|
280
|
+
case 'spam':
|
|
281
|
+
return 'GoodVibes/Spam';
|
|
282
|
+
case 'priority':
|
|
283
|
+
return 'GoodVibes/Priority';
|
|
284
|
+
default:
|
|
285
|
+
return 'GoodVibes/Normal';
|
|
286
|
+
}
|
|
287
|
+
}
|