clementine-agent 1.18.27 → 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,93 @@ 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
+ });
7105
+ // Recent episodes — durable consolidated session summaries.
7106
+ app.get('/api/memory/episodes', async (req, res) => {
7107
+ try {
7108
+ const gateway = await getGateway();
7109
+ const store = gateway.assistant?.memoryStore;
7110
+ if (!store || typeof store.listRecentEpisodes !== 'function') {
7111
+ res.status(503).json({ error: 'Episodes store not available' });
7112
+ return;
7113
+ }
7114
+ const limit = Math.min(parseInt(String(req.query.limit ?? '30'), 10) || 30, 200);
7115
+ const sessionKey = req.query.session ? String(req.query.session) : undefined;
7116
+ const sinceParam = req.query.since ? String(req.query.since) : '';
7117
+ // since: '24h' | '7d' | '30d' | '' (all) | ISO string
7118
+ let sinceIso;
7119
+ if (sinceParam === '24h')
7120
+ sinceIso = new Date(Date.now() - 24 * 3600_000).toISOString();
7121
+ else if (sinceParam === '7d')
7122
+ sinceIso = new Date(Date.now() - 7 * 24 * 3600_000).toISOString();
7123
+ else if (sinceParam === '30d')
7124
+ sinceIso = new Date(Date.now() - 30 * 24 * 3600_000).toISOString();
7125
+ else if (sinceParam)
7126
+ sinceIso = sinceParam;
7127
+ const episodes = store.listRecentEpisodes({ limit, sessionKey, sinceIso });
7128
+ res.json({ ok: true, episodes });
7129
+ }
7130
+ catch (err) {
7131
+ res.status(500).json({ error: String(err) });
7132
+ }
7133
+ });
7047
7134
  // Coverage + recall telemetry for both chunks and transcripts. Powers the
7048
7135
  // Memory Coverage card showing whether dense recall is actually earning its
7049
7136
  // keep on the current corpus.
@@ -14998,6 +15085,39 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14998
15085
  <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
14999
15086
  </div>
15000
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>
15104
+ <div class="card" style="margin-bottom:14px">
15105
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
15106
+ <span>Recent episodes</span>
15107
+ <div style="display:flex;align-items:center;gap:8px">
15108
+ <select id="episodes-filter-since" onchange="refreshRecentEpisodes()" style="font-size:12px;padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text)">
15109
+ <option value="24h">Last 24h</option>
15110
+ <option value="7d" selected>Last 7d</option>
15111
+ <option value="30d">Last 30d</option>
15112
+ <option value="">All</option>
15113
+ </select>
15114
+ <span style="font-size:11px;color:var(--text-muted)">Consolidated session summaries</span>
15115
+ </div>
15116
+ </div>
15117
+ <div class="card-body" id="panel-recent-episodes" style="padding:0">
15118
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
15119
+ </div>
15120
+ </div>
15001
15121
  <div class="card">
15002
15122
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
15003
15123
  <span>Self-correction (supersedes)</span>
@@ -18508,6 +18628,8 @@ function switchTab(group, tab) {
18508
18628
  // Consolidated Memory tab: search results + stats + MEMORY.md + recent writes + supersedes + coverage strip.
18509
18629
  refreshMemory();
18510
18630
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
18631
+ if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
18632
+ if (typeof refreshCommitments === 'function') refreshCommitments();
18511
18633
  if (typeof refreshSupersedes === 'function') refreshSupersedes();
18512
18634
  if (typeof refreshCoverageStrip === 'function') refreshCoverageStrip();
18513
18635
  }
@@ -24865,6 +24987,8 @@ async function submitQuickAddMemory() {
24865
24987
  setTimeout(function() {
24866
24988
  closeQuickAddMemory();
24867
24989
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
24990
+ if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
24991
+ if (typeof refreshCommitments === 'function') refreshCommitments();
24868
24992
  if (typeof refreshMemory === 'function') refreshMemory();
24869
24993
  }, 600);
24870
24994
  } catch (err) {
@@ -25018,6 +25142,133 @@ async function refreshRecentWrites() {
25018
25142
  }
25019
25143
  }
25020
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
+
25223
+ async function refreshRecentEpisodes() {
25224
+ var el = document.getElementById('panel-recent-episodes');
25225
+ if (!el) return;
25226
+ try {
25227
+ var sel = document.getElementById('episodes-filter-since');
25228
+ var since = sel ? sel.value : '7d';
25229
+ var url = '/api/memory/episodes?limit=30' + (since ? '&since=' + encodeURIComponent(since) : '');
25230
+ var r = await apiFetch(url);
25231
+ var d = await r.json();
25232
+ if (!d.ok || !Array.isArray(d.episodes)) {
25233
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
25234
+ return;
25235
+ }
25236
+ if (d.episodes.length === 0) {
25237
+ el.innerHTML = '<div class="empty-state" style="padding:14px">No episodes yet. They land automatically when a session has been idle for ~20 min with at least 3 exchanges.</div>';
25238
+ return;
25239
+ }
25240
+ var html = '<table class="data-table" style="width:100%">';
25241
+ html += '<thead><tr>'
25242
+ + '<th style="width:120px">When</th>'
25243
+ + '<th style="width:160px">Session</th>'
25244
+ + '<th>Summary</th>'
25245
+ + '<th style="width:140px">Topics</th>'
25246
+ + '<th style="width:120px">Outcome</th>'
25247
+ + '<th style="width:50px;text-align:right">Open</th>'
25248
+ + '</tr></thead><tbody>';
25249
+ for (var i = 0; i < d.episodes.length; i++) {
25250
+ var ep = d.episodes[i];
25251
+ var when = '';
25252
+ try { when = new Date(ep.createdAt + 'Z').toLocaleString(); } catch { when = ep.createdAt; }
25253
+ var topics = (ep.topics || []).slice(0, 3).map(esc).join(', ');
25254
+ var openCount = (ep.openLoops || []).length;
25255
+ var openColor = openCount > 0 ? '#f59e0b' : 'var(--text-muted)';
25256
+ html += '<tr>'
25257
+ + '<td style="font-size:11px;color:var(--text-muted)">' + esc(when) + '</td>'
25258
+ + '<td style="font-size:11px">' + esc(ep.sessionKey) + '</td>'
25259
+ + '<td style="font-size:12px">' + esc(ep.summary) + '</td>'
25260
+ + '<td style="font-size:11px;color:var(--text-muted)">' + (topics || '—') + '</td>'
25261
+ + '<td style="font-size:11px">' + esc(ep.outcome || '—') + '</td>'
25262
+ + '<td style="text-align:right;font-weight:600;color:' + openColor + '">' + openCount + '</td>'
25263
+ + '</tr>';
25264
+ }
25265
+ html += '</tbody></table>';
25266
+ el.innerHTML = html;
25267
+ } catch (err) {
25268
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
25269
+ }
25270
+ }
25271
+
25021
25272
  async function memoryHealthAction(action, extra) {
25022
25273
  var labels = { 'janitor': 'cleanup', 'rebuild-fts': 'FTS rebuild', 'fix-orphans': 'orphan fix', 'install-dense-model': 'local embedding model install/verify', 'reembed-dense': 'dense embedding backfill' };
25023
25274
  if (!confirm('Run ' + (labels[action] || action) + ' now?')) 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
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Episodic consolidation — turn raw transcript ranges into durable, indexed
3
+ * episodes that hybrid recall can surface across sessions.
4
+ *
5
+ * Why not just keep transcripts? Transcripts are noisy and minute-grained.
6
+ * "What did we decide about auth?" should match a clean summary of the
7
+ * decision, not the eight messages where we worked toward it. Episodes
8
+ * compress a session range into {summary, topics, entities, outcome,
9
+ * openLoops}, persist that to the episodes table, and also write the
10
+ * summary into chunks so PR-1's hybrid recall picks them up automatically.
11
+ *
12
+ * The pass is driven by the heartbeat: every few minutes we look for
13
+ * sessions that have been idle for ≥20 min with ≥3 new exchanges and
14
+ * consolidate up to a small bounded number per pass to keep LLM cost
15
+ * predictable.
16
+ */
17
+ import Anthropic from '@anthropic-ai/sdk';
18
+ import type { MemoryStore } from '../memory/store.js';
19
+ import { type CommitmentOwner } from './commitments.js';
20
+ export interface EpisodicConsolidationOptions {
21
+ /** Minutes of inactivity before a session becomes consolidation-eligible. */
22
+ idleMinutes?: number;
23
+ /** Minimum turns since last cursor for a session to qualify. */
24
+ minExchanges?: number;
25
+ /** Cap LLM calls per pass to bound cost. */
26
+ maxSessionsPerPass?: number;
27
+ /** How long to back off after a consolidation failure for a session. */
28
+ failBackoffMinutes?: number;
29
+ /** Override Anthropic client (used by tests). */
30
+ anthropicClient?: Pick<Anthropic, 'messages'>;
31
+ /** Override the model id (used by tests). */
32
+ model?: string;
33
+ /** Wallclock now() — used by tests for deterministic timestamps. */
34
+ now?: () => Date;
35
+ }
36
+ export interface ExtractedCommitment {
37
+ text: string;
38
+ owner: CommitmentOwner;
39
+ dueHint?: string;
40
+ }
41
+ export interface EpisodeExtraction {
42
+ summary: string;
43
+ topics: string[];
44
+ entities: string[];
45
+ outcome: string;
46
+ openLoops: string[];
47
+ commitments: ExtractedCommitment[];
48
+ }
49
+ interface CandidateRow {
50
+ sessionKey: string;
51
+ startTranscriptId: number;
52
+ endTranscriptId: number;
53
+ startedAt: string;
54
+ endedAt: string;
55
+ exchanges: number;
56
+ }
57
+ export interface ConsolidationPassResult {
58
+ consolidated: number;
59
+ failed: number;
60
+ skipped: number;
61
+ candidates: number;
62
+ }
63
+ /** Parse the model's output as JSON, tolerating leading/trailing whitespace and
64
+ * occasional code fences. Returns null on any structural problem. */
65
+ export declare function parseEpisodeJson(raw: string): EpisodeExtraction | null;
66
+ /**
67
+ * Consolidate a single candidate session range. Returns the new episode id
68
+ * + chunk id on success, or null on failure (the caller bumps the failure
69
+ * cursor so we don't retry every tick).
70
+ */
71
+ export declare function consolidateOneSession(store: MemoryStore, candidate: CandidateRow, opts?: EpisodicConsolidationOptions): Promise<{
72
+ episodeId: number;
73
+ chunkId: number | null;
74
+ } | null>;
75
+ /**
76
+ * Run one bounded consolidation pass. Designed to be called from the
77
+ * heartbeat tick — quick to no-op when nothing's eligible, capped at
78
+ * `maxSessionsPerPass` LLM calls when work exists.
79
+ */
80
+ export declare function runEpisodicConsolidationPass(store: MemoryStore, opts?: EpisodicConsolidationOptions): Promise<ConsolidationPassResult>;
81
+ export {};
82
+ //# sourceMappingURL=episodic-consolidation.d.ts.map