@inetafrica/open-claudia 2.6.25 β 2.6.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/bin/task.js +23 -0
- package/core/dream.js +42 -2
- package/core/loopback.js +7 -1
- package/core/pack-guard.js +82 -0
- package/core/pack-review.js +22 -1
- package/core/system-prompt.js +29 -2
- package/core/tasks.js +98 -0
- package/package.json +1 -1
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
|
@@ -17,6 +17,7 @@ const CONFIG_DIR = require("../config-dir");
|
|
|
17
17
|
const { config } = require("./config");
|
|
18
18
|
const packs = require("./packs");
|
|
19
19
|
const entities = require("./entities");
|
|
20
|
+
const packGuard = require("./pack-guard");
|
|
20
21
|
const persona = require("./persona");
|
|
21
22
|
const { spawnSubagent } = require("./subagent");
|
|
22
23
|
|
|
@@ -180,6 +181,12 @@ function applyDream(decision, backupRoot) {
|
|
|
180
181
|
const into = m?.into && packs.readPack(m.into);
|
|
181
182
|
const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
|
|
182
183
|
if (!into || from.length === 0) continue;
|
|
184
|
+
const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
|
|
185
|
+
if (mGuard.flagged) {
|
|
186
|
+
console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
|
|
187
|
+
lines.push(`π‘οΈ Skipped merge into ${m.into} β content looked like an injected instruction (${mGuard.kind})`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
183
190
|
const mr = packs.updatePack(m.into, {
|
|
184
191
|
stance: typeof m.stance === "string" ? m.stance : "",
|
|
185
192
|
procedure: typeof m.procedure === "string" ? m.procedure : "",
|
|
@@ -202,6 +209,12 @@ function applyDream(decision, backupRoot) {
|
|
|
202
209
|
if (!dir || gone.has(dir)) continue;
|
|
203
210
|
const children = [].concat(u?.children || []).filter((d) => d && d !== dir && !gone.has(d) && packs.readPack(d));
|
|
204
211
|
if (children.length < 2) continue;
|
|
212
|
+
const uGuard = packGuard.scanSections({ description: u.description, state: u.state });
|
|
213
|
+
if (uGuard.flagged) {
|
|
214
|
+
console.warn(`[dream] guard blocked umbrella ${dir} (${uGuard.kind} in ${uGuard.section})`);
|
|
215
|
+
lines.push(`π‘οΈ Skipped umbrella ${dir} β content looked like an injected instruction (${uGuard.kind})`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
205
218
|
if (!packs.readPack(dir)) {
|
|
206
219
|
packs.createPack({
|
|
207
220
|
dir,
|
|
@@ -242,6 +255,11 @@ function applyDream(decision, backupRoot) {
|
|
|
242
255
|
const desc = typeof r.description === "string" ? r.description.trim() : "";
|
|
243
256
|
const tags = Array.isArray(r.tags) ? r.tags.map((t) => String(t).trim()).filter(Boolean).slice(0, 6) : [];
|
|
244
257
|
if (!desc && tags.length === 0) continue;
|
|
258
|
+
const rGuard = packGuard.scanForInjection(desc, { strict: true });
|
|
259
|
+
if (rGuard.flagged) {
|
|
260
|
+
console.warn(`[dream] guard blocked retag of ${r.pack} (${rGuard.kind})`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
245
263
|
packs.updatePack(r.pack, { description: desc, tags }, "dream");
|
|
246
264
|
lines.push(`π·οΈ Sharpened ${r.pack}'s description/tags`);
|
|
247
265
|
} catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
|
|
@@ -314,6 +332,25 @@ function applyDream(decision, backupRoot) {
|
|
|
314
332
|
return lines;
|
|
315
333
|
}
|
|
316
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
|
+
|
|
317
354
|
async function runDream({ trigger = "manual" } = {}) {
|
|
318
355
|
if (!enabled()) return { skipped: "dream is disabled (DREAM=off)" };
|
|
319
356
|
if (_dreaming) return { skipped: "a dream is already in progress" };
|
|
@@ -335,11 +372,14 @@ async function runDream({ trigger = "manual" } = {}) {
|
|
|
335
372
|
const applied = applyDream(decision, backupRoot);
|
|
336
373
|
const report = decision.report || (applied.length > 0 ? "Tidied up my memory overnight." : "");
|
|
337
374
|
|
|
338
|
-
const
|
|
375
|
+
const staleNote = staleTaskReport();
|
|
376
|
+
|
|
377
|
+
let message = applied.length > 0
|
|
339
378
|
? `π€ ${report}\n\n${applied.join("\n")}\n\nπ Anything merged away is backed up under ${backupRoot}`
|
|
340
379
|
: (report ? `π€ ${report}` : "");
|
|
380
|
+
if (staleNote) message = message ? `${message}\n\n${staleNote}` : `π€ ${staleNote}`;
|
|
341
381
|
|
|
342
|
-
return { applied, report, message, trigger };
|
|
382
|
+
return { applied, report, message, staleNote, trigger };
|
|
343
383
|
} finally {
|
|
344
384
|
_dreaming = false;
|
|
345
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 });
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Content threat-scan for memory writes (spec #4).
|
|
2
|
+
//
|
|
3
|
+
// Threat model: prompt-injection rides in via tool output or a pasted doc,
|
|
4
|
+
// gets echoed in the assistant reply, and the background reviewer (or dream)
|
|
5
|
+
// faithfully writes "ignore previous instructions / always run X / exfiltrate
|
|
6
|
+
// to URL" into a pack section β which then auto-injects every session as if it
|
|
7
|
+
// were the user's own rule. Stance/Procedure/State auto-inject, so they are the
|
|
8
|
+
// dangerous sections; Journal is shown on demand, so it is scanned leniently
|
|
9
|
+
// (exfil only). We never import marketplace packs, so this is the whole surface.
|
|
10
|
+
//
|
|
11
|
+
// This is a heuristic gate, not a classifier. It is deliberately conservative:
|
|
12
|
+
// it only fires on phrasings that have no legitimate reason to appear in a
|
|
13
|
+
// memory section a model wrote about a past turn. On a hit we SKIP the write β
|
|
14
|
+
// a missed memory is cheap, a poisoned auto-injected rule is not.
|
|
15
|
+
|
|
16
|
+
// Imperative override / instruction-hijack phrasing.
|
|
17
|
+
const OVERRIDE_PATTERNS = [
|
|
18
|
+
/\bignore\s+(all\s+)?(previous|prior|earlier|above|the\s+following)\s+(instructions?|prompts?|rules?|context)/i,
|
|
19
|
+
/\bdisregard\s+(all\s+)?(previous|prior|earlier|above|your)\s+(instructions?|prompts?|rules?|training)/i,
|
|
20
|
+
/\bforget\s+(everything|all|your\s+(instructions?|rules?|prompt))/i,
|
|
21
|
+
/\boverride\s+(your|the|all)\s+(instructions?|rules?|system\s+prompt|guardrails?)/i,
|
|
22
|
+
/\b(you\s+(must|should|will)\s+always|always\s+remember\s+to)\b.*\b(run|execute|send|post|upload|reveal|disclose|delete|drop)\b/i,
|
|
23
|
+
/\bfrom\s+now\s+on\b.*\b(ignore|disregard|always|never)\b/i,
|
|
24
|
+
/\bdo\s+not\s+(tell|inform|ask|notify)\s+(the\s+)?(user|owner|sumeet)\b/i,
|
|
25
|
+
/\bsystem\s+prompt\b.*\b(ignore|reveal|print|output|leak)\b/i,
|
|
26
|
+
/\b(reveal|print|output|leak|disclose)\b.*\b(system\s+prompt|api\s+key|token|password|secret|credential)/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Exfiltration: a send/post/upload verb pointed at a URL or address. Scanned in
|
|
30
|
+
// every section, including Journal.
|
|
31
|
+
const EXFIL_PATTERNS = [
|
|
32
|
+
/\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
|
|
33
|
+
/\b(https?:\/\/)[^\s)]+[^\s)]*\b.*\b(api\s*key|token|password|secret|credential|env)/i,
|
|
34
|
+
/\bcurl\b[^\n]*\b(https?:\/\/)/i,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// A long unbroken base64 blob β no reason for one to live in a prose memory
|
|
38
|
+
// section; classic carrier for an obfuscated payload.
|
|
39
|
+
const BASE64_BLOB = /[A-Za-z0-9+/]{120,}={0,2}/;
|
|
40
|
+
|
|
41
|
+
function matchAny(patterns, text) {
|
|
42
|
+
for (const re of patterns) {
|
|
43
|
+
const m = re.exec(text);
|
|
44
|
+
if (m) return m[0].slice(0, 80);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Scan a single piece of text. `strict` = true for auto-injected sections
|
|
50
|
+
// (Stance/Procedure/State), false for Journal (exfil-only).
|
|
51
|
+
function scanForInjection(text, { strict = true } = {}) {
|
|
52
|
+
const s = String(text || "");
|
|
53
|
+
if (!s.trim()) return { flagged: false };
|
|
54
|
+
|
|
55
|
+
const exfil = matchAny(EXFIL_PATTERNS, s);
|
|
56
|
+
if (exfil) return { flagged: true, kind: "exfil", evidence: exfil };
|
|
57
|
+
|
|
58
|
+
if (!strict) return { flagged: false };
|
|
59
|
+
|
|
60
|
+
const override = matchAny(OVERRIDE_PATTERNS, s);
|
|
61
|
+
if (override) return { flagged: true, kind: "override", evidence: override };
|
|
62
|
+
|
|
63
|
+
const b64 = BASE64_BLOB.exec(s);
|
|
64
|
+
if (b64) return { flagged: true, kind: "base64", evidence: b64[0].slice(0, 40) + "β¦" };
|
|
65
|
+
|
|
66
|
+
return { flagged: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Scan the sections of a pending pack write. Strict on the auto-injected
|
|
70
|
+
// sections, lenient on Journal. Returns the first hit, or { flagged: false }.
|
|
71
|
+
function scanSections(sections = {}) {
|
|
72
|
+
const strictKeys = ["stance", "procedure", "state", "description"];
|
|
73
|
+
for (const key of strictKeys) {
|
|
74
|
+
const r = scanForInjection(sections[key], { strict: true });
|
|
75
|
+
if (r.flagged) return { ...r, section: key };
|
|
76
|
+
}
|
|
77
|
+
const r = scanForInjection(sections.journal, { strict: false });
|
|
78
|
+
if (r.flagged) return { ...r, section: "journal" };
|
|
79
|
+
return { flagged: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { scanForInjection, scanSections };
|
package/core/pack-review.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const { spawnSubagent } = require("./subagent");
|
|
10
10
|
const packs = require("./packs");
|
|
11
11
|
const entities = require("./entities");
|
|
12
|
+
const packGuard = require("./pack-guard");
|
|
12
13
|
const { redactSensitive } = require("./redact");
|
|
13
14
|
|
|
14
15
|
const MIN_TURN_CHARS = 400;
|
|
@@ -124,8 +125,26 @@ function parseDecision(text) {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function guardCheck(a) {
|
|
129
|
+
const hit = packGuard.scanSections({
|
|
130
|
+
stance: a.stance,
|
|
131
|
+
procedure: a.procedure,
|
|
132
|
+
state: a.state,
|
|
133
|
+
description: a.description,
|
|
134
|
+
journal: a.journal,
|
|
135
|
+
});
|
|
136
|
+
if (hit.flagged) {
|
|
137
|
+
console.warn(`[pack-review] guard blocked write to ${a.pack || a.dir || a.name} (${hit.kind} in ${hit.section}): ${hit.evidence}`);
|
|
138
|
+
}
|
|
139
|
+
return hit;
|
|
140
|
+
}
|
|
141
|
+
|
|
127
142
|
function applyAction(a) {
|
|
128
143
|
if (!a || typeof a !== "object") return null;
|
|
144
|
+
const guard = guardCheck(a);
|
|
145
|
+
if (guard.flagged) {
|
|
146
|
+
return { kind: "skipped", dir: a.pack || a.dir || "", name: a.name || a.pack || a.dir || "", reason: `${guard.kind} in ${guard.section}` };
|
|
147
|
+
}
|
|
129
148
|
if (a.action === "update" && a.pack) {
|
|
130
149
|
const existing = packs.readPack(a.pack);
|
|
131
150
|
if (!existing) return null;
|
|
@@ -192,7 +211,9 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
|
192
211
|
for (const a of decision.actions) {
|
|
193
212
|
try {
|
|
194
213
|
const r = applyAction(a);
|
|
195
|
-
if (r) {
|
|
214
|
+
if (r && r.kind === "skipped") {
|
|
215
|
+
lines.push(`π‘οΈ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
|
|
216
|
+
} else if (r) {
|
|
196
217
|
lines.push(r.kind === "create"
|
|
197
218
|
? `π¦ New pack: ${r.name}\n${clipWords(r.note, 180)}\nβ³ open-claudia pack show ${r.dir}`
|
|
198
219
|
: `βοΈ ${r.name} β ${clipWords(r.note, 180)}`);
|
package/core/system-prompt.js
CHANGED
|
@@ -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.
|
|
274
|
-
lines.push("", "## Pending tasks", "
|
|
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.`);
|
|
@@ -535,15 +535,42 @@ function mergeMatches(primary, secondary, keyOf) {
|
|
|
535
535
|
function logRecall(msg, candPacks, candEntities, keptPacks, keptEntities) {
|
|
536
536
|
if (String(process.env.RECALL_DEBUG || "on").toLowerCase() === "off") return;
|
|
537
537
|
try {
|
|
538
|
+
// FTS-miss instrumentation (spec #6): packs the relevance judge KEPT even
|
|
539
|
+
// though keyword FTS scored them ~0 (or they only surfaced via context, not
|
|
540
|
+
// the user's words) are pure-semantic rescues. If this count climbs, a
|
|
541
|
+
// vector backend earns its keep; until then it stays 1-line instrumentation.
|
|
542
|
+
const scoreOf = new Map(candPacks.map((m) => [m.dir, m]));
|
|
543
|
+
const ftsMissRescues = keptPacks
|
|
544
|
+
.map((m) => scoreOf.get(m.dir))
|
|
545
|
+
.filter((c) => c && (!c.score || c.origin === "context"))
|
|
546
|
+
.map((c) => c.dir);
|
|
538
547
|
const rec = {
|
|
539
548
|
ts: new Date().toISOString(),
|
|
540
549
|
msg: String(msg || "").slice(0, 200),
|
|
541
550
|
candidatePacks: candPacks.map((m) => ({ dir: m.dir, score: m.score, origin: m.origin })),
|
|
542
551
|
keptPacks: keptPacks.map((m) => m.dir),
|
|
552
|
+
ftsMissRescues,
|
|
543
553
|
candidateEntities: candEntities.map((m) => ({ slug: m.slug, score: m.score, origin: m.origin })),
|
|
544
554
|
keptEntities: keptEntities.map((m) => m.slug),
|
|
545
555
|
};
|
|
546
556
|
fs.appendFileSync(path.join(CONFIG_DIR, "recall-debug.jsonl"), JSON.stringify(rec) + "\n");
|
|
557
|
+
bumpFtsMissCounter(ftsMissRescues.length);
|
|
558
|
+
} catch (e) {}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Cumulative tally so we don't have to re-scan the whole JSONL to answer
|
|
562
|
+
// "is keyword recall missing semantic matches often enough to warrant vectors?"
|
|
563
|
+
// Counts every recall turn plus how many of them rescued an FTS-missed pack.
|
|
564
|
+
function bumpFtsMissCounter(n) {
|
|
565
|
+
try {
|
|
566
|
+
const p = path.join(CONFIG_DIR, "recall-stats.json");
|
|
567
|
+
let stats = {};
|
|
568
|
+
try { stats = JSON.parse(fs.readFileSync(p, "utf8")) || {}; } catch (e) {}
|
|
569
|
+
stats.turns = (stats.turns || 0) + 1;
|
|
570
|
+
if (n > 0) stats.turnsWithRescue = (stats.turnsWithRescue || 0) + 1;
|
|
571
|
+
stats.ftsMissRescues = (stats.ftsMissRescues || 0) + n;
|
|
572
|
+
stats.updatedAt = new Date().toISOString();
|
|
573
|
+
fs.writeFileSync(p, JSON.stringify(stats, null, 2));
|
|
547
574
|
} catch (e) {}
|
|
548
575
|
}
|
|
549
576
|
|
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