@inetafrica/open-claudia 3.5.14 → 3.5.17
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 +58 -0
- package/Dockerfile +3 -1
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +304 -53
- package/channels/kazee/bot-handoffs.js +230 -0
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/browser-view.js +200 -0
- package/core/config.js +19 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/passive-replies.js +4 -2
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex-hook.js +5 -1
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +264 -8
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +17 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +20 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-provider-tool-hooks.js +17 -0
- package/test-thread-lanes.js +18 -4
- package/web.js +148 -50
package/core/tasks.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// Tasks support a single level of hierarchy via parentId: a top-level
|
|
7
7
|
// "plan" task can have child subtasks. This lets the agent break a big
|
|
8
8
|
// piece of work into a plan whose progress is visible at a glance.
|
|
9
|
+
//
|
|
10
|
+
// Every thread of a channel shares the channel's file, so a task records the
|
|
11
|
+
// conversation it was made in: `threadId` is the chat thread, null the main
|
|
12
|
+
// timeline. Tasks written before that have no threadId field at all and
|
|
13
|
+
// belong to no conversation in particular.
|
|
9
14
|
|
|
10
15
|
const fs = require("fs");
|
|
11
16
|
const path = require("path");
|
|
@@ -28,7 +33,11 @@ function load(adapter, channelId) {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
function save(adapter, channelId, list) {
|
|
31
|
-
|
|
36
|
+
saveFile(filePathFor(adapter, channelId), list);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function saveFile(f, list) {
|
|
40
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
32
41
|
const tmp = `${f}.tmp`;
|
|
33
42
|
fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
|
|
34
43
|
fs.renameSync(tmp, f);
|
|
@@ -55,13 +64,20 @@ function add(adapter, channelId, content, opts = {}) {
|
|
|
55
64
|
createdAt: Date.now(),
|
|
56
65
|
updatedAt: Date.now(),
|
|
57
66
|
};
|
|
67
|
+
// A subtask belongs to its plan's conversation; a new root to the caller's.
|
|
68
|
+
if (parentId) {
|
|
69
|
+
const parent = list.find((x) => x.id === parentId);
|
|
70
|
+
if (Object.prototype.hasOwnProperty.call(parent, "threadId")) t.threadId = parent.threadId;
|
|
71
|
+
} else if (opts.threadId !== undefined) {
|
|
72
|
+
t.threadId = opts.threadId ? String(opts.threadId) : null;
|
|
73
|
+
}
|
|
58
74
|
list.push(t);
|
|
59
75
|
save(adapter, channelId, list);
|
|
60
76
|
return t;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
function plan(adapter, channelId, title, subtaskContents, opts = {}) {
|
|
64
|
-
const parent = add(adapter, channelId, title, { description: opts.description || null });
|
|
80
|
+
const parent = add(adapter, channelId, title, { description: opts.description || null, threadId: opts.threadId });
|
|
65
81
|
const children = [];
|
|
66
82
|
for (const c of subtaskContents || []) {
|
|
67
83
|
if (!String(c || "").trim()) continue;
|
|
@@ -165,7 +181,10 @@ function list(adapter, channelId, opts = {}) {
|
|
|
165
181
|
}
|
|
166
182
|
|
|
167
183
|
function tree(adapter, channelId) {
|
|
168
|
-
|
|
184
|
+
return treeOf(load(adapter, channelId));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function treeOf(all) {
|
|
169
188
|
const byParent = new Map();
|
|
170
189
|
const roots = [];
|
|
171
190
|
for (const t of all) {
|
|
@@ -234,7 +253,11 @@ function lastActivityOf(root) {
|
|
|
234
253
|
}
|
|
235
254
|
|
|
236
255
|
function openTree(adapter, channelId) {
|
|
237
|
-
return
|
|
256
|
+
return openTreeOf(load(adapter, channelId));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function openTreeOf(all) {
|
|
260
|
+
return treeOf(all)
|
|
238
261
|
.map((r) => ({ ...r, children: (r.children || []).filter((c) => c.status !== "completed") }))
|
|
239
262
|
.filter((r) => r.status !== "completed" || r.children.length > 0);
|
|
240
263
|
}
|
|
@@ -254,15 +277,48 @@ function get(adapter, channelId, id) {
|
|
|
254
277
|
// Used by the dream task-hygiene pass to surface cold work for a user
|
|
255
278
|
// decision — it flags and asks, never deletes (tasks are user intent).
|
|
256
279
|
function staleRoots(adapter, channelId, opts = {}) {
|
|
280
|
+
return staleRootsOf(load(adapter, channelId), opts);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function staleRootsOf(all, opts = {}) {
|
|
257
284
|
const days = Number(opts.days) || STALE_DAYS;
|
|
258
285
|
const now = opts.now || Date.now();
|
|
259
|
-
return
|
|
286
|
+
return openTreeOf(all)
|
|
260
287
|
.map((r) => ({ root: r, idleDays: (now - lastActivityOf(r)) / DAY_MS }))
|
|
261
288
|
.filter((x) => x.idleDays > days)
|
|
262
289
|
.sort((a, b) => b.idleDays - a.idleDays)
|
|
263
290
|
.map((x) => ({ id: x.root.id, content: x.root.content, status: x.root.status, idleDays: Math.round(x.idleDays) }));
|
|
264
291
|
}
|
|
265
292
|
|
|
293
|
+
// Every task list on this bot: one file per adapter+channel in TASKS_DIR (not
|
|
294
|
+
// the archive below it). Task kinds are owner-only at the loopback, so every
|
|
295
|
+
// list is the owner's, whichever channel it lives in. Files named like test
|
|
296
|
+
// fixtures are skipped. `label` names the list for a report.
|
|
297
|
+
function taskLists(dir = TASKS_DIR) {
|
|
298
|
+
let names = [];
|
|
299
|
+
try { names = fs.readdirSync(dir); } catch (e) { return []; }
|
|
300
|
+
return names
|
|
301
|
+
.filter((name) => name.endsWith(".json") && !/test|fixture/i.test(name))
|
|
302
|
+
.sort()
|
|
303
|
+
.map((name) => ({ name, file: path.join(dir, name), label: listLabel(name) }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function listLabel(name) {
|
|
307
|
+
const base = String(name).replace(/\.json$/, "");
|
|
308
|
+
const cut = base.indexOf("-");
|
|
309
|
+
if (cut < 0) return base;
|
|
310
|
+
const adapter = base.slice(0, cut);
|
|
311
|
+
const channel = base.slice(cut + 1);
|
|
312
|
+
return `${adapter} ${channel.length > 14 ? `${channel.slice(0, 6)}…${channel.slice(-6)}` : channel}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function loadFile(file) {
|
|
316
|
+
try {
|
|
317
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
318
|
+
return Array.isArray(raw) ? raw : [];
|
|
319
|
+
} catch (e) { return []; }
|
|
320
|
+
}
|
|
321
|
+
|
|
266
322
|
function renderRootFull(root, num, showIds) {
|
|
267
323
|
const idTag = showIds ? ` (${root.id})` : "";
|
|
268
324
|
const lines = [`${num}. ${statusMark(root)} ${root.content}${idTag}`];
|
|
@@ -309,6 +365,148 @@ function formatForInjection(adapter, channelId, opts = {}) {
|
|
|
309
365
|
return lines.join("\n");
|
|
310
366
|
}
|
|
311
367
|
|
|
368
|
+
// --- Archive ---------------------------------------------------------------
|
|
369
|
+
// A task can leave the live list without being lost: archive moves a root and
|
|
370
|
+
// its subtasks to <TASKS_DIR>/archive/<same file name>, restore moves them back
|
|
371
|
+
// with the same ids. Archiving always needs an explicit reason saying how the
|
|
372
|
+
// task closed — "done: <proof>", "replaced: <by what>", "dropped: <who
|
|
373
|
+
// decided>" or "handed: <to whom>" — stored as closedReason + reason text with
|
|
374
|
+
// archivedAt. Nothing archives by age or on its own.
|
|
375
|
+
const CLOSED_REASONS = ["done", "replaced", "dropped", "handed"];
|
|
376
|
+
|
|
377
|
+
function parseCloseReason(text) {
|
|
378
|
+
const m = /^\s*(done|replaced|dropped|handed)\s*:\s*(\S[\s\S]*)$/i.exec(String(text || ""));
|
|
379
|
+
if (!m) {
|
|
380
|
+
throw new Error('archive needs a reason: "done: <proof>", "replaced: <by what>", "dropped: <who decided>" or "handed: <to whom>"');
|
|
381
|
+
}
|
|
382
|
+
return { closedReason: m[1].toLowerCase(), reason: m[2].trim() };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function archiveFileFor(liveFile) {
|
|
386
|
+
return path.join(path.dirname(liveFile), "archive", path.basename(liveFile));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function archiveInFile(liveFile, id, reasonText, opts = {}) {
|
|
390
|
+
const { closedReason, reason } = parseCloseReason(reasonText);
|
|
391
|
+
const live = loadFile(liveFile);
|
|
392
|
+
const root = live.find((t) => t.id === id);
|
|
393
|
+
if (!root) return null;
|
|
394
|
+
if (root.parentId) throw new Error(`${id} is a subtask; archive its plan (${root.parentId}) instead`);
|
|
395
|
+
const moving = live.filter((t) => t.id === id || t.parentId === id);
|
|
396
|
+
const archivedAt = opts.now || Date.now();
|
|
397
|
+
const stamped = moving.map((t) => ({ ...t, archivedAt, closedReason, reason }));
|
|
398
|
+
// Archive first: a crash in between leaves a copy in both, never in neither.
|
|
399
|
+
const archiveFile = archiveFileFor(liveFile);
|
|
400
|
+
const ids = new Set(moving.map((t) => t.id));
|
|
401
|
+
saveFile(archiveFile, [...loadFile(archiveFile).filter((t) => !ids.has(t.id)), ...stamped]);
|
|
402
|
+
saveFile(liveFile, live.filter((t) => !ids.has(t.id)));
|
|
403
|
+
return { archived: stamped.find((t) => t.id === id), alsoArchived: stamped.length - 1 };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function restoreInFile(liveFile, id) {
|
|
407
|
+
const archiveFile = archiveFileFor(liveFile);
|
|
408
|
+
const archived = loadFile(archiveFile);
|
|
409
|
+
const root = archived.find((t) => t.id === id);
|
|
410
|
+
if (!root) return null;
|
|
411
|
+
if (root.parentId) throw new Error(`${id} is a subtask; restore its plan (${root.parentId}) instead`);
|
|
412
|
+
const live = loadFile(liveFile);
|
|
413
|
+
if (live.some((t) => t.id === id)) throw new Error(`${id} is already in the live list`);
|
|
414
|
+
const moving = archived.filter((t) => t.id === id || t.parentId === id);
|
|
415
|
+
const ids = new Set(moving.map((t) => t.id));
|
|
416
|
+
const restored = moving.map(({ archivedAt, closedReason, reason, ...t }) => t);
|
|
417
|
+
saveFile(liveFile, [...live.filter((t) => !ids.has(t.id)), ...restored]);
|
|
418
|
+
saveFile(archiveFile, archived.filter((t) => !ids.has(t.id)));
|
|
419
|
+
return { restored: restored.find((t) => t.id === id), alsoRestored: restored.length - 1 };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Archived roots (with their subtasks), most recently archived first;
|
|
423
|
+
// `search` matches the title, description or reason (any case).
|
|
424
|
+
function archivedInFile(liveFile, opts = {}) {
|
|
425
|
+
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 20;
|
|
426
|
+
const needle = String(opts.search || "").trim().toLowerCase();
|
|
427
|
+
const hit = (t) => [t.content, t.description, t.reason].some((v) => String(v || "").toLowerCase().includes(needle));
|
|
428
|
+
return treeOf(loadFile(archiveFileFor(liveFile)))
|
|
429
|
+
.filter((root) => !needle || hit(root) || (root.children || []).some(hit))
|
|
430
|
+
.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0))
|
|
431
|
+
.slice(0, limit);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function archive(adapter, channelId, id, reasonText, opts = {}) {
|
|
435
|
+
return archiveInFile(filePathFor(adapter, channelId), id, reasonText, opts);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function restore(adapter, channelId, id) {
|
|
439
|
+
return restoreInFile(filePathFor(adapter, channelId), id);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function archived(adapter, channelId, opts = {}) {
|
|
443
|
+
return archivedInFile(filePathFor(adapter, channelId), opts);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- Per-conversation injection -----------------------------------------
|
|
447
|
+
// The first turn of a session used to get the whole channel's tree, and every
|
|
448
|
+
// thread shares one channel file (hundreds of roots, ~100k chars on a busy
|
|
449
|
+
// owner chat). Now it gets this conversation's own open roots in full, one
|
|
450
|
+
// line about the rest of the chat, and a pointer to `task list`.
|
|
451
|
+
|
|
452
|
+
const CONVO_MAX_ROOTS = 12;
|
|
453
|
+
const CONVO_MAX_CHARS = 6000;
|
|
454
|
+
const SECTION_MAX_CHARS = 7500;
|
|
455
|
+
const ELSEWHERE_TITLES = 5;
|
|
456
|
+
const TITLE_CHARS = 80;
|
|
457
|
+
|
|
458
|
+
function clip(text, max) {
|
|
459
|
+
const s = String(text || "").replace(/\s+/g, " ").trim();
|
|
460
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Whether a root was made in this conversation (threadId null = the main
|
|
464
|
+
// timeline). A legacy root (no threadId field) belongs to none.
|
|
465
|
+
function inConversation(root, threadId) {
|
|
466
|
+
if (!Object.prototype.hasOwnProperty.call(root, "threadId")) return false;
|
|
467
|
+
return (root.threadId || null) === (threadId ? String(threadId) : null);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function formatConversationSection(adapter, channelId, opts = {}) {
|
|
471
|
+
const showIds = opts.showIds !== false;
|
|
472
|
+
const maxRoots = Number.isFinite(opts.maxRoots) ? opts.maxRoots : CONVO_MAX_ROOTS;
|
|
473
|
+
const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : CONVO_MAX_CHARS;
|
|
474
|
+
const cap = Number.isFinite(opts.sectionMaxChars) ? opts.sectionMaxChars : SECTION_MAX_CHARS;
|
|
475
|
+
const byRecency = openTree(adapter, channelId).sort((a, b) => lastActivityOf(b) - lastActivityOf(a));
|
|
476
|
+
if (byRecency.length === 0) return "";
|
|
477
|
+
const own = byRecency.filter((r) => inConversation(r, opts.threadId));
|
|
478
|
+
const elsewhere = byRecency.filter((r) => !inConversation(r, opts.threadId));
|
|
479
|
+
|
|
480
|
+
const lines = [
|
|
481
|
+
"## Pending tasks (this conversation)",
|
|
482
|
+
"Open tasks made in this conversation, most recently worked first. Mark a task in_progress when you actually start it and done when finished (`open-claudia task done <id>`), so the list stays honest. If something in our conversation shows a task is already finished, proactively offer to close it. Shown once per session.",
|
|
483
|
+
"",
|
|
484
|
+
];
|
|
485
|
+
if (own.length === 0) lines.push("None of the open tasks were made in this conversation.");
|
|
486
|
+
let used = 0;
|
|
487
|
+
let shown = 0;
|
|
488
|
+
for (const root of own) {
|
|
489
|
+
if (shown >= maxRoots) break;
|
|
490
|
+
let block = renderRootFull(root, shown + 1, showIds).join("\n");
|
|
491
|
+
if (used + block.length > maxChars) {
|
|
492
|
+
if (shown > 0) break;
|
|
493
|
+
block = clip(block, maxChars); // one oversized root still shows, cut
|
|
494
|
+
}
|
|
495
|
+
lines.push(block);
|
|
496
|
+
used += block.length;
|
|
497
|
+
shown += 1;
|
|
498
|
+
}
|
|
499
|
+
if (own.length > shown) lines.push(`+${own.length - shown} more: open-claudia task list`);
|
|
500
|
+
if (elsewhere.length > 0) {
|
|
501
|
+
const inProgress = elsewhere.filter((r) => r.status === "in_progress").length;
|
|
502
|
+
const titles = elsewhere.slice(0, ELSEWHERE_TITLES).map((r) => `"${clip(r.content, TITLE_CHARS)}"`).join("; ");
|
|
503
|
+
lines.push("", `Elsewhere in this chat: ${elsewhere.length} open (${inProgress} in progress); most recent: ${titles}`);
|
|
504
|
+
}
|
|
505
|
+
lines.push("", "Run `open-claudia task list` for the full tree (`task get <id>` for one task).");
|
|
506
|
+
const text = lines.join("\n");
|
|
507
|
+
return text.length > cap ? `${text.slice(0, cap - 1)}…` : text;
|
|
508
|
+
}
|
|
509
|
+
|
|
312
510
|
module.exports = {
|
|
313
511
|
filePathFor,
|
|
314
512
|
load, save,
|
|
@@ -317,5 +515,9 @@ module.exports = {
|
|
|
317
515
|
pendingSummary,
|
|
318
516
|
format, formatTree, statusMark,
|
|
319
517
|
get, staleRoots, formatForInjection, lastActivityOf,
|
|
320
|
-
|
|
518
|
+
formatConversationSection, inConversation,
|
|
519
|
+
taskLists, loadFile, staleRootsOf, treeOf, openTreeOf,
|
|
520
|
+
archive, restore, archived, archiveInFile, restoreInFile, archivedInFile, archiveFileFor,
|
|
521
|
+
parseCloseReason, CLOSED_REASONS,
|
|
522
|
+
STALE_DAYS, RECENCY_FULL, SECTION_MAX_CHARS,
|
|
321
523
|
};
|