@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/Dockerfile +3 -1
  3. package/README.md +1 -0
  4. package/bin/cli.js +1 -1
  5. package/bin/task.js +51 -0
  6. package/bot.js +19 -0
  7. package/channels/kazee/adapter.js +304 -53
  8. package/channels/kazee/bot-handoffs.js +230 -0
  9. package/channels/telegram/adapter.js +4 -2
  10. package/core/actions.js +8 -0
  11. package/core/browser-view.js +200 -0
  12. package/core/config.js +19 -1
  13. package/core/cost-report.js +3 -0
  14. package/core/cron-schedule.js +59 -0
  15. package/core/dream.js +141 -34
  16. package/core/guardrail.js +5 -0
  17. package/core/handlers.js +24 -12
  18. package/core/lessons.js +11 -5
  19. package/core/loopback.js +29 -0
  20. package/core/passive-replies.js +4 -2
  21. package/core/providers/claude-events.js +9 -0
  22. package/core/providers/codex-hook.js +5 -1
  23. package/core/providers/codex.js +48 -0
  24. package/core/relationship.js +1 -1
  25. package/core/router.js +6 -1
  26. package/core/runner.js +264 -8
  27. package/core/safe-filename.js +47 -0
  28. package/core/scheduler.js +137 -36
  29. package/core/system-prompt.js +89 -16
  30. package/core/tasks.js +208 -6
  31. package/core/turn-journal.js +506 -0
  32. package/core/utility-agent.js +17 -1
  33. package/package.json +4 -4
  34. package/test-fixtures/fake-agent-cli.js +17 -1
  35. package/test-guardrail.js +19 -0
  36. package/test-kazee-context-media.js +1 -1
  37. package/test-provider-dream.js +3 -1
  38. package/test-provider-events.js +21 -0
  39. package/test-provider-language.js +20 -1
  40. package/test-provider-pack-review.js +3 -1
  41. package/test-provider-scheduler.js +3 -1
  42. package/test-provider-tool-hooks.js +17 -0
  43. package/test-thread-lanes.js +18 -4
  44. 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
- const f = filePathFor(adapter, channelId);
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
- const all = load(adapter, channelId);
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 tree(adapter, channelId)
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 openTree(adapter, channelId)
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
- STALE_DAYS, RECENCY_FULL,
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
  };