@inetafrica/open-claudia 3.0.25 → 3.0.27
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/CHANGELOG.md +16 -0
- package/bin/agency-ledger.js +91 -0
- package/bin/cli.js +15 -0
- package/bin/prompt-budget.js +49 -0
- package/bin/recall.js +6 -0
- package/channels/spaces/adapter.js +33 -8
- package/channels/telegram/adapter.js +26 -3
- package/core/actions.js +25 -0
- package/core/agency/deliberate.js +332 -0
- package/core/agency/ledger.js +294 -0
- package/core/connected-apps.js +15 -0
- package/core/guardrail.js +251 -0
- package/core/handlers.js +41 -1
- package/core/prompt-budget.js +165 -0
- package/core/runner.js +26 -2
- package/core/state.js +16 -0
- package/core/system-prompt.js +29 -0
- package/package.json +6 -2
- package/test-agency-deliberate.js +119 -0
- package/test-agency-ledger.js +114 -0
- package/test-guardrail.js +126 -0
- package/test-prompt-budget.js +78 -0
- package/test-provider-language.js +12 -9
- package/test-telegram-poll-recovery.js +81 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Agency deliberation — advisory "what should I do next?" (Track B1).
|
|
2
|
+
//
|
|
3
|
+
// Consumes the read-only ledger (core/agency/ledger) and PROPOSES a single best
|
|
4
|
+
// next action with a transparent reason. It acts on nothing — B1 is advisory
|
|
5
|
+
// only; the act/schedule loop is B2+ (gated behind a shadow-mode review).
|
|
6
|
+
//
|
|
7
|
+
// Pipeline: triage (deterministic prefilter cuts the backlog to what could
|
|
8
|
+
// matter now) → rank (explainable algorithmic score) → optional judge (a cheap
|
|
9
|
+
// model disambiguates a close top-N and refines the action type). Default-
|
|
10
|
+
// silence: if nothing clears the confidence floor, it proposes nothing.
|
|
11
|
+
//
|
|
12
|
+
// The judge is injectable and OFF by default, so deliberation is deterministic,
|
|
13
|
+
// network-free, and safe to run repeatedly in shadow mode. When enabled it can
|
|
14
|
+
// never break the proposal — any judge failure falls back to the deterministic
|
|
15
|
+
// pick.
|
|
16
|
+
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const configDir = require(path.join(__dirname, "..", "..", "config-dir"));
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 86400000;
|
|
22
|
+
const ACTIONS = ["act", "schedule", "ask", "park"];
|
|
23
|
+
|
|
24
|
+
// Tunables — deliberately conservative so shadow mode stays quiet unless
|
|
25
|
+
// something genuinely warrants attention.
|
|
26
|
+
const CONFIDENCE_FLOOR = 25; // below this top score → propose nothing
|
|
27
|
+
const CLEAR_MARGIN = 20; // top beats second by this → skip the judge
|
|
28
|
+
const DUE_SOON_DAYS = 3;
|
|
29
|
+
const CANDIDATE_STALE_DAYS = 30;
|
|
30
|
+
const TOP_N = 5;
|
|
31
|
+
|
|
32
|
+
// --- shadow log (mirrors core/prompt-budget storage pattern) ------------
|
|
33
|
+
const LOG_FILE = path.join(configDir, "agency-deliberation.jsonl");
|
|
34
|
+
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
function shadowEnabled() {
|
|
37
|
+
const v = String(process.env.AGENCY_SHADOW || "on").toLowerCase();
|
|
38
|
+
return v !== "off" && v !== "0" && v !== "false";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toMs(v) {
|
|
42
|
+
if (!v) return 0;
|
|
43
|
+
if (typeof v === "number") return v;
|
|
44
|
+
const t = Date.parse(v);
|
|
45
|
+
return Number.isFinite(t) ? t : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function daysUntil(due, now) {
|
|
49
|
+
const dueMs = toMs(due);
|
|
50
|
+
if (!dueMs) return null;
|
|
51
|
+
return (dueMs - now) / DAY_MS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- triage: cut the backlog to what could plausibly matter now ---------
|
|
55
|
+
// Notifications are awareness, not work to "pick" — excluded from candidates.
|
|
56
|
+
// An item survives triage if it is overdue, due soon, already in progress,
|
|
57
|
+
// high priority, or a fresh owner assignment. Everything else is background
|
|
58
|
+
// (parked by default) so a 300-item list collapses to a handful.
|
|
59
|
+
function isCandidate(e, now) {
|
|
60
|
+
if (e.source === "notification") return false;
|
|
61
|
+
if (e.status === "done") return false;
|
|
62
|
+
if (e.overdue) return true;
|
|
63
|
+
const d = daysUntil(e.due, now);
|
|
64
|
+
if (d != null && d <= DUE_SOON_DAYS) return true;
|
|
65
|
+
if (e.status === "in_progress") return true;
|
|
66
|
+
if (e.priority === "high") return true;
|
|
67
|
+
if (e.source === "spaces" && e.staleDays != null && e.staleDays <= CANDIDATE_STALE_DAYS) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- rank: explainable score + the signals that fired -------------------
|
|
72
|
+
function scoreOf(e, now) {
|
|
73
|
+
const signals = [];
|
|
74
|
+
let score = 0;
|
|
75
|
+
|
|
76
|
+
if (e.overdue) {
|
|
77
|
+
const over = Math.min(Math.max(0, -(daysUntil(e.due, now) || 0)), 30);
|
|
78
|
+
score += 100 + over * 2;
|
|
79
|
+
signals.push({ k: "overdue", w: 100 + over * 2, label: `overdue ${Math.round(over)}d` });
|
|
80
|
+
} else {
|
|
81
|
+
const d = daysUntil(e.due, now);
|
|
82
|
+
if (d != null && d <= DUE_SOON_DAYS) {
|
|
83
|
+
const w = 60 - Math.max(0, d) * 12;
|
|
84
|
+
score += w;
|
|
85
|
+
signals.push({ k: "due-soon", w, label: d < 1 ? "due today" : `due in ${Math.round(d)}d` });
|
|
86
|
+
} else if (d != null) {
|
|
87
|
+
const w = Math.max(4, 20 - Math.min(d, 30));
|
|
88
|
+
score += w;
|
|
89
|
+
signals.push({ k: "has-due", w, label: `due in ${Math.round(d)}d` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (e.status === "in_progress") {
|
|
94
|
+
score += 30;
|
|
95
|
+
signals.push({ k: "in-progress", w: 30, label: "already in progress" });
|
|
96
|
+
}
|
|
97
|
+
if (e.priority === "high") {
|
|
98
|
+
score += 25;
|
|
99
|
+
signals.push({ k: "priority", w: 25, label: "high priority" });
|
|
100
|
+
} else if (e.priority === "medium") {
|
|
101
|
+
score += 8;
|
|
102
|
+
signals.push({ k: "priority", w: 8, label: "medium priority" });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Unblocks others: a plan/task with open subtasks gates that downstream work.
|
|
106
|
+
const openSubs = (e.meta && typeof e.meta.subtasks === "number")
|
|
107
|
+
? Math.max(0, e.meta.subtasks - (e.meta.subtasksDone || 0)) : 0;
|
|
108
|
+
if (openSubs > 0) {
|
|
109
|
+
score += 15;
|
|
110
|
+
signals.push({ k: "unblocks", w: 15, label: `unblocks ${openSubs} subtask${openSubs > 1 ? "s" : ""}` });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Staleness: mild nudge for neglected-but-active work; mild penalty for very
|
|
114
|
+
// cold items so abandoned cruft doesn't float up on staleness alone.
|
|
115
|
+
if (e.staleDays != null) {
|
|
116
|
+
if (e.staleDays > CANDIDATE_STALE_DAYS && !e.overdue) {
|
|
117
|
+
score -= 10;
|
|
118
|
+
signals.push({ k: "cold", w: -10, label: `untouched ${Math.round(e.staleDays)}d` });
|
|
119
|
+
} else if (e.staleDays > 7 && (e.status === "in_progress" || e.overdue)) {
|
|
120
|
+
score += 6;
|
|
121
|
+
signals.push({ k: "neglected", w: 6, label: `neglected ${Math.round(e.staleDays)}d` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (e.meta && e.meta.space) signals.push({ k: "context", w: 0, label: e.meta.space });
|
|
126
|
+
|
|
127
|
+
signals.sort((a, b) => Math.abs(b.w) - Math.abs(a.w));
|
|
128
|
+
return { score, signals };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Deterministic action-type guess (the judge can override when enabled).
|
|
132
|
+
function guessAction(e, now) {
|
|
133
|
+
if (e.source === "spaces") {
|
|
134
|
+
// Assigned work: if it's overdue/soon it likely needs a nudge to the
|
|
135
|
+
// assignee/owner; otherwise it's work to do.
|
|
136
|
+
if (e.overdue || (daysUntil(e.due, now) ?? 99) <= DUE_SOON_DAYS) return "ask";
|
|
137
|
+
return "act";
|
|
138
|
+
}
|
|
139
|
+
if (e.status === "in_progress") return "act";
|
|
140
|
+
const d = daysUntil(e.due, now);
|
|
141
|
+
if (d != null && d > DUE_SOON_DAYS) return "schedule";
|
|
142
|
+
return "act";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- default judge: cheap model, injectable, never fatal ----------------
|
|
146
|
+
// Uses the low tier so it maps to Haiku (or the provider's cheapest). purpose
|
|
147
|
+
// "recall" is reused only because it is the established cheap per-turn judgment
|
|
148
|
+
// lane; spawnUtilityAgent does not log recall metrics, so nothing is polluted.
|
|
149
|
+
async function defaultJudge(top, ctx) {
|
|
150
|
+
let spawnUtilityAgent;
|
|
151
|
+
try { ({ spawnUtilityAgent } = require("../utility-agent")); }
|
|
152
|
+
catch (e) { return null; }
|
|
153
|
+
|
|
154
|
+
const lines = top.map((c, i) =>
|
|
155
|
+
`${i + 1}. [${c.id}] (${c.source}) ${c.title} — signals: ${c.signals.map((s) => s.label).join(", ") || "none"}`,
|
|
156
|
+
).join("\n");
|
|
157
|
+
const prompt = [
|
|
158
|
+
"You are triaging the assistant owner's work queue. Pick the SINGLE item to do next.",
|
|
159
|
+
"Prefer items that are overdue, unblock others, or are already in progress. Be decisive.",
|
|
160
|
+
"",
|
|
161
|
+
"Candidates:",
|
|
162
|
+
lines,
|
|
163
|
+
"",
|
|
164
|
+
'Reply with JSON only: {"pickId": "<id>", "actionType": "act|schedule|ask|park", "why": "<one short sentence>", "confidence": "high|medium|low"}',
|
|
165
|
+
].join("\n");
|
|
166
|
+
|
|
167
|
+
const outputSchema = {
|
|
168
|
+
type: "object",
|
|
169
|
+
required: ["pickId", "actionType", "why"],
|
|
170
|
+
properties: {
|
|
171
|
+
pickId: { type: "string" },
|
|
172
|
+
actionType: { type: "string", enum: ACTIONS },
|
|
173
|
+
why: { type: "string" },
|
|
174
|
+
confidence: { type: "string", enum: ["high", "medium", "low"] },
|
|
175
|
+
},
|
|
176
|
+
additionalProperties: true,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const r = await spawnUtilityAgent(prompt, {
|
|
181
|
+
purpose: "recall",
|
|
182
|
+
tier: "low",
|
|
183
|
+
readOnly: true,
|
|
184
|
+
allowedTools: [],
|
|
185
|
+
outputSchema,
|
|
186
|
+
timeoutMs: 30000,
|
|
187
|
+
});
|
|
188
|
+
if (r && r.ok && r.json && r.json.pickId) return r.json;
|
|
189
|
+
} catch (e) { /* fall through to deterministic */ }
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- deliberate: the pipeline -------------------------------------------
|
|
194
|
+
async function deliberate(ledger, opts = {}) {
|
|
195
|
+
const now = opts.now || (ledger && ledger.generatedAt) || Date.now();
|
|
196
|
+
const entries = (ledger && ledger.entries) || [];
|
|
197
|
+
const counts = (ledger && ledger.counts) || null;
|
|
198
|
+
|
|
199
|
+
const ranked = entries
|
|
200
|
+
.filter((e) => isCandidate(e, now))
|
|
201
|
+
.map((e) => ({ ...e, ...scoreOf(e, now) }))
|
|
202
|
+
.sort((a, b) => b.score - a.score);
|
|
203
|
+
|
|
204
|
+
const base = {
|
|
205
|
+
generatedAt: now,
|
|
206
|
+
proposed: false,
|
|
207
|
+
pick: null,
|
|
208
|
+
actionType: null,
|
|
209
|
+
why: [],
|
|
210
|
+
confidence: "low",
|
|
211
|
+
method: "deterministic",
|
|
212
|
+
candidateCount: ranked.length,
|
|
213
|
+
candidates: ranked.slice(0, TOP_N).map((c) => ({ id: c.id, source: c.source, title: c.title, score: Math.round(c.score) })),
|
|
214
|
+
counts,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (!ranked.length || ranked[0].score < CONFIDENCE_FLOOR) return base;
|
|
218
|
+
|
|
219
|
+
const top = ranked.slice(0, TOP_N);
|
|
220
|
+
let pick = ranked[0];
|
|
221
|
+
let actionType = guessAction(pick, now);
|
|
222
|
+
let method = "deterministic";
|
|
223
|
+
let judgeWhy = null;
|
|
224
|
+
const margin = ranked.length > 1 ? ranked[0].score - ranked[1].score : Infinity;
|
|
225
|
+
let confidence = margin >= CLEAR_MARGIN ? "high" : "medium";
|
|
226
|
+
|
|
227
|
+
const useJudge = opts.useJudge === true && ranked.length > 1 && margin < CLEAR_MARGIN;
|
|
228
|
+
if (useJudge) {
|
|
229
|
+
const judge = opts.judge || defaultJudge;
|
|
230
|
+
let verdict = null;
|
|
231
|
+
try { verdict = await judge(top, { now }); } catch (e) { verdict = null; }
|
|
232
|
+
if (verdict && verdict.pickId) {
|
|
233
|
+
const chosen = top.find((c) => c.id === verdict.pickId);
|
|
234
|
+
if (chosen) {
|
|
235
|
+
pick = chosen;
|
|
236
|
+
if (ACTIONS.includes(verdict.actionType)) actionType = verdict.actionType;
|
|
237
|
+
if (verdict.why) judgeWhy = String(verdict.why);
|
|
238
|
+
if (["high", "medium", "low"].includes(verdict.confidence)) confidence = verdict.confidence;
|
|
239
|
+
method = "judge";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const why = pick.signals.filter((s) => s.w !== 0 || s.k === "context").map((s) => s.label);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
...base,
|
|
248
|
+
proposed: true,
|
|
249
|
+
pick: {
|
|
250
|
+
id: pick.id, source: pick.source, kind: pick.kind, title: pick.title,
|
|
251
|
+
status: pick.status, due: pick.due, priority: pick.priority,
|
|
252
|
+
score: Math.round(pick.score), meta: pick.meta,
|
|
253
|
+
},
|
|
254
|
+
actionType,
|
|
255
|
+
why,
|
|
256
|
+
judgeWhy,
|
|
257
|
+
confidence,
|
|
258
|
+
method,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- shadow record: append one line per real deliberation ---------------
|
|
263
|
+
function record(proposal, meta = {}) {
|
|
264
|
+
if (!shadowEnabled() || !proposal) return;
|
|
265
|
+
try {
|
|
266
|
+
const row = {
|
|
267
|
+
at: new Date().toISOString(),
|
|
268
|
+
proposed: !!proposal.proposed,
|
|
269
|
+
pickId: proposal.pick ? proposal.pick.id : null,
|
|
270
|
+
source: proposal.pick ? proposal.pick.source : null,
|
|
271
|
+
title: proposal.pick ? proposal.pick.title : null,
|
|
272
|
+
actionType: proposal.actionType || null,
|
|
273
|
+
confidence: proposal.confidence || null,
|
|
274
|
+
method: proposal.method || null,
|
|
275
|
+
score: proposal.pick ? proposal.pick.score : null,
|
|
276
|
+
candidateCount: proposal.candidateCount || 0,
|
|
277
|
+
channel: meta.channel || null,
|
|
278
|
+
};
|
|
279
|
+
try {
|
|
280
|
+
const st = fs.statSync(LOG_FILE);
|
|
281
|
+
if (st.size > MAX_LOG_BYTES) fs.renameSync(LOG_FILE, `${LOG_FILE}.1`);
|
|
282
|
+
} catch (e) {}
|
|
283
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(row) + "\n");
|
|
284
|
+
} catch (e) { /* best effort */ }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- render: advisory text (no action taken) ----------------------------
|
|
288
|
+
const ACTION_VERB = {
|
|
289
|
+
act: "Do it now",
|
|
290
|
+
schedule: "Schedule it",
|
|
291
|
+
ask: "Nudge / ask",
|
|
292
|
+
park: "Park it",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
function render(proposal) {
|
|
296
|
+
if (!proposal || !proposal.proposed) {
|
|
297
|
+
return "Nothing pressing to pick right now — nothing overdue, due soon, or in progress that clears the bar. 🟢";
|
|
298
|
+
}
|
|
299
|
+
const p = proposal.pick;
|
|
300
|
+
const conf = proposal.confidence ? ` · ${proposal.confidence} confidence` : "";
|
|
301
|
+
const via = proposal.method === "judge" ? " · judged" : "";
|
|
302
|
+
const lines = [];
|
|
303
|
+
lines.push(`Next up (advisory${conf}${via})`);
|
|
304
|
+
lines.push("─".repeat(18));
|
|
305
|
+
lines.push(`${p.title}`);
|
|
306
|
+
lines.push(` ${ACTION_VERB[proposal.actionType] || proposal.actionType} · ${p.source}${p.id ? ` · ${p.id}` : ""}`);
|
|
307
|
+
if (proposal.judgeWhy) lines.push(` Why: ${proposal.judgeWhy}`);
|
|
308
|
+
else if (proposal.why && proposal.why.length) lines.push(` Why: ${proposal.why.join(" · ")}`);
|
|
309
|
+
if (proposal.candidateCount > 1) {
|
|
310
|
+
lines.push("");
|
|
311
|
+
lines.push(`Considered ${proposal.candidateCount}; runners-up:`);
|
|
312
|
+
for (const c of proposal.candidates.slice(1, 4)) lines.push(` · ${c.title} (${c.score})`);
|
|
313
|
+
}
|
|
314
|
+
lines.push("");
|
|
315
|
+
lines.push("Advisory only — I won't act on this without your go-ahead.");
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = {
|
|
320
|
+
deliberate,
|
|
321
|
+
render,
|
|
322
|
+
record,
|
|
323
|
+
scoreOf,
|
|
324
|
+
isCandidate,
|
|
325
|
+
guessAction,
|
|
326
|
+
defaultJudge,
|
|
327
|
+
shadowEnabled,
|
|
328
|
+
LOG_FILE,
|
|
329
|
+
ACTIONS,
|
|
330
|
+
CONFIDENCE_FLOOR,
|
|
331
|
+
CLEAR_MARGIN,
|
|
332
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Agency ledger — read-only work aggregator (Track B0).
|
|
2
|
+
//
|
|
3
|
+
// Unifies three sources into one normalized, ranked work list:
|
|
4
|
+
// • persistent per-channel tasks (core/tasks) — the agent's own plan/subtasks
|
|
5
|
+
// • Spaces tasks assigned to the owner (`spaces mine`, via the spaces tool)
|
|
6
|
+
// • connector inbox notifications (core/connected-apps) — awareness items
|
|
7
|
+
//
|
|
8
|
+
// Pure visibility, zero autonomy: this module only reads and normalizes. The
|
|
9
|
+
// deliberation/act layer (Track B1+) consumes build()/collect() output; it never
|
|
10
|
+
// writes here. Credentials never touch this module — the Spaces fetch is
|
|
11
|
+
// delegated to the `spaces` tool subprocess (keyring-injected), so core holds no
|
|
12
|
+
// secrets. build() is a pure function (no I/O) so it is unit-testable in full;
|
|
13
|
+
// collect() is the thin impure orchestrator that gathers the three sources.
|
|
14
|
+
|
|
15
|
+
const { execFile } = require("child_process");
|
|
16
|
+
|
|
17
|
+
const DAY_MS = 86400000;
|
|
18
|
+
const DEFAULT_STALE_DAYS = 14;
|
|
19
|
+
|
|
20
|
+
function toMs(v) {
|
|
21
|
+
if (!v) return 0;
|
|
22
|
+
if (typeof v === "number") return v;
|
|
23
|
+
const t = Date.parse(v);
|
|
24
|
+
return Number.isFinite(t) ? t : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function staleDaysOf(lastTouched, now) {
|
|
28
|
+
if (!lastTouched) return null;
|
|
29
|
+
return Math.max(0, (now - lastTouched) / DAY_MS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- normalizers: each source → the common entry shape ------------------
|
|
33
|
+
// { source, kind, id, title, status, priority, due, lastTouched, staleDays,
|
|
34
|
+
// blockedOn, overdue, meta }
|
|
35
|
+
|
|
36
|
+
function normalizeTask(t, now) {
|
|
37
|
+
const lastTouched = toMs(t.updatedAt || t.createdAt);
|
|
38
|
+
const status = t.status === "in_progress" ? "in_progress"
|
|
39
|
+
: t.status === "completed" ? "done"
|
|
40
|
+
: "open";
|
|
41
|
+
return {
|
|
42
|
+
source: "task",
|
|
43
|
+
kind: t.parentId ? "subtask" : "task",
|
|
44
|
+
id: String(t.id || ""),
|
|
45
|
+
title: t.content || "(untitled)",
|
|
46
|
+
status,
|
|
47
|
+
priority: null,
|
|
48
|
+
due: null,
|
|
49
|
+
lastTouched,
|
|
50
|
+
staleDays: staleDaysOf(lastTouched, now),
|
|
51
|
+
blockedOn: null,
|
|
52
|
+
overdue: false,
|
|
53
|
+
meta: { parentId: t.parentId || null, description: t.description || null },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeSpacesTask(t, now) {
|
|
58
|
+
const lastTouched = toMs(t.updatedAt || t.createdAt);
|
|
59
|
+
const due = t.dueDate || null;
|
|
60
|
+
const dueMs = toMs(due);
|
|
61
|
+
const status = t.status === "in_progress" ? "in_progress"
|
|
62
|
+
: (t.status === "completed" || t.status === "done" || t.completedAt) ? "done"
|
|
63
|
+
: "open";
|
|
64
|
+
const space = t.spaceId && typeof t.spaceId === "object" ? (t.spaceId.name || "") : "";
|
|
65
|
+
return {
|
|
66
|
+
source: "spaces",
|
|
67
|
+
kind: "assignment",
|
|
68
|
+
id: String(t.id || t._id || ""),
|
|
69
|
+
title: t.title || "(untitled)",
|
|
70
|
+
status,
|
|
71
|
+
priority: t.priority || null,
|
|
72
|
+
due,
|
|
73
|
+
lastTouched,
|
|
74
|
+
staleDays: staleDaysOf(lastTouched, now),
|
|
75
|
+
blockedOn: t.parentTaskId || null,
|
|
76
|
+
overdue: !!(dueMs && dueMs < now && status !== "done"),
|
|
77
|
+
meta: {
|
|
78
|
+
space,
|
|
79
|
+
subtasks: t.subtaskCount || 0,
|
|
80
|
+
subtasksDone: t.subtaskCompletedCount || 0,
|
|
81
|
+
tags: Array.isArray(t.tags) ? t.tags : [],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeInbox(item, now) {
|
|
87
|
+
const lastTouched = toMs(item.createdAt);
|
|
88
|
+
const actor = item.actorName ? `${item.actorName} ` : "";
|
|
89
|
+
const verb = item.type || "notification";
|
|
90
|
+
const on = item.taskTitle ? ` on "${item.taskTitle}"` : "";
|
|
91
|
+
const title = `${actor}${verb}${on}`.trim() || item.text || "(notification)";
|
|
92
|
+
return {
|
|
93
|
+
source: "notification",
|
|
94
|
+
kind: item.type || "notification",
|
|
95
|
+
id: String(item.id || ""),
|
|
96
|
+
title,
|
|
97
|
+
status: "info",
|
|
98
|
+
priority: null,
|
|
99
|
+
due: null,
|
|
100
|
+
lastTouched,
|
|
101
|
+
staleDays: staleDaysOf(lastTouched, now),
|
|
102
|
+
blockedOn: null,
|
|
103
|
+
overdue: false,
|
|
104
|
+
meta: {
|
|
105
|
+
app: item.appName || item.appId || "",
|
|
106
|
+
actor: item.actorName || "",
|
|
107
|
+
taskId: item.taskId || "",
|
|
108
|
+
text: item.text || "",
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Pure-visibility ordering (NOT the Track B1 deliberation score): overdue first,
|
|
114
|
+
// then anything with a due date by soonest, then in-progress, then most recently
|
|
115
|
+
// touched. Stable + explainable so the ledger always reads sensibly.
|
|
116
|
+
function rankKey(e) {
|
|
117
|
+
const dueMs = toMs(e.due);
|
|
118
|
+
return [
|
|
119
|
+
e.overdue ? 0 : 1,
|
|
120
|
+
dueMs || Number.MAX_SAFE_INTEGER,
|
|
121
|
+
e.status === "in_progress" ? 0 : 1,
|
|
122
|
+
-(e.lastTouched || 0),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cmpKey(a, b) {
|
|
127
|
+
for (let i = 0; i < a.length; i++) {
|
|
128
|
+
if (a[i] < b[i]) return -1;
|
|
129
|
+
if (a[i] > b[i]) return 1;
|
|
130
|
+
}
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Pure: raw source arrays → normalized, de-completed, ranked ledger + counts.
|
|
135
|
+
function build({ tasks = [], spacesMine = [], inboxes = [], now = Date.now(), staleDays = DEFAULT_STALE_DAYS } = {}) {
|
|
136
|
+
const entries = [];
|
|
137
|
+
for (const t of tasks) entries.push(normalizeTask(t, now));
|
|
138
|
+
for (const t of spacesMine) entries.push(normalizeSpacesTask(t, now));
|
|
139
|
+
for (const it of inboxes) entries.push(normalizeInbox(it, now));
|
|
140
|
+
|
|
141
|
+
// The ledger is a live work list — completed items drop off.
|
|
142
|
+
const open = entries.filter((e) => e.status !== "done");
|
|
143
|
+
open.sort((a, b) => cmpKey(rankKey(a), rankKey(b)));
|
|
144
|
+
|
|
145
|
+
const byStatus = {};
|
|
146
|
+
const bySource = {};
|
|
147
|
+
let overdue = 0;
|
|
148
|
+
let stale = 0;
|
|
149
|
+
for (const e of open) {
|
|
150
|
+
byStatus[e.status] = (byStatus[e.status] || 0) + 1;
|
|
151
|
+
bySource[e.source] = (bySource[e.source] || 0) + 1;
|
|
152
|
+
if (e.overdue) overdue += 1;
|
|
153
|
+
if (e.staleDays != null && e.staleDays > staleDays) stale += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
generatedAt: now,
|
|
158
|
+
counts: { total: open.length, overdue, stale, byStatus, bySource },
|
|
159
|
+
entries: open,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Default Spaces fetch: shell the read-only `spaces mine` tool verb. Keyring
|
|
164
|
+
// creds are injected only into that subprocess, so this module never holds the
|
|
165
|
+
// central login. Best-effort — any failure yields [] and the ledger still shows
|
|
166
|
+
// tasks + notifications.
|
|
167
|
+
function defaultFetchSpacesMine({ timeoutMs = 15000 } = {}) {
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
execFile(
|
|
170
|
+
"open-claudia",
|
|
171
|
+
["tool", "run", "spaces", "mine", "--json"],
|
|
172
|
+
{ timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 },
|
|
173
|
+
(err, stdout) => {
|
|
174
|
+
if (err || !stdout) return resolve([]);
|
|
175
|
+
try {
|
|
176
|
+
// Tool stdout can carry ExperimentalWarning/SQLite noise before the JSON.
|
|
177
|
+
const start = stdout.indexOf("[");
|
|
178
|
+
const parsed = JSON.parse(start >= 0 ? stdout.slice(start) : stdout);
|
|
179
|
+
resolve(Array.isArray(parsed) ? parsed : []);
|
|
180
|
+
} catch (e) { resolve([]); }
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Impure orchestrator: gather the three sources, then build(). Every source is
|
|
187
|
+
// best-effort so a single failure never blanks the ledger.
|
|
188
|
+
async function collect({ adapter, channelId, now = Date.now(), staleDays = DEFAULT_STALE_DAYS, includeSpaces = true, fetchSpacesMine } = {}) {
|
|
189
|
+
let tasks = [];
|
|
190
|
+
try {
|
|
191
|
+
if (adapter && channelId) tasks = require("../tasks").list(adapter, channelId);
|
|
192
|
+
} catch (e) { tasks = []; }
|
|
193
|
+
|
|
194
|
+
let spacesMine = [];
|
|
195
|
+
if (includeSpaces) {
|
|
196
|
+
try {
|
|
197
|
+
const fetch = fetchSpacesMine || defaultFetchSpacesMine;
|
|
198
|
+
const r = await fetch({});
|
|
199
|
+
spacesMine = Array.isArray(r) ? r : [];
|
|
200
|
+
} catch (e) { spacesMine = []; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let inboxes = [];
|
|
204
|
+
try {
|
|
205
|
+
inboxes = require("../connected-apps").getAllInboxes() || [];
|
|
206
|
+
} catch (e) { inboxes = []; }
|
|
207
|
+
|
|
208
|
+
return build({ tasks, spacesMine, inboxes, now, staleDays });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- rendering ----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function ago(ms, now) {
|
|
214
|
+
if (!ms) return "";
|
|
215
|
+
const s = Math.max(0, Math.round((now - ms) / 1000));
|
|
216
|
+
if (s < 90) return "just now";
|
|
217
|
+
const m = Math.round(s / 60);
|
|
218
|
+
if (m < 90) return `${m}m ago`;
|
|
219
|
+
const h = Math.round(m / 60);
|
|
220
|
+
if (h < 36) return `${h}h ago`;
|
|
221
|
+
return `${Math.round(h / 24)}d ago`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function dueLabel(due, now) {
|
|
225
|
+
const dueMs = toMs(due);
|
|
226
|
+
if (!dueMs) return "";
|
|
227
|
+
const days = Math.round((dueMs - now) / DAY_MS);
|
|
228
|
+
if (days < 0) return `overdue ${-days}d`;
|
|
229
|
+
if (days === 0) return "due today";
|
|
230
|
+
if (days === 1) return "due tomorrow";
|
|
231
|
+
return `due ${days}d`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const SOURCE_LABEL = {
|
|
235
|
+
spaces: "Spaces (assigned to me)",
|
|
236
|
+
task: "Local tasks (this channel)",
|
|
237
|
+
notification: "Notifications (inbox)",
|
|
238
|
+
};
|
|
239
|
+
const SOURCE_ORDER = ["spaces", "task", "notification"];
|
|
240
|
+
|
|
241
|
+
function mark(e) {
|
|
242
|
+
if (e.overdue) return "!";
|
|
243
|
+
if (e.status === "in_progress") return "~";
|
|
244
|
+
if (e.source === "notification") return "•";
|
|
245
|
+
return " ";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderEntry(e, now, showIds) {
|
|
249
|
+
const bits = [];
|
|
250
|
+
const d = dueLabel(e.due, now);
|
|
251
|
+
if (d) bits.push(d);
|
|
252
|
+
if (e.status === "in_progress" && !e.overdue) bits.push("in progress");
|
|
253
|
+
if (e.priority && e.priority !== "medium") bits.push(e.priority);
|
|
254
|
+
if (e.meta && e.meta.space) bits.push(e.meta.space);
|
|
255
|
+
const a = ago(e.lastTouched, now);
|
|
256
|
+
if (a && (e.source !== "spaces" || !d)) bits.push(a);
|
|
257
|
+
const tail = bits.length ? ` · ${bits.join(" · ")}` : "";
|
|
258
|
+
const id = showIds && e.id ? ` (${e.id})` : "";
|
|
259
|
+
return ` ${mark(e)} ${e.title}${tail}${id}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function render(ledger, opts = {}) {
|
|
263
|
+
const now = opts.now || ledger.generatedAt || Date.now();
|
|
264
|
+
const limit = Number.isFinite(opts.limitPerSource) ? opts.limitPerSource : 10;
|
|
265
|
+
const showIds = !!opts.showIds;
|
|
266
|
+
const c = ledger.counts || { total: 0, overdue: 0, stale: 0 };
|
|
267
|
+
const lines = ["Agenda", "──────"];
|
|
268
|
+
const summary = [`${c.total} open`];
|
|
269
|
+
if (c.overdue) summary.push(`${c.overdue} overdue`);
|
|
270
|
+
if (c.stale) summary.push(`${c.stale} stale`);
|
|
271
|
+
lines.push(summary.join(" · "));
|
|
272
|
+
|
|
273
|
+
for (const src of SOURCE_ORDER) {
|
|
274
|
+
const items = (ledger.entries || []).filter((e) => e.source === src);
|
|
275
|
+
if (!items.length) continue;
|
|
276
|
+
lines.push("");
|
|
277
|
+
lines.push(`${SOURCE_LABEL[src] || src} — ${items.length}`);
|
|
278
|
+
for (const e of items.slice(0, limit)) lines.push(renderEntry(e, now, showIds));
|
|
279
|
+
if (items.length > limit) lines.push(` …and ${items.length - limit} more`);
|
|
280
|
+
}
|
|
281
|
+
if (!(ledger.entries || []).length) lines.push("", "Nothing on your plate. 🎉");
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
build,
|
|
287
|
+
collect,
|
|
288
|
+
render,
|
|
289
|
+
normalizeTask,
|
|
290
|
+
normalizeSpacesTask,
|
|
291
|
+
normalizeInbox,
|
|
292
|
+
defaultFetchSpacesMine,
|
|
293
|
+
DEFAULT_STALE_DAYS,
|
|
294
|
+
};
|
package/core/connected-apps.js
CHANGED
|
@@ -204,6 +204,20 @@ function snapshot() {
|
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// Public: flat, normalized list of every connector inbox item across all apps,
|
|
208
|
+
// each tagged with the app it came from. Read-only; lazily loads the on-disk
|
|
209
|
+
// store so it works from any process (the parent poller keeps the file fresh).
|
|
210
|
+
function getAllInboxes() {
|
|
211
|
+
if (!store) store = loadStore();
|
|
212
|
+
const nameById = new Map(store.apps.map((a) => [String(a.id || a.name), a.name || String(a.id || "")]));
|
|
213
|
+
const out = [];
|
|
214
|
+
for (const [appId, state] of Object.entries(store.perApp || {})) {
|
|
215
|
+
const appName = nameById.get(appId) || appId;
|
|
216
|
+
for (const it of state.inbox || []) out.push({ appId, appName, ...it });
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
207
221
|
function initConnectedApps() {
|
|
208
222
|
if (!truthy(config.OC_CONNECTED_APPS || process.env.OC_CONNECTED_APPS, false)) {
|
|
209
223
|
return { started: false, reason: "OC_CONNECTED_APPS disabled" };
|
|
@@ -231,5 +245,6 @@ module.exports = {
|
|
|
231
245
|
registerConnector,
|
|
232
246
|
fetchConnectedApps,
|
|
233
247
|
snapshot,
|
|
248
|
+
getAllInboxes,
|
|
234
249
|
STORE_FILE,
|
|
235
250
|
};
|