@inetafrica/open-claudia 3.0.26 → 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 +9 -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/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 +19 -1
- 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 -10
- package/test-telegram-poll-recovery.js +81 -0
|
@@ -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
|
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Track C — dynamic owner-guardrail capture. When the OWNER states a durable
|
|
2
|
+
// behavioural rule mid-chat ("from now on ...", "never ...", "stop ...ing"), a
|
|
3
|
+
// deterministic post-turn detector either captures it straight into lessons.md
|
|
4
|
+
// (always-loaded) or asks first, so the correction holds on the next turn with
|
|
5
|
+
// no code change and no deploy.
|
|
6
|
+
//
|
|
7
|
+
// Invariants:
|
|
8
|
+
// - Owner-only, HARD. Rules are NEVER captured from external speakers — the
|
|
9
|
+
// caller gates on relationship.guardActive(speaker) and consider() re-checks.
|
|
10
|
+
// - Advisory-tier. A captured rule layers ABOVE lessons but can never override
|
|
11
|
+
// the code-fixed hard rules (tool-first, enforcer). Safety rails stay in code.
|
|
12
|
+
// - No model call. Detection is phrase-based and default-silent; the ambiguous
|
|
13
|
+
// tier defers to the owner (a Y/N button), not to a model that could invent
|
|
14
|
+
// a rule. Clear "from now on / never / stop" phrasings auto-capture with an
|
|
15
|
+
// announce + one-tap undo; softer imperatives ask first.
|
|
16
|
+
// - Dedupe against existing lessons (normalized text) so the same rule
|
|
17
|
+
// reinforces instead of cluttering the always-on budget.
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const crypto = require("crypto");
|
|
22
|
+
const { atomicWriteFileSync } = require("./fsutil");
|
|
23
|
+
|
|
24
|
+
const CONFIG_DIR = require("../config-dir");
|
|
25
|
+
const lessons = require("./lessons");
|
|
26
|
+
|
|
27
|
+
const PENDING_FILE = path.join(CONFIG_DIR, "guardrail-pending.json");
|
|
28
|
+
const PENDING_TTL_MS = Number(process.env.GUARDRAIL_PENDING_TTL_MS || 24 * 60 * 60 * 1000);
|
|
29
|
+
const MIN_RULE_CHARS = 10;
|
|
30
|
+
const MIN_RULE_WORDS = 3;
|
|
31
|
+
const MAX_RULE_CHARS = lessons.MAX_LESSON_CHARS || 240;
|
|
32
|
+
|
|
33
|
+
// Operability control (mirrors pack-review's PACK_REVIEW gate). Default ON —
|
|
34
|
+
// this is not a hidden kill-switch; it lets the capture path be disabled
|
|
35
|
+
// without a deploy if it ever misfires, and lets tests force it off.
|
|
36
|
+
function enabled() {
|
|
37
|
+
return String(process.env.GUARDRAIL_CAPTURE || "on").toLowerCase() !== "off";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── detection (pure, deterministic) ─────────────────────────────────
|
|
41
|
+
|
|
42
|
+
// Explicit durable markers → high confidence (auto-capture). Matched anywhere.
|
|
43
|
+
const HIGH_MARKERS = [
|
|
44
|
+
/\bfrom now on\b/i,
|
|
45
|
+
/\bfrom here on(?: out)?\b/i,
|
|
46
|
+
/\bgoing forward\b/i,
|
|
47
|
+
/\bin (?:the )?future\b/i,
|
|
48
|
+
/\bnever again\b/i,
|
|
49
|
+
/\bmake (?:it|this|that) a (?:standing )?rule\b/i,
|
|
50
|
+
/\bas a (?:standing )?rule\b/i,
|
|
51
|
+
/\balways remember to\b/i,
|
|
52
|
+
];
|
|
53
|
+
// Clear negative directive at the start of a (cleaned) clause → high. "stop"
|
|
54
|
+
// must be followed by a gerund so "stop summarizing" captures but "stop the
|
|
55
|
+
// server" (a one-off action) does not.
|
|
56
|
+
const HIGH_NEG = /^(?:never\s+\w+|stop\s+\w+ing|(?:don'?t|do not) ever\b)/i;
|
|
57
|
+
// Softer imperatives → medium (ask the owner first).
|
|
58
|
+
const MED_OPENER = /^(?:always|don'?t|do not|make sure|be sure|remember to|prefer|avoid)\b/i;
|
|
59
|
+
|
|
60
|
+
// Junk that pattern-matches a directive but is never a rule.
|
|
61
|
+
const STOP_RULES = new Set(["never mind", "nevermind", "stop it", "never ever", "don't worry", "do not worry"]);
|
|
62
|
+
|
|
63
|
+
function splitSentences(text) {
|
|
64
|
+
return String(text || "")
|
|
65
|
+
.split(/(?<=[.!?])\s+|\n+/)
|
|
66
|
+
.map((s) => s.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function endsAsQuestion(s) {
|
|
71
|
+
return /\?\s*$/.test(String(s || ""));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stripOpener(s) {
|
|
75
|
+
return String(s || "").replace(/^(?:ok(?:ay)?|hey|so|and|also|well|please|now)[,:\s]+/i, "").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Turn a directive sentence into a clean, imperative one-line rule: drop soft
|
|
79
|
+
// openers, leading durable markers, and polite lead-ins; collapse whitespace;
|
|
80
|
+
// capitalize; cap length. Keeps "never/stop/always/make sure" intact — those
|
|
81
|
+
// are the rule's core and drive classification.
|
|
82
|
+
function cleanRule(sentence) {
|
|
83
|
+
let s = stripOpener(sentence);
|
|
84
|
+
s = s.replace(/^(?:from now on|from here on(?: out)?|going forward|in (?:the )?future)[,:\s]+/i, "");
|
|
85
|
+
s = s.replace(/^(?:can you|could you|i(?:'d| would)? (?:like|want) you to|i want you to)\s+/i, "");
|
|
86
|
+
s = s.replace(/\s+/g, " ").trim().replace(/[\s,;:]+$/g, "");
|
|
87
|
+
if (s) s = s[0].toUpperCase() + s.slice(1);
|
|
88
|
+
return s.slice(0, MAX_RULE_CHARS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ruleOk(rule) {
|
|
92
|
+
if (!rule || rule.length < MIN_RULE_CHARS) return false;
|
|
93
|
+
const low = rule.toLowerCase();
|
|
94
|
+
if (STOP_RULES.has(low)) return false;
|
|
95
|
+
if (low.split(/\s+/).length < MIN_RULE_WORDS) return false;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Returns { isRule, confidence: "high"|"medium", rule, trigger } or { isRule:false }.
|
|
100
|
+
// High short-circuits; a medium hit is remembered as the fallback.
|
|
101
|
+
function detect(text) {
|
|
102
|
+
let medium = null;
|
|
103
|
+
for (const s of splitSentences(text)) {
|
|
104
|
+
if (endsAsQuestion(s)) continue;
|
|
105
|
+
const rule = cleanRule(s);
|
|
106
|
+
if (!ruleOk(rule)) continue;
|
|
107
|
+
const hasMarker = HIGH_MARKERS.some((re) => re.test(s));
|
|
108
|
+
if (hasMarker || HIGH_NEG.test(rule)) {
|
|
109
|
+
return { isRule: true, confidence: "high", rule, trigger: hasMarker ? "marker" : "directive" };
|
|
110
|
+
}
|
|
111
|
+
if (!medium && MED_OPENER.test(rule)) medium = { isRule: true, confidence: "medium", rule, trigger: "soft" };
|
|
112
|
+
}
|
|
113
|
+
return medium || { isRule: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── capture (dedupe against existing lessons) ───────────────────────
|
|
117
|
+
|
|
118
|
+
function findDuplicate(rule) {
|
|
119
|
+
const norm = lessons.normalize(rule);
|
|
120
|
+
return lessons.readLessons().find((l) => lessons.normalize(l.text) === norm) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Add the rule as a lesson, or reinforce the existing twin. origin marks
|
|
124
|
+
// provenance (guardrail = owner stated it in chat); lessons meta carries the date.
|
|
125
|
+
function capture(rule, { origin = "guardrail" } = {}) {
|
|
126
|
+
const dup = findDuplicate(rule);
|
|
127
|
+
const target = dup ? dup.text : rule;
|
|
128
|
+
const r = lessons.addLesson({ text: target, origin });
|
|
129
|
+
return {
|
|
130
|
+
added: !!r.added,
|
|
131
|
+
reinforced: !!r.reinforced,
|
|
132
|
+
duplicate: !!dup,
|
|
133
|
+
id: r.id || (dup && dup.id) || lessons.lessonId(target),
|
|
134
|
+
text: target,
|
|
135
|
+
count: r.count,
|
|
136
|
+
overCap: !!r.overCap,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── pending store (medium-confidence Y/N confirms) ──────────────────
|
|
141
|
+
|
|
142
|
+
function readPending() {
|
|
143
|
+
try { return JSON.parse(fs.readFileSync(PENDING_FILE, "utf-8")) || {}; }
|
|
144
|
+
catch (e) { return {}; }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writePending(obj) {
|
|
148
|
+
fs.mkdirSync(path.dirname(PENDING_FILE), { recursive: true, mode: 0o700 });
|
|
149
|
+
atomicWriteFileSync(PENDING_FILE, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function prunePending(obj, now) {
|
|
153
|
+
for (const [tok, rec] of Object.entries(obj)) {
|
|
154
|
+
if (!rec || now - (rec.createdAt || 0) > PENDING_TTL_MS) delete obj[tok];
|
|
155
|
+
}
|
|
156
|
+
return obj;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function putPending(rule, meta = {}, now = Date.now()) {
|
|
160
|
+
const obj = prunePending(readPending(), now);
|
|
161
|
+
const token = crypto.randomBytes(5).toString("hex");
|
|
162
|
+
obj[token] = { rule, channel: meta.channel || null, createdAt: now };
|
|
163
|
+
writePending(obj);
|
|
164
|
+
return token;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function takePending(token, now = Date.now()) {
|
|
168
|
+
const obj = prunePending(readPending(), now);
|
|
169
|
+
const rec = obj[token] || null;
|
|
170
|
+
if (rec) { delete obj[token]; }
|
|
171
|
+
writePending(obj);
|
|
172
|
+
return rec;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── render (pure) ───────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function clip(s) {
|
|
178
|
+
return String(s || "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderCaptured(res) {
|
|
182
|
+
if (!res.added) return `📌 Already a standing rule — reinforced: "${clip(res.text)}"`;
|
|
183
|
+
return `📌 Got it — I'll keep this loaded as a standing rule from now on:\n"${clip(res.text)}"` +
|
|
184
|
+
`${res.overCap ? "\n(Over the lessons cap — the nightly dream will tidy.)" : ""}` +
|
|
185
|
+
`\nTap Undo if that's not what you meant.`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderAsk(rule) {
|
|
189
|
+
return `Want me to make this a standing rule (always loaded)?\n"${clip(rule)}"`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── orchestrator ────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
// Owner-gated. `announce(text, opts?)` sends to the chat (opts may carry a
|
|
195
|
+
// keyboard); omit it for a dry run. Returns a small result for testability.
|
|
196
|
+
async function consider({ text, speaker, announce, now = Date.now() } = {}) {
|
|
197
|
+
if (!enabled()) return { action: "disabled" };
|
|
198
|
+
const relationship = require("./relationship");
|
|
199
|
+
if (relationship.guardActive(speaker)) return { action: "external-skip" };
|
|
200
|
+
|
|
201
|
+
const d = detect(text);
|
|
202
|
+
if (!d.isRule) return { action: "silent" };
|
|
203
|
+
|
|
204
|
+
if (d.confidence === "high") {
|
|
205
|
+
const res = capture(d.rule);
|
|
206
|
+
if (typeof announce === "function") {
|
|
207
|
+
const opts = res.added
|
|
208
|
+
? { keyboard: { inline_keyboard: [[{ text: "↩️ Undo", callback_data: `grd:undo:${res.id}` }]] } }
|
|
209
|
+
: undefined;
|
|
210
|
+
try { await announce(renderCaptured(res), opts); } catch (e) {}
|
|
211
|
+
}
|
|
212
|
+
return { action: res.added ? "captured" : "reinforced", id: res.id, rule: res.text, duplicate: res.duplicate };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const token = putPending(d.rule, { channel: speaker && speaker.channelId }, now);
|
|
216
|
+
if (typeof announce === "function") {
|
|
217
|
+
const opts = { keyboard: { inline_keyboard: [[
|
|
218
|
+
{ text: "✅ Make it a rule", callback_data: `grd:save:${token}` },
|
|
219
|
+
{ text: "Skip", callback_data: `grd:no:${token}` },
|
|
220
|
+
]] } };
|
|
221
|
+
try { await announce(renderAsk(d.rule), opts); } catch (e) {}
|
|
222
|
+
}
|
|
223
|
+
return { action: "asked", token, rule: d.rule };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── callback actions (owner-gated by the caller) ────────────────────
|
|
227
|
+
|
|
228
|
+
function confirmPending(token, now = Date.now()) {
|
|
229
|
+
const rec = takePending(token, now);
|
|
230
|
+
if (!rec) return { ok: false, reason: "expired" };
|
|
231
|
+
const res = capture(rec.rule);
|
|
232
|
+
return { ok: true, ...res };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function dropPending(token, now = Date.now()) {
|
|
236
|
+
const rec = takePending(token, now);
|
|
237
|
+
return { ok: !!rec, rule: rec ? rec.rule : "" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function undo(lessonId) {
|
|
241
|
+
return { ok: !!lessons.removeLesson(lessonId) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
PENDING_FILE, PENDING_TTL_MS,
|
|
246
|
+
enabled, detect, cleanRule, ruleOk, splitSentences,
|
|
247
|
+
findDuplicate, capture,
|
|
248
|
+
putPending, takePending, prunePending,
|
|
249
|
+
consider, confirmPending, dropPending, undo,
|
|
250
|
+
renderCaptured, renderAsk,
|
|
251
|
+
};
|
package/core/handlers.js
CHANGED
|
@@ -12,7 +12,8 @@ const {
|
|
|
12
12
|
CHAT_ID, discoverProviderExecutable, SOUL_FILE,
|
|
13
13
|
} = require("./config");
|
|
14
14
|
const { register } = require("./commands");
|
|
15
|
-
const { send, deleteMessage } = require("./io");
|
|
15
|
+
const { send, deleteMessage, typing } = require("./io");
|
|
16
|
+
const { currentAdapter, currentChannelId } = require("./context");
|
|
16
17
|
const {
|
|
17
18
|
currentState, saveState, rootSession,
|
|
18
19
|
freshUsage, getActiveProvider, getProjectKey, getProviderSession, setProviderSession, clearProviderSession,
|
|
@@ -1063,6 +1064,45 @@ register({
|
|
|
1063
1064
|
},
|
|
1064
1065
|
});
|
|
1065
1066
|
|
|
1067
|
+
register({
|
|
1068
|
+
name: "agenda", aliases: ["agency"], description: "Aggregated read-only work list (tasks + Spaces + notifications)",
|
|
1069
|
+
handler: async (env) => {
|
|
1070
|
+
if (!authorized(env)) return;
|
|
1071
|
+
const adapter = currentAdapter();
|
|
1072
|
+
const channelId = currentChannelId();
|
|
1073
|
+
const ledger = require("./agency/ledger");
|
|
1074
|
+
try { typing(); } catch (_) {}
|
|
1075
|
+
let result;
|
|
1076
|
+
try {
|
|
1077
|
+
result = await ledger.collect({ adapter: adapter && adapter.id, channelId });
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
return send(`Couldn't build the agenda: ${e.message}`);
|
|
1080
|
+
}
|
|
1081
|
+
send(ledger.render(result, { now: result.generatedAt, limitPerSource: 8 }));
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
register({
|
|
1086
|
+
name: "next", description: "Advisory: what I'd pick to do next, and why (acts on nothing)",
|
|
1087
|
+
handler: async (env) => {
|
|
1088
|
+
if (!authorized(env)) return;
|
|
1089
|
+
const adapter = currentAdapter();
|
|
1090
|
+
const channelId = currentChannelId();
|
|
1091
|
+
const ledger = require("./agency/ledger");
|
|
1092
|
+
const deliberate = require("./agency/deliberate");
|
|
1093
|
+
try { typing(); } catch (_) {}
|
|
1094
|
+
let proposal;
|
|
1095
|
+
try {
|
|
1096
|
+
const result = await ledger.collect({ adapter: adapter && adapter.id, channelId });
|
|
1097
|
+
proposal = await deliberate.deliberate(result, { now: result.generatedAt });
|
|
1098
|
+
try { deliberate.record(proposal, { channel: adapter && adapter.id ? `${adapter.id}:${channelId}` : null }); } catch (_) {}
|
|
1099
|
+
} catch (e) {
|
|
1100
|
+
return send(`Couldn't deliberate: ${e.message}`);
|
|
1101
|
+
}
|
|
1102
|
+
send(deliberate.render(proposal));
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1066
1106
|
register({
|
|
1067
1107
|
name: "toolmode", description: "Tooling enforcement mode (strict = forced CLI, relaxed = scripting allowed)", args: "[strict|relaxed]", ownerOnly: true,
|
|
1068
1108
|
handler: async (env, { tail }) => {
|