@inetafrica/open-claudia 2.2.21 → 2.2.23
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 +14 -0
- package/bin/task.js +10 -4
- package/core/loopback.js +4 -0
- package/core/runner.js +175 -11
- package/core/system-prompt.js +55 -18
- package/core/tasks.js +43 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.2.23
|
|
4
|
+
- **Token economy: tasks (R2).** The per-channel todo list no longer rides every prompt at full size.
|
|
5
|
+
- **Done means gone**: completing a task now deletes it from the store instead of leaving an `[x]` row. Finishing the last subtask of a plan deletes the whole plan. `tasks.complete()` wraps the status flip plus a `prune()` pass; the loopback `task-update` handler routes `status: completed` through it and reports how many entries were removed, so the CLI can tell the agent what disappeared. `prune()` also retires legacy debris — plans whose subtasks are all completed but whose own status was never flipped.
|
|
6
|
+
- **Once-per-session tree injection**: the full pending-task tree is only injected into the first turn of a session (after restart, new conversation, or compaction) — exactly when the agent needs to rediscover where it left off. Later turns get a one-line count (`## Pending tasks: N open (M in progress)`) with a pointer to `open-claudia task list`. Previously a large tree (~60 plans on a busy channel) was re-sent uncached on every single user turn.
|
|
7
|
+
- `formatTree` gained a `hideCompleted` option so any injected tree only shows remaining work; CLI help and the system-prompt task docs updated to describe the new semantics.
|
|
8
|
+
|
|
9
|
+
## v2.2.22
|
|
10
|
+
- **Token economy: stable prompt-cache prefix (R1).** The per-turn churning lines — `Vault: unlocked/locked`, `Session: resuming/new`, and the full pending-tasks tree — moved out of the `--append-system-prompt` block into a "Runtime state (current turn)" header prepended to each user prompt (`promptWithDynamicContext`). The appended system prompt precedes the entire conversation in every API request, so any byte that changed between turns invalidated Anthropic's prompt-cache prefix and re-billed the whole history at 1.25x write price instead of 0.1x read price; on long sessions this churn was the dominant bot cost. `buildSystemPrompt()` is now byte-stable within a session (remaining interpolations — project path, channel, voice, team/speaker — only change on rare events), and the dynamic state rides at the end of the request where it is always uncached anyway. Transcripts still log the bare prompt; Cursor/Codex paths unchanged.
|
|
11
|
+
- Compaction is much less lossy. Three changes to `compactActiveSession`:
|
|
12
|
+
- **Two-tier brief**: every compaction brief is now archived verbatim to `~/.open-claudia/briefs/<project-prefix>-<timestamp>.md` before seeding the fresh session, and the seed prompt tells the new session where the archive lives. Repeated compactions previously re-summarized prior summaries, blurring older facts each round — now the new session can reread earlier briefs at full fidelity instead of guessing. The summary prompt also drops length pressure ("length is NOT a constraint") since the brief is archived anyway. The summarizer additionally emits a `=== CONDENSED SEED ===` section (300-500 words: goal, next step, gated actions, load-bearing paths); when present and the full brief made it to disk, only the condensed section is seeded into the live context — cutting the per-turn context cost of the carried summary — while the detailed brief stays on disk for on-demand reads. Missing marker or failed archive falls back to seeding the full text exactly as before.
|
|
13
|
+
- **Machine-generated repo state**: the seed prompt now includes a git-generated block (branch, dirty files, ahead/behind upstream, last commit) for the session cwd — or its direct child repos (capped at 10) when the cwd itself isn't a git repo. Repo state was exactly the class of fact summarizers hallucinated or dropped; this block is produced by `git`, not model recall, and the seed says to trust it over the summary on conflict.
|
|
14
|
+
- **Verbatim recency tail**: the last 6 user/assistant turns (1,500 chars each, compact-summary/seed entries filtered out) are carried into the seed raw from the project transcript. Users most often reference the exchange immediately before compaction ("as I said", "do that thing you showed"), which summaries flatten; now those turns survive as exact quotes. Captured before the summarizer runs so the tail reflects real conversation.
|
|
15
|
+
- All three collectors are fail-soft: any error (missing transcript, unreadable repo, git timeout) degrades to the previous behaviour rather than blocking compaction.
|
|
16
|
+
|
|
3
17
|
## v2.2.21
|
|
4
18
|
- Add Claude Fable 5 (`claude-fable-5`) to the Claude model picker and make it the default Claude Code model used when no per-user model override is selected. The default can still be overridden with `CLAUDE_MODEL`.
|
|
5
19
|
|
package/bin/task.js
CHANGED
|
@@ -12,13 +12,14 @@ Per-channel todo list with optional plan/subtask hierarchy.
|
|
|
12
12
|
open-claudia task plan "<title>" "<sub1>" "<sub2>" ... [--description "<...>"]
|
|
13
13
|
open-claudia task list [--status pending|in_progress|completed]
|
|
14
14
|
open-claudia task start <id>
|
|
15
|
-
open-claudia task done <id>
|
|
15
|
+
open-claudia task done <id> # completes AND removes; last subtask done removes the plan
|
|
16
16
|
open-claudia task remove <id> # removing a plan removes its subtasks
|
|
17
|
-
open-claudia task clear-completed #
|
|
17
|
+
open-claudia task clear-completed # prune any leftover completed entries
|
|
18
18
|
|
|
19
19
|
Use plans for any work with 3+ distinct steps. Mark subtasks in_progress
|
|
20
20
|
as you start them and done as you finish, so a resumed turn or a new
|
|
21
|
-
turn after compaction can pick up where you left off.
|
|
21
|
+
turn after compaction can pick up where you left off. Done means gone:
|
|
22
|
+
the list only ever shows remaining work.
|
|
22
23
|
`;
|
|
23
24
|
|
|
24
25
|
function statusMark(t) {
|
|
@@ -106,7 +107,12 @@ async function runUpdate(args, status) {
|
|
|
106
107
|
try {
|
|
107
108
|
const res = await postJson("task-update", { id, status });
|
|
108
109
|
if (!res.ok) { console.error(`Not found: ${id}`); process.exit(1); }
|
|
109
|
-
|
|
110
|
+
if (status === "completed") {
|
|
111
|
+
const n = res.removedCount || 0;
|
|
112
|
+
console.log(n > 1 ? `Task ${id} completed and removed (plan finished — ${n} entries removed).` : `Task ${id} completed and removed from the list.`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`Task ${id} → ${status}.`);
|
|
115
|
+
}
|
|
110
116
|
process.exit(0);
|
|
111
117
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
112
118
|
}
|
package/core/loopback.js
CHANGED
|
@@ -224,6 +224,10 @@ async function handleJson(req, res, url, kind) {
|
|
|
224
224
|
|
|
225
225
|
if (kind === "task-update") {
|
|
226
226
|
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
227
|
+
if (payload.status === "completed") {
|
|
228
|
+
const result = tasksStore.complete(adapterId, channelId, payload.id);
|
|
229
|
+
return reply(res, result ? 200 : 404, { ok: !!result, task: result ? result.task : null, removedCount: result ? result.removedCount : 0 });
|
|
230
|
+
}
|
|
227
231
|
const patch = {};
|
|
228
232
|
if (payload.status) patch.status = payload.status;
|
|
229
233
|
if (typeof payload.content === "string") patch.content = payload.content;
|
package/core/runner.js
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
// progress UI. All IO routes through core/io.js so the same runner
|
|
4
4
|
// works on every channel.
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawn, execFileSync } = require("child_process");
|
|
7
9
|
const {
|
|
8
10
|
CLAUDE_PATH, DEFAULT_CLAUDE_MODEL, resolvedCursorPath, resolvedCodexPath,
|
|
9
11
|
AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT, botSubprocessEnv,
|
|
12
|
+
CONFIG_DIR,
|
|
10
13
|
} = require("./config");
|
|
11
14
|
const { currentState, saveState, recordSession, userOwnsClaudeSession } = require("./state");
|
|
12
15
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
13
|
-
const { buildSystemPrompt } = require("./system-prompt");
|
|
16
|
+
const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
|
|
14
17
|
const { redactSensitive } = require("./redact");
|
|
15
18
|
const { send, editMessage, sendVoice, splitMessage } = require("./io");
|
|
16
19
|
const { textToVoice, TTS_CMD } = require("./media");
|
|
@@ -130,7 +133,9 @@ function buildClaudeArgs(prompt, opts = {}) {
|
|
|
130
133
|
if (settings.permissionMode) args.push("--permission-mode", settings.permissionMode);
|
|
131
134
|
else args.push("--dangerously-skip-permissions");
|
|
132
135
|
if (settings.worktree) args.push("--worktree");
|
|
133
|
-
|
|
136
|
+
// Dynamic state rides in the user prompt so the appended system prompt
|
|
137
|
+
// stays byte-stable across turns and the prompt-cache prefix survives.
|
|
138
|
+
args.push(promptWithDynamicContext(prompt));
|
|
134
139
|
return args;
|
|
135
140
|
}
|
|
136
141
|
|
|
@@ -237,29 +242,177 @@ function compactSummaryPrompt() {
|
|
|
237
242
|
"",
|
|
238
243
|
"Do not include: secrets, raw tokens, full file dumps, or chat pleasantries.",
|
|
239
244
|
"Be concrete. Names of files, commands, flags, tags, and commits beat paraphrase. If a fact was load-bearing in this session, write it down verbatim.",
|
|
245
|
+
"Length is NOT a constraint for the brief — it is archived verbatim to disk and reread later. When in doubt, include the fact rather than dropping it for brevity.",
|
|
246
|
+
"",
|
|
247
|
+
"After the brief, output a final section that begins with a line containing exactly:",
|
|
248
|
+
"=== CONDENSED SEED ===",
|
|
249
|
+
"Below that marker write a tight condensed version, 300-500 words max: current goal, exact next step, hard constraints and gated actions (pushes, deploys, anything awaiting user approval), unresolved questions, and the load-bearing paths/IDs. The condensed section lives in the active context every single turn, so optimize it for token economy; the full brief above is read from disk only when needed, so optimize it for completeness.",
|
|
240
250
|
].join("\n");
|
|
241
251
|
}
|
|
242
252
|
|
|
243
|
-
|
|
244
|
-
|
|
253
|
+
const CONDENSED_MARKER_RE = /^[=#*\s]*CONDENSED SEED[=#*\s]*$/i;
|
|
254
|
+
|
|
255
|
+
function splitCompactionBrief(summary) {
|
|
256
|
+
const text = String(summary || "");
|
|
257
|
+
const lines = text.split("\n");
|
|
258
|
+
let markerIdx = -1;
|
|
259
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
260
|
+
if (lines[i].length <= 60 && CONDENSED_MARKER_RE.test(lines[i])) { markerIdx = i; break; }
|
|
261
|
+
}
|
|
262
|
+
if (markerIdx === -1) return { fullBrief: text, condensed: null };
|
|
263
|
+
const condensed = lines.slice(markerIdx + 1).join("\n").trim();
|
|
264
|
+
const fullBrief = lines.slice(0, markerIdx).join("\n").trim();
|
|
265
|
+
if (condensed.length < 80 || !fullBrief) return { fullBrief: text, condensed: null };
|
|
266
|
+
return { fullBrief, condensed };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function compactSeedPrompt(summary, extras = {}) {
|
|
270
|
+
const parts = [
|
|
245
271
|
"This is a compacted continuation of a previous Open Claudia session.",
|
|
246
272
|
"Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
|
|
247
273
|
"Continue from this state in future turns.",
|
|
248
274
|
"",
|
|
249
275
|
"Before telling the user you lack context on something they reference:",
|
|
250
276
|
"1. Check the summary below.",
|
|
251
|
-
"2.
|
|
277
|
+
"2. Read the full archived brief on disk (path given below, when present) — it is the detailed version of this summary.",
|
|
278
|
+
"3. Search the project transcript with `open-claudia transcript-window <pattern>`.",
|
|
252
279
|
" It returns each hit with surrounding turns of context, capped per turn so it stays bounded.",
|
|
253
280
|
" Useful flags: --before N / --after N (default 2), --max-turns K (default 10), --regex.",
|
|
254
281
|
" Fall back to `grep -n -C 5 <pattern> <transcript-path>` (path in your system prompt under",
|
|
255
282
|
" 'Project Transcript Memory') only if the helper does not fit your search.",
|
|
256
|
-
"Only ask the user if
|
|
283
|
+
"Only ask the user if all of these turn up nothing.",
|
|
257
284
|
"",
|
|
258
285
|
"If a fact in the summary contradicts current repo state (a file path, a command, a flag, a version), trust what you observe now and proceed without flagging it unless the user asks.",
|
|
259
286
|
"",
|
|
260
|
-
|
|
287
|
+
extras.condensed
|
|
288
|
+
? "Compacted summary (condensed — the full detailed brief is archived on disk at the path below):"
|
|
289
|
+
: "Compacted summary:",
|
|
261
290
|
summary,
|
|
262
|
-
]
|
|
291
|
+
];
|
|
292
|
+
if (extras.briefPath) {
|
|
293
|
+
parts.push(
|
|
294
|
+
"",
|
|
295
|
+
`Full detailed brief for this compaction: ${extras.briefPath}`,
|
|
296
|
+
`Read it before re-deriving or re-asking anything the summary covers only thinly. Older compaction briefs for this project live in the same directory (${path.dirname(extras.briefPath)}, same filename prefix) — repeated compaction blurs older facts, so consult them for older work before falling back to transcript search.`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (extras.repoFacts) {
|
|
300
|
+
parts.push(
|
|
301
|
+
"",
|
|
302
|
+
"Machine-generated repo state at compaction time (produced by git, not model recall — trust it over the summary if they disagree):",
|
|
303
|
+
extras.repoFacts
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (extras.recentTurns) {
|
|
307
|
+
parts.push(
|
|
308
|
+
"",
|
|
309
|
+
"Verbatim final turns before compaction (raw, most recent last). The user is most likely to reference these; treat them as exact quotes, unlike the summary above:",
|
|
310
|
+
extras.recentTurns
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return parts.join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const BRIEFS_DIR = path.join(CONFIG_DIR, "briefs");
|
|
317
|
+
|
|
318
|
+
function briefArchivePrefix(state = currentState()) {
|
|
319
|
+
const tinfo = transcriptProjectInfo(state);
|
|
320
|
+
if (tinfo && tinfo.transcriptPath) return path.basename(tinfo.transcriptPath, ".jsonl");
|
|
321
|
+
const channel = String(currentChannelId() || "unknown").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
322
|
+
return `channel-${channel}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function archiveCompactionBrief(summary, state = currentState()) {
|
|
326
|
+
try {
|
|
327
|
+
fs.mkdirSync(BRIEFS_DIR, { recursive: true, mode: 0o700 });
|
|
328
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
329
|
+
const filePath = path.join(BRIEFS_DIR, `${briefArchivePrefix(state)}-${stamp}.md`);
|
|
330
|
+
const header = [
|
|
331
|
+
`# Compaction brief — ${new Date().toISOString()}`,
|
|
332
|
+
state.currentSession ? `Project: ${state.currentSession.name} (${state.currentSession.dir})` : null,
|
|
333
|
+
"",
|
|
334
|
+
"",
|
|
335
|
+
].filter((line) => line !== null).join("\n");
|
|
336
|
+
fs.writeFileSync(filePath, header + summary + "\n", { mode: 0o600 });
|
|
337
|
+
return filePath;
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.warn(`[compact] brief archive failed: ${redactSensitive(e.message)}`);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function gitFactsForRepo(repoDir) {
|
|
345
|
+
const run = (args) => execFileSync("git", ["-C", repoDir, ...args], {
|
|
346
|
+
timeout: 4000, stdio: ["ignore", "pipe", "ignore"],
|
|
347
|
+
}).toString().trim();
|
|
348
|
+
const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
349
|
+
const dirty = run(["status", "--porcelain"]).split("\n").filter(Boolean);
|
|
350
|
+
const lastCommit = run(["log", "-1", "--format=%h %s"]);
|
|
351
|
+
let upstream;
|
|
352
|
+
try {
|
|
353
|
+
const [behind, ahead] = run(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]).split(/\s+/);
|
|
354
|
+
upstream = (ahead === "0" && behind === "0") ? "in sync with upstream" : `${ahead} ahead / ${behind} behind upstream`;
|
|
355
|
+
} catch (e) {
|
|
356
|
+
upstream = "no upstream";
|
|
357
|
+
}
|
|
358
|
+
const dirtyNote = dirty.length
|
|
359
|
+
? `${dirty.length} uncommitted: ${dirty.slice(0, 8).map((l) => l.trim().replace(/^\S+\s+/, "")).join(", ")}${dirty.length > 8 ? ", …" : ""}`
|
|
360
|
+
: "clean";
|
|
361
|
+
return `${repoDir} — branch ${branch}, ${dirtyNote}, ${upstream}, last commit: ${lastCommit}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function collectRepoStateFacts(cwd) {
|
|
365
|
+
try {
|
|
366
|
+
let repos = [];
|
|
367
|
+
if (fs.existsSync(path.join(cwd, ".git"))) {
|
|
368
|
+
repos = [cwd];
|
|
369
|
+
} else {
|
|
370
|
+
repos = fs.readdirSync(cwd, { withFileTypes: true })
|
|
371
|
+
.filter((d) => d.isDirectory() && fs.existsSync(path.join(cwd, d.name, ".git")))
|
|
372
|
+
.slice(0, 10)
|
|
373
|
+
.map((d) => path.join(cwd, d.name));
|
|
374
|
+
}
|
|
375
|
+
const lines = [];
|
|
376
|
+
for (const repo of repos) {
|
|
377
|
+
try { lines.push(`- ${gitFactsForRepo(repo)}`); } catch (e) { /* unreadable repo — skip */ }
|
|
378
|
+
}
|
|
379
|
+
return lines.length ? lines.join("\n") : null;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function collectRecentTurns(state = currentState(), maxTurns = 6, maxCharsPerTurn = 1500) {
|
|
386
|
+
try {
|
|
387
|
+
const tinfo = transcriptProjectInfo(state);
|
|
388
|
+
if (!tinfo || !tinfo.transcriptPath || !fs.existsSync(tinfo.transcriptPath)) return null;
|
|
389
|
+
const stat = fs.statSync(tinfo.transcriptPath);
|
|
390
|
+
const readBytes = Math.min(stat.size, 256 * 1024);
|
|
391
|
+
if (!readBytes) return null;
|
|
392
|
+
const fd = fs.openSync(tinfo.transcriptPath, "r");
|
|
393
|
+
const buf = Buffer.alloc(readBytes);
|
|
394
|
+
try {
|
|
395
|
+
fs.readSync(fd, buf, 0, readBytes, stat.size - readBytes);
|
|
396
|
+
} finally {
|
|
397
|
+
fs.closeSync(fd);
|
|
398
|
+
}
|
|
399
|
+
const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
|
|
400
|
+
const turns = [];
|
|
401
|
+
for (let i = lines.length - 1; i >= 0 && turns.length < maxTurns; i--) {
|
|
402
|
+
let entry;
|
|
403
|
+
try { entry = JSON.parse(lines[i]); } catch (e) { continue; /* partial first line of the byte window */ }
|
|
404
|
+
if (entry.role !== "user" && entry.role !== "assistant") continue;
|
|
405
|
+
const label = entry.metadata && entry.metadata.label;
|
|
406
|
+
if (label === "compact-summary" || label === "compact-seed") continue;
|
|
407
|
+
const text = String(entry.text || "").trim();
|
|
408
|
+
if (!text) continue;
|
|
409
|
+
const clipped = text.length > maxCharsPerTurn ? `${text.slice(0, maxCharsPerTurn)} …[truncated]` : text;
|
|
410
|
+
turns.push(`[${entry.role} @ ${entry.timestamp || "?"}]\n${clipped}`);
|
|
411
|
+
}
|
|
412
|
+
return turns.length ? turns.reverse().join("\n\n") : null;
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
263
416
|
}
|
|
264
417
|
|
|
265
418
|
function formatProgress(text, tools, currentTool, elapsed, toolDetail) {
|
|
@@ -380,6 +533,7 @@ async function compactActiveSession(cwd, opts = {}) {
|
|
|
380
533
|
|
|
381
534
|
state.isCompacting = true;
|
|
382
535
|
let summary;
|
|
536
|
+
const recentTurns = collectRecentTurns(state);
|
|
383
537
|
try {
|
|
384
538
|
if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
|
|
385
539
|
const summaryRun = await runClaudeCapture(compactSummaryPrompt(), cwd, { resumeSessionId: oldSessionId, skipAutoCompact: true, label: "compact-summary", timeoutMs: COMPACT_SUMMARY_TIMEOUT });
|
|
@@ -393,7 +547,12 @@ async function compactActiveSession(cwd, opts = {}) {
|
|
|
393
547
|
saveState();
|
|
394
548
|
}
|
|
395
549
|
|
|
396
|
-
const
|
|
550
|
+
const { fullBrief, condensed } = splitCompactionBrief(summary);
|
|
551
|
+
const briefPath = archiveCompactionBrief(fullBrief, state);
|
|
552
|
+
// Only seed with the condensed version when the full text actually made it to disk.
|
|
553
|
+
const seedSummary = (condensed && briefPath) ? condensed : (condensed ? `${fullBrief}\n\n${condensed}` : fullBrief);
|
|
554
|
+
const repoFacts = collectRepoStateFacts(cwd);
|
|
555
|
+
const seedRun = await runClaudeCapture(compactSeedPrompt(seedSummary, { briefPath, condensed: !!(condensed && briefPath), repoFacts, recentTurns }), cwd, { fresh: true, skipAutoCompact: true, label: "compact-seed" });
|
|
397
556
|
const newSessionId = seedRun.sessionId || state[sessionKey];
|
|
398
557
|
if (newSessionId) state[sessionKey] = newSessionId;
|
|
399
558
|
state.isFirstMessage = false;
|
|
@@ -739,7 +898,7 @@ async function runClaudeSilent(prompt, cwd, label) {
|
|
|
739
898
|
return new Promise((resolve) => {
|
|
740
899
|
const args = ["-p", "--output-format", "text", "--verbose",
|
|
741
900
|
"--append-system-prompt", buildSystemPrompt(),
|
|
742
|
-
"--dangerously-skip-permissions", prompt];
|
|
901
|
+
"--dangerously-skip-permissions", promptWithDynamicContext(prompt)];
|
|
743
902
|
const proc = spawn(CLAUDE_PATH, args, {
|
|
744
903
|
cwd, env: claudeSubprocessEnv(),
|
|
745
904
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -766,6 +925,11 @@ module.exports = {
|
|
|
766
925
|
getActiveSessionKey,
|
|
767
926
|
shouldAutoCompact,
|
|
768
927
|
effectiveCompactThreshold,
|
|
928
|
+
compactSeedPrompt,
|
|
929
|
+
splitCompactionBrief,
|
|
930
|
+
archiveCompactionBrief,
|
|
931
|
+
collectRepoStateFacts,
|
|
932
|
+
collectRecentTurns,
|
|
769
933
|
formatProgress,
|
|
770
934
|
preflightClaudeAuthMessage,
|
|
771
935
|
claudeEmptyFailureMessage,
|
package/core/system-prompt.js
CHANGED
|
@@ -28,17 +28,6 @@ function buildSystemPrompt() {
|
|
|
28
28
|
? (adapter.type === "kazee" ? "Kazee Chat" : adapter.type === "telegram" ? "Telegram" : adapter.type)
|
|
29
29
|
: "Telegram";
|
|
30
30
|
|
|
31
|
-
let pendingTasksBlock = "";
|
|
32
|
-
if (adapter && channelId) {
|
|
33
|
-
try {
|
|
34
|
-
const pending = tasksStore.pendingSummary(adapter.id, channelId);
|
|
35
|
-
if (pending.length > 0) {
|
|
36
|
-
const tree = tasksStore.formatTree(adapter.id, channelId, { showIds: true });
|
|
37
|
-
pendingTasksBlock = `\n## Pending tasks\nYou 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.\n\n${tree}\n`;
|
|
38
|
-
}
|
|
39
|
-
} catch (e) {}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
31
|
let teamBlock = "";
|
|
43
32
|
let currentSpeakerBlock = "";
|
|
44
33
|
try {
|
|
@@ -115,8 +104,7 @@ ${soul}
|
|
|
115
104
|
- Interface: ${channelLabel} chat through Open Claudia.
|
|
116
105
|
- Active project path: ${state.currentSession ? state.currentSession.dir : "none"}
|
|
117
106
|
- Voice notes: ${hasVoice ? "enabled" : "disabled"}
|
|
118
|
-
- Vault
|
|
119
|
-
- Session: ${state.lastSessionId ? "resuming existing conversation" : "new conversation"}
|
|
107
|
+
- Vault status, session state, and any pending tasks arrive in a "Runtime state" block at the top of each user message, not here.
|
|
120
108
|
|
|
121
109
|
## Stable Local Paths
|
|
122
110
|
- Bot code: ${path.join(BOT_DIR, "bot.js")}
|
|
@@ -127,7 +115,7 @@ ${soul}
|
|
|
127
115
|
- Received user files directory: ${FILES_DIR}
|
|
128
116
|
|
|
129
117
|
${transcriptPointerNote(state)}
|
|
130
|
-
${currentSpeakerBlock}${teamBlock}${
|
|
118
|
+
${currentSpeakerBlock}${teamBlock}${slashCommandsBlock}${channelFormattingBlock}
|
|
131
119
|
## Delivery
|
|
132
120
|
Reply normally in your final answer. To send a file, image, or voice clip back to the current chat, run the bot CLI from inside this task — channel context is already in the env:
|
|
133
121
|
- \`open-claudia send-file <path> [caption]\` — any document/binary
|
|
@@ -150,15 +138,14 @@ Persistent todo list with plans + subtasks (per channel; survives compaction and
|
|
|
150
138
|
|
|
151
139
|
Note: any \`<system-reminder>\` you see about "task tools" / TaskCreate / TaskUpdate is from the underlying Claude Code harness — that's a different, ephemeral todo system. Open Claudia work belongs in \`open-claudia task\` (the persistent, channel-scoped one). Don't double-track.
|
|
152
140
|
|
|
153
|
-
Use a plan when work is likely to outlive a single turn — i.e. it may hit a compaction, a restart, or a scheduled wakeup before completing, or its progress is something the user will want to see between turns. The plan is a parent task whose children are the steps. As you work, mark each subtask in_progress when you begin and completed when done — this is how a resumed turn sees where you left off.
|
|
141
|
+
Use a plan when work is likely to outlive a single turn — i.e. it may hit a compaction, a restart, or a scheduled wakeup before completing, or its progress is something the user will want to see between turns. The plan is a parent task whose children are the steps. As you work, mark each subtask in_progress when you begin and completed when done — this is how a resumed turn sees where you left off. Completed means removed: \`task done\` deletes the entry, and finishing the last subtask deletes the whole plan. The list only ever contains remaining work. The full pending tree is injected into the Runtime state block once per session (first turn after restart/compaction); later turns show just a count — run \`open-claudia task list\` when you need the tree again.
|
|
154
142
|
|
|
155
143
|
Skip the plan for work that visibly completes within the current turn (e.g. running a few CLI commands in sequence and replying). A plan there is overhead the user has to read past.
|
|
156
144
|
|
|
157
145
|
- \`open-claudia task plan "<plan title>" "<step 1>" "<step 2>" "<step 3>" [--description "<why / success criteria>"]\` — atomic create
|
|
158
146
|
- \`open-claudia task add "<content>" [--parent <plan-id>] [--description "..."]\` — add a single task or subtask
|
|
159
147
|
- \`open-claudia task list\` — renders as a tree
|
|
160
|
-
- \`open-claudia task start <id>\` / \`task done <id>\` / \`task remove <id>\` (removing a plan removes its subtasks)
|
|
161
|
-
- \`open-claudia task clear-completed\` — only clears plans whose subtasks are all done
|
|
148
|
+
- \`open-claudia task start <id>\` / \`task done <id>\` (completes AND removes; finishing the last subtask removes the plan) / \`task remove <id>\` (removing a plan removes its subtasks)
|
|
162
149
|
|
|
163
150
|
For one-off work under 3 steps, skip the plan and just track it in your own response.
|
|
164
151
|
|
|
@@ -214,4 +201,54 @@ If you tell the user "I'll check back in N minutes" or "I'll run this every morn
|
|
|
214
201
|
`.trim();
|
|
215
202
|
}
|
|
216
203
|
|
|
217
|
-
|
|
204
|
+
// Per-turn churning state. Lives in the user prompt, NOT the appended
|
|
205
|
+
// system prompt: the system prompt precedes the whole conversation in
|
|
206
|
+
// every API request, so any byte that changes between turns invalidates
|
|
207
|
+
// the prompt-cache prefix and re-bills the full history at write price
|
|
208
|
+
// instead of 0.1x read price. Keep buildSystemPrompt() byte-stable
|
|
209
|
+
// within a session; put anything that flips between turns here.
|
|
210
|
+
// The full task tree is large and rides the (uncached) tail of every
|
|
211
|
+
// prompt, so inject it only once per session: on the first turn after a
|
|
212
|
+
// bot restart, a new conversation, or a compaction (lastSessionId
|
|
213
|
+
// changes in all three cases). Later turns get a one-line count; the
|
|
214
|
+
// agent runs `open-claudia task list` when it needs detail.
|
|
215
|
+
const taskTreeInjectedFor = new Map(); // `${adapterId}:${channelId}` -> sessionId
|
|
216
|
+
|
|
217
|
+
function buildDynamicContextBlock() {
|
|
218
|
+
const state = currentState();
|
|
219
|
+
const adapter = currentAdapter();
|
|
220
|
+
const channelId = currentChannelId();
|
|
221
|
+
const lines = [
|
|
222
|
+
"## Runtime state (current turn)",
|
|
223
|
+
`- Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
|
|
224
|
+
`- Session: ${state.lastSessionId ? "resuming existing conversation" : "new conversation"}`,
|
|
225
|
+
];
|
|
226
|
+
if (adapter && channelId) {
|
|
227
|
+
try {
|
|
228
|
+
const pending = tasksStore.pendingSummary(adapter.id, channelId);
|
|
229
|
+
if (pending.length > 0) {
|
|
230
|
+
const key = `${adapter.id}:${channelId}`;
|
|
231
|
+
const sess = state.lastSessionId || "new";
|
|
232
|
+
if (taskTreeInjectedFor.get(key) !== sess) {
|
|
233
|
+
taskTreeInjectedFor.set(key, sess);
|
|
234
|
+
const tree = tasksStore.formatTree(adapter.id, channelId, { showIds: true, hideCompleted: true });
|
|
235
|
+
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);
|
|
236
|
+
} else {
|
|
237
|
+
const inProgress = pending.filter((t) => t.status === "in_progress").length;
|
|
238
|
+
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.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (e) {}
|
|
242
|
+
}
|
|
243
|
+
return lines.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function promptWithDynamicContext(prompt) {
|
|
247
|
+
try {
|
|
248
|
+
return `${buildDynamicContextBlock()}\n\nCurrent user request:\n${prompt}`;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return prompt;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { loadSoul, buildSystemPrompt, buildDynamicContextBlock, promptWithDynamicContext };
|
package/core/tasks.js
CHANGED
|
@@ -92,6 +92,42 @@ function remove(adapter, channelId, id) {
|
|
|
92
92
|
return { removed: target, alsoRemoved: ids.size - 1 };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Done means gone: completed items leave the list instead of lingering
|
|
96
|
+
// as [x] rows that get re-injected into every prompt. Removes completed
|
|
97
|
+
// subtasks, completed standalone tasks, and any plan whose subtasks are
|
|
98
|
+
// all completed (the plan is implicitly finished even if its own status
|
|
99
|
+
// was never flipped). Returns the remaining list.
|
|
100
|
+
function prune(adapter, channelId) {
|
|
101
|
+
const all = load(adapter, channelId);
|
|
102
|
+
const drop = new Set();
|
|
103
|
+
for (const t of all) {
|
|
104
|
+
if (t.parentId) {
|
|
105
|
+
if (t.status === "completed") drop.add(t.id);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const kids = all.filter((c) => c.parentId === t.id);
|
|
109
|
+
const kidsAllDone = kids.length > 0 && kids.every((c) => c.status === "completed");
|
|
110
|
+
if (kidsAllDone || (t.status === "completed" && kids.length === 0)) {
|
|
111
|
+
drop.add(t.id);
|
|
112
|
+
for (const c of kids) drop.add(c.id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (drop.size === 0) return all;
|
|
116
|
+
const remaining = all.filter((t) => !drop.has(t.id));
|
|
117
|
+
save(adapter, channelId, remaining);
|
|
118
|
+
return remaining;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Mark a task completed, then prune. Reports how many entries left the
|
|
122
|
+
// list so callers can tell the agent what disappeared.
|
|
123
|
+
function complete(adapter, channelId, id) {
|
|
124
|
+
const t = update(adapter, channelId, id, { status: "completed" });
|
|
125
|
+
if (!t) return null;
|
|
126
|
+
const before = load(adapter, channelId).length;
|
|
127
|
+
const remaining = prune(adapter, channelId);
|
|
128
|
+
return { task: t, removedCount: before - remaining.length };
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
function clearCompleted(adapter, channelId) {
|
|
96
132
|
const all = load(adapter, channelId);
|
|
97
133
|
const drop = new Set();
|
|
@@ -146,7 +182,12 @@ function format(t, idx) {
|
|
|
146
182
|
|
|
147
183
|
function formatTree(adapter, channelId, opts = {}) {
|
|
148
184
|
const showIds = opts.showIds !== false;
|
|
149
|
-
|
|
185
|
+
let t = tree(adapter, channelId);
|
|
186
|
+
if (opts.hideCompleted) {
|
|
187
|
+
t = t
|
|
188
|
+
.map((r) => ({ ...r, children: r.children.filter((c) => c.status !== "completed") }))
|
|
189
|
+
.filter((r) => r.status !== "completed" || r.children.length > 0);
|
|
190
|
+
}
|
|
150
191
|
const lines = [];
|
|
151
192
|
t.forEach((root, i) => {
|
|
152
193
|
const head = `${i + 1}. ${statusMark(root)} ${root.content}` + (showIds ? ` (${root.id})` : "");
|
|
@@ -163,6 +204,7 @@ module.exports = {
|
|
|
163
204
|
filePathFor,
|
|
164
205
|
load, save,
|
|
165
206
|
add, plan, update, remove, list, tree, clearCompleted,
|
|
207
|
+
prune, complete,
|
|
166
208
|
pendingSummary,
|
|
167
209
|
format, formatTree, statusMark,
|
|
168
210
|
};
|
package/package.json
CHANGED