@inetafrica/open-claudia 2.6.26 → 2.6.28

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/bin/task.js CHANGED
@@ -11,6 +11,7 @@ Per-channel todo list with optional plan/subtask hierarchy.
11
11
  open-claudia task add "<content>" [--parent <id>] [--description "<...>"]
12
12
  open-claudia task plan "<title>" "<sub1>" "<sub2>" ... [--description "<...>"]
13
13
  open-claudia task list [--status pending|in_progress|completed]
14
+ open-claudia task get <id> # full detail for one task (+ its plan/subtasks)
14
15
  open-claudia task start <id>
15
16
  open-claudia task done <id> # completes AND removes; last subtask done removes the plan
16
17
  # a plan won't complete while any subtask is still open
@@ -102,6 +103,27 @@ async function runList(args) {
102
103
  } catch (e) { console.error(e.message); process.exit(1); }
103
104
  }
104
105
 
106
+ async function runGet(args) {
107
+ const id = args[0];
108
+ if (!id) { console.error("Usage: task get <id>"); process.exit(2); }
109
+ try {
110
+ const res = await postJson("task-get", { id });
111
+ if (!res.ok) { console.error(`Not found: ${id}`); process.exit(1); }
112
+ const t = res.task;
113
+ if (res.parent) console.log(`Plan: ${statusMark(res.parent)} ${res.parent.content} (${res.parent.id})`);
114
+ console.log(`${statusMark(t)} ${t.content} (${t.id})`);
115
+ if (t.description) console.log(`\n${t.description}`);
116
+ const kids = res.children || [];
117
+ if (kids.length > 0) {
118
+ console.log("\nSubtasks:");
119
+ kids.forEach((c) => console.log(` ${statusMark(c)} ${c.content} (${c.id})`));
120
+ }
121
+ const upd = t.updatedAt ? new Date(t.updatedAt).toISOString() : "?";
122
+ console.log(`\nstatus: ${t.status} · updated: ${upd}`);
123
+ process.exit(0);
124
+ } catch (e) { console.error(e.message); process.exit(1); }
125
+ }
126
+
105
127
  async function runUpdate(args, status) {
106
128
  const id = args[0];
107
129
  if (!id) { console.error(`Usage: task ${status === "in_progress" ? "start" : "done"} <id>`); process.exit(2); }
@@ -152,6 +174,7 @@ async function run(args) {
152
174
  case "add": return runAdd(rest);
153
175
  case "plan": return runPlan(rest);
154
176
  case "list": case "ls": return runList(rest);
177
+ case "get": case "show": return runGet(rest);
155
178
  case "start": return runUpdate(rest, "in_progress");
156
179
  case "done": case "complete": return runUpdate(rest, "completed");
157
180
  case "pending": return runUpdate(rest, "pending");
package/core/dream.js CHANGED
@@ -332,6 +332,25 @@ function applyDream(decision, backupRoot) {
332
332
  return lines;
333
333
  }
334
334
 
335
+ // Task-hygiene pass: surface tasks gone cold for the owner to decide on.
336
+ // Flag-and-ask only — tasks are user intent, so dream NEVER edits or deletes
337
+ // them; it just lists the stalest few in the morning report. Reports against
338
+ // the owner's Telegram channel (where the dream summary lands).
339
+ const STALE_REPORT_MAX = Number(process.env.DREAM_STALE_TASK_MAX) || 8;
340
+ function staleTaskReport() {
341
+ try {
342
+ const tasks = require("./tasks");
343
+ const { CHAT_ID } = require("./config");
344
+ if (!CHAT_ID) return "";
345
+ const stale = tasks.staleRoots("telegram", String(CHAT_ID));
346
+ if (!stale.length) return "";
347
+ const shown = stale.slice(0, STALE_REPORT_MAX);
348
+ const lines = shown.map((s) => ` - ${s.content} (${s.id}) · ${s.idleDays}d idle`);
349
+ const more = stale.length > shown.length ? `\n …and ${stale.length - shown.length} more` : "";
350
+ return `🧹 ${stale.length} task${stale.length === 1 ? "" : "s"} have gone cold (untouched >${tasks.STALE_DAYS}d). Worth closing or reviving?\n${lines.join("\n")}${more}\nClose with \`open-claudia task done <id>\`, or tell me to.`;
351
+ } catch (e) { return ""; }
352
+ }
353
+
335
354
  async function runDream({ trigger = "manual" } = {}) {
336
355
  if (!enabled()) return { skipped: "dream is disabled (DREAM=off)" };
337
356
  if (_dreaming) return { skipped: "a dream is already in progress" };
@@ -353,11 +372,14 @@ async function runDream({ trigger = "manual" } = {}) {
353
372
  const applied = applyDream(decision, backupRoot);
354
373
  const report = decision.report || (applied.length > 0 ? "Tidied up my memory overnight." : "");
355
374
 
356
- const message = applied.length > 0
375
+ const staleNote = staleTaskReport();
376
+
377
+ let message = applied.length > 0
357
378
  ? `💤 ${report}\n\n${applied.join("\n")}\n\n🗄 Anything merged away is backed up under ${backupRoot}`
358
379
  : (report ? `💤 ${report}` : "");
380
+ if (staleNote) message = message ? `${message}\n\n${staleNote}` : `💤 ${staleNote}`;
359
381
 
360
- return { applied, report, message, trigger };
382
+ return { applied, report, message, staleNote, trigger };
361
383
  } finally {
362
384
  _dreaming = false;
363
385
  }
package/core/loopback.js CHANGED
@@ -73,7 +73,7 @@ function readBodyToFile(req, dest) {
73
73
  const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
74
74
  const JSON_KINDS = new Set([
75
75
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
76
- "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-tree", "task-clear-completed",
76
+ "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
77
77
  "people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
78
78
  "people-remove", "people-set-primary",
79
79
  "intros-list", "intros-approve", "intros-reject",
@@ -248,6 +248,12 @@ async function handleJson(req, res, url, kind) {
248
248
  return reply(res, 200, { ok: true, tasks: list });
249
249
  }
250
250
 
251
+ if (kind === "task-get") {
252
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
253
+ const got = tasksStore.get(adapterId, channelId, payload.id);
254
+ return reply(res, got ? 200 : 404, { ok: !!got, ...(got || {}) });
255
+ }
256
+
251
257
  if (kind === "task-clear-completed") {
252
258
  const remaining = tasksStore.clearCompleted(adapterId, channelId);
253
259
  return reply(res, 200, { ok: true, tasks: remaining });
package/core/runner.js CHANGED
@@ -130,6 +130,11 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
130
130
  const backend = state.settings?.backend || "claude";
131
131
  const parts = usageParts(usage, backend);
132
132
  const usageScope = opts.usageScope || "turn_delta";
133
+ // `parts.context` is the per-turn SUM of every API call's prefix, inflated
134
+ // by re-counting the cached prefix once per tool round-trip. When the caller
135
+ // tracked the true peak single-call prefix, report that as contextTokens and
136
+ // keep the inflated sum as billedContextTokens for cost attribution.
137
+ const hasPeak = Number.isFinite(opts.liveContextTokens) && opts.liveContextTokens > 0;
133
138
  const record = {
134
139
  ts: new Date().toISOString(),
135
140
  version: PKG_VERSION,
@@ -142,7 +147,8 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
142
147
  outputTokens: parts.output,
143
148
  cacheReadTokens: parts.cacheRead,
144
149
  cacheCreationTokens: parts.cacheCreation,
145
- contextTokens: parts.context,
150
+ contextTokens: hasPeak ? opts.liveContextTokens : parts.context,
151
+ billedContextTokens: parts.context,
146
152
  costUsd: typeof costUsd === "number" ? costUsd : 0,
147
153
  };
148
154
  if (opts.rawUsage && opts.rawUsage !== usage) {
@@ -785,6 +791,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
785
791
  let toolUses = [];
786
792
  let currentTool = null;
787
793
  let currentToolDetail = "";
794
+ // True context-window occupancy is the largest single API call's prefix
795
+ // (input + cache_read + cache_creation), NOT the per-turn sum the result
796
+ // event reports — that sum re-counts the cached prefix once per tool
797
+ // round-trip and balloons to millions. Track the peak per-call prefix.
798
+ let peakContextTokens = 0;
788
799
 
789
800
  // Hermes-style skill announcements: one chat line when a learned skill
790
801
  // is invoked or its SKILL.md is written/patched. Deduped per turn.
@@ -897,6 +908,10 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
897
908
  const lastNewline = state.streamBuffer.lastIndexOf("\n");
898
909
  state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
899
910
  for (const evt of events) {
911
+ if (evt.type === "assistant" && evt.message?.usage) {
912
+ const callPrefix = usageParts(evt.message.usage, settings.backend || "claude").context;
913
+ if (callPrefix > peakContextTokens) peakContextTokens = callPrefix;
914
+ }
900
915
  if (evt.type === "assistant" && evt.message?.content) {
901
916
  for (const block of evt.message.content) {
902
917
  if (block.type === "text") assistantText += block.text;
@@ -1003,8 +1018,10 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1003
1018
  // Codex already reports usage at turn.completed. A result usage block,
1004
1019
  // if present, is the same cumulative total and must not be counted again.
1005
1020
  if (evt.usage && settings.backend !== "codex") {
1006
- applyUsageToState(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, { usageScope: "turn_delta" });
1007
- logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)), { usageScope: "turn_delta" });
1021
+ const peakOpts = { usageScope: "turn_delta" };
1022
+ if (peakContextTokens > 0) peakOpts.liveContextTokens = peakContextTokens;
1023
+ applyUsageToState(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, peakOpts);
1024
+ logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)), peakOpts);
1008
1025
  }
1009
1026
  if (typeof evt.total_cost_usd === "number" && settings.backend === "codex") state.sessionUsage.costUsd += evt.total_cost_usd;
1010
1027
  saveState();
@@ -270,8 +270,8 @@ function buildDynamicContextBlock() {
270
270
  const sess = state.lastSessionId || "new";
271
271
  if (taskTreeInjectedFor.get(key) !== sess) {
272
272
  taskTreeInjectedFor.set(key, sess);
273
- const tree = tasksStore.formatTree(adapter.id, channelId, { showIds: true, hideCompleted: true });
274
- lines.push("", "## Pending tasks", "You may be resuming prior work. Before starting anything new, review these and decide whether to continue, update, or abandon them. Mark a subtask in_progress when you actually start it and completed when it's done. This tree is only shown once per session — run `open-claudia task list` to see it again later.", "", tree);
273
+ const tree = tasksStore.formatForInjection(adapter.id, channelId, { showIds: true });
274
+ lines.push("", "## Pending tasks", "Ranked by recent activity (`updatedAt`). The most-recently-worked items show in full; colder ones are title-only; stale ones (untouched >2w) are batched at the bottom — they're likely finished or abandoned, so close them with `open-claudia task done <id>` or reopen by working them. Mark a task in_progress when you actually start it and done when finished, so this ranking stays honest. If something in our conversation shows a task is already finished, proactively offer to close it. Shown once per session — run `open-claudia task list` for the full tree.", "", tree);
275
275
  } else {
276
276
  const inProgress = pending.filter((t) => t.status === "in_progress").length;
277
277
  lines.push("", `## Pending tasks: ${pending.length} open${inProgress > 0 ? ` (${inProgress} in progress)` : ""} — run \`open-claudia task list\` for the tree. Keep statuses current as you work.`);
package/core/tasks.js CHANGED
@@ -213,6 +213,102 @@ function formatTree(adapter, channelId, opts = {}) {
213
213
  return lines.join("\n");
214
214
  }
215
215
 
216
+ // --- Task hygiene (recency + staleness) ---------------------------------
217
+ // updatedAt is the honest activity signal — a task can rot in "in_progress"
218
+ // forever, so status is a poor proxy for what's actually being worked on.
219
+ // updatedAt only moves on mutation (add/update/start/done/edit), so this
220
+ // rewards keeping statuses current and lets cold work fall to the bottom.
221
+
222
+ const STALE_DAYS = Number(process.env.OPEN_CLAUDIA_TASK_STALE_DAYS) || 14;
223
+ const RECENCY_FULL = Number(process.env.OPEN_CLAUDIA_TASK_RECENCY_FULL) || 6;
224
+ const DAY_MS = 86400000;
225
+
226
+ // A plan is as fresh as its most recently touched part: a plan root rarely
227
+ // gets edited while its subtasks churn, so fold child activity into the root.
228
+ function lastActivityOf(root) {
229
+ let latest = root.updatedAt || root.createdAt || 0;
230
+ for (const c of root.children || []) {
231
+ latest = Math.max(latest, c.updatedAt || c.createdAt || 0);
232
+ }
233
+ return latest;
234
+ }
235
+
236
+ function openTree(adapter, channelId) {
237
+ return tree(adapter, channelId)
238
+ .map((r) => ({ ...r, children: (r.children || []).filter((c) => c.status !== "completed") }))
239
+ .filter((r) => r.status !== "completed" || r.children.length > 0);
240
+ }
241
+
242
+ // Fetch one task with its surrounding context, for the `task get <id>` hint
243
+ // the injection block points cold tasks at.
244
+ function get(adapter, channelId, id) {
245
+ const all = load(adapter, channelId);
246
+ const t = all.find((x) => x.id === id);
247
+ if (!t) return null;
248
+ const children = all.filter((c) => c.parentId === id);
249
+ const parent = t.parentId ? all.find((p) => p.id === t.parentId) || null : null;
250
+ return { task: t, children, parent };
251
+ }
252
+
253
+ // Roots untouched for longer than `days` (default STALE_DAYS), oldest first.
254
+ // Used by the dream task-hygiene pass to surface cold work for a user
255
+ // decision — it flags and asks, never deletes (tasks are user intent).
256
+ function staleRoots(adapter, channelId, opts = {}) {
257
+ const days = Number(opts.days) || STALE_DAYS;
258
+ const now = opts.now || Date.now();
259
+ return openTree(adapter, channelId)
260
+ .map((r) => ({ root: r, idleDays: (now - lastActivityOf(r)) / DAY_MS }))
261
+ .filter((x) => x.idleDays > days)
262
+ .sort((a, b) => b.idleDays - a.idleDays)
263
+ .map((x) => ({ id: x.root.id, content: x.root.content, status: x.root.status, idleDays: Math.round(x.idleDays) }));
264
+ }
265
+
266
+ function renderRootFull(root, num, showIds) {
267
+ const idTag = showIds ? ` (${root.id})` : "";
268
+ const lines = [`${num}. ${statusMark(root)} ${root.content}${idTag}`];
269
+ if (root.description) lines.push(` ${root.description}`);
270
+ for (const c of root.children || []) {
271
+ lines.push(` ${statusMark(c)} ${c.content}${showIds ? ` (${c.id})` : ""}`);
272
+ }
273
+ return lines;
274
+ }
275
+
276
+ // Recency-ranked injection body. The few most-recently-active roots render
277
+ // in full; the rest render title-only with a `task get` pointer; anything
278
+ // untouched past STALE_DAYS is batched one-line into a stale footer so it
279
+ // stops eating fresh tokens regardless of status. Returns "" when empty.
280
+ function formatForInjection(adapter, channelId, opts = {}) {
281
+ const showIds = opts.showIds !== false;
282
+ const now = opts.now || Date.now();
283
+ const fullCount = Number.isFinite(opts.fullCount) ? opts.fullCount : RECENCY_FULL;
284
+ const staleDays = Number(opts.staleDays) || STALE_DAYS;
285
+
286
+ const roots = openTree(adapter, channelId);
287
+ if (roots.length === 0) return "";
288
+
289
+ const decorated = roots.map((r) => ({ root: r, idleDays: (now - lastActivityOf(r)) / DAY_MS }));
290
+ const stale = decorated.filter((x) => x.idleDays > staleDays).sort((a, b) => b.idleDays - a.idleDays);
291
+ const active = decorated.filter((x) => x.idleDays <= staleDays).sort((a, b) => lastActivityOf(b.root) - lastActivityOf(a.root));
292
+
293
+ const lines = [];
294
+ let n = 0;
295
+ const full = active.slice(0, fullCount);
296
+ const brief = active.slice(fullCount);
297
+ for (const x of full) lines.push(...renderRootFull(x.root, ++n, showIds));
298
+ if (brief.length > 0) {
299
+ lines.push("", `Less recently touched — title only (run \`open-claudia task get <id>\` for detail):`);
300
+ for (const x of brief) lines.push(`${++n}. ${statusMark(x.root)} ${x.root.content}${showIds ? ` (${x.root.id})` : ""}`);
301
+ }
302
+ if (stale.length > 0) {
303
+ const staleMax = Number.isFinite(opts.staleMax) ? opts.staleMax : 10;
304
+ const shown = stale.slice(0, staleMax);
305
+ lines.push("", `+${stale.length} stale — untouched >${staleDays}d, likely finished or abandoned. Review and close with \`open-claudia task done <id>\` (or \`task get <id>\` first):`);
306
+ for (const x of shown) lines.push(` - ${statusMark(x.root)} ${x.root.content} (${x.root.id}) · ${Math.round(x.idleDays)}d idle`);
307
+ if (stale.length > shown.length) lines.push(` …and ${stale.length - shown.length} more — \`open-claudia task list\` for all`);
308
+ }
309
+ return lines.join("\n");
310
+ }
311
+
216
312
  module.exports = {
217
313
  filePathFor,
218
314
  load, save,
@@ -220,4 +316,6 @@ module.exports = {
220
316
  prune, complete,
221
317
  pendingSummary,
222
318
  format, formatTree, statusMark,
319
+ get, staleRoots, formatForInjection, lastActivityOf,
320
+ STALE_DAYS, RECENCY_FULL,
223
321
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.26",
3
+ "version": "2.6.28",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {