@pellux/goodvibes-agent 1.5.2 → 1.5.5

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.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Writing-style-matched draft reply composer.
3
+ *
4
+ * This module is PURE and DETERMINISTIC — no Date.now(), Math.random(),
5
+ * or I/O. It takes a corpus of prior sent messages and an inbound message
6
+ * and produces a draft body that mirrors the user's writing style.
7
+ *
8
+ * BEFORE-SEND REVIEW BOUNDARY
9
+ * ──────────────────────────────────────────────────────────────────────────
10
+ * This composer produces a DRAFT only. No send path is included here.
11
+ * Delivery MUST go through the existing confirmed SMTP / connector send
12
+ * route (EmailService.sendMail with confirm:true, or the equivalent MCP
13
+ * connector action with explicit user confirmation). The lane descriptor
14
+ * in style-reply-lane.ts enforces this boundary at the Personal Ops level.
15
+ */
16
+
17
+ import { containsSecretLikeText } from '../memory-safety.ts';
18
+ import type { EmailSummary } from './email-service.ts';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Style profile
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface StyleProfile {
25
+ /** Most common greeting prefix found in sent messages, e.g. 'Hi', 'Hello', 'Hey'. */
26
+ readonly greeting: string;
27
+ /** Most common sign-off found in sent messages, e.g. 'Thanks', 'Best', 'Cheers'. */
28
+ readonly signOff: string;
29
+ /** Median sentence count across sent messages (whole number, ≥1). */
30
+ readonly medianSentenceCount: number;
31
+ /** Dominant tone inferred from token analysis: 'formal' | 'casual' | 'neutral'. */
32
+ readonly tone: 'formal' | 'casual' | 'neutral';
33
+ /** True when the corpus is empty — all values are defaults. */
34
+ readonly isDefault: boolean;
35
+ }
36
+
37
+ /** Greeting tokens in priority order (most → least common in business email). */
38
+ const GREETING_TOKENS: readonly string[] = [
39
+ 'Hi', 'Hello', 'Hey', 'Dear', 'Greetings', 'Good morning', 'Good afternoon',
40
+ ];
41
+
42
+ /**
43
+ * Sign-off tokens ordered longest-first so that multi-word phrases
44
+ * ('Kind regards', 'Best regards') are matched before their single-word
45
+ * substrings ('Regards', 'Best'). mostFrequent breaks ties by earliest
46
+ * position in this array.
47
+ */
48
+ const SIGN_OFF_TOKENS: readonly string[] = [
49
+ 'Kind regards', 'Best regards', 'All the best', 'Thank you', 'Thanks',
50
+ 'Sincerely', 'Take care', 'Cheers', 'Regards', 'Best',
51
+ ];
52
+
53
+ /** Formal tokens that shift tone toward 'formal'. */
54
+ const FORMAL_TOKENS: readonly string[] = [
55
+ 'please', 'kindly', 'sincerely', 'regards', 'dear', 'pursuant', 'accordingly',
56
+ 'therefore', 'attached', 'enclosed', 'herewith',
57
+ ];
58
+
59
+ /** Casual tokens that shift tone toward 'casual'. */
60
+ const CASUAL_TOKENS: readonly string[] = [
61
+ 'hey', 'cheers', 'yep', 'nope', 'yeah', 'cool', 'awesome', 'sure thing',
62
+ 'no worries', 'sounds good', 'chat soon', 'catch you',
63
+ ];
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Internal helpers (exported for testing)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Count how many sentences a text body contains.
71
+ * Splits on '. ', '! ', '? ' and trailing punctuation.
72
+ * Returns at least 1 for any non-empty string.
73
+ */
74
+ export function countSentences(text: string): number {
75
+ const trimmed = text.trim();
76
+ if (!trimmed) return 0;
77
+ // Split on end-of-sentence punctuation followed by whitespace or end-of-string
78
+ const parts = trimmed.split(/[.!?](?:\s+|$)/).filter((s) => s.trim().length > 0);
79
+ return Math.max(1, parts.length);
80
+ }
81
+
82
+ /**
83
+ * Compute the median of a sorted number array.
84
+ * Returns `fallback` when the array is empty.
85
+ */
86
+ export function median(sorted: readonly number[], fallback: number): number {
87
+ if (sorted.length === 0) return fallback;
88
+ const mid = Math.floor(sorted.length / 2);
89
+ if (sorted.length % 2 === 1) return sorted[mid]!;
90
+ return Math.round(((sorted[mid - 1]!) + (sorted[mid]!)) / 2);
91
+ }
92
+
93
+ /**
94
+ * Find the most frequently occurring token in a list.
95
+ * When there are ties, the token appearing earliest in `candidates` wins.
96
+ * Returns `fallback` when no candidates match.
97
+ */
98
+ export function mostFrequent(
99
+ corpus: readonly string[],
100
+ candidates: readonly string[],
101
+ fallback: string,
102
+ ): string {
103
+ const lower = corpus.map((s) => s.toLowerCase());
104
+ const counts = candidates.map((token) => ({
105
+ token,
106
+ count: lower.filter((text) => text.includes(token.toLowerCase())).length,
107
+ }));
108
+ const best = counts.reduce(
109
+ (best, candidate) => (candidate.count > best.count ? candidate : best),
110
+ { token: fallback, count: 0 },
111
+ );
112
+ return best.count > 0 ? best.token : fallback;
113
+ }
114
+
115
+ /**
116
+ * Classify tone from a corpus of message bodies.
117
+ * Returns 'formal', 'casual', or 'neutral'.
118
+ */
119
+ export function classifyTone(bodies: readonly string[]): 'formal' | 'casual' | 'neutral' {
120
+ if (bodies.length === 0) return 'neutral';
121
+ const combined = bodies.join(' ').toLowerCase();
122
+ const formalHits = FORMAL_TOKENS.filter((t) => combined.includes(t)).length;
123
+ const casualHits = CASUAL_TOKENS.filter((t) => combined.includes(t)).length;
124
+ if (formalHits > casualHits) return 'formal';
125
+ if (casualHits > formalHits) return 'casual';
126
+ return 'neutral';
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Public API
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Extract a StyleProfile from a corpus of the user's prior sent messages.
135
+ *
136
+ * When `sentMessages` is empty, returns a neutral default profile.
137
+ * Never reads the clock; fully deterministic.
138
+ */
139
+ export function extractStyleProfile(sentMessages: readonly EmailSummary[]): StyleProfile {
140
+ if (sentMessages.length === 0) {
141
+ return {
142
+ greeting: 'Hi',
143
+ signOff: 'Thanks',
144
+ medianSentenceCount: 3,
145
+ tone: 'neutral',
146
+ isDefault: true,
147
+ };
148
+ }
149
+
150
+ const bodies = sentMessages.map((m) => m.bodyPreview).filter((b) => b.length > 0);
151
+ const greetingCorpus = bodies.map((b) => b.split('\n')[0] ?? '');
152
+ const signOffCorpus = bodies.map((b) => {
153
+ const lines = b.split('\n').filter((l) => l.trim().length > 0);
154
+ return lines[lines.length - 1] ?? '';
155
+ });
156
+
157
+ const greeting = mostFrequent(greetingCorpus, GREETING_TOKENS, 'Hi');
158
+ const signOff = mostFrequent(signOffCorpus, SIGN_OFF_TOKENS, 'Thanks');
159
+
160
+ const sentenceCounts = bodies
161
+ .map(countSentences)
162
+ .filter((n) => n > 0)
163
+ .sort((a, b) => a - b);
164
+
165
+ const medianSentenceCount = Math.max(1, median(sentenceCounts, 3));
166
+ const tone = classifyTone(bodies);
167
+
168
+ return {
169
+ greeting,
170
+ signOff,
171
+ medianSentenceCount,
172
+ tone,
173
+ isDefault: false,
174
+ };
175
+ }
176
+
177
+ export interface DraftReplyResult {
178
+ /** Fully composed draft body. Never includes credentials or secret-looking text. */
179
+ readonly body: string;
180
+ /** The style profile used to compose this draft. */
181
+ readonly profile: StyleProfile;
182
+ /** Subject line for the reply, prefixed with 'Re: ' if not already. */
183
+ readonly subject: string;
184
+ /** BEFORE-SEND REVIEW BOUNDARY: always true — this is a draft, never auto-sent. */
185
+ readonly requiresBeforeSendReview: true;
186
+ /** Human-readable reminder of the review boundary. */
187
+ readonly reviewBoundary: string;
188
+ }
189
+
190
+ /**
191
+ * Compose a draft reply to `inbound` in the user's style as described by
192
+ * `profile`. An optional `context` string (e.g. key points the user wants
193
+ * to include) is woven into the body.
194
+ *
195
+ * SAFETY GUARANTEES
196
+ * - Throws if the composed body or context contains secret-like text.
197
+ * - Never sends — returns a DraftReplyResult with requiresBeforeSendReview: true.
198
+ * - No Date.now() / Math.random() — deterministic output for a given input.
199
+ *
200
+ * @param inbound The email the user received and wants to reply to.
201
+ * @param profile Writing-style profile extracted from prior sent messages.
202
+ * @param context Optional key points / instructions to fold into the draft body.
203
+ */
204
+ export function composeDraftReply(
205
+ inbound: EmailSummary,
206
+ profile: StyleProfile,
207
+ context = '',
208
+ ): DraftReplyResult {
209
+ // Validate context for secret-like text before use
210
+ if (context && containsSecretLikeText(context)) {
211
+ throw new Error(
212
+ 'composeDraftReply: context contains secret-like text. ' +
213
+ 'Remove credentials or sensitive values before composing a draft.',
214
+ );
215
+ }
216
+
217
+ const senderName = extractSenderName(inbound.from);
218
+ const subjectLine = replySubject(inbound.subject);
219
+
220
+ // Build body paragraphs scaled to the user's median sentence count.
221
+ // We keep the length heuristic simple and deterministic.
222
+ const lines: string[] = [];
223
+
224
+ // Greeting
225
+ lines.push(`${profile.greeting}${senderName ? ` ${senderName}` : ''},`);
226
+ lines.push('');
227
+
228
+ // Acknowledgement sentence
229
+ const acknowledgement = buildAcknowledgement(inbound, profile.tone);
230
+ lines.push(acknowledgement);
231
+
232
+ // User-supplied context woven in as a separate paragraph
233
+ if (context.trim()) {
234
+ lines.push('');
235
+ lines.push(context.trim());
236
+ }
237
+
238
+ // Filler sentences to approximate median length (when no context provided
239
+ // and the profile expects more sentences than the acknowledgement alone)
240
+ if (!context.trim() && profile.medianSentenceCount > 2) {
241
+ lines.push('');
242
+ lines.push(buildPlaceholderBody(profile.medianSentenceCount - 1, profile.tone));
243
+ }
244
+
245
+ // Sign-off
246
+ lines.push('');
247
+ lines.push(`${profile.signOff},`);
248
+
249
+ const body = lines.join('\n');
250
+
251
+ // Assert no secret-like text in the composed output
252
+ if (containsSecretLikeText(body)) {
253
+ throw new Error(
254
+ 'composeDraftReply: composed draft body contains secret-like text. ' +
255
+ 'Review the inbound message content and context before composing.',
256
+ );
257
+ }
258
+
259
+ return {
260
+ body,
261
+ profile,
262
+ subject: subjectLine,
263
+ requiresBeforeSendReview: true,
264
+ reviewBoundary:
265
+ 'This is a DRAFT only. Review the content before sending. ' +
266
+ 'Sending requires confirm:true on the email send path (SMTP or connector) ' +
267
+ 'with explicit user confirmation of recipients and body.',
268
+ };
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Internal composition helpers
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /**
276
+ * Extract a first name from an RFC-5322-style From header value.
277
+ * Handles both "Display Name <addr>" and "addr" forms.
278
+ * Returns empty string when no usable name is found.
279
+ */
280
+ export function extractSenderName(from: string): string {
281
+ // Try "Display Name <email>" pattern
282
+ const displayMatch = /^([^<]+)</.exec(from.trim());
283
+ if (displayMatch) {
284
+ const display = displayMatch[1]!.trim().replace(/^["']+|["']+$/g, '').trim();
285
+ if (display) {
286
+ // Return first token only (first name)
287
+ return display.split(/\s+/)[0] ?? '';
288
+ }
289
+ }
290
+ // Fall back to local part of email address
291
+ const localMatch = /^([^@<]+)@/.exec(from.trim());
292
+ if (localMatch) {
293
+ const local = localMatch[1]!.trim();
294
+ // Capitalize first letter
295
+ return local.charAt(0).toUpperCase() + local.slice(1);
296
+ }
297
+ return '';
298
+ }
299
+
300
+ /** Ensure subject is prefixed with 'Re: ' exactly once. */
301
+ export function replySubject(subject: string): string {
302
+ const trimmed = subject.trim();
303
+ if (/^re:\s*/i.test(trimmed)) return trimmed;
304
+ return `Re: ${trimmed}`;
305
+ }
306
+
307
+ /** Build a natural acknowledgement sentence adapted to tone. */
308
+ function buildAcknowledgement(inbound: EmailSummary, tone: StyleProfile['tone']): string {
309
+ const subject = inbound.subject.trim();
310
+ switch (tone) {
311
+ case 'formal':
312
+ return subject
313
+ ? `Thank you for your message regarding "${subject}".`
314
+ : 'Thank you for reaching out.';
315
+ case 'casual':
316
+ return subject
317
+ ? `Thanks for your message about "${subject}"!`
318
+ : 'Thanks for reaching out!';
319
+ default:
320
+ return subject
321
+ ? `Thanks for your message about "${subject}".`
322
+ : 'Thanks for reaching out.';
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Build placeholder body sentences to approximate target sentence count.
328
+ * Returns one paragraph with `targetCount` filler sentences.
329
+ * Deterministic — no randomness.
330
+ */
331
+ function buildPlaceholderBody(targetCount: number, tone: StyleProfile['tone']): string {
332
+ const formalSentences = [
333
+ 'I have reviewed the relevant details and will follow up accordingly.',
334
+ 'Please let me know if you require any further information.',
335
+ 'I will ensure this is addressed in a timely manner.',
336
+ 'Kindly advise if you would like to discuss this further.',
337
+ ];
338
+ const casualSentences = [
339
+ 'Let me know if you have any questions.',
340
+ 'Happy to chat more about this if helpful.',
341
+ 'Feel free to reach out if anything else comes up.',
342
+ 'Let me know what works best for you.',
343
+ ];
344
+ const neutralSentences = [
345
+ 'Let me know if you need anything else.',
346
+ 'Happy to discuss further if useful.',
347
+ 'Please let me know if you have any questions.',
348
+ 'Looking forward to hearing from you.',
349
+ ];
350
+
351
+ const pool = tone === 'formal' ? formalSentences : tone === 'casual' ? casualSentences : neutralSentences;
352
+ // Take up to targetCount sentences from the pool (cycle if needed)
353
+ const sentences: string[] = [];
354
+ for (let i = 0; i < Math.min(targetCount, 4); i++) {
355
+ sentences.push(pool[i % pool.length]!);
356
+ }
357
+ return sentences.join(' ');
358
+ }
@@ -3,7 +3,8 @@ const DEFAULT_LIMIT = 10;
3
3
  export const MIN_PROMPT_MEMORY_CONFIDENCE = 70;
4
4
 
5
5
  export function isPromptActiveMemory(record: MemoryRecord): boolean {
6
- return record.reviewState === 'reviewed' && record.confidence >= MIN_PROMPT_MEMORY_CONFIDENCE;
6
+ // Fully autonomous learning: reviewState gate removed confidence alone qualifies a memory for prompt recall.
7
+ return record.confidence >= MIN_PROMPT_MEMORY_CONFIDENCE;
7
8
  }
8
9
 
9
10
  function sortMemoryForPrompt(left: MemoryRecord, right: MemoryRecord): number {
@@ -88,7 +88,7 @@ function buildProcedureBody(candidate: LearningCandidate): string {
88
88
  }
89
89
 
90
90
  sections.push(`## Notes`);
91
- sections.push('This skill was auto-proposed from a learning candidate. Review and refine the steps before enabling.');
91
+ sections.push('This skill was auto-proposed from a learning candidate and created enabled. Refine steps or disable via the skill registry as needed.');
92
92
 
93
93
  return sections.join('\n').trim();
94
94
  }
@@ -131,7 +131,7 @@ function buildTags(candidate: LearningCandidate): readonly string[] {
131
131
  * (all comparisons use the kebab slug of the name, case-insensitive)
132
132
  * - Skip any candidate whose text fields contain secret-like content
133
133
  * - Skip any candidate whose derived name or description is empty
134
- * - All drafts are created with enabled: false (caller's responsibility; registry enforces reviewState 'fresh')
134
+ * - All drafts are created with enabled: true (autonomous promotion; registry enforces reviewState 'fresh')
135
135
  */
136
136
  export function proposeSkillDrafts(input: ProposeSkillDraftsInput): readonly SkillDraftPayload[] {
137
137
  const { candidates, existingSkillNames, previouslyProposedNames } = input;
@@ -193,7 +193,7 @@ export function proposeSkillDrafts(input: ProposeSkillDraftsInput): readonly Ski
193
193
  procedure,
194
194
  triggers: triggers.length > 0 ? triggers : undefined,
195
195
  tags: tags.length > 0 ? tags : undefined,
196
- enabled: false,
196
+ enabled: true,
197
197
  source: 'agent',
198
198
  provenance: PROVENANCE,
199
199
  });