@inetafrica/open-claudia 2.9.0 → 2.9.3

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.9.3
4
+ - **Bot advertises a distinct "thinking" state before its first token (Kazee parity Phase 2).** The typing pipeline now carries an additive `state` discriminator (`'thinking' | 'typing'`) — no new socket event. The runner advertises `thinking` from the moment a turn starts until the first stream event (text delta, assistant content, tool call, or result), then flips to plain `typing`; Kazee clients render "<name> is thinking…" in the header/chat-list for that pre-token window and fall back to normal typing text otherwise. The Telegram adapter ignores `state` (its chat action has no such distinction), so the change is inert there. Pairs with the chat-central `message:interactiveUpdated` broadcast (server-authoritative button selection) landing in the same release across chat-central/web/mobile.
5
+ - **Ships the v2.9.2 work that never published.** v2.9.2 (Kazee REST media fallback when a socket send lacks a media path, plus an explicit `typing:stop` on turn end) was committed to main but no tag was ever pushed, so CI never published it. This tag carries it to npm alongside the thinking-state change.
6
+
7
+ ## v2.9.1
8
+ - **Post-turn reviewer no longer rewrites pack sections blind — context starvation fixed.** The reviewer prompt showed only the pack *index* (name + description), so a State rewrite was regenerated from the turn text alone and silently destroyed durable facts the turn didn't restate; worse, with no project name in the turn text it could attribute the work to the wrong pack entirely (observed live: an AQUELLE product-shot turn filed under Alpha Protein, wiping a promoted root-cause fact). The runner now passes the turn's **active packs** (recall-surfaced ∪ explicitly opened) into `reviewTurn`; the prompt includes their **full current Stance/Procedure/State + recent journal**, with two new rules: attribute updates to an active pack unless the turn clearly names another, and treat section rewrites as **merges** — carry forward every still-true fact, drop only what the turn contradicted or completed.
9
+ - **Structural enforcement, not just prompt guidance.** `applyAction` drops section rewrites (State/Stance/Procedure) targeting any pack whose current content the reviewer did not see — journal lines still land; the announce line notes the drop. Applies to the update path, the exists-on-create path, and the versioned-duplicate fold.
10
+ - **Provenance guard extended.** A user-authored Procedure now gets the same intent-wins protection as Stance (an automated writer never silently replaces it). State stays rewritable by design, but replacing a user-authored State is now flagged in the reviewer's announcement (⚠️) so the overwrite is visible instead of silent.
11
+
3
12
  ## v2.9.0
4
13
  - **Hybrid async approvals — a destructive run no longer burns the turn or dead-ends on a late press.** v2.8.0's chat approval blocked the CLI for 5 minutes and treated timeout as denial, which had two UX failures: an undecided window wasted minutes of live turn, and a button pressed after the window hit a dead-end (terse auto-ack, no agent reaction — the record was already denied). The flow is now two-phase. **Fast path**: the CLI waits ~90s; an inline Approve runs immediately, Deny refuses — unchanged semantics, just a tighter window. **Async path**: if undecided at 90s, the CLI marks the record `detached` and exits code 5 with instructions to end the turn ("pending your Approve/Deny — do NOT retry"); the record stays open for 24h. When the button is eventually pressed, the `apr:` handler sees the detached record and **schedules an immediate wakeup** in the originating channel carrying the decision and the exact command, so the agent reacts either way (runs it, or acknowledges the denial).
5
14
  - **One-shot approval tokens, fail-closed on every branch.** The woken agent redeems the decision with `tool run <name> <args> --approval <apr_id>`. `approvals.consume()` requires ALL of: status `approved`, never consumed before (single use — a replay is refused), within 24h of request, and a command line **byte-identical** to what the user saw on the buttons (a mismatch refuses without burning the token). `--approval` is honored on top-level runs only — a nested/composed tool can't smuggle a token — and redemption stamps `approvedVia: "chat-approval"` + exports `OPEN_CLAUDIA_APPROVED_TIER=destructive` exactly like an inline approve, so composition inheritance is unchanged. Undecided, denied, expired, unknown, or mismatched never runs.
@@ -256,6 +256,9 @@ class KazeeAdapter {
256
256
  body.type = "interactive";
257
257
  body.interactive = { buttons };
258
258
  }
259
+ // Progressive delivery of the bot's own answer — tell the server this is a
260
+ // streaming edit so it skips editHistory + the "edited" marker.
261
+ if (opts.streaming) body.streaming = true;
259
262
  try {
260
263
  await this._request("PUT", `/message/${encodeURIComponent(messageId)}`, body);
261
264
  } catch (e) {
@@ -299,30 +302,56 @@ class KazeeAdapter {
299
302
  console.error("Kazee upload returned no media id:", JSON.stringify(upload).slice(0, 300));
300
303
  return false;
301
304
  }
302
- // Post the message via the v2 socket event `message:send`, which
303
- // accepts `mediaIds` referencing already-saved Media docs. We avoid
304
- // the REST sendMessage action because it tries to re-Media.create
305
- // from media_url and that call omits required schema fields
306
- // (chat/bucketName/fileName/minioPath) it always 500s.
305
+ const type = this._kazeeFileType(fileName);
306
+ // Prefer the v2 socket event `message:send` (real-time fan-out), which
307
+ // accepts `mediaIds` referencing already-saved Media docs. If the socket
308
+ // is down or the ack errors, fall back to the REST sendMessage action —
309
+ // it now accepts already-uploaded media via `media_ids` (previously it
310
+ // only took media_url and re-Media.create'd, which always 500'd).
307
311
  const payload = {
308
312
  chatId: channelId,
309
313
  content: caption || "",
310
- type: this._kazeeFileType(fileName),
314
+ type,
311
315
  findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
312
316
  mediaIds: [mediaId],
313
317
  };
314
- const res = await this._socketEmit("message:send", payload, 30000);
315
- if (res && res.code) {
316
- console.error("Kazee message:send error:", res.code, res.message);
317
- return false;
318
+ try {
319
+ const res = await this._socketEmit("message:send", payload, 30000);
320
+ if (res && res.code) {
321
+ console.error(`Kazee message:send error (${res.code}), falling back to REST:`, res.message);
322
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption);
323
+ }
324
+ return true;
325
+ } catch (sockErr) {
326
+ console.error("Kazee message:send unavailable, falling back to REST:", sockErr.message);
327
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption);
318
328
  }
319
- return true;
320
329
  } catch (e) {
321
330
  console.error("Kazee sendFile error:", e.message);
322
331
  return false;
323
332
  }
324
333
  }
325
334
 
335
+ // REST fallback for posting an already-uploaded Media doc when the socket
336
+ // path is unavailable. Mirrors send()'s route (POST /chat/:id/message →
337
+ // sendMessage action) but references the saved media by id.
338
+ async _sendMediaViaRest(channelId, mediaId, type, caption) {
339
+ try {
340
+ await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, {
341
+ chat_id: channelId,
342
+ sender: this.botUserId,
343
+ content: caption || "",
344
+ type,
345
+ media_ids: [mediaId],
346
+ findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
347
+ });
348
+ return true;
349
+ } catch (e) {
350
+ console.error("Kazee REST media send failed:", e.message);
351
+ return false;
352
+ }
353
+ }
354
+
326
355
  // Promisified ack-style socket emit. chat-central v2 handlers respond
327
356
  // either with a success body or an { code, message, action } error.
328
357
  _socketEmit(event, payload, timeoutMs = 15000) {
@@ -355,8 +384,22 @@ class KazeeAdapter {
355
384
  return "file";
356
385
  }
357
386
 
358
- async typing(channelId) {
359
- try { this._socket?.emit?.("typing:start", { chatId: channelId }); } catch (e) {}
387
+ // `state` is an optional richer indicator: "thinking" (the bot is reasoning
388
+ // pre-first-token) vs the default "typing". Kazee clients render "thinking"
389
+ // distinctly; older clients ignore the field and fall back to "typing".
390
+ async typing(channelId, state) {
391
+ try {
392
+ const payload = { chatId: channelId };
393
+ if (state) payload.state = state;
394
+ this._socket?.emit?.("typing:start", payload);
395
+ } catch (e) {}
396
+ }
397
+
398
+ // Socket channels keep rendering "typing…" until an explicit stop. Telegram
399
+ // has no equivalent (its chat action self-expires), so this is Kazee-only and
400
+ // called via optional chaining from the core typing heartbeat cleanup.
401
+ async typingStop(channelId) {
402
+ try { this._socket?.emit?.("typing:stop", { chatId: channelId }); } catch (e) {}
360
403
  }
361
404
 
362
405
  async downloadMedia(media) {
@@ -351,7 +351,9 @@ class TelegramAdapter {
351
351
  }
352
352
  }
353
353
 
354
- async typing(channelId) {
354
+ // Telegram exposes only a single "typing" chat action, so the optional
355
+ // `state` (thinking vs typing) is accepted for signature parity but ignored.
356
+ async typing(channelId, _state) {
355
357
  try { await this.bot.sendChatAction(channelId, "typing"); } catch (e) {}
356
358
  }
357
359
 
package/channels/types.js CHANGED
@@ -55,7 +55,8 @@
55
55
  * delete(channelId, messageId): Promise<void>
56
56
  * sendVoice(channelId, oggPath): Promise<boolean>
57
57
  * sendFile(channelId, filePath, caption?): Promise<boolean>
58
- * typing(channelId): Promise<void>
58
+ * typing(channelId, state?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
59
+ * typingStop?(channelId): Promise<void> // optional — socket channels only
59
60
  * downloadMedia(mediaRef): Promise<localPath|null>
60
61
  * registerCommands(commands: CommandSpec[]): Promise<void>
61
62
  */
package/core/io.js CHANGED
@@ -52,11 +52,11 @@ async function sendFile(filePath, caption) {
52
52
  return adapter.sendFile(channelId, filePath, caption);
53
53
  }
54
54
 
55
- async function typing() {
55
+ async function typing(state) {
56
56
  const adapter = currentAdapter();
57
57
  const channelId = currentChannelId();
58
58
  if (!adapter || !channelId) return;
59
- return adapter.typing(channelId);
59
+ return adapter.typing(channelId, state);
60
60
  }
61
61
 
62
62
  function splitMessage(text, maxLen = 4000) {
@@ -58,7 +58,34 @@ function formatAnnouncement(lines) {
58
58
  return [header, ...lines].join("\n\n");
59
59
  }
60
60
 
61
- function buildReviewPrompt(userText, assistantText) {
61
+ // Full current content of the packs a turn actually worked with, rendered
62
+ // into the review prompt so section rewrites are merges (old content visible)
63
+ // instead of blind regenerations from the turn text — a blind State rewrite
64
+ // silently destroys durable facts the turn didn't restate.
65
+ const ACTIVE_PACK_MAX = 4;
66
+ const ACTIVE_SECTION_CHARS = 1500;
67
+
68
+ function renderActivePacks(activePacks) {
69
+ const dirs = (Array.isArray(activePacks) ? activePacks : []).slice(0, ACTIVE_PACK_MAX);
70
+ const blocks = [];
71
+ for (const dir of dirs) {
72
+ let p = null;
73
+ try { p = packs.readPack(dir); } catch (e) {}
74
+ if (!p) continue;
75
+ const sec = p.sections || {};
76
+ const journal = String(sec.Journal || "").split("\n").filter((l) => l.trim()).slice(-3).join("\n");
77
+ const parts = [`### ${p.dir}: ${p.name} — ${p.description || ""}`];
78
+ if (String(sec.Stance || "").trim()) parts.push(`Stance:\n${clip(sec.Stance.trim(), ACTIVE_SECTION_CHARS)}`);
79
+ if (String(sec.Procedure || "").trim()) parts.push(`Procedure:\n${clip(sec.Procedure.trim(), ACTIVE_SECTION_CHARS)}`);
80
+ if (String(sec.State || "").trim()) parts.push(`State (current):\n${clip(sec.State.trim(), ACTIVE_SECTION_CHARS)}`);
81
+ if (journal) parts.push(`Journal (recent):\n${journal}`);
82
+ blocks.push(parts.join("\n"));
83
+ }
84
+ return blocks.join("\n\n");
85
+ }
86
+
87
+ function buildReviewPrompt(userText, assistantText, activePacks) {
88
+ const activeBlock = renderActivePacks(activePacks);
62
89
  const index = packs.listPacks().map((p) => {
63
90
  const ab = p.kind === "ability" ? " ◆ability" : "";
64
91
  const prov = p.kind === "ability" && p.learned_on
@@ -93,7 +120,11 @@ A LESSON is a single always-loaded rule (NOT topic-gated like packs/entities —
93
120
 
94
121
  Existing packs:
95
122
  ${index}
123
+ ${activeBlock ? `
124
+ Packs ACTIVE during this turn — their FULL CURRENT content. The turn's work almost certainly belongs to one of these:
96
125
 
126
+ ${activeBlock}
127
+ ` : ""}
97
128
  Known entities:
98
129
  ${entityIndex}
99
130
 
@@ -112,6 +143,8 @@ ${clip(assistantText)}
112
143
 
113
144
  Decide. Rules:
114
145
  - Bias toward action: if the turn did real work on an identifiable topic (a named system, project, server, app, person, or domain), record it — at minimum a journal line. Most working turns deserve one. Reserve empty actions for small talk, pure status checks, and turns that contain nothing new.
146
+ - ATTRIBUTION: prefer updating one of the ACTIVE packs above. The turn text often does not name its project (pronouns, follow-ups, pasted URLs) — never attribute the work to a different pack from the index on thematic similarity alone when an active pack fits the work.
147
+ - 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).
115
148
  - 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.
116
149
  - 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.
117
150
  - 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.
@@ -189,7 +222,7 @@ function findDuplicatePack({ dir, name, description, tags }) {
189
222
  return null;
190
223
  }
191
224
 
192
- function applyAction(a) {
225
+ function applyAction(a, activeSet = null) {
193
226
  if (!a || typeof a !== "object") return null;
194
227
  const guard = guardCheck(a);
195
228
  if (guard.flagged) {
@@ -198,11 +231,17 @@ function applyAction(a) {
198
231
  if (a.action === "update" && a.pack) {
199
232
  const existing = packs.readPack(a.pack);
200
233
  if (!existing) return null;
234
+ // Structural enforcement of merge-not-regenerate: the reviewer only saw
235
+ // the full current sections of the turn's ACTIVE packs, so a section
236
+ // rewrite on any other pack is blind (generated from the turn text alone)
237
+ // and would destroy durable facts. Journal lines always land; section
238
+ // rewrites are dropped for packs outside the active set.
239
+ const blind = activeSet instanceof Set && !activeSet.has(a.pack);
201
240
  const r = packs.updatePack(a.pack, {
202
241
  journal: a.journal || "",
203
- state: typeof a.state === "string" ? a.state : "",
204
- stance: typeof a.stance === "string" ? a.stance : "",
205
- procedure: typeof a.procedure === "string" ? a.procedure : "",
242
+ state: !blind && typeof a.state === "string" ? a.state : "",
243
+ stance: !blind && typeof a.stance === "string" ? a.stance : "",
244
+ procedure: !blind && typeof a.procedure === "string" ? a.procedure : "",
206
245
  }, "reviewer");
207
246
  // Cross-project reuse: only abilities carry applied_on, so ignore the field
208
247
  // on context packs (keeps project trackers churn-free).
@@ -212,7 +251,15 @@ function applyAction(a) {
212
251
  if (packs.recordApplied(a.pack, proj)) appliedTo = proj;
213
252
  }
214
253
  }
215
- return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || (appliedTo ? `reused on ${appliedTo}` : "state updated"), protectedStance: !!r.protectedStance, appliedTo };
254
+ return {
255
+ kind: "update", dir: a.pack, name: existing.name,
256
+ note: a.journal || (appliedTo ? `reused on ${appliedTo}` : "state updated"),
257
+ protectedStance: !!r.protectedStance,
258
+ protectedProcedure: !!r.protectedProcedure,
259
+ overwroteUserState: !!r.overwroteUserState,
260
+ blindSkipped: blind && [a.state, a.stance, a.procedure].some((s) => typeof s === "string" && s.trim()),
261
+ appliedTo,
262
+ };
216
263
  }
217
264
  if (a.action === "create" && (a.dir || a.name)) {
218
265
  const dir = packs.slugify(a.dir || a.name);
@@ -223,7 +270,8 @@ function applyAction(a) {
223
270
  : [];
224
271
  const projects = applied_on.length ? applied_on : (learned_on ? [learned_on] : []);
225
272
  if (packs.readPack(dir)) {
226
- packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" }, "reviewer");
273
+ const blind = activeSet instanceof Set && !activeSet.has(dir);
274
+ packs.updatePack(dir, { journal: a.journal || "", state: blind ? "" : (a.state || "") }, "reviewer");
227
275
  if (kind === "ability") for (const proj of projects) packs.recordApplied(dir, proj);
228
276
  return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
229
277
  }
@@ -232,7 +280,8 @@ function applyAction(a) {
232
280
  // they are activity-named and cross-project by design).
233
281
  const dupeDir = kind === "context" ? findDuplicatePack({ dir, name: a.name, description: a.description, tags: a.tags }) : null;
234
282
  if (dupeDir && packs.readPack(dupeDir)) {
235
- packs.updatePack(dupeDir, { journal: a.journal || "", state: a.state || "" }, "reviewer");
283
+ const blind = activeSet instanceof Set && !activeSet.has(dupeDir);
284
+ packs.updatePack(dupeDir, { journal: a.journal || "", state: blind ? "" : (a.state || "") }, "reviewer");
236
285
  const ex = packs.readPack(dupeDir);
237
286
  return { kind: "update", dir: dupeDir, name: (ex && ex.name) || dupeDir, note: a.journal || "folded a new-pack proposal into the existing pack" };
238
287
  }
@@ -319,7 +368,7 @@ function shouldSkipReview({ userText, assistantText, signals = {} }) {
319
368
 
320
369
  // Fire-and-forget. `announce` is an async (text) => void bound to the
321
370
  // originating channel; failures are logged, never thrown into the turn.
322
- function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
371
+ function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks }) {
323
372
  if (!enabled()) return;
324
373
  const skip = shouldSkipReview({ userText, assistantText, signals });
325
374
  if (skip) {
@@ -329,7 +378,8 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
329
378
  return;
330
379
  }
331
380
 
332
- const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")));
381
+ const activeSet = Array.isArray(activePacks) && activePacks.length ? new Set(activePacks) : null;
382
+ const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")), activePacks);
333
383
 
334
384
  spawnSubagent(prompt, {
335
385
  model: REVIEW_MODEL,
@@ -343,7 +393,7 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
343
393
  const usedIds = []; // nodes this turn's work demonstrably touched
344
394
  for (const a of decision.actions) {
345
395
  try {
346
- const r = applyAction(a);
396
+ const r = applyAction(a, activeSet);
347
397
  if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
348
398
  if (r && r.kind === "skipped") {
349
399
  lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
@@ -352,7 +402,11 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
352
402
  } else if (r && r.kind === "create") {
353
403
  lines.push(`📦 New pack: ${r.name}${r.parent ? ` (filed under ${r.parent})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
354
404
  } else if (r) {
355
- lines.push(`✏️ ${r.name} ${clipWords(r.note, 180)}${r.appliedTo ? ` 🧩 (now also applies to ${r.appliedTo})` : ""}\n↳ open-claudia pack show ${r.dir}`);
405
+ const marks = [];
406
+ if (r.appliedTo) marks.push(` 🧩 (now also applies to ${r.appliedTo})`);
407
+ if (r.overwroteUserState) marks.push(" ⚠️ replaced a user-written State — check it kept your facts");
408
+ if (r.blindSkipped) marks.push(" (section rewrite dropped: pack wasn't active this turn — journal only)");
409
+ lines.push(`✏️ ${r.name} — ${clipWords(r.note, 180)}${marks.join("")}\n↳ open-claudia pack show ${r.dir}`);
356
410
  }
357
411
  } catch (e) {
358
412
  console.warn(`[pack-review] apply failed: ${e.message}`);
package/core/packs.js CHANGED
@@ -393,8 +393,26 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
393
393
  applied.push("Stance");
394
394
  }
395
395
  }
396
- if (typeof procedure === "string" && procedure.trim()) { pack.sections.Procedure = procedure.trim(); applied.push("Procedure"); }
397
- if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
396
+ let protectedProcedure = false;
397
+ if (typeof procedure === "string" && procedure.trim()) {
398
+ // Same intent-wins rule as Stance: a user-authored Procedure is a verified
399
+ // how-to — an automated writer never silently replaces it.
400
+ if (origin !== "user" && provenanceOf(dir, "Procedure") === "user") {
401
+ protectedProcedure = true;
402
+ } else {
403
+ pack.sections.Procedure = procedure.trim();
404
+ applied.push("Procedure");
405
+ }
406
+ }
407
+ // State stays rewritable by design (it tracks "now"), but replacing a
408
+ // user-authored State is flagged so the caller can announce the overwrite
409
+ // instead of it happening silently.
410
+ let overwroteUserState = false;
411
+ if (typeof state === "string" && state.trim()) {
412
+ if (origin !== "user" && provenanceOf(dir, "State") === "user") overwroteUserState = true;
413
+ pack.sections.State = state.trim();
414
+ applied.push("State");
415
+ }
398
416
  if (typeof journal === "string" && journal.trim()) {
399
417
  const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
400
418
  const line = journal.trim().replace(/\n+/g, " ");
@@ -409,6 +427,8 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
409
427
  writePack(pack);
410
428
  if (applied.length) setProvenance(dir, applied, origin);
411
429
  pack.protectedStance = protectedStance;
430
+ pack.protectedProcedure = protectedProcedure;
431
+ pack.overwroteUserState = overwroteUserState;
412
432
  return pack;
413
433
  }
414
434
 
package/core/runner.js CHANGED
@@ -812,12 +812,30 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
812
812
  // receipt through the recall/discoverer phase and into streaming, not just
813
813
  // for the ~5s a single typing action lasts. Stored on state so /stop can
814
814
  // clear it instantly. Cleared on close/error/cancel.
815
+ // While true, the bot is reasoning before its first output token, so the
816
+ // typing heartbeat advertises the richer "thinking" state. Flipped to false
817
+ // the moment the first assistant text / tool_use / text_delta arrives.
818
+ state.thinkingPhase = true;
815
819
  const startTyping = () => {
816
820
  if (!adapter || !channelId) return;
817
- adapter.typing(channelId).catch(() => {});
818
- if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId).catch(() => {}), 4000);
821
+ adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
822
+ if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {}), 4000);
823
+ };
824
+ // Called on the first real output token to leave the thinking phase and, if
825
+ // the turn is still live, immediately advertise plain "typing" so the client
826
+ // flips without waiting for the next 4s heartbeat.
827
+ const endThinking = () => {
828
+ if (!state.thinkingPhase) return;
829
+ state.thinkingPhase = false;
830
+ if (adapter && channelId && state.typingHeartbeat) adapter.typing(channelId).catch(() => {});
831
+ };
832
+ const stopTyping = () => {
833
+ state.thinkingPhase = false;
834
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
835
+ // Telegram's chat action auto-expires, but socket-based channels (Kazee)
836
+ // keep showing "typing…" until told to stop. Optional on the adapter.
837
+ if (adapter && channelId) { try { adapter.typingStop?.(channelId); } catch (e) {} }
819
838
  };
820
- const stopTyping = () => { if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; } };
821
839
  // Pre-spawn window (recall/compaction/auth): /stop has no process to kill, so
822
840
  // it sets state.cancelRequested and we bail at the checkpoint before spawning.
823
841
  state.preparingRun = true;
@@ -1041,8 +1059,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1041
1059
  // reflects what was read, not what was pushed. (consumeLastInjected is
1042
1060
  // drained here to keep the per-turn buffer from leaking into the next turn.)
1043
1061
  let turnRecallTier = "";
1062
+ // Pack dirs surfaced (recall-matched) for this turn — joined with
1063
+ // openedThisTurn at end-of-turn to tell the post-turn reviewer which packs
1064
+ // this turn's work belongs to (attribution + safe State rewrites).
1065
+ let surfacedPackDirs = [];
1044
1066
  try {
1045
1067
  const injected = require("./system-prompt").consumeLastInjected();
1068
+ if (injected && Array.isArray(injected.packDirs)) surfacedPackDirs = injected.packDirs;
1046
1069
  if (injected && injected.recall) {
1047
1070
  const r = injected.recall;
1048
1071
  turnRecallTier = String(r.tier || "");
@@ -1168,7 +1191,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1168
1191
  if (!state.statusMessageId && assistantText) {
1169
1192
  state.statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ replyTo: replyToMsgId }));
1170
1193
  } else if (state.statusMessageId) {
1171
- await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts());
1194
+ await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ streaming: true }));
1172
1195
  }
1173
1196
  lastUpdate = display;
1174
1197
  }
@@ -1190,6 +1213,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1190
1213
  const lastNewline = state.streamBuffer.lastIndexOf("\n");
1191
1214
  state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
1192
1215
  for (const evt of events) {
1216
+ // Leave the "thinking" phase the moment the model produces any real
1217
+ // output — streamed text, a finished assistant block, a tool call, or a
1218
+ // terminal result. Idempotent (guards on state.thinkingPhase internally).
1219
+ if (state.thinkingPhase && (
1220
+ (evt.type === "stream_event" && evt.event?.type === "content_block_delta" && evt.event.delta?.type === "text_delta") ||
1221
+ (evt.type === "assistant" && evt.message?.content) ||
1222
+ (evt.type === "tool_call" && evt.subtype === "started") ||
1223
+ evt.type === "result"
1224
+ )) {
1225
+ endThinking();
1226
+ }
1193
1227
  // Voice latency probe: first "system" event = CLI ready (cold-start done);
1194
1228
  // "result" = generation finished (before the TTS tail drains).
1195
1229
  if (voiceStreaming && vlFirstSysAt == null && evt.type === "system") vlFirstSysAt = Date.now();
@@ -1394,7 +1428,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1394
1428
  const firstChunk = chunks[0];
1395
1429
 
1396
1430
  if (state.statusMessageId && chunks.length === 1) {
1397
- await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts());
1431
+ await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts({ streaming: true }));
1398
1432
  } else {
1399
1433
  const sent = await send(firstChunk, telegramHtmlOpts({ replyTo: replyToMsgId }));
1400
1434
  if (!sent) await send(firstChunk);
@@ -1537,6 +1571,14 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1537
1571
  userText: prompt,
1538
1572
  assistantText,
1539
1573
  channelId,
1574
+ // Packs this turn demonstrably worked with: surfaced by recall for the
1575
+ // incoming message, or explicitly opened by the agent. The reviewer
1576
+ // sees their full current sections and must attribute updates here —
1577
+ // prevents cross-project misattribution and blind State rewrites.
1578
+ activePacks: [...new Set([
1579
+ ...surfacedPackDirs,
1580
+ ...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
1581
+ ])],
1540
1582
  signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
1541
1583
  announce: (text) => chatContext.run(store, () => send(text)),
1542
1584
  });
@@ -442,10 +442,10 @@ function tryUseRecallBudget(budget, text) {
442
442
  // What the last promptWithDynamicContext call freshly injected (not the
443
443
  // deduped repeats) — consumed by the runner to announce recalls in chat,
444
444
  // mirroring the write-side announcements.
445
- let lastInjected = { packs: [], entities: [], recall: null };
445
+ let lastInjected = { packs: [], packDirs: [], entities: [], recall: null };
446
446
  function consumeLastInjected() {
447
447
  const out = lastInjected;
448
- lastInjected = { packs: [], entities: [], recall: null };
448
+ lastInjected = { packs: [], packDirs: [], entities: [], recall: null };
449
449
  return out;
450
450
  }
451
451
 
@@ -743,7 +743,7 @@ function voiceReplyGuidance() {
743
743
  }
744
744
 
745
745
  async function promptWithDynamicContext(prompt, opts = {}) {
746
- lastInjected = { packs: [], entities: [], recall: null };
746
+ lastInjected = { packs: [], packDirs: [], entities: [], recall: null };
747
747
  try {
748
748
  const { userText, contextText } = recallMatchParts(prompt);
749
749
  let historyText = "";
@@ -770,6 +770,10 @@ async function promptWithDynamicContext(prompt, opts = {}) {
770
770
  const toolBlock = result.toolBlock || "";
771
771
  const episodeBlock = result.episodeBlock || "";
772
772
  const why = result.why || {};
773
+ // Pack DIRS surfaced for this turn (matched, whether or not the body was
774
+ // freshly injected or deduped) — consumed by the post-turn reviewer so it
775
+ // knows which packs the turn's work belongs to and can see their content.
776
+ lastInjected.packDirs = (result.packMatches || []).map((m) => m.dir).filter(Boolean);
773
777
  lastInjected.recall = {
774
778
  engine: engine.name || recall.activeEngineName(settings),
775
779
  gated: !!result.gated,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.9.0",
3
+ "version": "2.9.3",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {