clementine-agent 1.18.28 → 1.18.29

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
@@ -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,7 @@ 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';
31
32
  import { getBackgroundCreditBlock, isCreditBalanceError, markBackgroundCreditBlocked } from './credit-guard.js';
32
33
  import { appendTurnLedger, estimateTokensApprox, formatLastTurnLedger, readRecentTurnLedger } from './turn-ledger.js';
33
34
  import { assessGatewayContextHygiene, formatGatewayHygieneAnnotation } from './context-hygiene.js';
@@ -1521,6 +1522,7 @@ export class Gateway {
1521
1522
  // another turn is active.
1522
1523
  const localTurnStarted = Date.now();
1523
1524
  let transcriptCoverage;
1525
+ let openCommitments;
1524
1526
  if (this.isTrustedPersonalSession(sessionKey)) {
1525
1527
  try {
1526
1528
  const store = this.assistant.getMemoryStore?.();
@@ -1528,11 +1530,23 @@ export class Gateway {
1528
1530
  const cov = store.getTranscriptDenseCoverage();
1529
1531
  transcriptCoverage = { embedded: cov.embedded, total: cov.total };
1530
1532
  }
1533
+ if (store && typeof store.listCommitments === 'function') {
1534
+ // Pull session-scoped open commitments first, then pad with the
1535
+ // wider open list so commitments captured in other sessions still
1536
+ // surface in greetings (e.g. user said "remind me Friday" via
1537
+ // Slack, then opens a Discord DM Saturday).
1538
+ const list = store.listCommitments;
1539
+ const scoped = list({ status: 'open', sessionKey, limit: 10 });
1540
+ const wider = scoped.length < 6 ? list({ status: 'open', limit: 10 }) : [];
1541
+ const seen = new Set();
1542
+ const merged = [...scoped, ...wider].filter(c => !seen.has(c.id) && (seen.add(c.id), true));
1543
+ openCommitments = merged.slice(0, 10);
1544
+ }
1531
1545
  }
1532
- catch { /* coverage probe is best-effort */ }
1546
+ catch { /* probes are best-effort */ }
1533
1547
  }
1534
1548
  const activeContext = this.isTrustedPersonalSession(sessionKey)
1535
- ? buildActiveContextSnapshot(sessionKey, { baseDir: BASE_DIR, transcriptCoverage })
1549
+ ? buildActiveContextSnapshot(sessionKey, { baseDir: BASE_DIR, transcriptCoverage, openCommitments })
1536
1550
  : null;
1537
1551
  const contextDecision = decideContextPolicy({ text, activeContext });
1538
1552
  if (this.isTrustedPersonalSession(sessionKey)) {
@@ -1544,6 +1558,26 @@ export class Gateway {
1544
1558
  preferences: learning.preferences.length,
1545
1559
  }, 'Captured deterministic conversation learning signal');
1546
1560
  }
1561
+ // Best-effort: scan this user turn for an explicit commitment phrase
1562
+ // ("I'll fix that tomorrow"). Detection runs synchronously and
1563
+ // dedupes by fingerprint so re-running on the same text is a no-op.
1564
+ try {
1565
+ const detected = detectCommitmentInTurn(text, 'user');
1566
+ if (detected) {
1567
+ const store = this.assistant.getMemoryStore?.();
1568
+ if (store) {
1569
+ const recorded = recordDetectedCommitment(store, sessionKey, detected, { source: 'turn-detector' });
1570
+ if (recorded?.created) {
1571
+ logger.info({
1572
+ sessionKey, owner: detected.owner, dueHint: detected.dueHint, hasDueAt: !!detected.dueAt,
1573
+ }, 'Captured explicit user commitment');
1574
+ }
1575
+ }
1576
+ }
1577
+ }
1578
+ catch (err) {
1579
+ logger.debug({ err }, 'Commitment detection failed (non-fatal)');
1580
+ }
1547
1581
  }
1548
1582
  const localResponse = await this.handleLocalTurn(sessionKey, text, onText, contextDecision);
1549
1583
  if (localResponse !== null) {
@@ -703,6 +703,63 @@ export declare class MemoryStore {
703
703
  chunkId: number | null;
704
704
  createdAt: string;
705
705
  }>;
706
+ /**
707
+ * Insert a commitment, deduping on the fingerprint. If a row with the
708
+ * same fingerprint already exists, the existing id is returned and no
709
+ * write occurs — keeps the regex detector + LLM extractor from creating
710
+ * duplicates of the same promise.
711
+ */
712
+ upsertCommitment(entry: {
713
+ fingerprint: string;
714
+ source: string;
715
+ owner: 'user' | 'clementine';
716
+ text: string;
717
+ sessionKey?: string | null;
718
+ transcriptId?: number | null;
719
+ episodeId?: number | null;
720
+ dueAt?: string | null;
721
+ dueHint?: string | null;
722
+ }): {
723
+ id: number;
724
+ created: boolean;
725
+ };
726
+ /**
727
+ * List commitments with optional filters. Sorted by due_at ASC NULLS LAST
728
+ * so overdue + soon-due float to the top.
729
+ */
730
+ listCommitments(opts?: {
731
+ status?: 'open' | 'done' | 'cancelled';
732
+ sessionKey?: string;
733
+ owner?: 'user' | 'clementine';
734
+ overdueOnly?: boolean;
735
+ dueBeforeIso?: string;
736
+ limit?: number;
737
+ }): Array<{
738
+ id: number;
739
+ fingerprint: string;
740
+ source: string;
741
+ owner: 'user' | 'clementine';
742
+ text: string;
743
+ sessionKey: string | null;
744
+ transcriptId: number | null;
745
+ episodeId: number | null;
746
+ dueAt: string | null;
747
+ dueHint: string | null;
748
+ status: 'open' | 'done' | 'cancelled';
749
+ createdAt: string;
750
+ completedAt: string | null;
751
+ snoozedUntil: string | null;
752
+ }>;
753
+ /**
754
+ * Update commitment status. 'done' / 'cancelled' set completed_at; 'snooze'
755
+ * (with snoozeIso) bumps snoozed_until without changing status so the row
756
+ * stays open but suppressed from greeting until the snooze expires.
757
+ */
758
+ updateCommitmentStatus(id: number, update: {
759
+ status?: 'open' | 'done' | 'cancelled';
760
+ snoozeUntilIso?: string;
761
+ notes?: string;
762
+ }): boolean;
706
763
  /**
707
764
  * Fetch a slice of transcripts by id range for consolidation. Used by
708
765
  * 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,112 @@ export class MemoryStore {
3155
3182
  createdAt: row.created_at,
3156
3183
  }));
3157
3184
  }
3185
+ // ── Commitments ───────────────────────────────────────────────────
3186
+ /**
3187
+ * Insert a commitment, deduping on the fingerprint. If a row with the
3188
+ * same fingerprint already exists, the existing id is returned and no
3189
+ * write occurs — keeps the regex detector + LLM extractor from creating
3190
+ * duplicates of the same promise.
3191
+ */
3192
+ upsertCommitment(entry) {
3193
+ const existing = this.conn
3194
+ .prepare('SELECT id FROM commitments WHERE fingerprint = ?')
3195
+ .get(entry.fingerprint);
3196
+ if (existing) {
3197
+ return { id: existing.id, created: false };
3198
+ }
3199
+ const result = this.conn
3200
+ .prepare(`INSERT INTO commitments
3201
+ (fingerprint, source, owner, text, session_key, transcript_id, episode_id, due_at, due_hint)
3202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
3203
+ .run(entry.fingerprint, entry.source, entry.owner, entry.text, entry.sessionKey ?? null, entry.transcriptId ?? null, entry.episodeId ?? null, entry.dueAt ?? null, entry.dueHint ?? null);
3204
+ return { id: result.lastInsertRowid, created: true };
3205
+ }
3206
+ /**
3207
+ * List commitments with optional filters. Sorted by due_at ASC NULLS LAST
3208
+ * so overdue + soon-due float to the top.
3209
+ */
3210
+ listCommitments(opts = {}) {
3211
+ const params = [];
3212
+ const where = [];
3213
+ if (opts.status) {
3214
+ where.push('status = ?');
3215
+ params.push(opts.status);
3216
+ }
3217
+ if (opts.sessionKey) {
3218
+ where.push('session_key = ?');
3219
+ params.push(opts.sessionKey);
3220
+ }
3221
+ if (opts.owner) {
3222
+ where.push('owner = ?');
3223
+ params.push(opts.owner);
3224
+ }
3225
+ if (opts.overdueOnly) {
3226
+ where.push("due_at IS NOT NULL AND due_at < datetime('now') AND status = 'open'");
3227
+ }
3228
+ else if (opts.dueBeforeIso) {
3229
+ where.push('due_at IS NOT NULL AND due_at <= ?');
3230
+ params.push(opts.dueBeforeIso);
3231
+ }
3232
+ // Suppress snoozed rows from "open" view unless caller asked for status explicitly.
3233
+ // Wrapping both sides in datetime() so ISO-8601 strings (with T/Z/millis) and
3234
+ // SQLite's space-separated `datetime('now')` format compare correctly.
3235
+ if (opts.status === 'open' || (!opts.status && !opts.overdueOnly)) {
3236
+ where.push("(snoozed_until IS NULL OR datetime(snoozed_until) <= datetime('now'))");
3237
+ }
3238
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
3239
+ const sql = `SELECT * FROM commitments
3240
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
3241
+ ORDER BY (due_at IS NULL) ASC, due_at ASC, created_at DESC
3242
+ LIMIT ?`;
3243
+ params.push(limit);
3244
+ const rows = this.conn.prepare(sql).all(...params);
3245
+ return rows.map(r => ({
3246
+ id: r.id,
3247
+ fingerprint: r.fingerprint,
3248
+ source: r.source,
3249
+ owner: r.owner,
3250
+ text: r.text,
3251
+ sessionKey: r.session_key,
3252
+ transcriptId: r.transcript_id,
3253
+ episodeId: r.episode_id,
3254
+ dueAt: r.due_at,
3255
+ dueHint: r.due_hint,
3256
+ status: r.status,
3257
+ createdAt: r.created_at,
3258
+ completedAt: r.completed_at,
3259
+ snoozedUntil: r.snoozed_until,
3260
+ }));
3261
+ }
3262
+ /**
3263
+ * Update commitment status. 'done' / 'cancelled' set completed_at; 'snooze'
3264
+ * (with snoozeIso) bumps snoozed_until without changing status so the row
3265
+ * stays open but suppressed from greeting until the snooze expires.
3266
+ */
3267
+ updateCommitmentStatus(id, update) {
3268
+ const sets = [];
3269
+ const vals = [];
3270
+ if (update.status) {
3271
+ sets.push('status = ?');
3272
+ vals.push(update.status);
3273
+ if (update.status === 'done' || update.status === 'cancelled') {
3274
+ sets.push("completed_at = datetime('now')");
3275
+ }
3276
+ }
3277
+ if (update.snoozeUntilIso !== undefined) {
3278
+ sets.push('snoozed_until = ?');
3279
+ vals.push(update.snoozeUntilIso);
3280
+ }
3281
+ if (update.notes !== undefined) {
3282
+ sets.push('notes = ?');
3283
+ vals.push(update.notes);
3284
+ }
3285
+ if (sets.length === 0)
3286
+ return false;
3287
+ vals.push(id);
3288
+ const result = this.conn.prepare(`UPDATE commitments SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
3289
+ return result.changes > 0;
3290
+ }
3158
3291
  /**
3159
3292
  * Fetch a slice of transcripts by id range for consolidation. Used by
3160
3293
  * 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.29",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",