@inetafrica/open-claudia 3.5.71 → 3.5.73

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.73
4
+
5
+ 3.5.72 with a smaller bot image, from Sumeet's bot resource plan of 31 Oct. No code changes.
6
+
7
+ - **One copy of the app in the image** (`Dockerfile`): files are copied in already owned by the bot user instead of being re-written by recursive chmod/chown, which stored /app three times.
8
+ - **No leftover download caches**: each npm install clears its cache in the same step.
9
+ - **One Claude install** that the bot user can run (npm into /usr/local), checked at build time; agent-browser keeps only its linux-x64 binary.
10
+
11
+ ## 3.5.72
12
+
13
+ 3.5.71 plus the memory fixes tested on Sumeet's Mac from 22 to 28 Oct, released to every bot on his card of 31 Oct ("Release to all bots now, without the Maia rename"). The Maia rename stays on the Mac.
14
+
15
+ - **Topic lessons ride with their pack** (`core/dream.js`, `core/system-prompt.js`): a lesson tied to a pack loads when that pack does; the nightly dream reads the whole day, not only the last hours.
16
+ - **Settings in .env reach the recall pins** (`core/config.js`, `core/recall/tuning.js`): an operator pin such as `RECALL_MIN_RELEVANCE_SCORE=55` in .env now holds, and the dream cannot move a pinned knob.
17
+ - **Prompt fixes** (`core/system-prompt.js`, `core/help/background.md`, `core/session-core.js`, `core/providers/claude.js`): the tool named for a domain wins tool hints; foreground turns hide Claude Code's own timers and monitors (they die with the turn); the background section is 6.9k -> 1.5k chars with the full how-to in `open-claudia help background`; core instruction edits reach resumed sessions once; `CLAUDE_AUTO_MEMORY=off` turns Claude Code's own memory off.
18
+ - **Past conversations reach recall** (`core/recall/query.js`, `core/transcript-index.js`).
19
+ - **Settled decisions stay in memory** and show ahead of a long Stance (`core/packs.js`, `core/system-prompt.js`).
20
+ - **/engine x and /engine mem0** (`core/recall/external.js`): owner-only per-room switch to an outside memory search (`RECALL_X_CMD` / `RECALL_MEM0_CMD`, timeout `RECALL_EXTERNAL_TIMEOUT_MS`, read from .env too); a timeout, error or empty answer falls back to the discoverer for that turn. Default engine unchanged (discoverer).
21
+ - **Nightly introspection retries once after a stall** and says what stalled; `watch-add --every` must be a 5-field cron.
22
+
23
+ Tests: new `test-topic-lessons.js`, `test-env-pins.js`, `test-session-core.js`, `test-transcript-related.js`, `test-pack-decisions.js`, `test-recall-external.js`, `test-introspection-retry.js`, `test-watch-every-cron.js`.
24
+
3
25
  ## 3.5.71
4
26
 
5
27
  3.5.70 plus a fix to its one-time effort reset, found during the 3.5.70 fleet roll on 20 Oct.
package/Dockerfile CHANGED
@@ -1,15 +1,30 @@
1
1
  # Release image. The slow system layer (apt + chromium, ~250 packages) lives
2
2
  # in Dockerfile.base, published as the `:base` tag — rebuilt only when that
3
3
  # file changes. This build is minutes: fresh agent CLIs + app source.
4
+ #
5
+ # Size rules (each RUN's leftovers are baked into the image forever):
6
+ # - every npm install cleans its download cache in the SAME RUN;
7
+ # - /app is written once, already owned by claudia (COPY --chown, npm ci as
8
+ # claudia) — no recursive chmod/chown, which would store a second and
9
+ # third copy of every file in /app as new layers.
4
10
  ARG BASE_IMAGE=git.coders.africa:5050/kazee/agent-space/open-claudia:base
5
11
  FROM ${BASE_IMAGE}
6
12
 
7
- # Install Claude Code CLI
8
- RUN curl -fsSL https://claude.ai/install.sh | sh || \
9
- npm install -g @anthropic-ai/claude-code
13
+ # Claude Code CLI, one install: the npm global in /usr/local, which is on PATH
14
+ # and readable for the runtime user (1001, HOME=/data). This is the copy pods
15
+ # were already running (/usr/local/bin/claude -> /usr/local/lib/node_modules/
16
+ # @anthropic-ai/claude-code/bin/claude.exe). The old `claude.ai/install.sh | sh`
17
+ # step ran as root and installs into /root/.local, which user 1001 can neither
18
+ # see on PATH nor read — whenever it succeeded, `claude` would be missing in the
19
+ # pod. The postinstall hardlinks the native binary over the bin/claude.exe stub;
20
+ # the size check fails the build if only the stub is left.
21
+ RUN npm install -g @anthropic-ai/claude-code \
22
+ && test "$(stat -c %s "$(readlink -f /usr/local/bin/claude)")" -gt 1000000 \
23
+ && npm cache clean --force && rm -rf /root/.npm /tmp/*
10
24
 
11
25
  # Install OpenAI Codex CLI for the codex backend (unpinned: each image build bakes the then-latest CLI)
12
- RUN npm install -g @openai/codex
26
+ RUN npm install -g @openai/codex \
27
+ && npm cache clean --force && rm -rf /root/.npm /tmp/*
13
28
 
14
29
  # Browser automation for the agent: vercel-labs agent-browser (Rust CLI, token-efficient,
15
30
  # replaces Playwright MCP). Installed at build time as root so the non-root runtime user
@@ -17,31 +32,44 @@ RUN npm install -g @openai/codex
17
32
  # lives outside the /data VOLUME (build-time writes to /data are discarded at runtime).
18
33
  # Pinned: the live Browser view (core/browser-view.js) reads its session files and its
19
34
  # screen-stream protocol, which 0.38.x has (built-in stream per session, .stream port files).
20
- RUN npm install -g agent-browser@0.38.2
35
+ # The package ships a prebuilt binary for every platform (~15-18 MB each); its postinstall
36
+ # points /usr/local/bin/agent-browser at the one for this machine (glibc Linux), so the
37
+ # other six are deleted here.
38
+ RUN npm install -g agent-browser@0.38.2 \
39
+ && cd "$(npm root -g)/agent-browser/bin" \
40
+ && keep="agent-browser-linux-$(node -p process.arch)" \
41
+ && test -x "$keep" \
42
+ && find . -maxdepth 1 -type f -name 'agent-browser-*' ! -name "$keep" -delete \
43
+ && case "$(readlink -f /usr/local/bin/agent-browser)" in \
44
+ "$(pwd -P)/$keep"|"$(pwd -P)/agent-browser.js") ;; \
45
+ *) echo "agent-browser on PATH points somewhere unexpected" >&2; exit 1 ;; \
46
+ esac \
47
+ && npm cache clean --force && rm -rf /root/.npm /tmp/*
21
48
  ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
22
49
 
23
- # Create app directory
50
+ # Create app directory, owned by the runtime user so it can overlay new source
51
+ # into /app for in-place /upgrade (core/handlers.js inPlaceTarballUpgrade).
24
52
  WORKDIR /app
53
+ RUN chown claudia:claudia /app
25
54
 
26
- # Copy package files and install
27
- COPY package*.json ./
28
- RUN npm ci --production
55
+ # Copy package files and install as claudia, so node_modules is claudia's
56
+ # without a chown pass. The npm cache goes to a scratch dir removed in the same
57
+ # RUN (nothing lands in /root/.npm or /data/.npm).
58
+ COPY --chown=claudia:claudia package*.json ./
59
+ USER claudia
60
+ RUN npm ci --omit=dev --no-audit --no-fund --cache /tmp/oc-npm-cache \
61
+ && rm -rf /tmp/oc-npm-cache
29
62
 
30
- # Copy app source
31
- COPY . .
32
-
33
- # Ensure app files are readable regardless of host file perms
34
- RUN chmod -R a+rX /app
63
+ # Copy app source (already claudia's: in-place /upgrade overlays it)
64
+ COPY --chown=claudia:claudia . .
65
+ USER root
35
66
 
36
67
  # Expose the open-claudia CLI on PATH so spawned agents can send files, manage tasks, etc.
37
- RUN chmod +x /app/bin/cli.js && ln -s /app/bin/cli.js /usr/local/bin/open-claudia
38
-
39
- # Let the runtime user overlay new source into /app for in-place /upgrade.
40
- RUN chown -R claudia:claudia /app
41
-
42
- # Entrypoint auto-configures from env vars on first run
68
+ # Only the two scripts that must be executable are chmod'ed (git tracks both as 755;
69
+ # this keeps it true for checkouts that lost the bit) — not a recursive pass over /app.
43
70
  COPY docker-entrypoint.sh /usr/local/bin/
44
- RUN chmod +x /usr/local/bin/docker-entrypoint.sh
71
+ RUN chmod 755 /app/bin/cli.js /usr/local/bin/docker-entrypoint.sh \
72
+ && ln -s /app/bin/cli.js /usr/local/bin/open-claudia
45
73
 
46
74
  # Set up data directory ownership
47
75
  RUN mkdir -p /data/Workspace /data/.open-claudia /data/.claude && \
package/README.md CHANGED
@@ -177,7 +177,7 @@ When you select a project, the last conversation is automatically resumed. Tap "
177
177
  |---------|-------------|
178
178
  | `/learn [<hint>]` | Capture the last piece of work into the matching context pack |
179
179
  | `/skills [show\|remove <name>]` | List, show, or remove legacy learned skills |
180
- | `/engine [classic\|discoverer]` | Switch the per-chat memory recall engine (default `discoverer`) |
180
+ | `/engine [discoverer\|x\|mem0\|classic\|ask]` | Switch the per-chat memory recall engine (default `discoverer`; `x`/`mem0` run an external memory search, set by `RECALL_X_CMD`/`RECALL_MEM0_CMD`, and fall back to the discoverer on timeout, error or empty output) |
181
181
  | `/recall [on\|off]` | Toggle a per-turn "🧠 Recall this turn" debug line showing which packs/entities surfaced (and why, on discoverer) |
182
182
  | `/soul` | View/edit assistant identity and personality |
183
183
  | `/dreamsummary [on\|off]` | Toggle the post-dream memory summary in chat |
package/bin/cli.js CHANGED
@@ -612,6 +612,13 @@ switch (command) {
612
612
  break;
613
613
  }
614
614
 
615
+ case "help": {
616
+ // Long how-tos live here, out of the always-loaded instructions.
617
+ const topic = String(args[1] || "").replace(/[^a-z-]/g, "");
618
+ const file = topic && path.join(botDir, "core", "help", `${topic}.md`);
619
+ if (file && fs.existsSync(file)) { process.stdout.write(fs.readFileSync(file, "utf8")); break; }
620
+ }
621
+ // falls through to the usage text
615
622
  default:
616
623
  console.log(`
617
624
  Open Claudia — AI Coding Assistant via Telegram
package/bin/schedule.js CHANGED
@@ -184,6 +184,12 @@ async function runCronRemove(args) {
184
184
  } catch (e) { console.error(e.message); process.exit(1); }
185
185
  }
186
186
 
187
+ // A duration such as "5m" here once left watches that never polled.
188
+ function isFiveFieldCron(value) {
189
+ const fields = String(value).trim().split(/\s+/);
190
+ return fields.length === 5 && require("node-cron").validate(fields.join(" "));
191
+ }
192
+
187
193
  async function runWatchAdd(args) {
188
194
  if (args.length < 2 || args[0] === "--help") { console.log(HELP); process.exit(args[0] === "--help" ? 0 : 2); }
189
195
  const rest = args.slice();
@@ -202,6 +208,10 @@ async function runWatchAdd(args) {
202
208
  process.exit(2);
203
209
  }
204
210
  }
211
+ if (schedule !== null && !isFiveFieldCron(schedule)) {
212
+ console.error(`--every needs a 5-field cron such as "*/5 * * * *" (every 5 minutes), not "${schedule}".`);
213
+ process.exit(2);
214
+ }
205
215
  const check = rest[0];
206
216
  const prompt = rest.slice(1).join(" ");
207
217
  if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat] [--timeout <when>]"); process.exit(2); }
package/core/actions.js CHANGED
@@ -469,6 +469,7 @@ async function handleAction(envelope) {
469
469
  if (d.startsWith("eng:")) {
470
470
  const v = d.slice(4);
471
471
  const recall = require("./recall");
472
+ if (recall.OWNER_ONLY_ENGINES.includes(v) && !isChatOwner(envelope.channelId)) return send(`Owner only — the ${v} engine runs a local command.`);
472
473
  // The default engine is stored as null (so it shows "(default)"); any other
473
474
  // known engine is stored explicitly as an opt-out.
474
475
  state.settings.recallEngine = (v === recall.DEFAULT_ENGINE || !recall.listEngines().includes(v)) ? null : v;
package/core/config.js CHANGED
@@ -43,6 +43,16 @@ function saveEnvKey(key, value) {
43
43
 
44
44
  const config = loadEnv();
45
45
 
46
+ // One setting from the process env or, failing that, the bot's .env file.
47
+ // loadEnv() returns a merged object and never writes process.env, so code that
48
+ // reads process.env directly (operator pins, BOT_NAME) misses .env-only keys.
49
+ function settingValue(key) {
50
+ const live = process.env[key];
51
+ if (live !== undefined && live !== "") return live;
52
+ const fromFile = config[key];
53
+ return fromFile === undefined || fromFile === "" ? undefined : fromFile;
54
+ }
55
+
46
56
  function hasPathSeparator(value) {
47
57
  return value.includes("/") || value.includes("\\");
48
58
  }
@@ -367,6 +377,7 @@ function agentEnvSource() {
367
377
 
368
378
  module.exports = {
369
379
  config,
380
+ settingValue,
370
381
  parseEnvText,
371
382
  loadEnv,
372
383
  saveEnvKey,
package/core/day-seeds.js CHANGED
@@ -16,8 +16,9 @@ const CONFIG_DIR = require("../config-dir");
16
16
 
17
17
  const SEEDS_DIR = process.env.DAY_SEEDS_DIR ? path.resolve(process.env.DAY_SEEDS_DIR) : path.join(CONFIG_DIR, "seeds");
18
18
  // Safety cap on a single day's file; if exceeded we trim from the front so the
19
- // most recent compactions always survive.
20
- const MAX_DAY_SEED_CHARS = Number(process.env.DAY_SEED_MAX_CHARS || 60000);
19
+ // most recent compactions always survive. A busy day holds ~50 compactions of
20
+ // ~4k chars each; at the old 60k cap every day lost its morning by evening.
21
+ const MAX_DAY_SEED_CHARS = Number(process.env.DAY_SEED_MAX_CHARS || 400000);
21
22
 
22
23
  // Local calendar day (YYYY-MM-DD). Local, not UTC, so "what I worked on today"
23
24
  // lines up with the user's day and the 4am dream reads the right file.
@@ -91,8 +92,27 @@ function recentDaySeeds({ days = 2 } = {}) {
91
92
  return { dates: picked, text: texts.join("\n\n") };
92
93
  }
93
94
 
95
+ // A whole-day view inside a char budget: every compaction keeps its time,
96
+ // project and the head of its digest, so the reader sees the morning as well
97
+ // as the evening instead of whichever end a plain cut keeps.
98
+ function digestDaySeeds(text, budget = 30000) {
99
+ const t = String(text || "");
100
+ if (t.length <= budget) return t;
101
+ const parts = t.split(/\n(?=## \d{4}-\d{2}-\d{2}T)/).filter((p) => /^## \d{4}-/.test(p.trim()));
102
+ if (parts.length === 0) return t.slice(0, budget);
103
+ const share = Math.max(200, Math.floor(budget / parts.length) - 2);
104
+ const out = parts.map((p) => {
105
+ const lines = p.trim().split("\n");
106
+ const head = [lines[0], ...lines.slice(1).filter((l) => l.startsWith("Project: "))].join("\n");
107
+ const body = lines.slice(1).filter((l) => !/^(Project|Channel|Full brief): /.test(l)).join("\n").trim();
108
+ const room = Math.max(0, share - head.length - 1);
109
+ return `${head}\n${body.length > room ? body.slice(0, room) + "…" : body}`;
110
+ });
111
+ return out.join("\n\n").slice(0, budget);
112
+ }
113
+
94
114
  module.exports = {
95
- SEEDS_DIR, MAX_DAY_SEED_CHARS,
115
+ SEEDS_DIR, MAX_DAY_SEED_CHARS, digestDaySeeds,
96
116
  dayStamp, daySeedPath,
97
117
  appendDaySeed, readDaySeed, listDaySeedDates, recentDaySeeds,
98
118
  };
package/core/dream.js CHANGED
@@ -270,6 +270,7 @@ function buildDreamPrompt({ sinceTs = null, fullSweep = true, clusters = [], evi
270
270
  `Stance${packs.provenanceOf(p.dir, "Stance") === "user" ? " (USER-AUTHORED — do not rewrite)" : ""}: ${p.sections.Stance}`,
271
271
  `Procedure: ${p.sections.Procedure}`,
272
272
  `State: ${p.sections.State}`,
273
+ ...(p.sections.Decisions ? [`Decisions (append-only, carried on merge):\n${p.sections.Decisions}`] : []),
273
274
  `Journal:\n${p.sections.Journal}`,
274
275
  ].join("\n"), MAX_PACK_CHARS)).join("\n\n") || "(none)";
275
276
 
@@ -503,11 +504,14 @@ function applyDream(decision, backupRoot) {
503
504
  continue;
504
505
  }
505
506
  const fromJournals = from.map((d) => packs.readPack(d).sections.Journal || "");
507
+ // Settled decisions of absorbed packs move to the survivor verbatim.
508
+ const fromDecisions = from.flatMap((d) => String(packs.readPack(d).sections.Decisions || "").split("\n").filter((l) => l.trim()));
506
509
  const mr = packs.updatePack(m.into, {
507
510
  stance: typeof m.stance === "string" ? m.stance : "",
508
511
  procedure: typeof m.procedure === "string" ? m.procedure : "",
509
512
  state: typeof m.state === "string" ? m.state : "",
510
513
  journal: m.journal || `absorbed ${from.join(", ")}`,
514
+ decisions: fromDecisions,
511
515
  }, "dream");
512
516
  const ju = packs.unionJournals(m.into, fromJournals);
513
517
  for (const d of from) {
@@ -739,7 +743,8 @@ function buildIntrospectionPrompt() {
739
743
  Today: ${new Date().toISOString().slice(0, 10)}
740
744
 
741
745
  ═══ WHAT YOU WORKED ON (compaction digests of the day's conversations) ═══
742
- ${clip(seedText, 14000)}
746
+ ${seeds.text.trim() ? daySeeds.digestDaySeeds(seedText, 30000) : seedText}
747
+ (Each compaction above is cut to its opening lines; the full digests are in ${daySeeds.SEEDS_DIR}/<date>.md.)
743
748
 
744
749
  ═══ YOUR CURRENT SELF ═══
745
750
  Persona (your voice):
@@ -792,6 +797,19 @@ When done investigating, reply with ONLY this JSON (no prose, no code fences):
792
797
  "report": "<first-person chat message>"}`;
793
798
  }
794
799
 
800
+ // Introspection stalls on some nights: the 12-minute cap hits with almost
801
+ // nothing spent, while finished runs take 3-6 minutes. One timeout gets one
802
+ // fresh run; a used-up limit or any other failure is not repeated.
803
+ async function runIntrospectionWithRetry(runUtility, prompt, options, { log = console.warn } = {}) {
804
+ try {
805
+ return await runUtility(prompt, options);
806
+ } catch (e) {
807
+ if (e?.code !== "UTILITY_TIMEOUT") throw e;
808
+ log(`[dream] introspection timed out, retrying once: ${e.message}`);
809
+ return runUtility(prompt, options);
810
+ }
811
+ }
812
+
795
813
  function parseIntrospection(text) {
796
814
  const raw = String(text || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
797
815
  const start = raw.indexOf("{");
@@ -1330,7 +1348,7 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1330
1348
  // A used-up limit stops this step too: it runs with the dream after the reset.
1331
1349
  if (introspectEnabled() && !limitWait) {
1332
1350
  try {
1333
- const introRun = await runUtility(buildIntrospectionPrompt(), dreamUtilityOptions("introspection", {
1351
+ const introRun = await runIntrospectionWithRetry(runUtility, buildIntrospectionPrompt(), dreamUtilityOptions("introspection", {
1334
1352
  provider,
1335
1353
  cwd: CODE_ROOT,
1336
1354
  additionalReadOnlyDirectories: [CONFIG_DIR],
@@ -1822,7 +1840,7 @@ function initDream(adapters, deps = {}) {
1822
1840
 
1823
1841
  module.exports = {
1824
1842
  createDreamService, previewDream, runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies, dreamTimeoutMs,
1825
- buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
1843
+ buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport, runIntrospectionWithRetry,
1826
1844
  summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe, staleTaskReport,
1827
1845
  dreamSummaryTarget, runScheduledDream, dreamOverdue, createDreamCatchUp, DREAM_CATCHUP_MS,
1828
1846
  createDreamLimitWait, dreamLimitWait, dreamFailNote, DREAM_LIMIT_MAX_WAITS,
package/core/handlers.js CHANGED
@@ -1287,7 +1287,7 @@ register({
1287
1287
  });
1288
1288
 
1289
1289
  register({
1290
- name: "engine", description: "Switch memory recall engine", args: "[classic|discoverer|ask]",
1290
+ name: "engine", description: "Switch memory recall engine", args: "[discoverer|x|mem0|classic|ask]",
1291
1291
  handler: async (env, { tail }) => {
1292
1292
  if (!authorized(env)) return;
1293
1293
  const { settings } = currentState();
@@ -1295,6 +1295,7 @@ register({
1295
1295
  const active = recall.activeEngineName(settings);
1296
1296
  if (tail) {
1297
1297
  const v = tail.trim().toLowerCase();
1298
+ if (recall.OWNER_ONLY_ENGINES.includes(v) && !ownerEnv(env)) return send(`Owner only — the ${v} engine runs a local command.`);
1298
1299
  if (v === "default" || v === recall.DEFAULT_ENGINE) settings.recallEngine = null;
1299
1300
  else if (recall.listEngines().includes(v)) settings.recallEngine = v;
1300
1301
  else return send(`Unknown engine "${v}". Options: ${recall.listEngines().join(", ")}.`);
@@ -1305,9 +1306,12 @@ register({
1305
1306
  `Recall engine: ${active}${settings.recallEngine ? "" : " (default)"}\n\n` +
1306
1307
  "• discoverer — typed-edge graph + spreading activation + why-bullets (default)\n" +
1307
1308
  "• classic — keyword FTS + relevance judge (opt-out fallback)\n" +
1308
- "• ask — nothing auto-injected; I pull my own notes on demand and say so",
1309
+ "• ask — nothing auto-injected; I pull my own notes on demand and say so\n" +
1310
+ "• x — experimental: X memory facts search (falls back to discoverer)\n" +
1311
+ "• mem0 — experimental: Mem0 search (falls back to discoverer)",
1309
1312
  { keyboard: { inline_keyboard: [
1310
1313
  [{ text: "Discoverer (default)", callback_data: "eng:discoverer" }, { text: "Classic", callback_data: "eng:classic" }],
1314
+ [{ text: "X", callback_data: "eng:x" }, { text: "Mem0", callback_data: "eng:mem0" }],
1311
1315
  [{ text: "Ask (on-demand)", callback_data: "eng:ask" }],
1312
1316
  ] } },
1313
1317
  );
@@ -0,0 +1,51 @@
1
+ ## Background work
2
+ You can persist work across turns and wake yourself up later. These survive bot restarts.
3
+
4
+ Wake-ups and crons (real schedulers, not hallucinations — use them instead of saying "I'll check back later"):
5
+ - `open-claudia schedule-wakeup <when> "<prompt>"` — one-shot. `<when>` is a duration like `30s`/`5m`/`2h`/`1d` or an ISO datetime. When it fires you wake up in this same conversation with the prompt as if the user typed it.
6
+ - `open-claudia cron-add "<5-field cron>" "<prompt>"` — recurring.
7
+ - `open-claudia cron-list` — list all wakeups + crons on this channel.
8
+ - `open-claudia cron-remove <id>` — cancel one.
9
+
10
+ Watches (a silent background monitor — poll a condition WITHOUT waking every tick; wake only when it actually happens). Use this instead of a minute-by-minute cron whenever you'd otherwise fire a turn just to check "is it done yet?" and stay quiet most times:
11
+ - `open-claudia watch-add "<check-cmd>" "<wake-prompt>"` — the scheduler polls `<check-cmd>` as a shell command (default every minute). The poll is SILENT: nothing reaches the chat while the check keeps failing. The instant `<check-cmd>` exits 0, you wake in this same conversation with `<wake-prompt>`, exactly like a wakeup. Non-zero exit = still waiting = nothing happens. Default is ONE-SHOT (fires once, then removes itself); add `--repeat` to keep polling after each hit. `--every "<5-field cron>"` changes the poll cadence.
12
+ - Every watch has a hard timeout — `--timeout <duration|ISO>` (default 2h). A watch that reaches its timeout without the check ever passing FIRES anyway on the next poll, with the prompt marked "Watch expired" so you know the condition did NOT happen. No watch can end silently, so you no longer need a separate backup wakeup for every watch — but size `--timeout` honestly to the work being watched.
13
+ - `open-claudia watch-list` / `open-claudia watch-remove <id>` — inspect/cancel watches on this channel.
14
+ - The owner has a global master switch: `/watch off` pauses ALL watch polling (and `/watch on` resumes). If `watch-list` shows the switch OFF, watches won't fire until it's back on.
15
+ - Example: after kicking off a long external job, `open-claudia watch-add "test -f /tmp/build-done || exit 1" "the build finished — verify and report"` beats a wakeup guess, because it fires the moment it's actually done, not on a fixed timer. (Never use a watch to poll harness-tracked work you started — that notifies you automatically. Watches are for EXTERNAL state the harness can't see.)
16
+
17
+ The selected coding-agent provider may expose native scheduling tools. Do NOT use those for Open Claudia wakeups or crons: a provider runs as a per-turn subprocess, so its timers do not belong to the long-running bot. Only the `open-claudia` CLI commands above are authoritative and survive.
18
+
19
+ Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
20
+
21
+ Any provider-native reminder about task or plan tools refers to a separate, ephemeral todo system. Open Claudia work belongs in `open-claudia task` (the persistent, channel-scoped one). Do not double-track.
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ - `open-claudia task plan "<plan title>" "<step 1>" "<step 2>" "<step 3>" [--description "<why / success criteria>"]` — atomic create
28
+ - `open-claudia task add "<content>" [--parent <plan-id>] [--description "..."]` — add a single task or subtask
29
+ - `open-claudia task list` — renders as a tree
30
+ - `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)
31
+
32
+ For one-off work under 3 steps, skip the plan and just track it in your own response.
33
+
34
+ Cross-channel relay (talk to other team members on their primary channel — see Team block above for names):
35
+ - `open-claudia send-to --person "<name>" "<message>"` — sends to that person's primary channel.
36
+ - `open-claudia send-to --adapter <type> --channel <id> "<message>"` — explicit channel target.
37
+ - Be honest about provenance. If you are forwarding on someone's behalf, prefix the message: "From <speaker name>: ...". If you are reaching out autonomously (cron, wakeup, your own judgement), prefix with "Open Claudia: ...".
38
+ - Use sparingly — every relay is logged to the audit trail. Don't message people unprompted unless the user asked you to or the context obviously calls for it.
39
+
40
+ Thread hand-off (Kazee Chat, owner only — move work to another thread and wake it there):
41
+ - `open-claudia handoff --thread <threadId> "<message>" [--chat <chatId>] [--title "<label>"]` — posts the message in that thread (visibly, as you, marked as a hand-off from this thread) and wakes that thread's conversation to start work on it now; it waits behind a turn already running there. `--chat` defaults to this chat.
42
+ - Use it when the user asks for work to move to, or continue in, another thread. A thread's id is the part after `#thread:` in its conversation key (`transcript-search --all` hits show the key). The woken thread is told how to hand back. Refused on a non-owner's turn, into the thread you are in, and after 3 hand-offs in a row with no person writing in between.
43
+
44
+ Recent activity lookup (read other chats' transcripts on demand — don't try to carry everything in context):
45
+ - `open-claudia recent --person "<name>" [--limit 20]`
46
+ - `open-claudia recent --adapter <type> --channel <id> [--limit 20]`
47
+
48
+ People management (owner-only commands; safe to call to inspect):
49
+ - `open-claudia people list` / `people show <id-or-name>` — read-only, anyone can call.
50
+ - `open-claudia people note <id-or-name> "<note>"` — record something the team should remember.
51
+ - `open-claudia intros list` / `intros approve <id>` / `intros reject <id>` — owner-gated.
package/core/lessons.js CHANGED
@@ -175,6 +175,33 @@ function injectedLessons() {
175
175
  return out;
176
176
  }
177
177
 
178
+ // Lessons tied to one topic (their src names the pack) that the always-loaded
179
+ // block did not carry. The char budget keeps that block to the most
180
+ // reinforced few, so most lessons would otherwise never reach a turn; they
181
+ // ride along with their pack when its topic comes up instead.
182
+ function topicLessons(dir, { exclude = null, max = 3, maxChars = 900 } = {}) {
183
+ if (!dir) return [];
184
+ const lessons = readLessons().filter((l) => l.src === dir);
185
+ if (lessons.length === 0) return [];
186
+ const skip = exclude || new Set(injectedLessons().map((l) => l.id));
187
+ const meta = reconcileMeta(lessons);
188
+ const ranked = lessons.filter((l) => !skip.has(l.id)).sort((a, b) => {
189
+ const ma = meta[a.id] || {}, mb = meta[b.id] || {};
190
+ return (mb.reinforced || 0) - (ma.reinforced || 0)
191
+ || String(mb.created || "").localeCompare(String(ma.created || ""));
192
+ });
193
+ const out = [];
194
+ let used = 0;
195
+ for (const l of ranked) {
196
+ if (out.length >= max) break;
197
+ const line = `- ${l.text}`;
198
+ if (used + line.length + 1 > maxChars) continue;
199
+ out.push({ ...l, ...(meta[l.id] || {}) });
200
+ used += line.length + 1;
201
+ }
202
+ return out;
203
+ }
204
+
178
205
  // Add or reinforce a lesson. Dedupe is by normalized text (id), so the same
179
206
  // fact phrased the same way reinforces instead of duplicating. No eviction
180
207
  // here — the char budget bounds injection and dream enforces the count cap
@@ -277,7 +304,7 @@ module.exports = {
277
304
  LESSONS_FILE, LESSONS_META_FILE,
278
305
  MAX_LESSONS, MAX_LESSON_CHARS, MAX_LESSONS_CHARS,
279
306
  normalize, lessonId, parseLessons,
280
- readLessons, listLessons, loadLessonsBlock, injectedLessons, lessonLine,
307
+ readLessons, listLessons, loadLessonsBlock, injectedLessons, topicLessons, lessonLine,
281
308
  readMeta, writeMeta, reconcileMeta,
282
309
  addLesson, editLesson, removeLesson, saveLessonsRaw, lessonsExist,
283
310
  };
@@ -76,7 +76,7 @@ function scanForInjection(text, { strict = true } = {}) {
76
76
  // Scan the sections of a pending pack write. Strict on the auto-injected
77
77
  // sections, lenient on Journal. Returns the first hit, or { flagged: false }.
78
78
  function scanSections(sections = {}) {
79
- const strictKeys = ["stance", "procedure", "state", "description"];
79
+ const strictKeys = ["stance", "procedure", "state", "decisions", "description"];
80
80
  for (const key of strictKeys) {
81
81
  const r = scanForInjection(sections[key], { strict: true });
82
82
  if (r.flagged) return { ...r, section: key };
@@ -94,10 +94,12 @@ function renderActivePacks(activePacks) {
94
94
  if (stateTxt) {
95
95
  const words = stateTxt.split(/\s+/).length;
96
96
  const budget = words > 150
97
- ? ` — OVER BUDGET: this State must come back under 150 words; tighten wording and drop completed/stale detail (never true facts this turn didn't mention)`
97
+ ? ` — OVER BUDGET: this State must come back under 150 words; tighten wording and drop completed/stale detail (never true facts this turn didn't mention; a settled decision moves to "decisions", never just dropped)`
98
98
  : "; target 150";
99
99
  parts.push(`State (current, ${words} words${budget}):\n${clip(stateTxt, ACTIVE_SECTION_CHARS)}`);
100
100
  }
101
+ const decided = String(sec.Decisions || "").trim();
102
+ if (decided) parts.push(`Decisions (settled, append-only):\n${clip(decided, ACTIVE_SECTION_CHARS)}`);
101
103
  if (journal) parts.push(`Journal (recent):\n${journal}`);
102
104
  blocks.push(parts.join("\n"));
103
105
  }
@@ -159,8 +161,9 @@ function buildReviewPrompt(userText, assistantText, activePacks, denyBlock) {
159
161
  A context pack is a living document about ONE topic (a project, a system, a recurring task, a domain). It has four sections:
160
162
  - Stance: how to think about the topic — preferences, framing, hard rules the user has expressed.
161
163
  - Procedure: verified how-to steps and commands.
162
- - State: where work on the topic stands NOW — decisions made, open questions, next steps. Replaces wholesale.
163
- - Journal: one-line dated log of what happened each session.
164
+ - State: where work on the topic stands NOW — open questions, next steps, current status. Replaces wholesale every time, so it is NOT a safe home for anything that must last.
165
+ - Decisions: settled facts the user decided or confirmed — dates, deadlines, prices, numbers, choices, owners, yes/no answers — one dated line each. Append-only: lines are never rewritten or dropped.
166
+ - Journal: one-line dated log of what happened each session (only the last 30 lines are kept).
164
167
 
165
168
  An entity note is a short memory file about ONE specific named entity — a person, place, project, org, or system. It has:
166
169
  - Notes: current truth about the entity (who/what it is, role, preferences, relationships). Replaces wholesale.
@@ -213,6 +216,7 @@ Decide. Rules:
213
216
  - State/Stance/Procedure rewrites are MERGES, never regenerations: start from the pack's CURRENT content shown in the ACTIVE block above, carry forward every fact that is still true, drop only what this turn contradicted or completed, then fold in what changed. Never drop a durable fact merely because this turn did not mention it. If a pack's current content is NOT shown above, do not rewrite its sections — record a journal line only (section rewrites there are discarded).
214
217
  - LOCKED lines: a Stance line containing the word LOCKED is a constraint the user explicitly froze. NEVER weaken, reword, or drop a LOCKED line in a stance rewrite — carry it forward verbatim — unless THIS turn shows the user explicitly unlocking or changing it. COMPLIANCE CHECK: compare the assistant's reply against every LOCKED line in the ACTIVE packs above; if the reply violated one (did what the line forbids, or contradicted the locked choice), append a journal line on that pack recording it, e.g. "VIOLATION: reply used <X> where Stance locks <Y>." — violations must become visible, never silently absorbed.
215
218
  - UPDATE an existing pack when the turn touched that topic: append a journal line, and rewrite State if where-things-stand changed. Update Stance when the user expressed a lasting preference or rule; Procedure when a verified working method emerged.
219
+ - DECISIONS: whenever the turn shows the user settling something — choosing an option, answering a decision card, fixing a date or deadline, setting a price, number, limit or owner — add ONE line per decision to that pack action's "decisions" array. Write it so it stands alone months later: who, when, and the exact values in the user's own terms (e.g. "Sumeet 7 Oct (card D-01): launch dates are dogfood 13 Nov 2026, private beta 4 Dec 2026, public launch 1 Feb 2027"). Include it even if State or the Journal also mentions it. Skip a decision already listed in the pack's Decisions above; if the user CHANGED an earlier decision, add the new one and say what it replaces. Decisions may be added to ANY pack, active or not. No settled decision in the turn → omit "decisions".
216
220
  - PROMOTE durable conclusions out of the Journal. A confirmed root-cause diagnosis, an established fact, or a settled design decision must go into Stance/Procedure/State (the parts always injected in full), NOT only a Journal line — Journal is truncated to the last few lines on injection, so a conclusion left only there will silently fall out of recall over time.
217
221
  - CREATE a pack ONLY when the turn worked on a durable topic that NO existing pack covers. Before creating, scan the existing packs above for one already about this project/system/domain — if one exists, UPDATE it instead. A new pack must be a genuinely DISTINCT topic, never a narrower facet, version, release, sub-feature, phase, or component of a topic an existing pack already owns. In particular: a release or version of a project (e.g. a pack named after "<project> v2.6.39") is a Journal line + State update on that project's EXISTING pack — NEVER its own pack. Likewise sub-features of one system belong in that system's pack, not in siblings. When unsure whether something is a new topic or a facet of an existing one, treat it as a facet and UPDATE. Do not create packs for one-off trivia.
218
222
  - ABILITY vs CONTEXT: most creates are context packs (a project/system tracker). Create an ability (kind:"ability") only for a genuinely reusable how-to as defined above, and always set learned_on + applied_on to the originating project dir. When a turn re-used an EXISTING ability on a NEW project, do NOT create anything — UPDATE that ability and put the new project dir in "applied_on" so the cross-project reuse is recorded.
@@ -227,8 +231,8 @@ Decide. Rules:
227
231
 
228
232
  Reply with ONLY a JSON object, no prose, no code fences:
229
233
  {"actions": [
230
- {"action": "update", "pack": "<existing dir>", "journal": "<one sentence>", "subst": <0-10>, "state": "<full new State or null>", "stance": null, "procedure": null, "applied_on": ["<project dir, ONLY when this turn re-applied an existing ability to a new project>"]}
231
- | {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "kind": "context|ability", "learned_on": "<originating project dir — abilities only>", "applied_on": ["<project dirs — abilities only>"], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "journal": "<one sentence>", "subst": <0-10>}
234
+ {"action": "update", "pack": "<existing dir>", "journal": "<one sentence>", "subst": <0-10>, "state": "<full new State or null>", "decisions": ["<one settled decision per line, only when the turn settled one>"], "stance": null, "procedure": null, "applied_on": ["<project dir, ONLY when this turn re-applied an existing ability to a new project>"]}
235
+ | {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "kind": "context|ability", "learned_on": "<originating project dir — abilities only>", "applied_on": ["<project dirs — abilities only>"], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "decisions": ["<settled decisions, if any>"], "journal": "<one sentence>", "subst": <0-10>}
232
236
  ],
233
237
  "entities": [
234
238
  {"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>", "role": "<people only, or null>", "style": "<people only: how to talk to them, or null>", "knows": "<people only: what to assume they know, or null>"}
@@ -260,6 +264,7 @@ function guardCheck(a) {
260
264
  stance: a.stance,
261
265
  procedure: a.procedure,
262
266
  state: a.state,
267
+ decisions: Array.isArray(a.decisions) ? a.decisions.join("\n") : a.decisions,
263
268
  description: a.description,
264
269
  journal: a.journal,
265
270
  });
@@ -291,6 +296,14 @@ function findDuplicatePack({ dir, name, description, tags }) {
291
296
  return null;
292
297
  }
293
298
 
299
+ const MAX_DECISIONS_PER_ACTION = 5;
300
+
301
+ function decisionLines(a) {
302
+ return [].concat(a && a.decisions || [])
303
+ .filter((d) => typeof d === "string" && d.trim())
304
+ .slice(0, MAX_DECISIONS_PER_ACTION);
305
+ }
306
+
294
307
  function applyAction(a, activeSet = null) {
295
308
  if (!a || typeof a !== "object") return null;
296
309
  const guard = guardCheck(a);
@@ -311,7 +324,10 @@ function applyAction(a, activeSet = null) {
311
324
  // and would destroy durable facts. Journal lines always land; section
312
325
  // rewrites are dropped for packs outside the active set.
313
326
  const blind = activeSet instanceof Set && !activeSet.has(a.pack);
327
+ // Decisions are append-only, so unlike section rewrites they are safe on
328
+ // a pack the reviewer did not see in full.
314
329
  const r = packs.updatePack(a.pack, {
330
+ decisions: decisionLines(a),
315
331
  journal: journalLine,
316
332
  state: !blind && typeof a.state === "string" ? a.state : "",
317
333
  stance: !blind && typeof a.stance === "string" ? a.stance : "",
@@ -346,7 +362,7 @@ function applyAction(a, activeSet = null) {
346
362
  const projects = applied_on.length ? applied_on : (learned_on ? [learned_on] : []);
347
363
  if (packs.readPack(dir)) {
348
364
  const blind = activeSet instanceof Set && !activeSet.has(dir);
349
- packs.updatePack(dir, { journal: journalLine, state: blind ? "" : (a.state || "") }, "reviewer");
365
+ packs.updatePack(dir, { decisions: decisionLines(a), journal: journalLine, state: blind ? "" : (a.state || "") }, "reviewer");
350
366
  if (kind === "ability") for (const proj of projects) packs.recordApplied(dir, proj);
351
367
  return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
352
368
  }
@@ -356,7 +372,7 @@ function applyAction(a, activeSet = null) {
356
372
  const dupeDir = kind === "context" ? findDuplicatePack({ dir, name: a.name, description: a.description, tags: a.tags }) : null;
357
373
  if (dupeDir && packs.readPack(dupeDir)) {
358
374
  const blind = activeSet instanceof Set && !activeSet.has(dupeDir);
359
- packs.updatePack(dupeDir, { journal: journalLine, state: blind ? "" : (a.state || "") }, "reviewer");
375
+ packs.updatePack(dupeDir, { decisions: decisionLines(a), journal: journalLine, state: blind ? "" : (a.state || "") }, "reviewer");
360
376
  const ex = packs.readPack(dupeDir);
361
377
  return { kind: "update", dir: dupeDir, name: (ex && ex.name) || dupeDir, note: a.journal || "folded a new-pack proposal into the existing pack" };
362
378
  }
@@ -376,6 +392,7 @@ function applyAction(a, activeSet = null) {
376
392
  stance: a.stance || "",
377
393
  procedure: a.procedure || "",
378
394
  state: a.state || "",
395
+ decisions: decisionLines(a),
379
396
  journal: journalLine,
380
397
  kind,
381
398
  learned_on: kind === "ability" ? learned_on : "",