@pellux/goodvibes-tui 0.24.1 → 0.25.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
@@ -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 } from '../operator/index.ts';
14
+
15
+ export type TriageLabel = 'spam' | 'priority' | 'normal';
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 countLexicon(text: string, lexicon: ReadonlyMap<string, number>): number {
131
+ let total = 0;
132
+ for (const [term, weight] of lexicon) {
133
+ if (term.includes(' ')) {
134
+ // Phrase match: count non-overlapping occurrences.
135
+ let from = 0;
136
+ let idx = text.indexOf(term, from);
137
+ while (idx !== -1) {
138
+ total += weight;
139
+ from = idx + term.length;
140
+ idx = text.indexOf(term, from);
141
+ }
142
+ } else {
143
+ // Word-boundary match for single tokens.
144
+ const re = new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g');
145
+ const matches = text.match(re);
146
+ if (matches) total += weight * matches.length;
147
+ }
148
+ }
149
+ return total;
150
+ }
151
+
152
+ function escapeRegExp(value: string): string {
153
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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
+ /**
190
+ * Score a single inbound item for spam likelihood and priority likelihood,
191
+ * then resolve a single label. Deterministic and side-effect free.
192
+ */
193
+ export function scoreInboundItem(
194
+ item: InboundChannelItem,
195
+ options: TriageScorerOptions = {},
196
+ ): TriageScore {
197
+ const spamThreshold = options.spamThreshold ?? DEFAULT_SPAM_THRESHOLD;
198
+ const priorityThreshold = options.priorityThreshold ?? DEFAULT_PRIORITY_THRESHOLD;
199
+
200
+ const spamLexicon = mergeLexicon(SPAM_TERMS, options.extraSpamTerms, 1.5);
201
+ const priorityLexicon = mergeLexicon(PRIORITY_TERMS, options.extraPriorityTerms, 1.5);
202
+
203
+ const raw = normalizeText(item);
204
+ const text = raw.toLowerCase();
205
+
206
+ // ---- Spam evidence -----------------------------------------------------
207
+ // Bias term tuned so a clean message lands well below the threshold.
208
+ let spamLL = -2.4;
209
+ spamLL += countLexicon(text, spamLexicon);
210
+
211
+ const caps = upperCaseRatio(raw);
212
+ if (caps > 0.6) spamLL += 1.8;
213
+ else if (caps > 0.4) spamLL += 0.9;
214
+
215
+ const density = urlDensity(text);
216
+ if (density > 0.25) spamLL += 1.6;
217
+ else if (density > 0.1) spamLL += 0.8;
218
+
219
+ if (MONEY_PATTERN.test(raw)) spamLL += 0.8;
220
+ if (EXCESSIVE_PUNCT.test(raw)) spamLL += 0.6;
221
+ if (raw.length === 0) spamLL -= 1.0; // empty/no-text items are not spam
222
+
223
+ const spam = clamp01(sigmoid(spamLL));
224
+
225
+ // ---- Priority evidence -------------------------------------------------
226
+ let priorityLL = -2.2;
227
+ priorityLL += countLexicon(text, priorityLexicon);
228
+
229
+ // Direct conversations (1:1 DMs) are inherently more likely to be priority.
230
+ if (item.conversationKind === 'direct') priorityLL += 0.8;
231
+ if (item.unread === true) priorityLL += 0.3;
232
+ // A trailing question mark suggests an awaited answer.
233
+ if (/\?\s*$/.test(raw)) priorityLL += 0.5;
234
+
235
+ // Spam strongly suppresses priority — promotional text is rarely urgent.
236
+ priorityLL -= spam * 2.5;
237
+
238
+ const priority = clamp01(sigmoid(priorityLL));
239
+
240
+ // ---- Label resolution --------------------------------------------------
241
+ let label: TriageLabel = 'normal';
242
+ let score: number;
243
+ if (spam >= spamThreshold && spam >= priority) {
244
+ label = 'spam';
245
+ score = spam;
246
+ } else if (priority >= priorityThreshold) {
247
+ label = 'priority';
248
+ score = priority;
249
+ } else {
250
+ label = 'normal';
251
+ // Confidence in 'normal' is how far both signals sit below their gates.
252
+ score = clamp01(1 - Math.max(spam, priority));
253
+ }
254
+
255
+ return {
256
+ score: round2(score),
257
+ label,
258
+ signals: { spam: round2(spam), priority: round2(priority) },
259
+ };
260
+ }
261
+
262
+ function mergeLexicon(
263
+ base: ReadonlyMap<string, number>,
264
+ extra: readonly string[] | undefined,
265
+ extraWeight: number,
266
+ ): ReadonlyMap<string, number> {
267
+ if (!extra || extra.length === 0) return base;
268
+ const merged = new Map(base);
269
+ for (const term of extra) {
270
+ const key = term.trim().toLowerCase();
271
+ if (key.length === 0) continue;
272
+ merged.set(key, Math.max(merged.get(key) ?? 0, extraWeight));
273
+ }
274
+ return merged;
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
+ }