clementine-agent 1.18.28 → 1.18.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/context-policy.d.ts +10 -0
- package/dist/gateway/context-policy.js +25 -1
- package/dist/gateway/entity-registry.d.ts +47 -0
- package/dist/gateway/entity-registry.js +92 -0
- package/dist/gateway/episodic-consolidation.d.ts +7 -0
- package/dist/gateway/episodic-consolidation.js +50 -1
- package/dist/gateway/router.js +54 -3
- package/dist/memory/store.d.ts +76 -0
- package/dist/memory/store.js +220 -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
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* replies into status dumps.
|
|
8
8
|
*/
|
|
9
9
|
import type { ActiveContextItem, ActiveContextSnapshot } from './active-context.js';
|
|
10
|
+
import type { EntityMatch } from './entity-registry.js';
|
|
10
11
|
export type ContextTurnIntent = 'greeting' | 'ack' | 'status' | 'repair_request' | 'followup' | 'memory_correction' | 'work_request' | 'general_chat';
|
|
11
12
|
export type RequiredRetrieval = 'none' | 'event' | 'transcript';
|
|
12
13
|
export interface ContextPolicyDecision {
|
|
@@ -17,10 +18,19 @@ export interface ContextPolicyDecision {
|
|
|
17
18
|
requiredRetrieval: RequiredRetrieval;
|
|
18
19
|
retrievalQueries: string[];
|
|
19
20
|
debugReasons: string[];
|
|
21
|
+
triggeredEntities: EntityMatch[];
|
|
20
22
|
}
|
|
21
23
|
export interface ContextPolicyInput {
|
|
22
24
|
text: string;
|
|
23
25
|
activeContext?: ActiveContextSnapshot | null;
|
|
26
|
+
/**
|
|
27
|
+
* Pre-computed entity matches against the registry. Caller looks these
|
|
28
|
+
* up via entity-registry.findEntitiesInText so the policy module stays
|
|
29
|
+
* free of the store dependency. When matches arrive, the policy elevates
|
|
30
|
+
* recall to 'transcript' on non-trivial intents and seeds entity names
|
|
31
|
+
* into the retrieval queries.
|
|
32
|
+
*/
|
|
33
|
+
entityMatches?: EntityMatch[];
|
|
24
34
|
}
|
|
25
35
|
export declare function looksLikeVagueContextReference(text: string): boolean;
|
|
26
36
|
export declare function classifyContextTurn(text: string): ContextTurnIntent;
|
|
@@ -88,6 +88,7 @@ function buildRetrievalQueries(intent, text, activeContext) {
|
|
|
88
88
|
export function decideContextPolicy(input) {
|
|
89
89
|
const intent = classifyContextTurn(input.text);
|
|
90
90
|
const activeContext = input.activeContext ?? null;
|
|
91
|
+
const entityMatches = input.entityMatches ?? [];
|
|
91
92
|
const debugReasons = [`intent:${intent}`];
|
|
92
93
|
const proactiveSurface = (activeContext?.items ?? [])
|
|
93
94
|
.filter((item) => item.greetingEligible && !item.alreadySurfaced && !item.resolved)
|
|
@@ -98,8 +99,21 @@ export function decideContextPolicy(input) {
|
|
|
98
99
|
if (intent === 'repair_request' || intent === 'followup' || intent === 'memory_correction') {
|
|
99
100
|
requiredRetrieval = 'transcript';
|
|
100
101
|
}
|
|
102
|
+
// Entity-driven proactive recall: a known topic in the user's turn is a
|
|
103
|
+
// strong enough signal to pre-fetch related history without waiting for
|
|
104
|
+
// a vague-repair phrase. Skip on greeting/ack so a passing entity mention
|
|
105
|
+
// ("hey, how's the dashboard?") doesn't pull a wall of context into a
|
|
106
|
+
// friendly hello.
|
|
107
|
+
const elevatedByEntity = entityMatches.length > 0 && intent !== 'greeting' && intent !== 'ack';
|
|
108
|
+
if (elevatedByEntity && requiredRetrieval !== 'transcript') {
|
|
109
|
+
requiredRetrieval = 'transcript';
|
|
110
|
+
debugReasons.push('entity:elevated-retrieval');
|
|
111
|
+
}
|
|
101
112
|
if (requiredRetrieval !== 'none')
|
|
102
113
|
debugReasons.push(`retrieval:${requiredRetrieval}`);
|
|
114
|
+
if (entityMatches.length > 0) {
|
|
115
|
+
debugReasons.push(`entities:${entityMatches.map(e => e.name).join(',')}`);
|
|
116
|
+
}
|
|
103
117
|
const silentContextBlocks = [];
|
|
104
118
|
if (activeContext?.promptBlock
|
|
105
119
|
&& intent !== 'greeting'
|
|
@@ -112,14 +126,24 @@ export function decideContextPolicy(input) {
|
|
|
112
126
|
? activeContext?.greetingLine ?? 'Hey. I am here.'
|
|
113
127
|
: 'Hey. I am here.'
|
|
114
128
|
: null;
|
|
129
|
+
const baseQueries = buildRetrievalQueries(intent, input.text, activeContext);
|
|
130
|
+
// Prepend entity display names so the recall search prioritizes them.
|
|
131
|
+
// Dedup against the base lexical queries so we don't pay twice for the
|
|
132
|
+
// same term.
|
|
133
|
+
const entityQueries = entityMatches.map(e => e.display);
|
|
134
|
+
const merged = [...new Set([...entityQueries, ...baseQueries])]
|
|
135
|
+
.map(q => q.trim())
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.slice(0, 6);
|
|
115
138
|
return {
|
|
116
139
|
turnIntent: intent,
|
|
117
140
|
silentContextBlocks,
|
|
118
141
|
visibleOpening,
|
|
119
142
|
proactiveSurface,
|
|
120
143
|
requiredRetrieval,
|
|
121
|
-
retrievalQueries:
|
|
144
|
+
retrievalQueries: merged,
|
|
122
145
|
debugReasons,
|
|
146
|
+
triggeredEntities: entityMatches,
|
|
123
147
|
};
|
|
124
148
|
}
|
|
125
149
|
//# sourceMappingURL=context-policy.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity registry — detects when a user turn mentions a topic / entity
|
|
3
|
+
* Clementine already has context on, so recall can fire without waiting
|
|
4
|
+
* for vague-repair phrases like "what did we decide?".
|
|
5
|
+
*
|
|
6
|
+
* The registry is a flattened, mention-frequency-ranked snapshot of:
|
|
7
|
+
* - chunks.topic (curated knowledge)
|
|
8
|
+
* - episodes.topics + episodes.entities (consolidated session memory)
|
|
9
|
+
*
|
|
10
|
+
* Cached per store dbPath with a 5-minute TTL — the registry only changes
|
|
11
|
+
* when episodes consolidate or new chunks land, both of which are minutes-
|
|
12
|
+
* scale events. Invalidating less often keeps the chat path fast.
|
|
13
|
+
*/
|
|
14
|
+
import type { MemoryStore } from '../memory/store.js';
|
|
15
|
+
export interface RegistryEntity {
|
|
16
|
+
name: string;
|
|
17
|
+
display: string;
|
|
18
|
+
kind: 'topic' | 'entity';
|
|
19
|
+
count: number;
|
|
20
|
+
}
|
|
21
|
+
export interface EntityMatch {
|
|
22
|
+
name: string;
|
|
23
|
+
display: string;
|
|
24
|
+
kind: 'topic' | 'entity';
|
|
25
|
+
}
|
|
26
|
+
/** Read the registry from cache, refreshing if stale or missing. Tests can
|
|
27
|
+
* call invalidateEntityRegistry() between cases to bypass the cache. */
|
|
28
|
+
export declare function getEntityRegistry(store: MemoryStore, opts?: {
|
|
29
|
+
now?: number;
|
|
30
|
+
key?: string;
|
|
31
|
+
}): RegistryEntity[];
|
|
32
|
+
/** Drop cached registry entries — used by tests and by code paths that
|
|
33
|
+
* know they just mutated the registry source (e.g. after a fresh episode
|
|
34
|
+
* consolidation pass). */
|
|
35
|
+
export declare function invalidateEntityRegistry(key?: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* Find registry entities mentioned in the input text, with word-boundary
|
|
38
|
+
* matching so "auth" doesn't match "author". Multi-word entities are
|
|
39
|
+
* matched as contiguous word sequences. Longer matches are preferred
|
|
40
|
+
* (more specific), with mention-count as the tiebreaker.
|
|
41
|
+
*
|
|
42
|
+
* Returns at most `maxMatches` (default 5) entities, deduplicated.
|
|
43
|
+
*/
|
|
44
|
+
export declare function findEntitiesInText(text: string, registry: RegistryEntity[], opts?: {
|
|
45
|
+
maxMatches?: number;
|
|
46
|
+
}): EntityMatch[];
|
|
47
|
+
//# sourceMappingURL=entity-registry.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const REGISTRY_TTL_MS = 5 * 60 * 1000;
|
|
2
|
+
const cache = new Map();
|
|
3
|
+
/** Words that trigger pointless matches when allowed (too generic). Augments
|
|
4
|
+
* the per-entity length filter — "we" or "do" would never make it past the
|
|
5
|
+
* 3-char floor anyway, but common-but-bare nouns sometimes do, and they
|
|
6
|
+
* cause false positives across unrelated turns. */
|
|
7
|
+
const ENTITY_STOPWORDS = new Set([
|
|
8
|
+
'the', 'and', 'but', 'for', 'are', 'was', 'has', 'had', 'have', 'this',
|
|
9
|
+
'that', 'with', 'from', 'they', 'them', 'their', 'these', 'those',
|
|
10
|
+
'about', 'into', 'over', 'just', 'than', 'then', 'when', 'what', 'where',
|
|
11
|
+
'while', 'will', 'would', 'could', 'should',
|
|
12
|
+
]);
|
|
13
|
+
/** Read the registry from cache, refreshing if stale or missing. Tests can
|
|
14
|
+
* call invalidateEntityRegistry() between cases to bypass the cache. */
|
|
15
|
+
export function getEntityRegistry(store, opts = {}) {
|
|
16
|
+
const key = opts.key ?? store.dbPath ?? 'default';
|
|
17
|
+
const now = opts.now ?? Date.now();
|
|
18
|
+
const cached = cache.get(key);
|
|
19
|
+
if (cached && now - cached.loadedAt < REGISTRY_TTL_MS) {
|
|
20
|
+
return cached.entries;
|
|
21
|
+
}
|
|
22
|
+
let entries = [];
|
|
23
|
+
try {
|
|
24
|
+
if (typeof store.getEntityRegistrySnapshot === 'function') {
|
|
25
|
+
entries = store.getEntityRegistrySnapshot({ minCount: 1, maxItems: 500 });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { /* registry probe is best-effort */ }
|
|
29
|
+
cache.set(key, { entries, loadedAt: now });
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
32
|
+
/** Drop cached registry entries — used by tests and by code paths that
|
|
33
|
+
* know they just mutated the registry source (e.g. after a fresh episode
|
|
34
|
+
* consolidation pass). */
|
|
35
|
+
export function invalidateEntityRegistry(key) {
|
|
36
|
+
if (key)
|
|
37
|
+
cache.delete(key);
|
|
38
|
+
else
|
|
39
|
+
cache.clear();
|
|
40
|
+
}
|
|
41
|
+
function normalizeForMatch(text) {
|
|
42
|
+
return text
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
45
|
+
.replace(/\s+/g, ' ')
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Find registry entities mentioned in the input text, with word-boundary
|
|
50
|
+
* matching so "auth" doesn't match "author". Multi-word entities are
|
|
51
|
+
* matched as contiguous word sequences. Longer matches are preferred
|
|
52
|
+
* (more specific), with mention-count as the tiebreaker.
|
|
53
|
+
*
|
|
54
|
+
* Returns at most `maxMatches` (default 5) entities, deduplicated.
|
|
55
|
+
*/
|
|
56
|
+
export function findEntitiesInText(text, registry, opts = {}) {
|
|
57
|
+
const max = Math.max(1, opts.maxMatches ?? 5);
|
|
58
|
+
if (!text || registry.length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
const haystack = ` ${normalizeForMatch(text)} `;
|
|
61
|
+
if (haystack.trim().length < 3)
|
|
62
|
+
return [];
|
|
63
|
+
const candidates = [];
|
|
64
|
+
for (const entry of registry) {
|
|
65
|
+
if (entry.name.length < 3)
|
|
66
|
+
continue;
|
|
67
|
+
if (entry.name.split(' ').length === 1 && ENTITY_STOPWORDS.has(entry.name))
|
|
68
|
+
continue;
|
|
69
|
+
const needle = ` ${entry.name} `;
|
|
70
|
+
if (haystack.includes(needle)) {
|
|
71
|
+
candidates.push({ entry, specificity: entry.name.length });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Specificity desc, then count desc — multi-word matches win, frequency
|
|
75
|
+
// breaks ties between equally-specific candidates.
|
|
76
|
+
candidates.sort((a, b) => b.specificity - a.specificity || b.entry.count - a.entry.count);
|
|
77
|
+
// Dedup: skip a candidate if a longer already-accepted match fully
|
|
78
|
+
// contains its name (e.g. don't surface "dashboard" if "dashboard
|
|
79
|
+
// refactor" already matched).
|
|
80
|
+
const accepted = [];
|
|
81
|
+
const acceptedNames = [];
|
|
82
|
+
for (const { entry } of candidates) {
|
|
83
|
+
if (acceptedNames.some(n => n.includes(entry.name)))
|
|
84
|
+
continue;
|
|
85
|
+
accepted.push({ name: entry.name, display: entry.display, kind: entry.kind });
|
|
86
|
+
acceptedNames.push(entry.name);
|
|
87
|
+
if (accepted.length >= max)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
return accepted;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=entity-registry.js.map
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import Anthropic from '@anthropic-ai/sdk';
|
|
18
18
|
import type { MemoryStore } from '../memory/store.js';
|
|
19
|
+
import { type CommitmentOwner } from './commitments.js';
|
|
19
20
|
export interface EpisodicConsolidationOptions {
|
|
20
21
|
/** Minutes of inactivity before a session becomes consolidation-eligible. */
|
|
21
22
|
idleMinutes?: number;
|
|
@@ -32,12 +33,18 @@ export interface EpisodicConsolidationOptions {
|
|
|
32
33
|
/** Wallclock now() — used by tests for deterministic timestamps. */
|
|
33
34
|
now?: () => Date;
|
|
34
35
|
}
|
|
36
|
+
export interface ExtractedCommitment {
|
|
37
|
+
text: string;
|
|
38
|
+
owner: CommitmentOwner;
|
|
39
|
+
dueHint?: string;
|
|
40
|
+
}
|
|
35
41
|
export interface EpisodeExtraction {
|
|
36
42
|
summary: string;
|
|
37
43
|
topics: string[];
|
|
38
44
|
entities: string[];
|
|
39
45
|
outcome: string;
|
|
40
46
|
openLoops: string[];
|
|
47
|
+
commitments: ExtractedCommitment[];
|
|
41
48
|
}
|
|
42
49
|
interface CandidateRow {
|
|
43
50
|
sessionKey: string;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import Anthropic from '@anthropic-ai/sdk';
|
|
18
18
|
import pino from 'pino';
|
|
19
19
|
import { MODELS } from '../config.js';
|
|
20
|
+
import { fingerprintCommitment, parseRelativeDue, } from './commitments.js';
|
|
20
21
|
const logger = pino({
|
|
21
22
|
name: 'clementine.episodic-consolidation',
|
|
22
23
|
level: process.env.CLEMENTINE_CONSOLIDATION_LOG_LEVEL || 'warn',
|
|
@@ -31,7 +32,10 @@ const SYSTEM_PROMPT = [
|
|
|
31
32
|
' "topics": string[] (lowercase noun phrases, max 6),',
|
|
32
33
|
' "entities": string[] (named things: files, services, people; max 8),',
|
|
33
34
|
' "outcome": string (one short clause: decided / implemented / discussed / blocked / none),',
|
|
34
|
-
' "openLoops": string[] (unresolved follow-ups; empty array if none, max 5)',
|
|
35
|
+
' "openLoops": string[] (unresolved follow-ups; empty array if none, max 5),',
|
|
36
|
+
' "commitments": Array<{ text: string, owner: "user" | "clementine", dueHint?: string }>',
|
|
37
|
+
' (explicit promises only — "I\'ll do X", "remind me to Y", "by Friday".',
|
|
38
|
+
' owner = whoever committed: user vs the assistant. Empty array if none, max 5.)',
|
|
35
39
|
'}',
|
|
36
40
|
].join('\n');
|
|
37
41
|
function buildUserPrompt(turns) {
|
|
@@ -71,12 +75,28 @@ export function parseEpisodeJson(raw) {
|
|
|
71
75
|
const summary = typeof obj.summary === 'string' ? obj.summary.trim() : '';
|
|
72
76
|
if (!summary)
|
|
73
77
|
return null;
|
|
78
|
+
const rawCommitments = Array.isArray(obj.commitments) ? obj.commitments : [];
|
|
79
|
+
const commitments = [];
|
|
80
|
+
for (const raw of rawCommitments) {
|
|
81
|
+
if (!raw || typeof raw !== 'object')
|
|
82
|
+
continue;
|
|
83
|
+
const c = raw;
|
|
84
|
+
const text = typeof c.text === 'string' ? c.text.trim() : '';
|
|
85
|
+
const owner = c.owner === 'clementine' || c.owner === 'user' ? c.owner : null;
|
|
86
|
+
if (!text || !owner)
|
|
87
|
+
continue;
|
|
88
|
+
const dueHint = typeof c.dueHint === 'string' && c.dueHint.trim() ? c.dueHint.trim() : undefined;
|
|
89
|
+
commitments.push({ text: text.slice(0, 220), owner, dueHint });
|
|
90
|
+
if (commitments.length >= 5)
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
74
93
|
return {
|
|
75
94
|
summary,
|
|
76
95
|
topics: arr(obj.topics).slice(0, 6),
|
|
77
96
|
entities: arr(obj.entities).slice(0, 8),
|
|
78
97
|
outcome: typeof obj.outcome === 'string' ? obj.outcome.trim().slice(0, 200) : '',
|
|
79
98
|
openLoops: arr(obj.openLoops).slice(0, 5),
|
|
99
|
+
commitments,
|
|
80
100
|
};
|
|
81
101
|
}
|
|
82
102
|
function getAnthropicClient(opts) {
|
|
@@ -149,6 +169,34 @@ export async function consolidateOneSession(store, candidate, opts = {}) {
|
|
|
149
169
|
transcriptIds,
|
|
150
170
|
chunkId,
|
|
151
171
|
});
|
|
172
|
+
// Lift extracted commitments into first-class rows. Fingerprint dedupe
|
|
173
|
+
// keeps these from colliding with regex-detected commitments captured
|
|
174
|
+
// in real time on the same turns.
|
|
175
|
+
let commitmentsCreated = 0;
|
|
176
|
+
for (const c of extraction.commitments) {
|
|
177
|
+
try {
|
|
178
|
+
const normalized = c.text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
179
|
+
if (!normalized)
|
|
180
|
+
continue;
|
|
181
|
+
const fp = fingerprintCommitment(candidate.sessionKey, c.owner, normalized);
|
|
182
|
+
const dueAt = c.dueHint ? parseRelativeDue(c.dueHint) ?? null : null;
|
|
183
|
+
const result = store.upsertCommitment({
|
|
184
|
+
fingerprint: fp,
|
|
185
|
+
source: 'episode-extractor',
|
|
186
|
+
owner: c.owner,
|
|
187
|
+
text: c.text,
|
|
188
|
+
sessionKey: candidate.sessionKey,
|
|
189
|
+
episodeId: insert.episodeId,
|
|
190
|
+
dueAt,
|
|
191
|
+
dueHint: c.dueHint ?? null,
|
|
192
|
+
});
|
|
193
|
+
if (result.created)
|
|
194
|
+
commitmentsCreated++;
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
logger.debug({ err }, 'Failed to persist extracted commitment');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
152
200
|
store.updateConsolidationCursor(candidate.sessionKey, {
|
|
153
201
|
lastTranscriptId: candidate.endTranscriptId,
|
|
154
202
|
success: true,
|
|
@@ -158,6 +206,7 @@ export async function consolidateOneSession(store, candidate, opts = {}) {
|
|
|
158
206
|
episodeId: insert.episodeId,
|
|
159
207
|
chunkId,
|
|
160
208
|
turns: turns.length,
|
|
209
|
+
commitmentsCreated,
|
|
161
210
|
}, 'Consolidated episode');
|
|
162
211
|
return { episodeId: insert.episodeId, chunkId };
|
|
163
212
|
}
|
package/dist/gateway/router.js
CHANGED
|
@@ -28,6 +28,8 @@ import { recordProactiveNotificationEvent, } from './notification-context.js';
|
|
|
28
28
|
import { isInternalSyntheticPrompt, resolveRecentOperationalContext } from './recent-context.js';
|
|
29
29
|
import { decideContextPolicy } from './context-policy.js';
|
|
30
30
|
import { persistConversationLearning } from './conversation-learning.js';
|
|
31
|
+
import { detectCommitmentInTurn, recordDetectedCommitment } from './commitments.js';
|
|
32
|
+
import { findEntitiesInText, getEntityRegistry } from './entity-registry.js';
|
|
31
33
|
import { getBackgroundCreditBlock, isCreditBalanceError, markBackgroundCreditBlocked } from './credit-guard.js';
|
|
32
34
|
import { appendTurnLedger, estimateTokensApprox, formatLastTurnLedger, readRecentTurnLedger } from './turn-ledger.js';
|
|
33
35
|
import { assessGatewayContextHygiene, formatGatewayHygieneAnnotation } from './context-hygiene.js';
|
|
@@ -1521,6 +1523,7 @@ export class Gateway {
|
|
|
1521
1523
|
// another turn is active.
|
|
1522
1524
|
const localTurnStarted = Date.now();
|
|
1523
1525
|
let transcriptCoverage;
|
|
1526
|
+
let openCommitments;
|
|
1524
1527
|
if (this.isTrustedPersonalSession(sessionKey)) {
|
|
1525
1528
|
try {
|
|
1526
1529
|
const store = this.assistant.getMemoryStore?.();
|
|
@@ -1528,13 +1531,41 @@ export class Gateway {
|
|
|
1528
1531
|
const cov = store.getTranscriptDenseCoverage();
|
|
1529
1532
|
transcriptCoverage = { embedded: cov.embedded, total: cov.total };
|
|
1530
1533
|
}
|
|
1534
|
+
if (store && typeof store.listCommitments === 'function') {
|
|
1535
|
+
// Pull session-scoped open commitments first, then pad with the
|
|
1536
|
+
// wider open list so commitments captured in other sessions still
|
|
1537
|
+
// surface in greetings (e.g. user said "remind me Friday" via
|
|
1538
|
+
// Slack, then opens a Discord DM Saturday).
|
|
1539
|
+
const list = store.listCommitments;
|
|
1540
|
+
const scoped = list({ status: 'open', sessionKey, limit: 10 });
|
|
1541
|
+
const wider = scoped.length < 6 ? list({ status: 'open', limit: 10 }) : [];
|
|
1542
|
+
const seen = new Set();
|
|
1543
|
+
const merged = [...scoped, ...wider].filter(c => !seen.has(c.id) && (seen.add(c.id), true));
|
|
1544
|
+
openCommitments = merged.slice(0, 10);
|
|
1545
|
+
}
|
|
1531
1546
|
}
|
|
1532
|
-
catch { /*
|
|
1547
|
+
catch { /* probes are best-effort */ }
|
|
1533
1548
|
}
|
|
1534
1549
|
const activeContext = this.isTrustedPersonalSession(sessionKey)
|
|
1535
|
-
? buildActiveContextSnapshot(sessionKey, { baseDir: BASE_DIR, transcriptCoverage })
|
|
1550
|
+
? buildActiveContextSnapshot(sessionKey, { baseDir: BASE_DIR, transcriptCoverage, openCommitments })
|
|
1536
1551
|
: null;
|
|
1537
|
-
|
|
1552
|
+
// Entity recall: if the user mentions something we already have context
|
|
1553
|
+
// on (a chunk topic or an episode entity), elevate retrieval so the
|
|
1554
|
+
// model gets the relevant history without waiting for a repair phrase.
|
|
1555
|
+
let entityMatches = [];
|
|
1556
|
+
if (this.isTrustedPersonalSession(sessionKey)) {
|
|
1557
|
+
try {
|
|
1558
|
+
const store = this.assistant.getMemoryStore?.();
|
|
1559
|
+
if (store) {
|
|
1560
|
+
const registry = getEntityRegistry(store);
|
|
1561
|
+
if (registry.length > 0) {
|
|
1562
|
+
entityMatches = findEntitiesInText(text, registry);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
catch { /* entity registry probe is best-effort */ }
|
|
1567
|
+
}
|
|
1568
|
+
const contextDecision = decideContextPolicy({ text, activeContext, entityMatches });
|
|
1538
1569
|
if (this.isTrustedPersonalSession(sessionKey)) {
|
|
1539
1570
|
const learning = persistConversationLearning(sessionKey, text, this.assistant.getMemoryStore?.());
|
|
1540
1571
|
if (learning?.corrections.length || learning?.preferences.length) {
|
|
@@ -1544,6 +1575,26 @@ export class Gateway {
|
|
|
1544
1575
|
preferences: learning.preferences.length,
|
|
1545
1576
|
}, 'Captured deterministic conversation learning signal');
|
|
1546
1577
|
}
|
|
1578
|
+
// Best-effort: scan this user turn for an explicit commitment phrase
|
|
1579
|
+
// ("I'll fix that tomorrow"). Detection runs synchronously and
|
|
1580
|
+
// dedupes by fingerprint so re-running on the same text is a no-op.
|
|
1581
|
+
try {
|
|
1582
|
+
const detected = detectCommitmentInTurn(text, 'user');
|
|
1583
|
+
if (detected) {
|
|
1584
|
+
const store = this.assistant.getMemoryStore?.();
|
|
1585
|
+
if (store) {
|
|
1586
|
+
const recorded = recordDetectedCommitment(store, sessionKey, detected, { source: 'turn-detector' });
|
|
1587
|
+
if (recorded?.created) {
|
|
1588
|
+
logger.info({
|
|
1589
|
+
sessionKey, owner: detected.owner, dueHint: detected.dueHint, hasDueAt: !!detected.dueAt,
|
|
1590
|
+
}, 'Captured explicit user commitment');
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
catch (err) {
|
|
1596
|
+
logger.debug({ err }, 'Commitment detection failed (non-fatal)');
|
|
1597
|
+
}
|
|
1547
1598
|
}
|
|
1548
1599
|
const localResponse = await this.handleLocalTurn(sessionKey, text, onText, contextDecision);
|
|
1549
1600
|
if (localResponse !== null) {
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -703,6 +703,82 @@ export declare class MemoryStore {
|
|
|
703
703
|
chunkId: number | null;
|
|
704
704
|
createdAt: string;
|
|
705
705
|
}>;
|
|
706
|
+
/**
|
|
707
|
+
* Pull a flattened, deduplicated snapshot of named topics + entities the
|
|
708
|
+
* agent already knows about, ranked by mention frequency. Sources:
|
|
709
|
+
* - chunks.topic (curated knowledge — the strongest signal)
|
|
710
|
+
* - episodes.topics (LLM-extracted topic phrases per session)
|
|
711
|
+
* - episodes.entities (LLM-extracted named things)
|
|
712
|
+
*
|
|
713
|
+
* Used by the entity-registry module to detect when a user turn mentions
|
|
714
|
+
* something we have prior context on, so recall can fire proactively.
|
|
715
|
+
*/
|
|
716
|
+
getEntityRegistrySnapshot(opts?: {
|
|
717
|
+
minCount?: number;
|
|
718
|
+
maxItems?: number;
|
|
719
|
+
}): Array<{
|
|
720
|
+
name: string;
|
|
721
|
+
display: string;
|
|
722
|
+
kind: 'topic' | 'entity';
|
|
723
|
+
count: number;
|
|
724
|
+
}>;
|
|
725
|
+
/**
|
|
726
|
+
* Insert a commitment, deduping on the fingerprint. If a row with the
|
|
727
|
+
* same fingerprint already exists, the existing id is returned and no
|
|
728
|
+
* write occurs — keeps the regex detector + LLM extractor from creating
|
|
729
|
+
* duplicates of the same promise.
|
|
730
|
+
*/
|
|
731
|
+
upsertCommitment(entry: {
|
|
732
|
+
fingerprint: string;
|
|
733
|
+
source: string;
|
|
734
|
+
owner: 'user' | 'clementine';
|
|
735
|
+
text: string;
|
|
736
|
+
sessionKey?: string | null;
|
|
737
|
+
transcriptId?: number | null;
|
|
738
|
+
episodeId?: number | null;
|
|
739
|
+
dueAt?: string | null;
|
|
740
|
+
dueHint?: string | null;
|
|
741
|
+
}): {
|
|
742
|
+
id: number;
|
|
743
|
+
created: boolean;
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* List commitments with optional filters. Sorted by due_at ASC NULLS LAST
|
|
747
|
+
* so overdue + soon-due float to the top.
|
|
748
|
+
*/
|
|
749
|
+
listCommitments(opts?: {
|
|
750
|
+
status?: 'open' | 'done' | 'cancelled';
|
|
751
|
+
sessionKey?: string;
|
|
752
|
+
owner?: 'user' | 'clementine';
|
|
753
|
+
overdueOnly?: boolean;
|
|
754
|
+
dueBeforeIso?: string;
|
|
755
|
+
limit?: number;
|
|
756
|
+
}): Array<{
|
|
757
|
+
id: number;
|
|
758
|
+
fingerprint: string;
|
|
759
|
+
source: string;
|
|
760
|
+
owner: 'user' | 'clementine';
|
|
761
|
+
text: string;
|
|
762
|
+
sessionKey: string | null;
|
|
763
|
+
transcriptId: number | null;
|
|
764
|
+
episodeId: number | null;
|
|
765
|
+
dueAt: string | null;
|
|
766
|
+
dueHint: string | null;
|
|
767
|
+
status: 'open' | 'done' | 'cancelled';
|
|
768
|
+
createdAt: string;
|
|
769
|
+
completedAt: string | null;
|
|
770
|
+
snoozedUntil: string | null;
|
|
771
|
+
}>;
|
|
772
|
+
/**
|
|
773
|
+
* Update commitment status. 'done' / 'cancelled' set completed_at; 'snooze'
|
|
774
|
+
* (with snoozeIso) bumps snoozed_until without changing status so the row
|
|
775
|
+
* stays open but suppressed from greeting until the snooze expires.
|
|
776
|
+
*/
|
|
777
|
+
updateCommitmentStatus(id: number, update: {
|
|
778
|
+
status?: 'open' | 'done' | 'cancelled';
|
|
779
|
+
snoozeUntilIso?: string;
|
|
780
|
+
notes?: string;
|
|
781
|
+
}): boolean;
|
|
706
782
|
/**
|
|
707
783
|
* Fetch a slice of transcripts by id range for consolidation. Used by
|
|
708
784
|
* the consolidation module to materialize the conversation it's about
|
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,199 @@ export class MemoryStore {
|
|
|
3155
3182
|
createdAt: row.created_at,
|
|
3156
3183
|
}));
|
|
3157
3184
|
}
|
|
3185
|
+
// ── Entity registry ───────────────────────────────────────────────
|
|
3186
|
+
/**
|
|
3187
|
+
* Pull a flattened, deduplicated snapshot of named topics + entities the
|
|
3188
|
+
* agent already knows about, ranked by mention frequency. Sources:
|
|
3189
|
+
* - chunks.topic (curated knowledge — the strongest signal)
|
|
3190
|
+
* - episodes.topics (LLM-extracted topic phrases per session)
|
|
3191
|
+
* - episodes.entities (LLM-extracted named things)
|
|
3192
|
+
*
|
|
3193
|
+
* Used by the entity-registry module to detect when a user turn mentions
|
|
3194
|
+
* something we have prior context on, so recall can fire proactively.
|
|
3195
|
+
*/
|
|
3196
|
+
getEntityRegistrySnapshot(opts = {}) {
|
|
3197
|
+
const minCount = Math.max(1, opts.minCount ?? 1);
|
|
3198
|
+
const maxItems = Math.max(1, Math.min(opts.maxItems ?? 500, 5000));
|
|
3199
|
+
const counts = new Map();
|
|
3200
|
+
const accept = (raw, kind) => {
|
|
3201
|
+
if (!raw)
|
|
3202
|
+
return;
|
|
3203
|
+
const display = raw.trim();
|
|
3204
|
+
if (display.length < 3 || display.length > 80)
|
|
3205
|
+
return;
|
|
3206
|
+
const name = display.toLowerCase();
|
|
3207
|
+
const existing = counts.get(name);
|
|
3208
|
+
if (existing) {
|
|
3209
|
+
existing.count++;
|
|
3210
|
+
// Topics from chunks outrank LLM-derived ones for kind classification.
|
|
3211
|
+
if (kind === 'topic')
|
|
3212
|
+
existing.kind = 'topic';
|
|
3213
|
+
}
|
|
3214
|
+
else {
|
|
3215
|
+
counts.set(name, { display, kind, count: 1 });
|
|
3216
|
+
}
|
|
3217
|
+
};
|
|
3218
|
+
try {
|
|
3219
|
+
const topicRows = this.conn
|
|
3220
|
+
.prepare(`SELECT topic, COUNT(*) as cnt FROM chunks
|
|
3221
|
+
WHERE topic IS NOT NULL AND length(trim(topic)) > 0
|
|
3222
|
+
GROUP BY topic`)
|
|
3223
|
+
.all();
|
|
3224
|
+
for (const r of topicRows) {
|
|
3225
|
+
const existing = counts.get(r.topic.trim().toLowerCase());
|
|
3226
|
+
if (existing)
|
|
3227
|
+
existing.count += r.cnt - 1; // already added 1 above
|
|
3228
|
+
accept(r.topic, 'topic');
|
|
3229
|
+
if (existing) {
|
|
3230
|
+
// Increment with the SQL-derived count (offset by the 1 accept added).
|
|
3231
|
+
const e = counts.get(r.topic.trim().toLowerCase());
|
|
3232
|
+
if (e)
|
|
3233
|
+
e.count = Math.max(e.count, r.cnt);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
catch { /* chunks.topic column missing or query fails */ }
|
|
3238
|
+
try {
|
|
3239
|
+
const epRows = this.conn
|
|
3240
|
+
.prepare(`SELECT topics, entities FROM episodes`)
|
|
3241
|
+
.all();
|
|
3242
|
+
for (const row of epRows) {
|
|
3243
|
+
if (row.topics) {
|
|
3244
|
+
try {
|
|
3245
|
+
const arr = JSON.parse(row.topics);
|
|
3246
|
+
if (Array.isArray(arr))
|
|
3247
|
+
for (const t of arr)
|
|
3248
|
+
if (typeof t === 'string')
|
|
3249
|
+
accept(t, 'topic');
|
|
3250
|
+
}
|
|
3251
|
+
catch { /* skip malformed JSON */ }
|
|
3252
|
+
}
|
|
3253
|
+
if (row.entities) {
|
|
3254
|
+
try {
|
|
3255
|
+
const arr = JSON.parse(row.entities);
|
|
3256
|
+
if (Array.isArray(arr))
|
|
3257
|
+
for (const e of arr)
|
|
3258
|
+
if (typeof e === 'string')
|
|
3259
|
+
accept(e, 'entity');
|
|
3260
|
+
}
|
|
3261
|
+
catch { /* skip malformed JSON */ }
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
catch { /* episodes table missing */ }
|
|
3266
|
+
const all = [...counts.entries()]
|
|
3267
|
+
.map(([name, v]) => ({ name, display: v.display, kind: v.kind, count: v.count }))
|
|
3268
|
+
.filter(e => e.count >= minCount);
|
|
3269
|
+
all.sort((a, b) => b.count - a.count || a.name.length - b.name.length);
|
|
3270
|
+
return all.slice(0, maxItems);
|
|
3271
|
+
}
|
|
3272
|
+
// ── Commitments ───────────────────────────────────────────────────
|
|
3273
|
+
/**
|
|
3274
|
+
* Insert a commitment, deduping on the fingerprint. If a row with the
|
|
3275
|
+
* same fingerprint already exists, the existing id is returned and no
|
|
3276
|
+
* write occurs — keeps the regex detector + LLM extractor from creating
|
|
3277
|
+
* duplicates of the same promise.
|
|
3278
|
+
*/
|
|
3279
|
+
upsertCommitment(entry) {
|
|
3280
|
+
const existing = this.conn
|
|
3281
|
+
.prepare('SELECT id FROM commitments WHERE fingerprint = ?')
|
|
3282
|
+
.get(entry.fingerprint);
|
|
3283
|
+
if (existing) {
|
|
3284
|
+
return { id: existing.id, created: false };
|
|
3285
|
+
}
|
|
3286
|
+
const result = this.conn
|
|
3287
|
+
.prepare(`INSERT INTO commitments
|
|
3288
|
+
(fingerprint, source, owner, text, session_key, transcript_id, episode_id, due_at, due_hint)
|
|
3289
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
3290
|
+
.run(entry.fingerprint, entry.source, entry.owner, entry.text, entry.sessionKey ?? null, entry.transcriptId ?? null, entry.episodeId ?? null, entry.dueAt ?? null, entry.dueHint ?? null);
|
|
3291
|
+
return { id: result.lastInsertRowid, created: true };
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* List commitments with optional filters. Sorted by due_at ASC NULLS LAST
|
|
3295
|
+
* so overdue + soon-due float to the top.
|
|
3296
|
+
*/
|
|
3297
|
+
listCommitments(opts = {}) {
|
|
3298
|
+
const params = [];
|
|
3299
|
+
const where = [];
|
|
3300
|
+
if (opts.status) {
|
|
3301
|
+
where.push('status = ?');
|
|
3302
|
+
params.push(opts.status);
|
|
3303
|
+
}
|
|
3304
|
+
if (opts.sessionKey) {
|
|
3305
|
+
where.push('session_key = ?');
|
|
3306
|
+
params.push(opts.sessionKey);
|
|
3307
|
+
}
|
|
3308
|
+
if (opts.owner) {
|
|
3309
|
+
where.push('owner = ?');
|
|
3310
|
+
params.push(opts.owner);
|
|
3311
|
+
}
|
|
3312
|
+
if (opts.overdueOnly) {
|
|
3313
|
+
where.push("due_at IS NOT NULL AND due_at < datetime('now') AND status = 'open'");
|
|
3314
|
+
}
|
|
3315
|
+
else if (opts.dueBeforeIso) {
|
|
3316
|
+
where.push('due_at IS NOT NULL AND due_at <= ?');
|
|
3317
|
+
params.push(opts.dueBeforeIso);
|
|
3318
|
+
}
|
|
3319
|
+
// Suppress snoozed rows from "open" view unless caller asked for status explicitly.
|
|
3320
|
+
// Wrapping both sides in datetime() so ISO-8601 strings (with T/Z/millis) and
|
|
3321
|
+
// SQLite's space-separated `datetime('now')` format compare correctly.
|
|
3322
|
+
if (opts.status === 'open' || (!opts.status && !opts.overdueOnly)) {
|
|
3323
|
+
where.push("(snoozed_until IS NULL OR datetime(snoozed_until) <= datetime('now'))");
|
|
3324
|
+
}
|
|
3325
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
|
|
3326
|
+
const sql = `SELECT * FROM commitments
|
|
3327
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
3328
|
+
ORDER BY (due_at IS NULL) ASC, due_at ASC, created_at DESC
|
|
3329
|
+
LIMIT ?`;
|
|
3330
|
+
params.push(limit);
|
|
3331
|
+
const rows = this.conn.prepare(sql).all(...params);
|
|
3332
|
+
return rows.map(r => ({
|
|
3333
|
+
id: r.id,
|
|
3334
|
+
fingerprint: r.fingerprint,
|
|
3335
|
+
source: r.source,
|
|
3336
|
+
owner: r.owner,
|
|
3337
|
+
text: r.text,
|
|
3338
|
+
sessionKey: r.session_key,
|
|
3339
|
+
transcriptId: r.transcript_id,
|
|
3340
|
+
episodeId: r.episode_id,
|
|
3341
|
+
dueAt: r.due_at,
|
|
3342
|
+
dueHint: r.due_hint,
|
|
3343
|
+
status: r.status,
|
|
3344
|
+
createdAt: r.created_at,
|
|
3345
|
+
completedAt: r.completed_at,
|
|
3346
|
+
snoozedUntil: r.snoozed_until,
|
|
3347
|
+
}));
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* Update commitment status. 'done' / 'cancelled' set completed_at; 'snooze'
|
|
3351
|
+
* (with snoozeIso) bumps snoozed_until without changing status so the row
|
|
3352
|
+
* stays open but suppressed from greeting until the snooze expires.
|
|
3353
|
+
*/
|
|
3354
|
+
updateCommitmentStatus(id, update) {
|
|
3355
|
+
const sets = [];
|
|
3356
|
+
const vals = [];
|
|
3357
|
+
if (update.status) {
|
|
3358
|
+
sets.push('status = ?');
|
|
3359
|
+
vals.push(update.status);
|
|
3360
|
+
if (update.status === 'done' || update.status === 'cancelled') {
|
|
3361
|
+
sets.push("completed_at = datetime('now')");
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
if (update.snoozeUntilIso !== undefined) {
|
|
3365
|
+
sets.push('snoozed_until = ?');
|
|
3366
|
+
vals.push(update.snoozeUntilIso);
|
|
3367
|
+
}
|
|
3368
|
+
if (update.notes !== undefined) {
|
|
3369
|
+
sets.push('notes = ?');
|
|
3370
|
+
vals.push(update.notes);
|
|
3371
|
+
}
|
|
3372
|
+
if (sets.length === 0)
|
|
3373
|
+
return false;
|
|
3374
|
+
vals.push(id);
|
|
3375
|
+
const result = this.conn.prepare(`UPDATE commitments SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
3376
|
+
return result.changes > 0;
|
|
3377
|
+
}
|
|
3158
3378
|
/**
|
|
3159
3379
|
* Fetch a slice of transcripts by id range for consolidation. Used by
|
|
3160
3380
|
* the consolidation module to materialize the conversation it's about
|