clementine-agent 1.1.5 → 1.1.7

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.
@@ -115,7 +115,19 @@ export async function sendChunked(channel, text) {
115
115
  return;
116
116
  }
117
117
  text = sanitizeResponse(text);
118
- for (const chunk of chunkText(text, 1900)) {
118
+ // Last-line outbound credential redaction. Dispatcher-level redaction
119
+ // (gateway/notifications.ts) covers cron/heartbeat sends, but chat
120
+ // replies bypass the dispatcher and arrive here directly. Idempotent:
121
+ // re-redacting an already-redacted string is a no-op since the
122
+ // [REDACTED:label] markers don't match any pattern or known value.
123
+ const { redactSecrets } = await import('../security/redact.js');
124
+ const { text: redacted, stats } = redactSecrets(text);
125
+ if (stats.redactionCount > 0) {
126
+ // Log via console — pino isn't imported here and adding an import would
127
+ // bloat this lightweight utility module.
128
+ console.warn(`[clementine] sendChunked: redacted ${stats.redactionCount} credential-shaped value(s) [${stats.labelsHit.join(',')}]`);
129
+ }
130
+ for (const chunk of chunkText(redacted, 1900)) {
119
131
  await channel.send(chunk);
120
132
  }
121
133
  }
@@ -13,7 +13,15 @@ export function mdToSlack(text) {
13
13
  }
14
14
  // ── Chunked sending ───────────────────────────────────────────────────
15
15
  export async function sendChunkedSlack(client, channel, text, threadTs) {
16
- let remaining = text;
16
+ // Last-line outbound credential redaction. Same rationale as
17
+ // discord-utils.sendChunked — chat replies bypass the dispatcher and
18
+ // arrive here directly, so apply redaction at the channel boundary.
19
+ const { redactSecrets } = await import('../security/redact.js');
20
+ const { text: redacted, stats } = redactSecrets(text);
21
+ if (stats.redactionCount > 0) {
22
+ console.warn(`[clementine] sendChunkedSlack: redacted ${stats.redactionCount} credential-shaped value(s) [${stats.labelsHit.join(',')}]`);
23
+ }
24
+ let remaining = redacted;
17
25
  while (remaining) {
18
26
  if (remaining.length <= SLACK_MSG_LIMIT) {
19
27
  await client.chat.postMessage({ channel, text: remaining, thread_ts: threadTs });
@@ -17,7 +17,14 @@ function mdToTelegram(text) {
17
17
  }
18
18
  // ── Chunked sending ───────────────────────────────────────────────────
19
19
  async function sendChunked(bot, chatId, text) {
20
- let remaining = text;
20
+ // Last-line outbound credential redaction. Same rationale as
21
+ // discord-utils.sendChunked — chat replies bypass the dispatcher.
22
+ const { redactSecrets } = await import('../security/redact.js');
23
+ const { text: redacted, stats } = redactSecrets(text);
24
+ if (stats.redactionCount > 0) {
25
+ console.warn(`[clementine] telegram sendChunked: redacted ${stats.redactionCount} credential-shaped value(s) [${stats.labelsHit.join(',')}]`);
26
+ }
27
+ let remaining = redacted;
21
28
  while (remaining) {
22
29
  if (remaining.length <= TELEGRAM_MSG_LIMIT) {
23
30
  await bot.api.sendMessage(chatId, remaining);
@@ -4322,7 +4322,12 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4322
4322
  try {
4323
4323
  const gateway = await getGateway();
4324
4324
  const response = await gateway.handleMessage('dashboard:web', message);
4325
- res.json({ ok: true, response });
4325
+ // Outbound credential redaction — same defense applied at the channel
4326
+ // edges. Dashboard is admin-only but a leaked credential in chat output
4327
+ // could still end up in browser history, screenshots, etc.
4328
+ const { redactSecrets } = await import('../security/redact.js');
4329
+ const { text: redacted } = redactSecrets(response ?? '');
4330
+ res.json({ ok: true, response: redacted });
4326
4331
  }
4327
4332
  catch (err) {
4328
4333
  res.status(500).json({ error: String(err) });
package/dist/cli/index.js CHANGED
@@ -1918,7 +1918,7 @@ configCmd
1918
1918
  });
1919
1919
  configCmd
1920
1920
  .command('migrate-to-keychain')
1921
- .description('Move plaintext credentials in .env into the macOS keychain (in place)')
1921
+ .description('Move plaintext credentials in .env into the macOS keychain (NOT recommended in v1.1.4+ — keychain entries can produce per-process approval prompts; plain .env at mode 0600 is the supported default)')
1922
1922
  .option('--dry-run', 'Show what would migrate without writing anything')
1923
1923
  .option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
1924
1924
  .action(async (opts) => {
@@ -16,7 +16,6 @@
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { computeEffectiveConfig } from './effective-config.js';
19
- import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
20
19
  // ── Type expectations ───────────────────────────────────────────────
21
20
  //
22
21
  // Keys that must parse as a finite number when set. The inspector already
@@ -165,41 +164,32 @@ function checkChannelRequirements(cfg, findings) {
165
164
  }
166
165
  }
167
166
  function checkPlaintextSecretsInEnv(_cfg, baseDir, findings) {
168
- // Scan .env directly for sensitive-looking keys that hold long plaintext
169
- // values. Stops bot tokens from quietly sitting in .env when they should
170
- // be in the keychain.
167
+ // Sanity check on .env file permissions.
168
+ //
169
+ // History: this function previously WARNED whenever credential-shaped keys
170
+ // (DISCORD_TOKEN, *_API_KEY, etc.) sat as plaintext in .env, recommending
171
+ // migration to the macOS Keychain. After the 2026-04-26 rabbit hole
172
+ // (commits 88cfd99 .. c5a2eb5) we reversed that recommendation: plaintext
173
+ // .env at mode 0600 is the supported default, and keychain is opt-in only.
174
+ // The old warning is now misleading guidance, so it's removed.
175
+ //
176
+ // What we DO check: file mode. If .env is world-readable or group-readable
177
+ // we flag that as a real risk regardless of what's inside.
171
178
  const envPath = path.join(baseDir, '.env');
172
179
  if (!existsSync(envPath))
173
180
  return;
174
- let raw;
175
181
  try {
176
- raw = readFileSync(envPath, 'utf-8');
177
- }
178
- catch {
179
- return;
180
- }
181
- for (const line of raw.split('\n')) {
182
- const trimmed = line.trim();
183
- if (!trimmed || trimmed.startsWith('#'))
184
- continue;
185
- const eq = trimmed.indexOf('=');
186
- if (eq === -1)
187
- continue;
188
- const key = trimmed.slice(0, eq);
189
- const value = trimmed.slice(eq + 1);
190
- if (!isSensitiveEnvKey(key))
191
- continue;
192
- if (value.startsWith('keychain:'))
193
- continue; // already a ref — fine
194
- if (value.length < 16)
195
- continue; // probably a config-shaped value (port number, etc.)
196
- findings.push({
197
- severity: 'warning',
198
- key,
199
- message: `${key} is stored as plaintext in .env. Credential-shaped keys should live in the keychain on macOS.`,
200
- fix: `# In a chat with Clementine: env_set ${key} <value> storage=auto (auto routes credentials to keychain)`,
201
- });
182
+ const st = require('node:fs').statSync(envPath);
183
+ const worldOrGroupReadable = (st.mode & 0o077) !== 0;
184
+ if (worldOrGroupReadable) {
185
+ findings.push({
186
+ severity: 'error',
187
+ message: `.env file is readable by other users (mode ${(st.mode & 0o777).toString(8)}). Restrict to owner-only.`,
188
+ fix: `chmod 600 ${envPath}`,
189
+ });
190
+ }
202
191
  }
192
+ catch { /* stat failed — non-fatal, doctor continues */ }
203
193
  }
204
194
  function checkRangeSanity(cfg, findings) {
205
195
  const byKey = new Map(cfg.entries.map(e => [e.key, e]));
@@ -358,6 +358,18 @@ function applyAdvisorRuleFix(jobName, autoApply, opts) {
358
358
  }
359
359
  appendAudit({ kind: 'advisor-rule', jobName, file: targetPath, ruleId: autoApply.ruleId, diff });
360
360
  logger.info({ jobName, ruleId: autoApply.ruleId, file: targetPath }, 'Applied advisor-rule fix');
361
+ // Phase 8.1 — record this autoApply for verification. The next
362
+ // AUTOAPPLY_VERDICT_WINDOW non-skipped runs decide whether the rule
363
+ // helped; if not, fix-verification auto-reverts (deletes the file).
364
+ // Lazy import to avoid circular dependency (fix-verification imports
365
+ // failure-monitor which transitively touches the cron path).
366
+ import('./fix-verification.js').then(({ recordAutoApplyForVerification }) => {
367
+ recordAutoApplyForVerification(jobName, {
368
+ kind: 'advisor-rule',
369
+ file: targetPath,
370
+ ruleId: autoApply.ruleId,
371
+ });
372
+ }).catch(err => logger.warn({ err, jobName }, 'Failed to record autoApply for verification'));
361
373
  return {
362
374
  ok: true,
363
375
  message: `Wrote advisor rule ${autoApply.ruleId} (hot-reloads on next eval)`,
@@ -408,6 +420,15 @@ function applyPromptOverrideFix(jobName, autoApply, opts) {
408
420
  diff,
409
421
  });
410
422
  logger.info({ jobName, scope: autoApply.scope, scopeKey: autoApply.scopeKey, file: targetPath }, 'Applied prompt-override fix');
423
+ // Phase 8.1 — same multi-run verification flow as advisor-rule.
424
+ import('./fix-verification.js').then(({ recordAutoApplyForVerification }) => {
425
+ recordAutoApplyForVerification(jobName, {
426
+ kind: 'prompt-override',
427
+ file: targetPath,
428
+ scope: autoApply.scope,
429
+ scopeKey: autoApply.scopeKey,
430
+ });
431
+ }).catch(err => logger.warn({ err, jobName }, 'Failed to record autoApply for verification'));
411
432
  return {
412
433
  ok: true,
413
434
  message: `Wrote prompt override ${autoApply.scope}${autoApply.scopeKey ? `:${autoApply.scopeKey}` : ''}`,
@@ -13,6 +13,29 @@ interface PendingVerification {
13
13
  recordedAt: string;
14
14
  preFailureCount: number;
15
15
  preLastError: string | null;
16
+ /** Used by Phase 8.1 — when set, the verifier is also responsible for
17
+ * deleting this artifact if the fix doesn't help. Existing CRON.md edits
18
+ * leave this unset (they're hand-edits, not auto-applies, so we never
19
+ * revert them automatically). */
20
+ autoApply?: AutoApplyTracker;
21
+ /** Run-by-run history accumulated since the fix was applied. Single-run
22
+ * verdicts (the original CRON.md flow) only need the first entry; multi-
23
+ * run autoApply verifications need the accumulated sample. */
24
+ postRunOutcomes?: Array<'ok' | 'error' | 'retried'>;
25
+ }
26
+ /**
27
+ * Tracks an autoApply that's currently being verified. When the verdict
28
+ * window closes negatively, revertFix() uses these fields to undo.
29
+ */
30
+ export interface AutoApplyTracker {
31
+ kind: 'advisor-rule' | 'prompt-override';
32
+ /** Absolute path of the file the apply wrote. */
33
+ file: string;
34
+ /** advisor-rule only: the rule's id, used by the loader's hot-reload. */
35
+ ruleId?: string;
36
+ /** prompt-override only: scope label for the verdict message. */
37
+ scope?: 'global' | 'agent' | 'job';
38
+ scopeKey?: string;
16
39
  }
17
40
  /**
18
41
  * Compare an old and new jobs list and record verifications for any job that:
@@ -25,15 +48,32 @@ interface PendingVerification {
25
48
  * pending verification" rather than waiting for a run that will never come.
26
49
  */
27
50
  export declare function recordEditsForFailingJobs(oldJobs: CronJobDefinition[], newJobs: CronJobDefinition[]): void;
51
+ /**
52
+ * Phase 8.1 — record a pending verification for an autoApply (advisor-rule
53
+ * or prompt-override) so the verifier can roll the fix back if the next
54
+ * AUTOAPPLY_VERDICT_WINDOW runs don't show improvement.
55
+ *
56
+ * Called from fix-applier.applyFix on success. Idempotent: if a previous
57
+ * verification for the same job is still pending, the new tracker overwrites
58
+ * it (the most-recent fix is the one we're verifying).
59
+ */
60
+ export declare function recordAutoApplyForVerification(jobName: string, tracker: AutoApplyTracker): void;
28
61
  /**
29
62
  * After a cron run completes, check whether we were waiting on a fix
30
- * verification for this job. If so, send the owner a verdict and clear it.
63
+ * verification for this job. Two flows:
64
+ *
65
+ * 1. Hand-edit (CRON.md) — verdict on the FIRST non-skipped run. Original
66
+ * Phase 7 behavior, preserved.
67
+ * 2. AutoApply (advisor-rule / prompt-override) — accumulate up to
68
+ * AUTOAPPLY_VERDICT_WINDOW outcomes, then decide. If 0 successes,
69
+ * revert the file. Either way, DM the verdict.
31
70
  *
32
- * Skipped runs (circuit breaker, pre-check exit, etc.) don't carry signal
33
- * and shouldn't count as a verdict either way.
71
+ * Skipped runs don't carry signal and don't advance the window in either flow.
34
72
  */
35
73
  export declare function checkAndDeliverVerification(entry: CronRunEntry, send: (text: string) => Promise<unknown>): Promise<void>;
36
74
  /** Read-only accessor for dashboards or debugging. */
37
75
  export declare function listPendingVerifications(): PendingVerification[];
76
+ /** Test helper — clear all state. */
77
+ export declare function _resetVerificationState(): void;
38
78
  export {};
39
79
  //# sourceMappingURL=fix-verification.d.ts.map
@@ -15,6 +15,13 @@ import { BASE_DIR } from '../config.js';
15
15
  import { computeBrokenJobs } from './failure-monitor.js';
16
16
  const logger = pino({ name: 'clementine.fix-verification' });
17
17
  const STATE_FILE = path.join(BASE_DIR, 'cron', 'fix-verifications.json');
18
+ /**
19
+ * Number of post-fix runs we accumulate before deciding an autoApply
20
+ * verdict. Single sample is too noisy; ten is too patient. Three is
21
+ * a tight window: 0/3 successes after a "fix" is overwhelming evidence
22
+ * the fix didn't help.
23
+ */
24
+ const AUTOAPPLY_VERDICT_WINDOW = 3;
18
25
  function loadState() {
19
26
  try {
20
27
  if (!existsSync(STATE_FILE))
@@ -109,12 +116,62 @@ export function recordEditsForFailingJobs(oldJobs, newJobs) {
109
116
  if (mutated)
110
117
  saveState(state);
111
118
  }
119
+ /**
120
+ * Phase 8.1 — record a pending verification for an autoApply (advisor-rule
121
+ * or prompt-override) so the verifier can roll the fix back if the next
122
+ * AUTOAPPLY_VERDICT_WINDOW runs don't show improvement.
123
+ *
124
+ * Called from fix-applier.applyFix on success. Idempotent: if a previous
125
+ * verification for the same job is still pending, the new tracker overwrites
126
+ * it (the most-recent fix is the one we're verifying).
127
+ */
128
+ export function recordAutoApplyForVerification(jobName, tracker) {
129
+ const state = loadState();
130
+ const broken = computeBrokenJobs();
131
+ const b = broken.find(x => x.jobName === jobName);
132
+ state.pending[jobName] = {
133
+ jobName,
134
+ recordedAt: new Date().toISOString(),
135
+ preFailureCount: b?.errorCount48h ?? 0,
136
+ preLastError: b?.lastErrors[0] ?? null,
137
+ autoApply: tracker,
138
+ postRunOutcomes: [],
139
+ };
140
+ saveState(state);
141
+ logger.info({ job: jobName, kind: tracker.kind, file: tracker.file }, 'Recorded autoApply for verification — will track next runs');
142
+ }
143
+ /**
144
+ * Undo an autoApply by deleting the file the apply wrote. Best-effort:
145
+ * a missing file is not an error (might have been hand-deleted). Returns
146
+ * true if a file was actually removed.
147
+ */
148
+ function revertAutoApply(tracker) {
149
+ try {
150
+ if (existsSync(tracker.file)) {
151
+ // Use unlinkSync from fs — kept dynamic to avoid a top-of-file import
152
+ // we don't otherwise need.
153
+ const { unlinkSync } = require('node:fs');
154
+ unlinkSync(tracker.file);
155
+ logger.warn({ file: tracker.file, kind: tracker.kind }, 'Reverted autoApply — fix did not help');
156
+ return true;
157
+ }
158
+ }
159
+ catch (err) {
160
+ logger.warn({ err, file: tracker.file }, 'Failed to delete autoApply file during revert');
161
+ }
162
+ return false;
163
+ }
112
164
  /**
113
165
  * After a cron run completes, check whether we were waiting on a fix
114
- * verification for this job. If so, send the owner a verdict and clear it.
166
+ * verification for this job. Two flows:
167
+ *
168
+ * 1. Hand-edit (CRON.md) — verdict on the FIRST non-skipped run. Original
169
+ * Phase 7 behavior, preserved.
170
+ * 2. AutoApply (advisor-rule / prompt-override) — accumulate up to
171
+ * AUTOAPPLY_VERDICT_WINDOW outcomes, then decide. If 0 successes,
172
+ * revert the file. Either way, DM the verdict.
115
173
  *
116
- * Skipped runs (circuit breaker, pre-check exit, etc.) don't carry signal
117
- * and shouldn't count as a verdict either way.
174
+ * Skipped runs don't carry signal and don't advance the window in either flow.
118
175
  */
119
176
  export async function checkAndDeliverVerification(entry, send) {
120
177
  if (entry.status === 'skipped')
@@ -123,15 +180,60 @@ export async function checkAndDeliverVerification(entry, send) {
123
180
  const pending = state.pending[entry.jobName];
124
181
  if (!pending)
125
182
  return;
183
+ // Hand-edit flow — single-run verdict, unchanged.
184
+ if (!pending.autoApply) {
185
+ delete state.pending[entry.jobName];
186
+ saveState(state);
187
+ const ok = entry.status === 'ok';
188
+ const verdict = ok ? '✅ succeeded' : '⚠️ still failing';
189
+ const ageMin = Math.max(1, Math.round((Date.now() - Date.parse(pending.recordedAt)) / 60000));
190
+ const detail = ok ? '' : `\nError: ${(entry.error ?? 'unknown').split('\n')[0].slice(0, 200)}`;
191
+ const msg = `**[Fix verification]** \`${entry.jobName}\` ${verdict} on its first run after edit (${ageMin}m later).${detail}`;
192
+ try {
193
+ await send(msg);
194
+ }
195
+ catch (err) {
196
+ logger.warn({ err, job: entry.jobName }, 'Failed to send fix verification DM');
197
+ }
198
+ return;
199
+ }
200
+ // AutoApply flow — accumulate the sample first.
201
+ const outcomes = pending.postRunOutcomes ?? [];
202
+ outcomes.push(entry.status);
203
+ pending.postRunOutcomes = outcomes;
204
+ if (outcomes.length < AUTOAPPLY_VERDICT_WINDOW) {
205
+ // Not enough sample yet — persist accumulated state, wait for more runs.
206
+ saveState(state);
207
+ return;
208
+ }
209
+ // Decision time.
126
210
  delete state.pending[entry.jobName];
127
211
  saveState(state);
128
- const ok = entry.status === 'ok';
129
- const verdict = ok ? '✅ succeeded' : '⚠️ still failing';
212
+ const successes = outcomes.filter(o => o === 'ok').length;
130
213
  const ageMin = Math.max(1, Math.round((Date.now() - Date.parse(pending.recordedAt)) / 60000));
131
- const detail = ok
132
- ? ''
133
- : `\nError: ${(entry.error ?? 'unknown').split('\n')[0].slice(0, 200)}`;
134
- const msg = `**[Fix verification]** \`${entry.jobName}\` ${verdict} on its first run after edit (${ageMin}m later).${detail}`;
214
+ const tracker = pending.autoApply;
215
+ const scopeLabel = tracker.scope
216
+ ? `${tracker.kind}:${tracker.scope}${tracker.scopeKey ? `:${tracker.scopeKey}` : ''}`
217
+ : `${tracker.kind}${tracker.ruleId ? `:${tracker.ruleId}` : ''}`;
218
+ if (successes === 0) {
219
+ // Fix didn't help — revert and notify.
220
+ const reverted = revertAutoApply(tracker);
221
+ const msg = `**[Fix verification — REVERTED]** \`${entry.jobName}\`: ` +
222
+ `auto-applied ${scopeLabel} did not help (0/${outcomes.length} runs succeeded over ${ageMin}m). ` +
223
+ (reverted ? `Reverted ${path.basename(tracker.file)}.` : `Tried to revert but file was already gone.`);
224
+ try {
225
+ await send(msg);
226
+ }
227
+ catch (err) {
228
+ logger.warn({ err, job: entry.jobName }, 'Failed to send fix-revert DM');
229
+ }
230
+ logger.warn({ job: entry.jobName, scopeLabel, reverted }, 'Auto-reverted ineffective autoApply');
231
+ return;
232
+ }
233
+ const verdict = successes === outcomes.length
234
+ ? `✅ verified — ${successes}/${outcomes.length} runs succeeded`
235
+ : `⚠️ partial — ${successes}/${outcomes.length} runs succeeded`;
236
+ const msg = `**[Fix verification]** \`${entry.jobName}\`: auto-applied ${scopeLabel} ${verdict} over ${ageMin}m.`;
135
237
  try {
136
238
  await send(msg);
137
239
  }
@@ -143,4 +245,8 @@ export async function checkAndDeliverVerification(entry, send) {
143
245
  export function listPendingVerifications() {
144
246
  return Object.values(loadState().pending);
145
247
  }
248
+ /** Test helper — clear all state. */
249
+ export function _resetVerificationState() {
250
+ saveState({ pending: {} });
251
+ }
146
252
  //# sourceMappingURL=fix-verification.js.map
@@ -50,10 +50,20 @@ export class NotificationDispatcher {
50
50
  }
51
51
  /** Send a notification; automatically queues for retry on total failure. */
52
52
  async send(text, context) {
53
- const result = await this.sendDirect(text, context);
54
- // If delivery failed and there were actual senders (not "no channels"), queue for retry
53
+ // Outbound credential redaction happens HERE, at the public entrypoint,
54
+ // BEFORE any failure could enqueue the message for retry. Otherwise an
55
+ // un-redacted credential would persist to ~/.clementine/.delivery-queue.json
56
+ // for the retry window. Pattern-based + known-value scan; cheap enough to
57
+ // run on every send. See src/security/redact.ts for policy.
58
+ const { text: redacted, stats: redactionStats } = redactSecrets(text);
59
+ if (redactionStats.redactionCount > 0) {
60
+ logger.warn({ count: redactionStats.redactionCount, labels: redactionStats.labelsHit, sessionKey: context?.sessionKey }, `Redacted ${redactionStats.redactionCount} credential-shaped value(s) before delivery`);
61
+ }
62
+ const result = await this.sendDirect(redacted, context);
63
+ // If delivery failed and there were actual senders (not "no channels"), queue for retry.
64
+ // Stored text is already-redacted so disk persistence never holds a credential.
55
65
  if (!result.delivered && this.senders.size > 0) {
56
- this._retryQueue.enqueue(text, context);
66
+ this._retryQueue.enqueue(redacted, context);
57
67
  }
58
68
  return result;
59
69
  }
@@ -63,18 +73,12 @@ export class NotificationDispatcher {
63
73
  logger.warn('No notification senders registered — message dropped');
64
74
  return { delivered: false, channelErrors: { _: 'no channels registered' } };
65
75
  }
66
- // Outbound credential redactionlast-line defense against the agent
67
- // accidentally (or via prompt injection) shipping a credential to a
68
- // public channel. Pattern-based + known-value scan; cheap enough to
69
- // run on every send. See src/security/redact.ts for the policy.
70
- const { text: redacted, stats: redactionStats } = redactSecrets(text);
71
- if (redactionStats.redactionCount > 0) {
72
- logger.warn({ count: redactionStats.redactionCount, labels: redactionStats.labelsHit, sessionKey: context?.sessionKey }, `Redacted ${redactionStats.redactionCount} credential-shaped value(s) before delivery`);
73
- }
74
- // Sanity cap only — each channel sender handles its own chunking/truncation
75
- const capped = redacted.length > MAX_MESSAGE_LENGTH
76
- ? redacted.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
77
- : redacted;
76
+ // Sanity cap onlyeach channel sender handles its own chunking/truncation.
77
+ // Redaction happens at send() (public entrypoint) before any retry-enqueue,
78
+ // so anything reaching here is already safe.
79
+ const capped = text.length > MAX_MESSAGE_LENGTH
80
+ ? text.slice(0, MAX_MESSAGE_LENGTH - 20) + '\n\n_(truncated)_'
81
+ : text;
78
82
  // If sessionKey is set, route only to the channel that owns it.
79
83
  // Fan out to all channels only when no originating channel is known.
80
84
  const targetChannel = context?.sessionKey ? channelForSessionKey(context.sessionKey) : null;
@@ -1094,6 +1094,7 @@ export class MemoryStore {
1094
1094
  }
1095
1095
  const rows = this.conn.prepare(sql).all(...params);
1096
1096
  const scored = [];
1097
+ const nowMs = Date.now();
1097
1098
  for (const row of rows) {
1098
1099
  try {
1099
1100
  const vec = embeddingsModule.deserializeEmbedding(row.embedding);
@@ -1109,6 +1110,16 @@ export class MemoryStore {
1109
1110
  // Soft isolation: apply boost (only when not strict)
1110
1111
  if (!strict && agentSlug && row.agent_slug === agentSlug)
1111
1112
  score *= 1.4;
1113
+ // Temporal decay — same policy as FTS scoring (Phase 9d). Without
1114
+ // this, vector and FTS rankings disagree on freshness: FTS prefers
1115
+ // recent at equal relevance but vector treats all timestamps
1116
+ // equally, so MMR rerank surfaces stale matches when vector wins.
1117
+ // Same 30-day half-life, same 0.4 floor — see store.ts FTS path
1118
+ // for design rationale.
1119
+ if (row.updated_at) {
1120
+ const daysOld = Math.max(0, (nowMs - new Date(row.updated_at).getTime()) / 86_400_000);
1121
+ score *= Math.max(0.4, temporalDecay(daysOld, 30));
1122
+ }
1112
1123
  scored.push({
1113
1124
  sourceFile: row.source_file,
1114
1125
  section: row.section,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",