@agentprojectcontext/apx 1.78.0 → 1.79.0
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/package.json +1 -1
- package/src/core/agent/run-agent.js +30 -5
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +70 -1
- package/src/core/channels/telegram/ask-callbacks.js +35 -0
- package/src/core/channels/telegram/dispatch.js +3 -0
- package/src/core/channels/telegram/reply.js +30 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +322 -60
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +4 -0
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Signals — deterministic detection of things worth noticing.
|
|
2
|
+
//
|
|
3
|
+
// THE SPLIT THIS FILE EXISTS FOR: detection is cheap and mechanical; judgement
|
|
4
|
+
// is expensive and contextual. Mixing them means paying a language model every
|
|
5
|
+
// five minutes to conclude that nothing is happening. Everything here is a
|
|
6
|
+
// pure function over stored state — no model, no network, no clock beyond the
|
|
7
|
+
// `now` you pass in — so a watch routine that finds nothing costs nothing, and
|
|
8
|
+
// every rule below is testable with synthetic state.
|
|
9
|
+
//
|
|
10
|
+
// Thresholds are PARAMETERS, never constants. "Stale after 7 days" is a
|
|
11
|
+
// judgement about how someone works, and judgements belong to the profile.
|
|
12
|
+
//
|
|
13
|
+
// A signal is:
|
|
14
|
+
// { type, project_id, project_name, severity, subject, detected_at, payload }
|
|
15
|
+
//
|
|
16
|
+
// `subject` is a one-line human phrasing — the detector already knows exactly
|
|
17
|
+
// what it found, and making the model re-derive it from payload fields is how
|
|
18
|
+
// summaries drift from the data they claim to describe.
|
|
19
|
+
import { listTasks } from "#core/stores/tasks.js";
|
|
20
|
+
import { listCommitments } from "#core/stores/commitments.js";
|
|
21
|
+
import { nowIso } from "#core/util/time.js";
|
|
22
|
+
|
|
23
|
+
/** Every detector, keyed by the type it emits. */
|
|
24
|
+
export const SIGNAL_TYPES = Object.freeze([
|
|
25
|
+
"overdue_task",
|
|
26
|
+
"blocked_task",
|
|
27
|
+
"stale_project",
|
|
28
|
+
"commitment_due",
|
|
29
|
+
"overdue_commitment",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Defaults chosen to be quiet. A watcher that cries on day one gets turned off. */
|
|
33
|
+
export const DEFAULT_THRESHOLDS = Object.freeze({
|
|
34
|
+
blocked_hours: 48,
|
|
35
|
+
stale_project_days: 7,
|
|
36
|
+
commitment_lead_hours: 48,
|
|
37
|
+
/** Detectors to run. Empty = all of them. */
|
|
38
|
+
types: [],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// detectors — each takes (project, opts) and returns Signal[]
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** A task whose due date has passed and which nobody closed. */
|
|
46
|
+
function detectOverdueTasks(project, { now }) {
|
|
47
|
+
const today = now.slice(0, 10);
|
|
48
|
+
return listTasks(project.storagePath, { state: "open" })
|
|
49
|
+
.filter((t) => t.due && t.due < today)
|
|
50
|
+
.map((t) =>
|
|
51
|
+
signal(project, {
|
|
52
|
+
type: "overdue_task",
|
|
53
|
+
// A task one day late and one three weeks late are not the same event.
|
|
54
|
+
severity: daysBetween(t.due, today) >= 7 ? "high" : "normal",
|
|
55
|
+
subject: `"${t.title}" was due ${t.due}`,
|
|
56
|
+
payload: { task_id: t.id, title: t.title, due: t.due, days_late: daysBetween(t.due, today) },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A task sitting in `blocked` long enough that nobody is coming back to it.
|
|
63
|
+
*
|
|
64
|
+
* Uses updated_at, not created_at: a task blocked this morning is a normal
|
|
65
|
+
* working state, and flagging it would train the user to ignore the watcher.
|
|
66
|
+
*/
|
|
67
|
+
function detectBlockedTasks(project, { now, blocked_hours }) {
|
|
68
|
+
const cutoff = new Date(Date.parse(now) - blocked_hours * 3_600_000).toISOString();
|
|
69
|
+
return listTasks(project.storagePath, { state: "open", status: "blocked" })
|
|
70
|
+
.filter((t) => (t.updated_at || t.created_at || "") < cutoff)
|
|
71
|
+
.map((t) =>
|
|
72
|
+
signal(project, {
|
|
73
|
+
type: "blocked_task",
|
|
74
|
+
severity: "normal",
|
|
75
|
+
subject: `"${t.title}" has been blocked since ${(t.updated_at || t.created_at || "").slice(0, 10)}`,
|
|
76
|
+
payload: { task_id: t.id, title: t.title, since: t.updated_at || t.created_at },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A project nobody has touched.
|
|
83
|
+
*
|
|
84
|
+
* WHAT "ACTIVITY" MEANS HERE: the newest task or commitment event in the
|
|
85
|
+
* project's store. APX has no single per-project activity timestamp, and
|
|
86
|
+
* folding every conversation on a five-minute tick would defeat the point of a
|
|
87
|
+
* cheap detector. So this measures what it can actually see, and the phrasing
|
|
88
|
+
* says so — "no task or commitment activity", not "nothing happened". A caller
|
|
89
|
+
* with a better timestamp can pass `last_activity_at` and it wins.
|
|
90
|
+
*
|
|
91
|
+
* Claiming more than the data supports is the failure mode that kills trust in
|
|
92
|
+
* a watcher: one confident "you have abandoned this" about a project the user
|
|
93
|
+
* worked on all week, and they stop believing the rest.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately ONE signal per project rather than per silent item — the point
|
|
96
|
+
* is "you have forgotten about this", and saying it once is the whole message.
|
|
97
|
+
*
|
|
98
|
+
* A project with nothing recorded at all is skipped: a freshly registered
|
|
99
|
+
* project is not neglected, and greeting someone with "this is stale" the day
|
|
100
|
+
* they add it is exactly what gets a watcher muted.
|
|
101
|
+
*/
|
|
102
|
+
function detectStaleProject(project, { now, stale_project_days }) {
|
|
103
|
+
const last = project.last_activity_at || lastRecordedActivity(project.storagePath);
|
|
104
|
+
if (!last) return [];
|
|
105
|
+
const days = daysBetween(last.slice(0, 10), now.slice(0, 10));
|
|
106
|
+
if (days < stale_project_days) return [];
|
|
107
|
+
const measured = project.last_activity_at ? "activity" : "task or commitment activity";
|
|
108
|
+
return [
|
|
109
|
+
signal(project, {
|
|
110
|
+
type: "stale_project",
|
|
111
|
+
severity: days >= stale_project_days * 3 ? "normal" : "low",
|
|
112
|
+
subject: `no ${measured} on ${project.name || project.path || "this project"} for ${days} days`,
|
|
113
|
+
payload: { days, last_activity_at: last, measured },
|
|
114
|
+
}),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Newest task or commitment timestamp in a project store, or "" when empty. */
|
|
119
|
+
function lastRecordedActivity(storagePath) {
|
|
120
|
+
let newest = "";
|
|
121
|
+
const bump = (v) => { if (v && v > newest) newest = v; };
|
|
122
|
+
try {
|
|
123
|
+
for (const t of listTasks(storagePath, { state: "all" })) bump(t.updated_at || t.created_at);
|
|
124
|
+
} catch { /* unreadable → treated as no evidence, not as staleness */ }
|
|
125
|
+
try {
|
|
126
|
+
for (const c of listCommitments(storagePath, { state: "all" })) bump(c.updated_at || c.created_at);
|
|
127
|
+
} catch { /* same */ }
|
|
128
|
+
return newest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A promise coming due inside the lead window — still time to keep it. */
|
|
132
|
+
function detectCommitmentsDue(project, { now, commitment_lead_hours }) {
|
|
133
|
+
const horizon = new Date(Date.parse(now) + commitment_lead_hours * 3_600_000).toISOString();
|
|
134
|
+
return listCommitments(project.storagePath, { state: "open" })
|
|
135
|
+
.filter((c) => c.due && c.due >= now && c.due <= horizon)
|
|
136
|
+
.map((c) =>
|
|
137
|
+
signal(project, {
|
|
138
|
+
type: "commitment_due",
|
|
139
|
+
// Higher than an equivalent task on purpose: someone is waiting, and a
|
|
140
|
+
// warning that arrives in time is worth more than one that arrives after.
|
|
141
|
+
severity: "high",
|
|
142
|
+
subject: `you promised ${c.counterparty}: ${c.body} — due ${c.due.slice(0, 10)}`,
|
|
143
|
+
payload: { commitment_id: c.id, counterparty: c.counterparty, due: c.due, body: c.body },
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A promise already past its date and still open. The costliest thing here. */
|
|
149
|
+
function detectOverdueCommitments(project, { now }) {
|
|
150
|
+
return listCommitments(project.storagePath, { state: "open", overdue: true, now })
|
|
151
|
+
.map((c) =>
|
|
152
|
+
signal(project, {
|
|
153
|
+
type: "overdue_commitment",
|
|
154
|
+
severity: "critical",
|
|
155
|
+
subject: `you owe ${c.counterparty}: ${c.body} — was due ${c.due.slice(0, 10)}`,
|
|
156
|
+
payload: {
|
|
157
|
+
commitment_id: c.id, counterparty: c.counterparty, due: c.due, body: c.body,
|
|
158
|
+
days_late: daysBetween(c.due.slice(0, 10), now.slice(0, 10)),
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DETECTORS = {
|
|
165
|
+
overdue_task: detectOverdueTasks,
|
|
166
|
+
blocked_task: detectBlockedTasks,
|
|
167
|
+
stale_project: detectStaleProject,
|
|
168
|
+
commitment_due: detectCommitmentsDue,
|
|
169
|
+
overdue_commitment: detectOverdueCommitments,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// public API
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run the configured detectors over the given projects.
|
|
178
|
+
*
|
|
179
|
+
* @param {{id, name?, path?, storagePath, last_activity_at?}[]} projects
|
|
180
|
+
* @param {object} opts thresholds + `types` + `now` (ISO, injectable for tests)
|
|
181
|
+
* @returns {{ signals: object[], skipped: {id, error}[] }}
|
|
182
|
+
*/
|
|
183
|
+
export function detectSignals(projects, opts = {}) {
|
|
184
|
+
const cfg = { ...DEFAULT_THRESHOLDS, ...opts, now: opts.now || nowIso() };
|
|
185
|
+
const wanted = Array.isArray(cfg.types) && cfg.types.length
|
|
186
|
+
? cfg.types.filter((t) => t in DETECTORS)
|
|
187
|
+
: SIGNAL_TYPES;
|
|
188
|
+
|
|
189
|
+
const signals = [];
|
|
190
|
+
const skipped = [];
|
|
191
|
+
|
|
192
|
+
for (const project of projects || []) {
|
|
193
|
+
if (!project?.storagePath) continue;
|
|
194
|
+
for (const type of wanted) {
|
|
195
|
+
try {
|
|
196
|
+
signals.push(...DETECTORS[type](project, cfg));
|
|
197
|
+
} catch (e) {
|
|
198
|
+
// One unreadable log must not blank the whole sweep — and must not be
|
|
199
|
+
// silent either, or the watcher reports "all clear" when it is blind.
|
|
200
|
+
skipped.push({ id: project.id, type, error: e?.message || String(e) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
signals.sort(bySeverityThenDate);
|
|
206
|
+
return { signals, skipped };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const SEVERITY_RANK = { critical: 0, high: 1, normal: 2, low: 3 };
|
|
210
|
+
|
|
211
|
+
function bySeverityThenDate(a, b) {
|
|
212
|
+
const s = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
|
|
213
|
+
if (s !== 0) return s;
|
|
214
|
+
return String(a.subject).localeCompare(String(b.subject));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Render signals as the block a routine hands the model.
|
|
219
|
+
*
|
|
220
|
+
* Pre-rendered rather than passed as JSON because the model's job here is
|
|
221
|
+
* judgement — "is any of this worth interrupting for?" — not parsing.
|
|
222
|
+
*/
|
|
223
|
+
export function formatSignals(signals) {
|
|
224
|
+
if (!signals?.length) return "";
|
|
225
|
+
const lines = signals.map(
|
|
226
|
+
(s) => `- [${s.severity}] ${s.project_name ? `${s.project_name}: ` : ""}${s.subject}`,
|
|
227
|
+
);
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The highest severity present, for the interruption budget. */
|
|
232
|
+
export function peakSeverity(signals) {
|
|
233
|
+
let best = "low";
|
|
234
|
+
for (const s of signals || []) {
|
|
235
|
+
if ((SEVERITY_RANK[s.severity] ?? 9) < (SEVERITY_RANK[best] ?? 9)) best = s.severity;
|
|
236
|
+
}
|
|
237
|
+
return signals?.length ? best : "low";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Thresholds from a profile's config, falling back to the defaults. */
|
|
241
|
+
export function thresholdsFromConfig(profileConfig = {}) {
|
|
242
|
+
const pick = (key, fallback) => {
|
|
243
|
+
const v = Number.parseInt(profileConfig[key], 10);
|
|
244
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
blocked_hours: pick("blocked_task_hours", DEFAULT_THRESHOLDS.blocked_hours),
|
|
248
|
+
stale_project_days: pick("stale_project_days", DEFAULT_THRESHOLDS.stale_project_days),
|
|
249
|
+
commitment_lead_hours: pick("commitment_lead_hours", DEFAULT_THRESHOLDS.commitment_lead_hours),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function signal(project, fields) {
|
|
256
|
+
return {
|
|
257
|
+
project_id: project.id ?? null,
|
|
258
|
+
project_name: project.name || project.path || "",
|
|
259
|
+
detected_at: nowIso(),
|
|
260
|
+
...fields,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Whole days between two YYYY-MM-DD strings. Negative when `to` precedes `from`. */
|
|
265
|
+
function daysBetween(from, to) {
|
|
266
|
+
const a = Date.parse(`${String(from).slice(0, 10)}T00:00:00Z`);
|
|
267
|
+
const b = Date.parse(`${String(to).slice(0, 10)}T00:00:00Z`);
|
|
268
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
|
|
269
|
+
return Math.round((b - a) / 86_400_000);
|
|
270
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Commitments per project.
|
|
2
|
+
//
|
|
3
|
+
// Append-only JSONL event log, one file per month under
|
|
4
|
+
// ~/.apx/projects/<apxId>/commitments/YYYY-MM.jsonl
|
|
5
|
+
//
|
|
6
|
+
// A TASK is something to do. A COMMITMENT is something PROMISED TO A PERSON:
|
|
7
|
+
// it has a counterparty, a date you gave them, and the channel you said it on.
|
|
8
|
+
// Breaking one costs trust, not just throughput, and that is why it is a type
|
|
9
|
+
// and not a tag.
|
|
10
|
+
//
|
|
11
|
+
// The tag version is tempting and wrong. The day you want "everything I owe
|
|
12
|
+
// Ana" you would be substring-matching your way through task titles, and
|
|
13
|
+
// "kept" versus "renegotiated" — the distinction that tells you whether a
|
|
14
|
+
// relationship is fine — has nowhere to live at all.
|
|
15
|
+
//
|
|
16
|
+
// Events:
|
|
17
|
+
// create — the promise (counterparty, body, due, origin_channel, …)
|
|
18
|
+
// update — shallow-merge patch
|
|
19
|
+
// kept — delivered
|
|
20
|
+
// missed — the date passed without delivery. Recorded, not hidden:
|
|
21
|
+
// a system that quietly drops what you failed to do cannot
|
|
22
|
+
// tell you that you keep failing the same person.
|
|
23
|
+
// renegotiate — a NEW date, agreed with them. Distinct from missing:
|
|
24
|
+
// moving a date with someone is not the same as letting it
|
|
25
|
+
// slide, and the history keeps both.
|
|
26
|
+
//
|
|
27
|
+
// State: "open" → "kept" | "missed" | "renegotiated" → (renegotiated reopens
|
|
28
|
+
// as "open" with a new due, keeping the previous date in `history`).
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { nowIso } from "../util/time.js";
|
|
32
|
+
import { shortId as makeShortId } from "../util/ids.js";
|
|
33
|
+
|
|
34
|
+
export const COMMITMENT_STATES = Object.freeze(["open", "kept", "missed", "renegotiated"]);
|
|
35
|
+
|
|
36
|
+
function commitmentsDir(storagePath) {
|
|
37
|
+
return path.join(storagePath, "commitments");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function monthlyFile(storagePath, date = new Date()) {
|
|
41
|
+
const ym = date.toISOString().slice(0, 7); // YYYY-MM
|
|
42
|
+
return path.join(commitmentsDir(storagePath), `${ym}.jsonl`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function shortId() {
|
|
46
|
+
return makeShortId("c");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendEvent(storagePath, event) {
|
|
50
|
+
const file = monthlyFile(storagePath);
|
|
51
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
+
fs.appendFileSync(file, JSON.stringify(event) + "\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readAllEvents(storagePath) {
|
|
56
|
+
const dir = commitmentsDir(storagePath);
|
|
57
|
+
if (!fs.existsSync(dir)) return [];
|
|
58
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
59
|
+
const events = [];
|
|
60
|
+
for (const f of files) {
|
|
61
|
+
const text = fs.readFileSync(path.join(dir, f), "utf8");
|
|
62
|
+
for (const line of text.split("\n")) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
try {
|
|
65
|
+
const ev = JSON.parse(line);
|
|
66
|
+
if (ev && ev.id && ev.op) events.push(ev);
|
|
67
|
+
} catch {
|
|
68
|
+
// One bad write must not blank the projection.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
events.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""));
|
|
73
|
+
return events;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectState(events) {
|
|
77
|
+
const rows = new Map();
|
|
78
|
+
for (const ev of events) {
|
|
79
|
+
const existing = rows.get(ev.id);
|
|
80
|
+
switch (ev.op) {
|
|
81
|
+
case "create": {
|
|
82
|
+
if (existing) break; // duplicate create — keep the first
|
|
83
|
+
rows.set(ev.id, {
|
|
84
|
+
id: ev.id,
|
|
85
|
+
created_at: ev.ts,
|
|
86
|
+
updated_at: ev.ts,
|
|
87
|
+
state: "open",
|
|
88
|
+
counterparty: ev.counterparty || "",
|
|
89
|
+
body: ev.body || "",
|
|
90
|
+
promised_at: ev.promised_at || ev.ts,
|
|
91
|
+
due: ev.due || null,
|
|
92
|
+
origin_channel: ev.origin_channel || null,
|
|
93
|
+
origin_message_ref: ev.origin_message_ref || null,
|
|
94
|
+
created_by: ev.created_by || null,
|
|
95
|
+
// Every date this promise has ever had. Renegotiating twice is a
|
|
96
|
+
// fact about the relationship, and it is only visible if kept.
|
|
97
|
+
history: [],
|
|
98
|
+
meta: ev.meta && typeof ev.meta === "object" ? { ...ev.meta } : {},
|
|
99
|
+
});
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "update": {
|
|
103
|
+
if (!existing) break;
|
|
104
|
+
const patch = ev.patch && typeof ev.patch === "object" ? ev.patch : {};
|
|
105
|
+
for (const k of Object.keys(patch)) {
|
|
106
|
+
if (k === "id" || k === "state" || k === "created_at" || k === "history") continue;
|
|
107
|
+
existing[k] = patch[k];
|
|
108
|
+
}
|
|
109
|
+
existing.updated_at = ev.ts;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "kept": {
|
|
113
|
+
if (!existing) break;
|
|
114
|
+
existing.state = "kept";
|
|
115
|
+
existing.closed_at = ev.ts;
|
|
116
|
+
existing.note = ev.note || existing.note || null;
|
|
117
|
+
existing.updated_at = ev.ts;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "missed": {
|
|
121
|
+
if (!existing) break;
|
|
122
|
+
existing.state = "missed";
|
|
123
|
+
existing.closed_at = ev.ts;
|
|
124
|
+
existing.note = ev.note || existing.note || null;
|
|
125
|
+
existing.updated_at = ev.ts;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "renegotiate": {
|
|
129
|
+
if (!existing) break;
|
|
130
|
+
existing.history.push({
|
|
131
|
+
due: existing.due,
|
|
132
|
+
moved_at: ev.ts,
|
|
133
|
+
note: ev.note || null,
|
|
134
|
+
});
|
|
135
|
+
existing.due = ev.due || existing.due;
|
|
136
|
+
// Back to open: a renegotiated promise is a live promise with a new
|
|
137
|
+
// date, not a closed one. "renegotiated" as a resting state would hide
|
|
138
|
+
// it from every "what do I owe people" view.
|
|
139
|
+
existing.state = "open";
|
|
140
|
+
existing.renegotiated_count = (existing.renegotiated_count || 0) + 1;
|
|
141
|
+
existing.updated_at = ev.ts;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
default:
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rows;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
// Public API
|
|
153
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Record a promise.
|
|
157
|
+
* fields: { counterparty (required), body (required), due?, promised_at?,
|
|
158
|
+
* origin_channel?, origin_message_ref?, created_by?, meta? }
|
|
159
|
+
*/
|
|
160
|
+
export function createCommitment(storagePath, fields) {
|
|
161
|
+
if (!fields || typeof fields !== "object") throw new Error("createCommitment: fields required");
|
|
162
|
+
if (!fields.counterparty || typeof fields.counterparty !== "string") {
|
|
163
|
+
// The counterparty IS the type. Without it this is a task.
|
|
164
|
+
throw new Error("createCommitment: counterparty required");
|
|
165
|
+
}
|
|
166
|
+
if (!fields.body || typeof fields.body !== "string") {
|
|
167
|
+
throw new Error("createCommitment: body required");
|
|
168
|
+
}
|
|
169
|
+
const id = shortId();
|
|
170
|
+
appendEvent(storagePath, {
|
|
171
|
+
id,
|
|
172
|
+
ts: nowIso(),
|
|
173
|
+
op: "create",
|
|
174
|
+
counterparty: fields.counterparty.trim(),
|
|
175
|
+
body: fields.body.trim(),
|
|
176
|
+
promised_at: fields.promised_at || nowIso(),
|
|
177
|
+
due: fields.due || null,
|
|
178
|
+
origin_channel: fields.origin_channel || null,
|
|
179
|
+
origin_message_ref: fields.origin_message_ref || null,
|
|
180
|
+
created_by: fields.created_by || null,
|
|
181
|
+
meta: fields.meta && typeof fields.meta === "object" ? fields.meta : {},
|
|
182
|
+
});
|
|
183
|
+
return getCommitment(storagePath, id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Newest first, id as tiebreak — same reasoning as tasks.js byNewest. */
|
|
187
|
+
function byNewest(a, b) {
|
|
188
|
+
const t = (b.created_at || "").localeCompare(a.created_at || "");
|
|
189
|
+
return t !== 0 ? t : String(b.id || "").localeCompare(String(a.id || ""));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Soonest deadline first; undated last. The order the anchors want. */
|
|
193
|
+
function byDue(a, b) {
|
|
194
|
+
if (!a.due && !b.due) return byNewest(a, b);
|
|
195
|
+
if (!a.due) return 1;
|
|
196
|
+
if (!b.due) return -1;
|
|
197
|
+
const d = a.due.localeCompare(b.due);
|
|
198
|
+
return d !== 0 ? d : byNewest(a, b);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* List commitments.
|
|
203
|
+
*
|
|
204
|
+
* opts: { state, counterparty, due_before, due_after, overdue, updated_since,
|
|
205
|
+
* sort: "due"|"newest", limit }
|
|
206
|
+
* Default state is "open" — the useful question is what you still owe.
|
|
207
|
+
*/
|
|
208
|
+
export function listCommitments(storagePath, opts = {}) {
|
|
209
|
+
let out = [...projectState(readAllEvents(storagePath)).values()];
|
|
210
|
+
|
|
211
|
+
if (opts.state && opts.state !== "all") {
|
|
212
|
+
out = out.filter((c) => c.state === opts.state);
|
|
213
|
+
} else if (!opts.state) {
|
|
214
|
+
out = out.filter((c) => c.state === "open");
|
|
215
|
+
}
|
|
216
|
+
if (opts.counterparty) {
|
|
217
|
+
// Case-insensitive substring: counterparty is free text, not a CRM key,
|
|
218
|
+
// so "ana" must find "Ana Pérez" or the field is unusable.
|
|
219
|
+
const needle = String(opts.counterparty).toLowerCase();
|
|
220
|
+
out = out.filter((c) => String(c.counterparty).toLowerCase().includes(needle));
|
|
221
|
+
}
|
|
222
|
+
if (opts.due_before) out = out.filter((c) => c.due && c.due <= opts.due_before);
|
|
223
|
+
if (opts.due_after) out = out.filter((c) => c.due && c.due >= opts.due_after);
|
|
224
|
+
if (opts.overdue) {
|
|
225
|
+
const now = opts.now || nowIso();
|
|
226
|
+
out = out.filter((c) => c.state === "open" && c.due && c.due < now);
|
|
227
|
+
}
|
|
228
|
+
if (opts.updated_since) {
|
|
229
|
+
out = out.filter((c) => (c.updated_at || c.created_at || "") >= opts.updated_since);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
out.sort(opts.sort === "newest" ? byNewest : byDue);
|
|
233
|
+
if (opts.limit && Number.isFinite(opts.limit)) out = out.slice(0, opts.limit);
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The same query folded across registered projects. Mirrors
|
|
239
|
+
* listTasksAcrossProjects — a chief of staff lives in the cross-project layer,
|
|
240
|
+
* and a promise made in a meeting rarely knows which repo it belongs to.
|
|
241
|
+
*
|
|
242
|
+
* A project whose log is unreadable is SKIPPED, not fatal.
|
|
243
|
+
*/
|
|
244
|
+
export function listCommitmentsAcrossProjects(projects, opts = {}) {
|
|
245
|
+
const { limit, ...perProject } = opts || {};
|
|
246
|
+
const commitments = [];
|
|
247
|
+
const skipped = [];
|
|
248
|
+
|
|
249
|
+
for (const entry of projects || []) {
|
|
250
|
+
if (!entry?.storagePath) continue;
|
|
251
|
+
try {
|
|
252
|
+
for (const c of listCommitments(entry.storagePath, perProject)) {
|
|
253
|
+
commitments.push({
|
|
254
|
+
...c,
|
|
255
|
+
project_id: entry.id,
|
|
256
|
+
project_name: entry.name || entry.path || String(entry.id),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
skipped.push({ id: entry.id, error: e?.message || String(e) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
commitments.sort(opts.sort === "newest" ? byNewest : byDue);
|
|
265
|
+
return {
|
|
266
|
+
commitments: Number.isFinite(limit) && limit > 0 ? commitments.slice(0, limit) : commitments,
|
|
267
|
+
skipped,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Get one by id or unique id prefix (≥ 3 chars). */
|
|
272
|
+
export function getCommitment(storagePath, idOrPrefix) {
|
|
273
|
+
if (!idOrPrefix || typeof idOrPrefix !== "string") return null;
|
|
274
|
+
const rows = projectState(readAllEvents(storagePath));
|
|
275
|
+
if (rows.has(idOrPrefix)) return rows.get(idOrPrefix);
|
|
276
|
+
if (idOrPrefix.length < 3) return null;
|
|
277
|
+
const matches = [...rows.values()].filter((c) => c.id.startsWith(idOrPrefix));
|
|
278
|
+
return matches.length === 1 ? matches[0] : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function patchCommitment(storagePath, idOrPrefix, patch) {
|
|
282
|
+
const existing = getCommitment(storagePath, idOrPrefix);
|
|
283
|
+
if (!existing) return null;
|
|
284
|
+
if (!patch || typeof patch !== "object") return existing;
|
|
285
|
+
appendEvent(storagePath, { id: existing.id, ts: nowIso(), op: "update", patch });
|
|
286
|
+
return getCommitment(storagePath, existing.id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Delivered. */
|
|
290
|
+
export function keepCommitment(storagePath, idOrPrefix, note = null) {
|
|
291
|
+
return close(storagePath, idOrPrefix, "kept", note);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The date passed and it did not happen. */
|
|
295
|
+
export function missCommitment(storagePath, idOrPrefix, note = null) {
|
|
296
|
+
return close(storagePath, idOrPrefix, "missed", note);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function close(storagePath, idOrPrefix, op, note) {
|
|
300
|
+
const existing = getCommitment(storagePath, idOrPrefix);
|
|
301
|
+
if (!existing) return null;
|
|
302
|
+
appendEvent(storagePath, { id: existing.id, ts: nowIso(), op, note: note || null });
|
|
303
|
+
return getCommitment(storagePath, existing.id);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A new date, agreed with them. Requires the new date: "renegotiated, no idea
|
|
308
|
+
* until when" is how a promise disappears.
|
|
309
|
+
*/
|
|
310
|
+
export function renegotiateCommitment(storagePath, idOrPrefix, due, note = null) {
|
|
311
|
+
if (!due) throw new Error("renegotiateCommitment: a new due date is required");
|
|
312
|
+
const existing = getCommitment(storagePath, idOrPrefix);
|
|
313
|
+
if (!existing) return null;
|
|
314
|
+
appendEvent(storagePath, {
|
|
315
|
+
id: existing.id, ts: nowIso(), op: "renegotiate", due, note: note || null,
|
|
316
|
+
});
|
|
317
|
+
return getCommitment(storagePath, existing.id);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Counts for status displays and anchors. */
|
|
321
|
+
export function countCommitments(storagePath, now = nowIso()) {
|
|
322
|
+
const rows = [...projectState(readAllEvents(storagePath)).values()];
|
|
323
|
+
const open = rows.filter((c) => c.state === "open");
|
|
324
|
+
return {
|
|
325
|
+
open: open.length,
|
|
326
|
+
kept: rows.filter((c) => c.state === "kept").length,
|
|
327
|
+
missed: rows.filter((c) => c.state === "missed").length,
|
|
328
|
+
overdue: open.filter((c) => c.due && c.due < now).length,
|
|
329
|
+
total: rows.length,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -684,6 +684,10 @@ export function readGlobalThread({ channel, date, _globalMessagesDir } = {}) {
|
|
|
684
684
|
...(r.actor_kind ? { actor_kind: r.actor_kind } : {}),
|
|
685
685
|
...(r.meta?.model ? { model: r.meta.model } : {}),
|
|
686
686
|
...(usage && typeof usage === "object" ? { usage } : {}),
|
|
687
|
+
// What the turn actually did. Recorded compactly at write time
|
|
688
|
+
// (core/agent/tool-summary.js) because the live tool events are gone
|
|
689
|
+
// by the time anyone reads the thread back.
|
|
690
|
+
...(r.meta?.tool_summary ? { tool_summary: r.meta.tool_summary } : {}),
|
|
687
691
|
};
|
|
688
692
|
});
|
|
689
693
|
return { id: date, channel, messages };
|
|
@@ -27,6 +27,17 @@ function writeFile(projectPath, routines) {
|
|
|
27
27
|
|
|
28
28
|
export function parseSchedule(s, baseMs = Date.now()) {
|
|
29
29
|
if (!s || typeof s !== "string") return { kind: "invalid" };
|
|
30
|
+
|
|
31
|
+
// "manual" — runs only when someone runs it. It USED to work by accident:
|
|
32
|
+
// the string failed cron parsing, came back "invalid", and an invalid
|
|
33
|
+
// schedule never becomes due. Correct outcome, wrong reason, and it meant
|
|
34
|
+
// every surface reported a deliberate choice as a broken expression.
|
|
35
|
+
if (s.trim().toLowerCase() === "manual") return { kind: "manual" };
|
|
36
|
+
|
|
37
|
+
// Tolerate a leading "cron " label. The web editor's own presets wrote
|
|
38
|
+
// `cron 0 9 * * *`, which cron-parser rejects — so picking "daily at 9am"
|
|
39
|
+
// in the panel produced a routine that never ran, with nothing to show why.
|
|
40
|
+
const labelled = s.trim().replace(/^cron\s+/i, "");
|
|
30
41
|
|
|
31
42
|
if (s.startsWith("every:")) {
|
|
32
43
|
const spec = s.slice(6).trim();
|
|
@@ -46,7 +57,7 @@ export function parseSchedule(s, baseMs = Date.now()) {
|
|
|
46
57
|
|
|
47
58
|
// Fallback: Try parsing as standard cron expression using cron-parser
|
|
48
59
|
try {
|
|
49
|
-
const interval = CronExpressionParser.parse(
|
|
60
|
+
const interval = CronExpressionParser.parse(labelled, { currentDate: new Date(baseMs) });
|
|
50
61
|
return { kind: "cron", parser: interval };
|
|
51
62
|
} catch (err) {
|
|
52
63
|
return { kind: "invalid" };
|
|
@@ -55,7 +66,7 @@ export function parseSchedule(s, baseMs = Date.now()) {
|
|
|
55
66
|
|
|
56
67
|
export function computeNextRun(routine, baseMs = Date.now()) {
|
|
57
68
|
const sched = parseSchedule(routine.schedule, baseMs);
|
|
58
|
-
if (sched.kind === "invalid") return null;
|
|
69
|
+
if (sched.kind === "invalid" || sched.kind === "manual") return null;
|
|
59
70
|
if (sched.kind === "once") {
|
|
60
71
|
return sched.atMs > baseMs
|
|
61
72
|
? new Date(sched.atMs).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
@@ -227,7 +238,10 @@ export function getDueRoutines(projectPath, nowStr) {
|
|
|
227
238
|
// CRITICAL: If the schedule cannot be parsed, NEVER run it.
|
|
228
239
|
// Otherwise, an invalid schedule (like a cron string) sets next_run_at to null,
|
|
229
240
|
// which previously caused it to be considered ALWAYS due and spam execution every 5 seconds!
|
|
230
|
-
|
|
241
|
+
const kind = parseSchedule(r.schedule).kind;
|
|
242
|
+
// "manual" is a deliberate never-on-a-clock, not a broken expression —
|
|
243
|
+
// both are skipped here, but only one of them is a problem to report.
|
|
244
|
+
if (kind === "invalid" || kind === "manual") return false;
|
|
231
245
|
return (!r.next_run_at || r.next_run_at <= nowStr);
|
|
232
246
|
});
|
|
233
247
|
}
|