@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.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +30 -5
  3. package/src/core/agent/tool-summary.js +65 -0
  4. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  5. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  6. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  7. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  8. package/src/core/agent/tools/names.js +6 -0
  9. package/src/core/agent/tools/registry.js +9 -0
  10. package/src/core/agent/tools/tool-call-parser.js +70 -1
  11. package/src/core/channels/telegram/ask-callbacks.js +35 -0
  12. package/src/core/channels/telegram/dispatch.js +3 -0
  13. package/src/core/channels/telegram/reply.js +30 -5
  14. package/src/core/config/paths.js +3 -0
  15. package/src/core/config/redact.js +22 -0
  16. package/src/core/daemon/service.js +238 -0
  17. package/src/core/engines/gemini.js +322 -60
  18. package/src/core/engines/openai-compatible.js +21 -2
  19. package/src/core/memory/consolidate.js +225 -0
  20. package/src/core/nudge/index.js +192 -0
  21. package/src/core/nudge/policy.js +143 -0
  22. package/src/core/nudge/store.js +141 -0
  23. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  24. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  25. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  26. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  27. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  28. package/src/core/routines/runner.js +102 -3
  29. package/src/core/routines/signals.js +270 -0
  30. package/src/core/stores/commitments.js +331 -0
  31. package/src/core/stores/messages.js +4 -0
  32. package/src/core/stores/routines.js +17 -3
  33. package/src/core/util/thinking.js +51 -0
  34. package/src/host/daemon/api/commitments.js +135 -0
  35. package/src/host/daemon/api/nudges.js +112 -0
  36. package/src/host/daemon/api/routines.js +24 -0
  37. package/src/host/daemon/api/self-memory.js +50 -0
  38. package/src/host/daemon/api/telegram.js +42 -4
  39. package/src/host/daemon/api/voice.js +3 -1
  40. package/src/host/daemon/api.js +6 -0
  41. package/src/host/daemon/callback-reconciler.js +16 -0
  42. package/src/host/daemon/plugins/desktop/index.js +7 -1
  43. package/src/host/daemon/plugins/telegram/index.js +7 -2
  44. package/src/host/daemon/wakeup.js +17 -3
  45. package/src/interfaces/cli/commands/commitment.js +154 -0
  46. package/src/interfaces/cli/commands/daemon.js +57 -0
  47. package/src/interfaces/cli/commands/memory.js +73 -0
  48. package/src/interfaces/cli/commands/nudge.js +130 -0
  49. package/src/interfaces/cli/help/index.js +2 -2
  50. package/src/interfaces/cli/routes/commitment.js +19 -0
  51. package/src/interfaces/cli/routes/daemon.js +7 -1
  52. package/src/interfaces/cli/routes/index.js +4 -0
  53. package/src/interfaces/cli/routes/memory.js +10 -2
  54. package/src/interfaces/cli/routes/nudge.js +17 -0
  55. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  56. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  57. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  58. package/src/interfaces/web/dist/index.html +2 -2
  59. package/src/interfaces/web/package-lock.json +11 -10
  60. package/src/interfaces/web/src/components/Section.tsx +18 -3
  61. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  62. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  63. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  64. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  65. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  66. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  67. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  68. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  69. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  70. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  71. package/src/interfaces/web/src/components/ui.tsx +1 -0
  72. package/src/interfaces/web/src/constants/index.ts +1 -0
  73. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  74. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  75. package/src/interfaces/web/src/i18n/en.ts +127 -0
  76. package/src/interfaces/web/src/i18n/es.ts +127 -0
  77. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  78. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  79. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  80. package/src/interfaces/web/src/lib/cron.ts +196 -0
  81. package/src/interfaces/web/src/lib/when.ts +32 -0
  82. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  83. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  84. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  85. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  86. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  87. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  88. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  89. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  90. package/src/interfaces/web/src/types/daemon.ts +10 -1
  91. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  92. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  93. 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(s, { currentDate: new Date(baseMs) });
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
- if (parseSchedule(r.schedule).kind === "invalid") return false;
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
  }