clementine-agent 1.18.28 → 1.18.30

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.
@@ -7044,6 +7044,64 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7044
7044
  res.status(500).json({ error: String(err) });
7045
7045
  }
7046
7046
  });
7047
+ // Commitments — durable promises tracked across sessions.
7048
+ app.get('/api/memory/commitments', async (req, res) => {
7049
+ try {
7050
+ const gateway = await getGateway();
7051
+ const store = gateway.assistant?.memoryStore;
7052
+ if (!store || typeof store.listCommitments !== 'function') {
7053
+ res.status(503).json({ error: 'Commitments store not available' });
7054
+ return;
7055
+ }
7056
+ const status = req.query.status ? String(req.query.status) : 'open';
7057
+ const owner = req.query.owner ? String(req.query.owner) : undefined;
7058
+ const overdueOnly = String(req.query.overdueOnly ?? '') === '1';
7059
+ const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 500);
7060
+ const commitments = store.listCommitments({
7061
+ status: ['open', 'done', 'cancelled'].includes(status) ? status : 'open',
7062
+ owner: owner === 'user' || owner === 'clementine' ? owner : undefined,
7063
+ overdueOnly,
7064
+ limit,
7065
+ });
7066
+ res.json({ ok: true, commitments });
7067
+ }
7068
+ catch (err) {
7069
+ res.status(500).json({ error: String(err) });
7070
+ }
7071
+ });
7072
+ app.post('/api/memory/commitments/action', async (req, res) => {
7073
+ try {
7074
+ const gateway = await getGateway();
7075
+ const store = gateway.assistant?.memoryStore;
7076
+ if (!store || typeof store.updateCommitmentStatus !== 'function') {
7077
+ res.status(503).json({ error: 'Commitments store not available' });
7078
+ return;
7079
+ }
7080
+ const id = Number(req.body?.id);
7081
+ const action = String(req.body?.action ?? '');
7082
+ if (!Number.isInteger(id) || id <= 0) {
7083
+ res.status(400).json({ error: 'id required' });
7084
+ return;
7085
+ }
7086
+ let updated = false;
7087
+ if (action === 'done' || action === 'cancelled' || action === 'reopen') {
7088
+ updated = store.updateCommitmentStatus(id, { status: action === 'reopen' ? 'open' : action });
7089
+ }
7090
+ else if (action === 'snooze') {
7091
+ const hours = Number(req.body?.hours ?? 24);
7092
+ const until = new Date(Date.now() + Math.max(1, hours) * 3600_000).toISOString();
7093
+ updated = store.updateCommitmentStatus(id, { snoozeUntilIso: until });
7094
+ }
7095
+ else {
7096
+ res.status(400).json({ error: 'invalid action' });
7097
+ return;
7098
+ }
7099
+ res.json({ ok: updated });
7100
+ }
7101
+ catch (err) {
7102
+ res.status(500).json({ error: String(err) });
7103
+ }
7104
+ });
7047
7105
  // Recent episodes — durable consolidated session summaries.
7048
7106
  app.get('/api/memory/episodes', async (req, res) => {
7049
7107
  try {
@@ -15027,6 +15085,22 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15027
15085
  <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
15028
15086
  </div>
15029
15087
  </div>
15088
+ <div class="card" style="margin-bottom:14px">
15089
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
15090
+ <span>Open commitments</span>
15091
+ <div style="display:flex;align-items:center;gap:8px">
15092
+ <select id="commitments-filter-status" onchange="refreshCommitments()" style="font-size:12px;padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text)">
15093
+ <option value="open" selected>Open</option>
15094
+ <option value="done">Done</option>
15095
+ <option value="cancelled">Cancelled</option>
15096
+ </select>
15097
+ <span style="font-size:11px;color:var(--text-muted)">Promises tracked across sessions</span>
15098
+ </div>
15099
+ </div>
15100
+ <div class="card-body" id="panel-commitments" style="padding:0">
15101
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
15102
+ </div>
15103
+ </div>
15030
15104
  <div class="card" style="margin-bottom:14px">
15031
15105
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
15032
15106
  <span>Recent episodes</span>
@@ -18555,6 +18629,7 @@ function switchTab(group, tab) {
18555
18629
  refreshMemory();
18556
18630
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
18557
18631
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
18632
+ if (typeof refreshCommitments === 'function') refreshCommitments();
18558
18633
  if (typeof refreshSupersedes === 'function') refreshSupersedes();
18559
18634
  if (typeof refreshCoverageStrip === 'function') refreshCoverageStrip();
18560
18635
  }
@@ -24913,6 +24988,7 @@ async function submitQuickAddMemory() {
24913
24988
  closeQuickAddMemory();
24914
24989
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
24915
24990
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
24991
+ if (typeof refreshCommitments === 'function') refreshCommitments();
24916
24992
  if (typeof refreshMemory === 'function') refreshMemory();
24917
24993
  }, 600);
24918
24994
  } catch (err) {
@@ -25066,6 +25142,84 @@ async function refreshRecentWrites() {
25066
25142
  }
25067
25143
  }
25068
25144
 
25145
+ async function refreshCommitments() {
25146
+ var el = document.getElementById('panel-commitments');
25147
+ if (!el) return;
25148
+ try {
25149
+ var sel = document.getElementById('commitments-filter-status');
25150
+ var status = sel ? sel.value : 'open';
25151
+ var r = await apiFetch('/api/memory/commitments?limit=50&status=' + encodeURIComponent(status));
25152
+ var d = await r.json();
25153
+ if (!d.ok || !Array.isArray(d.commitments)) {
25154
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
25155
+ return;
25156
+ }
25157
+ if (d.commitments.length === 0) {
25158
+ el.innerHTML = '<div class="empty-state" style="padding:14px">No commitments. They land automatically when you say things like "I\\'ll fix that tomorrow" or "remind me to call them Friday".</div>';
25159
+ return;
25160
+ }
25161
+ var html = '<table class="data-table" style="width:100%">';
25162
+ html += '<thead><tr>'
25163
+ + '<th style="width:80px">Owner</th>'
25164
+ + '<th>Promise</th>'
25165
+ + '<th style="width:140px">Due</th>'
25166
+ + '<th style="width:100px">Source</th>'
25167
+ + '<th style="width:200px">Actions</th>'
25168
+ + '</tr></thead><tbody>';
25169
+ var nowMs = Date.now();
25170
+ for (var i = 0; i < d.commitments.length; i++) {
25171
+ var c = d.commitments[i];
25172
+ var ownerLabel = c.owner === 'clementine' ? 'I' : 'You';
25173
+ var ownerColor = c.owner === 'clementine' ? '#a78bfa' : '#10b981';
25174
+ var dueText = '—';
25175
+ var dueColor = 'var(--text-muted)';
25176
+ if (c.dueAt) {
25177
+ try {
25178
+ var dueMs = new Date(c.dueAt).getTime();
25179
+ var deltaMs = dueMs - nowMs;
25180
+ dueText = new Date(c.dueAt).toLocaleString();
25181
+ if (deltaMs < 0) { dueText = 'OVERDUE — ' + dueText; dueColor = '#ef4444'; }
25182
+ else if (deltaMs < 86_400_000) { dueColor = '#f59e0b'; }
25183
+ } catch { dueText = c.dueAt; }
25184
+ } else if (c.dueHint) {
25185
+ dueText = c.dueHint;
25186
+ }
25187
+ var actions = '';
25188
+ if (c.status === 'open') {
25189
+ actions = '<button class="btn-sm" onclick="commitmentAction(' + c.id + ', \\'done\\')">Done</button>'
25190
+ + ' <button class="btn-sm" onclick="commitmentAction(' + c.id + ', \\'snooze\\', 24)" title="Snooze 24h">Snooze</button>'
25191
+ + ' <button class="btn-sm" onclick="commitmentAction(' + c.id + ', \\'cancelled\\')">Cancel</button>';
25192
+ } else {
25193
+ actions = '<button class="btn-sm" onclick="commitmentAction(' + c.id + ', \\'reopen\\')">Reopen</button>';
25194
+ }
25195
+ html += '<tr>'
25196
+ + '<td style="font-size:11px;color:' + ownerColor + ';font-weight:600">' + ownerLabel + '</td>'
25197
+ + '<td style="font-size:12px">' + esc(c.text) + '</td>'
25198
+ + '<td style="font-size:11px;color:' + dueColor + '">' + esc(dueText) + '</td>'
25199
+ + '<td style="font-size:11px;color:var(--text-muted)">' + esc(c.source) + '</td>'
25200
+ + '<td>' + actions + '</td>'
25201
+ + '</tr>';
25202
+ }
25203
+ html += '</tbody></table>';
25204
+ el.innerHTML = html;
25205
+ } catch (err) {
25206
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
25207
+ }
25208
+ }
25209
+
25210
+ async function commitmentAction(id, action, hours) {
25211
+ try {
25212
+ var body = { id: id, action: action };
25213
+ if (hours) body.hours = hours;
25214
+ var r = await apiJson('POST', '/api/memory/commitments/action', body);
25215
+ if (r.error) { toast('Action failed: ' + r.error, 'error'); return; }
25216
+ toast('Commitment ' + action, 'success');
25217
+ refreshCommitments();
25218
+ } catch (err) {
25219
+ toast('Failed: ' + String(err), 'error');
25220
+ }
25221
+ }
25222
+
25069
25223
  async function refreshRecentEpisodes() {
25070
25224
  var el = document.getElementById('panel-recent-episodes');
25071
25225
  if (!el) return;
@@ -7,7 +7,7 @@
7
7
  * run logs into the model.
8
8
  */
9
9
  export interface ActiveContextItem {
10
- source: 'notification' | 'background-task' | 'unleashed' | 'turn-ledger';
10
+ source: 'notification' | 'background-task' | 'unleashed' | 'turn-ledger' | 'commitment';
11
11
  label: string;
12
12
  detail: string;
13
13
  priority: number;
@@ -39,6 +39,19 @@ export interface ActiveContextOptions {
39
39
  embedded: number;
40
40
  total: number;
41
41
  };
42
+ /**
43
+ * Open commitments tied to this session (or owner-wide). Caller looks
44
+ * these up via store.listCommitments and threads them through so
45
+ * active-context.ts stays free of the store dependency.
46
+ */
47
+ openCommitments?: Array<{
48
+ id: number;
49
+ owner: 'user' | 'clementine';
50
+ text: string;
51
+ dueAt: string | null;
52
+ dueHint: string | null;
53
+ sessionKey: string | null;
54
+ }>;
42
55
  }
43
56
  export declare function buildActiveContextSnapshot(sessionKey: string, opts: ActiveContextOptions): ActiveContextSnapshot;
44
57
  //# sourceMappingURL=active-context.d.ts.map
@@ -271,6 +271,36 @@ function turnLedgerItems(surfaceHistory) {
271
271
  greetingEligible: false,
272
272
  }));
273
273
  }
274
+ function commitmentItems(opts) {
275
+ const list = opts.openCommitments ?? [];
276
+ if (list.length === 0)
277
+ return [];
278
+ const nowMs = opts.now ?? Date.now();
279
+ const dayMs = 24 * 60 * 60 * 1000;
280
+ return list.slice(0, 10).map((c) => {
281
+ const dueMs = c.dueAt ? Date.parse(c.dueAt) : NaN;
282
+ const overdue = Number.isFinite(dueMs) && dueMs < nowMs;
283
+ const dueWithin24 = Number.isFinite(dueMs) && dueMs >= nowMs && dueMs - nowMs <= dayMs;
284
+ let priority = 60;
285
+ if (overdue)
286
+ priority = 90;
287
+ else if (dueWithin24)
288
+ priority = 80;
289
+ else if (c.dueHint)
290
+ priority = 70;
291
+ const ownerLabel = c.owner === 'clementine' ? 'I committed' : 'You committed';
292
+ const dueLabel = overdue ? ' (overdue)' : dueWithin24 ? ' (due within 24h)' : c.dueHint ? ` (${c.dueHint})` : '';
293
+ return {
294
+ source: 'commitment',
295
+ label: `${ownerLabel}${dueLabel}`,
296
+ detail: cap(c.text),
297
+ priority,
298
+ timestamp: c.dueAt ?? undefined,
299
+ sourceId: `commitment:${c.id}`,
300
+ greetingEligible: overdue || dueWithin24,
301
+ };
302
+ });
303
+ }
274
304
  function formatPromptBlock(items, coverage) {
275
305
  if (items.length === 0)
276
306
  return null;
@@ -312,6 +342,7 @@ export function buildActiveContextSnapshot(sessionKey, opts) {
312
342
  ...notificationItems(sessionKey, opts),
313
343
  ...unleashedItems(opts, surfaceHistory),
314
344
  ...turnLedgerItems(surfaceHistory),
345
+ ...commitmentItems(opts),
315
346
  ]
316
347
  .sort((a, b) => {
317
348
  const priority = b.priority - a.priority;
@@ -0,0 +1,38 @@
1
+ import type { MemoryStore } from '../memory/store.js';
2
+ export type CommitmentOwner = 'user' | 'clementine';
3
+ export interface DetectedCommitment {
4
+ text: string;
5
+ owner: CommitmentOwner;
6
+ dueHint?: string;
7
+ dueAt?: string;
8
+ }
9
+ export declare function fingerprintCommitment(sessionKey: string, owner: CommitmentOwner, normalizedText: string): string;
10
+ /**
11
+ * Parse a relative date phrase into an ISO datetime. Returns null when the
12
+ * phrase isn't recognized; callers fall back to storing the raw `dueHint`
13
+ * so the model still sees the deadline. Default time-of-day for date-only
14
+ * phrases is 17:00 local (5pm) — close enough for "by Friday" semantics
15
+ * without forcing the user to be precise.
16
+ */
17
+ export declare function parseRelativeDue(phrase: string, now?: Date): string | null;
18
+ /**
19
+ * Scan a single turn for commitment phrases. Returns at most one commitment
20
+ * per turn — the first strong match wins. Returning multiple per turn is
21
+ * possible but tends to over-fire; we'd rather miss one and let the LLM
22
+ * extractor catch it during consolidation.
23
+ */
24
+ export declare function detectCommitmentInTurn(text: string, role: string): DetectedCommitment | null;
25
+ /**
26
+ * Persist a detected commitment via the store, deduping on a stable
27
+ * fingerprint. Designed to be called from the chat path; failures are
28
+ * swallowed because commitment recording must never break a turn.
29
+ */
30
+ export declare function recordDetectedCommitment(store: MemoryStore, sessionKey: string, detected: DetectedCommitment, meta: {
31
+ source: string;
32
+ transcriptId?: number;
33
+ episodeId?: number;
34
+ }): {
35
+ id: number;
36
+ created: boolean;
37
+ } | null;
38
+ //# sourceMappingURL=commitments.d.ts.map
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Commitments — first-class promises ("I'll fix the dashboard tomorrow",
3
+ * "remind me to call them Friday"). Lives alongside conversation-learning
4
+ * but tracks durable, actionable items that need surfacing in greetings
5
+ * until they're done or cancelled.
6
+ *
7
+ * Two ingest paths share a fingerprint-based dedupe so the same promise
8
+ * never gets recorded twice:
9
+ *
10
+ * 1. Per-turn regex detector (this file) — runs synchronously on user
11
+ * and assistant turns. High-precision: matches obvious phrasings only,
12
+ * to avoid spamming on every "I'll think about it" pleasantry.
13
+ *
14
+ * 2. LLM extraction during episodic consolidation — catches multi-turn
15
+ * and implicit commitments the regex misses. Runs after the session
16
+ * has been idle, so it sees the full context.
17
+ *
18
+ * Date parsing is intentionally tiny — we recognize common phrases
19
+ * ("tomorrow", "by Friday", "in 3 days", "next week") without pulling in
20
+ * a chrono dep. Anything we can't parse stays as `dueHint` text and is
21
+ * still surfaceable; it just doesn't drive overdue prioritization.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ export function fingerprintCommitment(sessionKey, owner, normalizedText) {
25
+ const key = `${sessionKey}|${owner}|${normalizedText}`;
26
+ return createHash('sha1').update(key).digest('hex').slice(0, 16);
27
+ }
28
+ function normalizeText(text) {
29
+ return text
30
+ .toLowerCase()
31
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
32
+ .replace(/\s+/g, ' ')
33
+ .trim()
34
+ .slice(0, 200);
35
+ }
36
+ const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
37
+ /**
38
+ * Parse a relative date phrase into an ISO datetime. Returns null when the
39
+ * phrase isn't recognized; callers fall back to storing the raw `dueHint`
40
+ * so the model still sees the deadline. Default time-of-day for date-only
41
+ * phrases is 17:00 local (5pm) — close enough for "by Friday" semantics
42
+ * without forcing the user to be precise.
43
+ */
44
+ export function parseRelativeDue(phrase, now = new Date()) {
45
+ if (!phrase)
46
+ return null;
47
+ const text = phrase.toLowerCase().trim();
48
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 17, 0, 0, 0);
49
+ if (/^today$/.test(text) || /\bby today\b/.test(text) || /\bend of (?:the )?day\b/.test(text) || /\beod\b/.test(text)) {
50
+ return today.toISOString();
51
+ }
52
+ if (/^tomorrow$|by tomorrow|tomorrow morning|tomorrow night|tomorrow evening/.test(text)) {
53
+ const d = new Date(today);
54
+ d.setDate(d.getDate() + 1);
55
+ return d.toISOString();
56
+ }
57
+ if (/\bnext week\b/.test(text)) {
58
+ const d = new Date(today);
59
+ d.setDate(d.getDate() + 7);
60
+ return d.toISOString();
61
+ }
62
+ if (/\bend of (?:the )?week\b/.test(text)) {
63
+ const d = new Date(today);
64
+ const daysToFri = (5 - d.getDay() + 7) % 7;
65
+ d.setDate(d.getDate() + (daysToFri === 0 ? 0 : daysToFri));
66
+ return d.toISOString();
67
+ }
68
+ const inN = text.match(/\bin (\d+) (day|days|week|weeks|hour|hours)\b/);
69
+ if (inN) {
70
+ const n = parseInt(inN[1], 10);
71
+ const unit = inN[2];
72
+ const d = new Date(now);
73
+ if (unit.startsWith('hour'))
74
+ d.setHours(d.getHours() + n);
75
+ else if (unit.startsWith('week'))
76
+ d.setDate(d.getDate() + 7 * n);
77
+ else
78
+ d.setDate(d.getDate() + n);
79
+ return d.toISOString();
80
+ }
81
+ for (let i = 0; i < DAYS.length; i++) {
82
+ const re = new RegExp(`\\b(?:by |on )?${DAYS[i]}\\b`);
83
+ if (re.test(text)) {
84
+ const d = new Date(today);
85
+ const delta = (i - d.getDay() + 7) % 7;
86
+ d.setDate(d.getDate() + (delta === 0 ? 7 : delta));
87
+ return d.toISOString();
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Match phrases that signal a real promise. We're deliberately strict:
94
+ * "I'll think about it", "I'll see what I can do", and other hedges are
95
+ * not commitments. We require an action verb after "I'll/I will" and a
96
+ * recognizable object phrase, OR an explicit "remind me to" / "by [day]".
97
+ */
98
+ const STRONG_USER_PATTERNS = [
99
+ // "I'll fix that tomorrow", "I'll send the doc by Friday"
100
+ /\bi(?:'| wi)ll\s+(?!think|see|try)([a-z][a-z'-]*\s+(?:[\w'-]+\s+){0,8}[\w'-]+)/i,
101
+ // "I need to ship this by Friday"
102
+ /\bi need to\s+([\w'-]+(?:\s+[\w'-]+){1,10})/i,
103
+ // "remind me to email them tomorrow"
104
+ /\bremind me to\s+([\w'-]+(?:\s+[\w'-]+){1,10})/i,
105
+ // "I should follow up Friday"
106
+ /\bi should\s+([\w'-]+(?:\s+[\w'-]+){1,10})/i,
107
+ ];
108
+ const STRONG_ASSISTANT_PATTERNS = [
109
+ // "I'll fix that tonight" — Clementine self-committing.
110
+ /\bi(?:'| wi)ll\s+(?!think|see|try|let)([a-z][a-z'-]*\s+(?:[\w'-]+\s+){0,8}[\w'-]+)/i,
111
+ ];
112
+ const DUE_HINT_RE = /\b(today|tomorrow|next week|end of (?:the )?week|end of day|eod|by (?:the )?(?:end of (?:the )?(?:day|week)|monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|today|next week)|on (?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)|in \d+ (?:day|days|week|weeks|hour|hours))\b/i;
113
+ /**
114
+ * Scan a single turn for commitment phrases. Returns at most one commitment
115
+ * per turn — the first strong match wins. Returning multiple per turn is
116
+ * possible but tends to over-fire; we'd rather miss one and let the LLM
117
+ * extractor catch it during consolidation.
118
+ */
119
+ export function detectCommitmentInTurn(text, role) {
120
+ if (!text || text.length < 8)
121
+ return null;
122
+ const isAssistant = role === 'assistant';
123
+ const patterns = isAssistant ? STRONG_ASSISTANT_PATTERNS : STRONG_USER_PATTERNS;
124
+ let matchedAction = null;
125
+ for (const pat of patterns) {
126
+ const m = text.match(pat);
127
+ if (m && m[1]) {
128
+ matchedAction = m[0].trim();
129
+ break;
130
+ }
131
+ }
132
+ if (!matchedAction)
133
+ return null;
134
+ // Truncate the surrounding sentence so the commitment text stays tight.
135
+ // Strip punctuation noise but keep the original case for the saved text.
136
+ const sentence = (text.match(new RegExp(`[^.!?\\n]*${matchedAction.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^.!?\\n]*`, 'i')) ?? [matchedAction])[0].trim();
137
+ const cleaned = sentence.replace(/\s+/g, ' ').slice(0, 220);
138
+ const hintMatch = cleaned.match(DUE_HINT_RE);
139
+ const dueHint = hintMatch ? hintMatch[0] : undefined;
140
+ const dueAt = dueHint ? parseRelativeDue(dueHint) ?? undefined : undefined;
141
+ return {
142
+ text: cleaned,
143
+ owner: isAssistant ? 'clementine' : 'user',
144
+ dueHint,
145
+ dueAt,
146
+ };
147
+ }
148
+ /**
149
+ * Persist a detected commitment via the store, deduping on a stable
150
+ * fingerprint. Designed to be called from the chat path; failures are
151
+ * swallowed because commitment recording must never break a turn.
152
+ */
153
+ export function recordDetectedCommitment(store, sessionKey, detected, meta) {
154
+ try {
155
+ const fingerprint = fingerprintCommitment(sessionKey, detected.owner, normalizeText(detected.text));
156
+ return store.upsertCommitment({
157
+ fingerprint,
158
+ source: meta.source,
159
+ owner: detected.owner,
160
+ text: detected.text,
161
+ sessionKey,
162
+ transcriptId: meta.transcriptId ?? null,
163
+ episodeId: meta.episodeId ?? null,
164
+ dueAt: detected.dueAt ?? null,
165
+ dueHint: detected.dueHint ?? null,
166
+ });
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ //# sourceMappingURL=commitments.js.map
@@ -7,6 +7,7 @@
7
7
  * replies into status dumps.
8
8
  */
9
9
  import type { ActiveContextItem, ActiveContextSnapshot } from './active-context.js';
10
+ import type { EntityMatch } from './entity-registry.js';
10
11
  export type ContextTurnIntent = 'greeting' | 'ack' | 'status' | 'repair_request' | 'followup' | 'memory_correction' | 'work_request' | 'general_chat';
11
12
  export type RequiredRetrieval = 'none' | 'event' | 'transcript';
12
13
  export interface ContextPolicyDecision {
@@ -17,10 +18,19 @@ export interface ContextPolicyDecision {
17
18
  requiredRetrieval: RequiredRetrieval;
18
19
  retrievalQueries: string[];
19
20
  debugReasons: string[];
21
+ triggeredEntities: EntityMatch[];
20
22
  }
21
23
  export interface ContextPolicyInput {
22
24
  text: string;
23
25
  activeContext?: ActiveContextSnapshot | null;
26
+ /**
27
+ * Pre-computed entity matches against the registry. Caller looks these
28
+ * up via entity-registry.findEntitiesInText so the policy module stays
29
+ * free of the store dependency. When matches arrive, the policy elevates
30
+ * recall to 'transcript' on non-trivial intents and seeds entity names
31
+ * into the retrieval queries.
32
+ */
33
+ entityMatches?: EntityMatch[];
24
34
  }
25
35
  export declare function looksLikeVagueContextReference(text: string): boolean;
26
36
  export declare function classifyContextTurn(text: string): ContextTurnIntent;
@@ -88,6 +88,7 @@ function buildRetrievalQueries(intent, text, activeContext) {
88
88
  export function decideContextPolicy(input) {
89
89
  const intent = classifyContextTurn(input.text);
90
90
  const activeContext = input.activeContext ?? null;
91
+ const entityMatches = input.entityMatches ?? [];
91
92
  const debugReasons = [`intent:${intent}`];
92
93
  const proactiveSurface = (activeContext?.items ?? [])
93
94
  .filter((item) => item.greetingEligible && !item.alreadySurfaced && !item.resolved)
@@ -98,8 +99,21 @@ export function decideContextPolicy(input) {
98
99
  if (intent === 'repair_request' || intent === 'followup' || intent === 'memory_correction') {
99
100
  requiredRetrieval = 'transcript';
100
101
  }
102
+ // Entity-driven proactive recall: a known topic in the user's turn is a
103
+ // strong enough signal to pre-fetch related history without waiting for
104
+ // a vague-repair phrase. Skip on greeting/ack so a passing entity mention
105
+ // ("hey, how's the dashboard?") doesn't pull a wall of context into a
106
+ // friendly hello.
107
+ const elevatedByEntity = entityMatches.length > 0 && intent !== 'greeting' && intent !== 'ack';
108
+ if (elevatedByEntity && requiredRetrieval !== 'transcript') {
109
+ requiredRetrieval = 'transcript';
110
+ debugReasons.push('entity:elevated-retrieval');
111
+ }
101
112
  if (requiredRetrieval !== 'none')
102
113
  debugReasons.push(`retrieval:${requiredRetrieval}`);
114
+ if (entityMatches.length > 0) {
115
+ debugReasons.push(`entities:${entityMatches.map(e => e.name).join(',')}`);
116
+ }
103
117
  const silentContextBlocks = [];
104
118
  if (activeContext?.promptBlock
105
119
  && intent !== 'greeting'
@@ -112,14 +126,24 @@ export function decideContextPolicy(input) {
112
126
  ? activeContext?.greetingLine ?? 'Hey. I am here.'
113
127
  : 'Hey. I am here.'
114
128
  : null;
129
+ const baseQueries = buildRetrievalQueries(intent, input.text, activeContext);
130
+ // Prepend entity display names so the recall search prioritizes them.
131
+ // Dedup against the base lexical queries so we don't pay twice for the
132
+ // same term.
133
+ const entityQueries = entityMatches.map(e => e.display);
134
+ const merged = [...new Set([...entityQueries, ...baseQueries])]
135
+ .map(q => q.trim())
136
+ .filter(Boolean)
137
+ .slice(0, 6);
115
138
  return {
116
139
  turnIntent: intent,
117
140
  silentContextBlocks,
118
141
  visibleOpening,
119
142
  proactiveSurface,
120
143
  requiredRetrieval,
121
- retrievalQueries: buildRetrievalQueries(intent, input.text, activeContext),
144
+ retrievalQueries: merged,
122
145
  debugReasons,
146
+ triggeredEntities: entityMatches,
123
147
  };
124
148
  }
125
149
  //# sourceMappingURL=context-policy.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Entity registry — detects when a user turn mentions a topic / entity
3
+ * Clementine already has context on, so recall can fire without waiting
4
+ * for vague-repair phrases like "what did we decide?".
5
+ *
6
+ * The registry is a flattened, mention-frequency-ranked snapshot of:
7
+ * - chunks.topic (curated knowledge)
8
+ * - episodes.topics + episodes.entities (consolidated session memory)
9
+ *
10
+ * Cached per store dbPath with a 5-minute TTL — the registry only changes
11
+ * when episodes consolidate or new chunks land, both of which are minutes-
12
+ * scale events. Invalidating less often keeps the chat path fast.
13
+ */
14
+ import type { MemoryStore } from '../memory/store.js';
15
+ export interface RegistryEntity {
16
+ name: string;
17
+ display: string;
18
+ kind: 'topic' | 'entity';
19
+ count: number;
20
+ }
21
+ export interface EntityMatch {
22
+ name: string;
23
+ display: string;
24
+ kind: 'topic' | 'entity';
25
+ }
26
+ /** Read the registry from cache, refreshing if stale or missing. Tests can
27
+ * call invalidateEntityRegistry() between cases to bypass the cache. */
28
+ export declare function getEntityRegistry(store: MemoryStore, opts?: {
29
+ now?: number;
30
+ key?: string;
31
+ }): RegistryEntity[];
32
+ /** Drop cached registry entries — used by tests and by code paths that
33
+ * know they just mutated the registry source (e.g. after a fresh episode
34
+ * consolidation pass). */
35
+ export declare function invalidateEntityRegistry(key?: string): void;
36
+ /**
37
+ * Find registry entities mentioned in the input text, with word-boundary
38
+ * matching so "auth" doesn't match "author". Multi-word entities are
39
+ * matched as contiguous word sequences. Longer matches are preferred
40
+ * (more specific), with mention-count as the tiebreaker.
41
+ *
42
+ * Returns at most `maxMatches` (default 5) entities, deduplicated.
43
+ */
44
+ export declare function findEntitiesInText(text: string, registry: RegistryEntity[], opts?: {
45
+ maxMatches?: number;
46
+ }): EntityMatch[];
47
+ //# sourceMappingURL=entity-registry.d.ts.map
@@ -0,0 +1,92 @@
1
+ const REGISTRY_TTL_MS = 5 * 60 * 1000;
2
+ const cache = new Map();
3
+ /** Words that trigger pointless matches when allowed (too generic). Augments
4
+ * the per-entity length filter — "we" or "do" would never make it past the
5
+ * 3-char floor anyway, but common-but-bare nouns sometimes do, and they
6
+ * cause false positives across unrelated turns. */
7
+ const ENTITY_STOPWORDS = new Set([
8
+ 'the', 'and', 'but', 'for', 'are', 'was', 'has', 'had', 'have', 'this',
9
+ 'that', 'with', 'from', 'they', 'them', 'their', 'these', 'those',
10
+ 'about', 'into', 'over', 'just', 'than', 'then', 'when', 'what', 'where',
11
+ 'while', 'will', 'would', 'could', 'should',
12
+ ]);
13
+ /** Read the registry from cache, refreshing if stale or missing. Tests can
14
+ * call invalidateEntityRegistry() between cases to bypass the cache. */
15
+ export function getEntityRegistry(store, opts = {}) {
16
+ const key = opts.key ?? store.dbPath ?? 'default';
17
+ const now = opts.now ?? Date.now();
18
+ const cached = cache.get(key);
19
+ if (cached && now - cached.loadedAt < REGISTRY_TTL_MS) {
20
+ return cached.entries;
21
+ }
22
+ let entries = [];
23
+ try {
24
+ if (typeof store.getEntityRegistrySnapshot === 'function') {
25
+ entries = store.getEntityRegistrySnapshot({ minCount: 1, maxItems: 500 });
26
+ }
27
+ }
28
+ catch { /* registry probe is best-effort */ }
29
+ cache.set(key, { entries, loadedAt: now });
30
+ return entries;
31
+ }
32
+ /** Drop cached registry entries — used by tests and by code paths that
33
+ * know they just mutated the registry source (e.g. after a fresh episode
34
+ * consolidation pass). */
35
+ export function invalidateEntityRegistry(key) {
36
+ if (key)
37
+ cache.delete(key);
38
+ else
39
+ cache.clear();
40
+ }
41
+ function normalizeForMatch(text) {
42
+ return text
43
+ .toLowerCase()
44
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
45
+ .replace(/\s+/g, ' ')
46
+ .trim();
47
+ }
48
+ /**
49
+ * Find registry entities mentioned in the input text, with word-boundary
50
+ * matching so "auth" doesn't match "author". Multi-word entities are
51
+ * matched as contiguous word sequences. Longer matches are preferred
52
+ * (more specific), with mention-count as the tiebreaker.
53
+ *
54
+ * Returns at most `maxMatches` (default 5) entities, deduplicated.
55
+ */
56
+ export function findEntitiesInText(text, registry, opts = {}) {
57
+ const max = Math.max(1, opts.maxMatches ?? 5);
58
+ if (!text || registry.length === 0)
59
+ return [];
60
+ const haystack = ` ${normalizeForMatch(text)} `;
61
+ if (haystack.trim().length < 3)
62
+ return [];
63
+ const candidates = [];
64
+ for (const entry of registry) {
65
+ if (entry.name.length < 3)
66
+ continue;
67
+ if (entry.name.split(' ').length === 1 && ENTITY_STOPWORDS.has(entry.name))
68
+ continue;
69
+ const needle = ` ${entry.name} `;
70
+ if (haystack.includes(needle)) {
71
+ candidates.push({ entry, specificity: entry.name.length });
72
+ }
73
+ }
74
+ // Specificity desc, then count desc — multi-word matches win, frequency
75
+ // breaks ties between equally-specific candidates.
76
+ candidates.sort((a, b) => b.specificity - a.specificity || b.entry.count - a.entry.count);
77
+ // Dedup: skip a candidate if a longer already-accepted match fully
78
+ // contains its name (e.g. don't surface "dashboard" if "dashboard
79
+ // refactor" already matched).
80
+ const accepted = [];
81
+ const acceptedNames = [];
82
+ for (const { entry } of candidates) {
83
+ if (acceptedNames.some(n => n.includes(entry.name)))
84
+ continue;
85
+ accepted.push({ name: entry.name, display: entry.display, kind: entry.kind });
86
+ acceptedNames.push(entry.name);
87
+ if (accepted.length >= max)
88
+ break;
89
+ }
90
+ return accepted;
91
+ }
92
+ //# sourceMappingURL=entity-registry.js.map
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import Anthropic from '@anthropic-ai/sdk';
18
18
  import type { MemoryStore } from '../memory/store.js';
19
+ import { type CommitmentOwner } from './commitments.js';
19
20
  export interface EpisodicConsolidationOptions {
20
21
  /** Minutes of inactivity before a session becomes consolidation-eligible. */
21
22
  idleMinutes?: number;
@@ -32,12 +33,18 @@ export interface EpisodicConsolidationOptions {
32
33
  /** Wallclock now() — used by tests for deterministic timestamps. */
33
34
  now?: () => Date;
34
35
  }
36
+ export interface ExtractedCommitment {
37
+ text: string;
38
+ owner: CommitmentOwner;
39
+ dueHint?: string;
40
+ }
35
41
  export interface EpisodeExtraction {
36
42
  summary: string;
37
43
  topics: string[];
38
44
  entities: string[];
39
45
  outcome: string;
40
46
  openLoops: string[];
47
+ commitments: ExtractedCommitment[];
41
48
  }
42
49
  interface CandidateRow {
43
50
  sessionKey: string;
@@ -17,6 +17,7 @@
17
17
  import Anthropic from '@anthropic-ai/sdk';
18
18
  import pino from 'pino';
19
19
  import { MODELS } from '../config.js';
20
+ import { fingerprintCommitment, parseRelativeDue, } from './commitments.js';
20
21
  const logger = pino({
21
22
  name: 'clementine.episodic-consolidation',
22
23
  level: process.env.CLEMENTINE_CONSOLIDATION_LOG_LEVEL || 'warn',
@@ -31,7 +32,10 @@ const SYSTEM_PROMPT = [
31
32
  ' "topics": string[] (lowercase noun phrases, max 6),',
32
33
  ' "entities": string[] (named things: files, services, people; max 8),',
33
34
  ' "outcome": string (one short clause: decided / implemented / discussed / blocked / none),',
34
- ' "openLoops": string[] (unresolved follow-ups; empty array if none, max 5)',
35
+ ' "openLoops": string[] (unresolved follow-ups; empty array if none, max 5),',
36
+ ' "commitments": Array<{ text: string, owner: "user" | "clementine", dueHint?: string }>',
37
+ ' (explicit promises only — "I\'ll do X", "remind me to Y", "by Friday".',
38
+ ' owner = whoever committed: user vs the assistant. Empty array if none, max 5.)',
35
39
  '}',
36
40
  ].join('\n');
37
41
  function buildUserPrompt(turns) {
@@ -71,12 +75,28 @@ export function parseEpisodeJson(raw) {
71
75
  const summary = typeof obj.summary === 'string' ? obj.summary.trim() : '';
72
76
  if (!summary)
73
77
  return null;
78
+ const rawCommitments = Array.isArray(obj.commitments) ? obj.commitments : [];
79
+ const commitments = [];
80
+ for (const raw of rawCommitments) {
81
+ if (!raw || typeof raw !== 'object')
82
+ continue;
83
+ const c = raw;
84
+ const text = typeof c.text === 'string' ? c.text.trim() : '';
85
+ const owner = c.owner === 'clementine' || c.owner === 'user' ? c.owner : null;
86
+ if (!text || !owner)
87
+ continue;
88
+ const dueHint = typeof c.dueHint === 'string' && c.dueHint.trim() ? c.dueHint.trim() : undefined;
89
+ commitments.push({ text: text.slice(0, 220), owner, dueHint });
90
+ if (commitments.length >= 5)
91
+ break;
92
+ }
74
93
  return {
75
94
  summary,
76
95
  topics: arr(obj.topics).slice(0, 6),
77
96
  entities: arr(obj.entities).slice(0, 8),
78
97
  outcome: typeof obj.outcome === 'string' ? obj.outcome.trim().slice(0, 200) : '',
79
98
  openLoops: arr(obj.openLoops).slice(0, 5),
99
+ commitments,
80
100
  };
81
101
  }
82
102
  function getAnthropicClient(opts) {
@@ -149,6 +169,34 @@ export async function consolidateOneSession(store, candidate, opts = {}) {
149
169
  transcriptIds,
150
170
  chunkId,
151
171
  });
172
+ // Lift extracted commitments into first-class rows. Fingerprint dedupe
173
+ // keeps these from colliding with regex-detected commitments captured
174
+ // in real time on the same turns.
175
+ let commitmentsCreated = 0;
176
+ for (const c of extraction.commitments) {
177
+ try {
178
+ const normalized = c.text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
179
+ if (!normalized)
180
+ continue;
181
+ const fp = fingerprintCommitment(candidate.sessionKey, c.owner, normalized);
182
+ const dueAt = c.dueHint ? parseRelativeDue(c.dueHint) ?? null : null;
183
+ const result = store.upsertCommitment({
184
+ fingerprint: fp,
185
+ source: 'episode-extractor',
186
+ owner: c.owner,
187
+ text: c.text,
188
+ sessionKey: candidate.sessionKey,
189
+ episodeId: insert.episodeId,
190
+ dueAt,
191
+ dueHint: c.dueHint ?? null,
192
+ });
193
+ if (result.created)
194
+ commitmentsCreated++;
195
+ }
196
+ catch (err) {
197
+ logger.debug({ err }, 'Failed to persist extracted commitment');
198
+ }
199
+ }
152
200
  store.updateConsolidationCursor(candidate.sessionKey, {
153
201
  lastTranscriptId: candidate.endTranscriptId,
154
202
  success: true,
@@ -158,6 +206,7 @@ export async function consolidateOneSession(store, candidate, opts = {}) {
158
206
  episodeId: insert.episodeId,
159
207
  chunkId,
160
208
  turns: turns.length,
209
+ commitmentsCreated,
161
210
  }, 'Consolidated episode');
162
211
  return { episodeId: insert.episodeId, chunkId };
163
212
  }
@@ -28,6 +28,8 @@ import { recordProactiveNotificationEvent, } from './notification-context.js';
28
28
  import { isInternalSyntheticPrompt, resolveRecentOperationalContext } from './recent-context.js';
29
29
  import { decideContextPolicy } from './context-policy.js';
30
30
  import { persistConversationLearning } from './conversation-learning.js';
31
+ import { detectCommitmentInTurn, recordDetectedCommitment } from './commitments.js';
32
+ import { findEntitiesInText, getEntityRegistry } from './entity-registry.js';
31
33
  import { getBackgroundCreditBlock, isCreditBalanceError, markBackgroundCreditBlocked } from './credit-guard.js';
32
34
  import { appendTurnLedger, estimateTokensApprox, formatLastTurnLedger, readRecentTurnLedger } from './turn-ledger.js';
33
35
  import { assessGatewayContextHygiene, formatGatewayHygieneAnnotation } from './context-hygiene.js';
@@ -1521,6 +1523,7 @@ export class Gateway {
1521
1523
  // another turn is active.
1522
1524
  const localTurnStarted = Date.now();
1523
1525
  let transcriptCoverage;
1526
+ let openCommitments;
1524
1527
  if (this.isTrustedPersonalSession(sessionKey)) {
1525
1528
  try {
1526
1529
  const store = this.assistant.getMemoryStore?.();
@@ -1528,13 +1531,41 @@ export class Gateway {
1528
1531
  const cov = store.getTranscriptDenseCoverage();
1529
1532
  transcriptCoverage = { embedded: cov.embedded, total: cov.total };
1530
1533
  }
1534
+ if (store && typeof store.listCommitments === 'function') {
1535
+ // Pull session-scoped open commitments first, then pad with the
1536
+ // wider open list so commitments captured in other sessions still
1537
+ // surface in greetings (e.g. user said "remind me Friday" via
1538
+ // Slack, then opens a Discord DM Saturday).
1539
+ const list = store.listCommitments;
1540
+ const scoped = list({ status: 'open', sessionKey, limit: 10 });
1541
+ const wider = scoped.length < 6 ? list({ status: 'open', limit: 10 }) : [];
1542
+ const seen = new Set();
1543
+ const merged = [...scoped, ...wider].filter(c => !seen.has(c.id) && (seen.add(c.id), true));
1544
+ openCommitments = merged.slice(0, 10);
1545
+ }
1531
1546
  }
1532
- catch { /* coverage probe is best-effort */ }
1547
+ catch { /* probes are best-effort */ }
1533
1548
  }
1534
1549
  const activeContext = this.isTrustedPersonalSession(sessionKey)
1535
- ? buildActiveContextSnapshot(sessionKey, { baseDir: BASE_DIR, transcriptCoverage })
1550
+ ? buildActiveContextSnapshot(sessionKey, { baseDir: BASE_DIR, transcriptCoverage, openCommitments })
1536
1551
  : null;
1537
- const contextDecision = decideContextPolicy({ text, activeContext });
1552
+ // Entity recall: if the user mentions something we already have context
1553
+ // on (a chunk topic or an episode entity), elevate retrieval so the
1554
+ // model gets the relevant history without waiting for a repair phrase.
1555
+ let entityMatches = [];
1556
+ if (this.isTrustedPersonalSession(sessionKey)) {
1557
+ try {
1558
+ const store = this.assistant.getMemoryStore?.();
1559
+ if (store) {
1560
+ const registry = getEntityRegistry(store);
1561
+ if (registry.length > 0) {
1562
+ entityMatches = findEntitiesInText(text, registry);
1563
+ }
1564
+ }
1565
+ }
1566
+ catch { /* entity registry probe is best-effort */ }
1567
+ }
1568
+ const contextDecision = decideContextPolicy({ text, activeContext, entityMatches });
1538
1569
  if (this.isTrustedPersonalSession(sessionKey)) {
1539
1570
  const learning = persistConversationLearning(sessionKey, text, this.assistant.getMemoryStore?.());
1540
1571
  if (learning?.corrections.length || learning?.preferences.length) {
@@ -1544,6 +1575,26 @@ export class Gateway {
1544
1575
  preferences: learning.preferences.length,
1545
1576
  }, 'Captured deterministic conversation learning signal');
1546
1577
  }
1578
+ // Best-effort: scan this user turn for an explicit commitment phrase
1579
+ // ("I'll fix that tomorrow"). Detection runs synchronously and
1580
+ // dedupes by fingerprint so re-running on the same text is a no-op.
1581
+ try {
1582
+ const detected = detectCommitmentInTurn(text, 'user');
1583
+ if (detected) {
1584
+ const store = this.assistant.getMemoryStore?.();
1585
+ if (store) {
1586
+ const recorded = recordDetectedCommitment(store, sessionKey, detected, { source: 'turn-detector' });
1587
+ if (recorded?.created) {
1588
+ logger.info({
1589
+ sessionKey, owner: detected.owner, dueHint: detected.dueHint, hasDueAt: !!detected.dueAt,
1590
+ }, 'Captured explicit user commitment');
1591
+ }
1592
+ }
1593
+ }
1594
+ }
1595
+ catch (err) {
1596
+ logger.debug({ err }, 'Commitment detection failed (non-fatal)');
1597
+ }
1547
1598
  }
1548
1599
  const localResponse = await this.handleLocalTurn(sessionKey, text, onText, contextDecision);
1549
1600
  if (localResponse !== null) {
@@ -703,6 +703,82 @@ export declare class MemoryStore {
703
703
  chunkId: number | null;
704
704
  createdAt: string;
705
705
  }>;
706
+ /**
707
+ * Pull a flattened, deduplicated snapshot of named topics + entities the
708
+ * agent already knows about, ranked by mention frequency. Sources:
709
+ * - chunks.topic (curated knowledge — the strongest signal)
710
+ * - episodes.topics (LLM-extracted topic phrases per session)
711
+ * - episodes.entities (LLM-extracted named things)
712
+ *
713
+ * Used by the entity-registry module to detect when a user turn mentions
714
+ * something we have prior context on, so recall can fire proactively.
715
+ */
716
+ getEntityRegistrySnapshot(opts?: {
717
+ minCount?: number;
718
+ maxItems?: number;
719
+ }): Array<{
720
+ name: string;
721
+ display: string;
722
+ kind: 'topic' | 'entity';
723
+ count: number;
724
+ }>;
725
+ /**
726
+ * Insert a commitment, deduping on the fingerprint. If a row with the
727
+ * same fingerprint already exists, the existing id is returned and no
728
+ * write occurs — keeps the regex detector + LLM extractor from creating
729
+ * duplicates of the same promise.
730
+ */
731
+ upsertCommitment(entry: {
732
+ fingerprint: string;
733
+ source: string;
734
+ owner: 'user' | 'clementine';
735
+ text: string;
736
+ sessionKey?: string | null;
737
+ transcriptId?: number | null;
738
+ episodeId?: number | null;
739
+ dueAt?: string | null;
740
+ dueHint?: string | null;
741
+ }): {
742
+ id: number;
743
+ created: boolean;
744
+ };
745
+ /**
746
+ * List commitments with optional filters. Sorted by due_at ASC NULLS LAST
747
+ * so overdue + soon-due float to the top.
748
+ */
749
+ listCommitments(opts?: {
750
+ status?: 'open' | 'done' | 'cancelled';
751
+ sessionKey?: string;
752
+ owner?: 'user' | 'clementine';
753
+ overdueOnly?: boolean;
754
+ dueBeforeIso?: string;
755
+ limit?: number;
756
+ }): Array<{
757
+ id: number;
758
+ fingerprint: string;
759
+ source: string;
760
+ owner: 'user' | 'clementine';
761
+ text: string;
762
+ sessionKey: string | null;
763
+ transcriptId: number | null;
764
+ episodeId: number | null;
765
+ dueAt: string | null;
766
+ dueHint: string | null;
767
+ status: 'open' | 'done' | 'cancelled';
768
+ createdAt: string;
769
+ completedAt: string | null;
770
+ snoozedUntil: string | null;
771
+ }>;
772
+ /**
773
+ * Update commitment status. 'done' / 'cancelled' set completed_at; 'snooze'
774
+ * (with snoozeIso) bumps snoozed_until without changing status so the row
775
+ * stays open but suppressed from greeting until the snooze expires.
776
+ */
777
+ updateCommitmentStatus(id: number, update: {
778
+ status?: 'open' | 'done' | 'cancelled';
779
+ snoozeUntilIso?: string;
780
+ notes?: string;
781
+ }): boolean;
706
782
  /**
707
783
  * Fetch a slice of transcripts by id range for consolidation. Used by
708
784
  * the consolidation module to materialize the conversation it's about
@@ -984,6 +984,33 @@ export class MemoryStore {
984
984
  last_success_at TEXT,
985
985
  fail_count INTEGER NOT NULL DEFAULT 0
986
986
  );
987
+ `);
988
+ // Commitments — first-class promises in either direction. owner = 'user'
989
+ // for "I'll fix that tomorrow" turns, 'clementine' for things she
990
+ // committed to do. fingerprint = sha1(session_key|owner|normalized_text)
991
+ // makes the explicit detector and the LLM episode extractor share an
992
+ // idempotent insert path so one promise doesn't get double-recorded.
993
+ this.conn.exec(`
994
+ CREATE TABLE IF NOT EXISTS commitments (
995
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
996
+ fingerprint TEXT NOT NULL UNIQUE,
997
+ source TEXT NOT NULL,
998
+ owner TEXT NOT NULL,
999
+ text TEXT NOT NULL,
1000
+ session_key TEXT,
1001
+ transcript_id INTEGER,
1002
+ episode_id INTEGER,
1003
+ due_at TEXT,
1004
+ due_hint TEXT,
1005
+ status TEXT NOT NULL DEFAULT 'open',
1006
+ created_at TEXT DEFAULT (datetime('now')),
1007
+ completed_at TEXT,
1008
+ snoozed_until TEXT,
1009
+ notes TEXT
1010
+ );
1011
+ CREATE INDEX IF NOT EXISTS idx_commitments_status ON commitments(status, due_at);
1012
+ CREATE INDEX IF NOT EXISTS idx_commitments_session ON commitments(session_key, status);
1013
+ CREATE INDEX IF NOT EXISTS idx_commitments_owner ON commitments(owner, status);
987
1014
  `);
988
1015
  // Soft-delete via a separate table — keeps the chunks_au trigger
989
1016
  // out of the path so we don't have to fight with the FTS5 contentless
@@ -3155,6 +3182,199 @@ export class MemoryStore {
3155
3182
  createdAt: row.created_at,
3156
3183
  }));
3157
3184
  }
3185
+ // ── Entity registry ───────────────────────────────────────────────
3186
+ /**
3187
+ * Pull a flattened, deduplicated snapshot of named topics + entities the
3188
+ * agent already knows about, ranked by mention frequency. Sources:
3189
+ * - chunks.topic (curated knowledge — the strongest signal)
3190
+ * - episodes.topics (LLM-extracted topic phrases per session)
3191
+ * - episodes.entities (LLM-extracted named things)
3192
+ *
3193
+ * Used by the entity-registry module to detect when a user turn mentions
3194
+ * something we have prior context on, so recall can fire proactively.
3195
+ */
3196
+ getEntityRegistrySnapshot(opts = {}) {
3197
+ const minCount = Math.max(1, opts.minCount ?? 1);
3198
+ const maxItems = Math.max(1, Math.min(opts.maxItems ?? 500, 5000));
3199
+ const counts = new Map();
3200
+ const accept = (raw, kind) => {
3201
+ if (!raw)
3202
+ return;
3203
+ const display = raw.trim();
3204
+ if (display.length < 3 || display.length > 80)
3205
+ return;
3206
+ const name = display.toLowerCase();
3207
+ const existing = counts.get(name);
3208
+ if (existing) {
3209
+ existing.count++;
3210
+ // Topics from chunks outrank LLM-derived ones for kind classification.
3211
+ if (kind === 'topic')
3212
+ existing.kind = 'topic';
3213
+ }
3214
+ else {
3215
+ counts.set(name, { display, kind, count: 1 });
3216
+ }
3217
+ };
3218
+ try {
3219
+ const topicRows = this.conn
3220
+ .prepare(`SELECT topic, COUNT(*) as cnt FROM chunks
3221
+ WHERE topic IS NOT NULL AND length(trim(topic)) > 0
3222
+ GROUP BY topic`)
3223
+ .all();
3224
+ for (const r of topicRows) {
3225
+ const existing = counts.get(r.topic.trim().toLowerCase());
3226
+ if (existing)
3227
+ existing.count += r.cnt - 1; // already added 1 above
3228
+ accept(r.topic, 'topic');
3229
+ if (existing) {
3230
+ // Increment with the SQL-derived count (offset by the 1 accept added).
3231
+ const e = counts.get(r.topic.trim().toLowerCase());
3232
+ if (e)
3233
+ e.count = Math.max(e.count, r.cnt);
3234
+ }
3235
+ }
3236
+ }
3237
+ catch { /* chunks.topic column missing or query fails */ }
3238
+ try {
3239
+ const epRows = this.conn
3240
+ .prepare(`SELECT topics, entities FROM episodes`)
3241
+ .all();
3242
+ for (const row of epRows) {
3243
+ if (row.topics) {
3244
+ try {
3245
+ const arr = JSON.parse(row.topics);
3246
+ if (Array.isArray(arr))
3247
+ for (const t of arr)
3248
+ if (typeof t === 'string')
3249
+ accept(t, 'topic');
3250
+ }
3251
+ catch { /* skip malformed JSON */ }
3252
+ }
3253
+ if (row.entities) {
3254
+ try {
3255
+ const arr = JSON.parse(row.entities);
3256
+ if (Array.isArray(arr))
3257
+ for (const e of arr)
3258
+ if (typeof e === 'string')
3259
+ accept(e, 'entity');
3260
+ }
3261
+ catch { /* skip malformed JSON */ }
3262
+ }
3263
+ }
3264
+ }
3265
+ catch { /* episodes table missing */ }
3266
+ const all = [...counts.entries()]
3267
+ .map(([name, v]) => ({ name, display: v.display, kind: v.kind, count: v.count }))
3268
+ .filter(e => e.count >= minCount);
3269
+ all.sort((a, b) => b.count - a.count || a.name.length - b.name.length);
3270
+ return all.slice(0, maxItems);
3271
+ }
3272
+ // ── Commitments ───────────────────────────────────────────────────
3273
+ /**
3274
+ * Insert a commitment, deduping on the fingerprint. If a row with the
3275
+ * same fingerprint already exists, the existing id is returned and no
3276
+ * write occurs — keeps the regex detector + LLM extractor from creating
3277
+ * duplicates of the same promise.
3278
+ */
3279
+ upsertCommitment(entry) {
3280
+ const existing = this.conn
3281
+ .prepare('SELECT id FROM commitments WHERE fingerprint = ?')
3282
+ .get(entry.fingerprint);
3283
+ if (existing) {
3284
+ return { id: existing.id, created: false };
3285
+ }
3286
+ const result = this.conn
3287
+ .prepare(`INSERT INTO commitments
3288
+ (fingerprint, source, owner, text, session_key, transcript_id, episode_id, due_at, due_hint)
3289
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
3290
+ .run(entry.fingerprint, entry.source, entry.owner, entry.text, entry.sessionKey ?? null, entry.transcriptId ?? null, entry.episodeId ?? null, entry.dueAt ?? null, entry.dueHint ?? null);
3291
+ return { id: result.lastInsertRowid, created: true };
3292
+ }
3293
+ /**
3294
+ * List commitments with optional filters. Sorted by due_at ASC NULLS LAST
3295
+ * so overdue + soon-due float to the top.
3296
+ */
3297
+ listCommitments(opts = {}) {
3298
+ const params = [];
3299
+ const where = [];
3300
+ if (opts.status) {
3301
+ where.push('status = ?');
3302
+ params.push(opts.status);
3303
+ }
3304
+ if (opts.sessionKey) {
3305
+ where.push('session_key = ?');
3306
+ params.push(opts.sessionKey);
3307
+ }
3308
+ if (opts.owner) {
3309
+ where.push('owner = ?');
3310
+ params.push(opts.owner);
3311
+ }
3312
+ if (opts.overdueOnly) {
3313
+ where.push("due_at IS NOT NULL AND due_at < datetime('now') AND status = 'open'");
3314
+ }
3315
+ else if (opts.dueBeforeIso) {
3316
+ where.push('due_at IS NOT NULL AND due_at <= ?');
3317
+ params.push(opts.dueBeforeIso);
3318
+ }
3319
+ // Suppress snoozed rows from "open" view unless caller asked for status explicitly.
3320
+ // Wrapping both sides in datetime() so ISO-8601 strings (with T/Z/millis) and
3321
+ // SQLite's space-separated `datetime('now')` format compare correctly.
3322
+ if (opts.status === 'open' || (!opts.status && !opts.overdueOnly)) {
3323
+ where.push("(snoozed_until IS NULL OR datetime(snoozed_until) <= datetime('now'))");
3324
+ }
3325
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
3326
+ const sql = `SELECT * FROM commitments
3327
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
3328
+ ORDER BY (due_at IS NULL) ASC, due_at ASC, created_at DESC
3329
+ LIMIT ?`;
3330
+ params.push(limit);
3331
+ const rows = this.conn.prepare(sql).all(...params);
3332
+ return rows.map(r => ({
3333
+ id: r.id,
3334
+ fingerprint: r.fingerprint,
3335
+ source: r.source,
3336
+ owner: r.owner,
3337
+ text: r.text,
3338
+ sessionKey: r.session_key,
3339
+ transcriptId: r.transcript_id,
3340
+ episodeId: r.episode_id,
3341
+ dueAt: r.due_at,
3342
+ dueHint: r.due_hint,
3343
+ status: r.status,
3344
+ createdAt: r.created_at,
3345
+ completedAt: r.completed_at,
3346
+ snoozedUntil: r.snoozed_until,
3347
+ }));
3348
+ }
3349
+ /**
3350
+ * Update commitment status. 'done' / 'cancelled' set completed_at; 'snooze'
3351
+ * (with snoozeIso) bumps snoozed_until without changing status so the row
3352
+ * stays open but suppressed from greeting until the snooze expires.
3353
+ */
3354
+ updateCommitmentStatus(id, update) {
3355
+ const sets = [];
3356
+ const vals = [];
3357
+ if (update.status) {
3358
+ sets.push('status = ?');
3359
+ vals.push(update.status);
3360
+ if (update.status === 'done' || update.status === 'cancelled') {
3361
+ sets.push("completed_at = datetime('now')");
3362
+ }
3363
+ }
3364
+ if (update.snoozeUntilIso !== undefined) {
3365
+ sets.push('snoozed_until = ?');
3366
+ vals.push(update.snoozeUntilIso);
3367
+ }
3368
+ if (update.notes !== undefined) {
3369
+ sets.push('notes = ?');
3370
+ vals.push(update.notes);
3371
+ }
3372
+ if (sets.length === 0)
3373
+ return false;
3374
+ vals.push(id);
3375
+ const result = this.conn.prepare(`UPDATE commitments SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
3376
+ return result.changes > 0;
3377
+ }
3158
3378
  /**
3159
3379
  * Fetch a slice of transcripts by id range for consolidation. Used by
3160
3380
  * the consolidation module to materialize the conversation it's about
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.28",
3
+ "version": "1.18.30",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",