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.
- package/dist/cli/dashboard.js +154 -0
- package/dist/gateway/active-context.d.ts +14 -1
- package/dist/gateway/active-context.js +31 -0
- package/dist/gateway/commitments.d.ts +38 -0
- package/dist/gateway/commitments.js +172 -0
- package/dist/gateway/episodic-consolidation.d.ts +7 -0
- package/dist/gateway/episodic-consolidation.js +50 -1
- package/dist/gateway/router.js +36 -2
- package/dist/memory/store.d.ts +57 -0
- package/dist/memory/store.js +133 -0
- package/package.json +1 -1
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
}
|
package/dist/gateway/router.js
CHANGED
|
@@ -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 { /*
|
|
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) {
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|