@inetafrica/open-claudia 2.6.23 → 2.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -335,7 +335,7 @@ Memory tools:
335
335
  (alias: ts; --all for every project; --help for options)
336
336
  open-claudia transcript-window <pattern> Search project transcript, show hits with context
337
337
  (alias: tw; --help for options)
338
- open-claudia pack list|show|match|migrate Context packs: living topic docs (skills + memory)
338
+ open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
339
339
  open-claudia entity list|show|match|note Entity notes: people/places/projects memory
340
340
  open-claudia dream [--dry-run] Run the memory consolidation pass now
341
341
 
package/bin/pack.js CHANGED
@@ -19,9 +19,36 @@ function run(args) {
19
19
  const all = packs.listPacks();
20
20
  if (all.length === 0) return console.log(`No packs yet (${packs.PACKS_DIR}).`);
21
21
  for (const p of all) {
22
- const used = p.last_used ? ` last-used ${p.last_used.slice(0, 10)}` : "";
22
+ const used = p.last_used ? ` last-used ${p.last_used.slice(0, 10)} (${p.usage_count || 0}×)` : "";
23
23
  console.log(`${p.dir} — ${p.name}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}${used}\n ${p.description}`);
24
24
  }
25
+ const archived = packs.listArchived();
26
+ if (archived.length) console.log(`\n(${archived.length} archived — open-claudia pack archived)`);
27
+ break;
28
+ }
29
+
30
+ case "archive": {
31
+ const p = packs.archivePack(rest[0]);
32
+ if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
33
+ console.log(`Archived pack ${p.dir} → ${path.join(packs.PACKS_DIR, ".archived", p.dir)}\nRestore with: open-claudia pack restore ${p.dir}`);
34
+ break;
35
+ }
36
+
37
+ case "restore": {
38
+ let p;
39
+ try { p = packs.restorePack(rest[0]); }
40
+ catch (e) { console.error(e.message); process.exitCode = 1; return; }
41
+ if (!p) { console.error(`No archived pack: ${rest[0]}`); process.exitCode = 1; return; }
42
+ console.log(`Restored pack ${p.dir}.`);
43
+ break;
44
+ }
45
+
46
+ case "archived": {
47
+ const all = packs.listArchived();
48
+ if (all.length === 0) return console.log("No archived packs.");
49
+ for (const p of all) {
50
+ console.log(`${p.dir} — ${p.name} (archived ${(p.archived || "").slice(0, 10)}, last-used ${(p.last_used || "never").slice(0, 10)}, ${p.usage_count || 0}×)`);
51
+ }
25
52
  break;
26
53
  }
27
54
 
@@ -29,6 +56,9 @@ function run(args) {
29
56
  const p = packs.findPack(rest[0]);
30
57
  if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
31
58
  console.log(fs.readFileSync(path.join(packs.PACKS_DIR, p.dir, "PACK.md"), "utf-8"));
59
+ const prov = packs.readProvenance(p.dir);
60
+ const authored = packs.SECTIONS.map((s) => `${s}=${prov.sections[s] || "user"}`).join(" ");
61
+ console.log(`\n# provenance: ${authored}${prov.lastWriter ? ` (last: ${prov.lastWriter} ${prov.ts})` : ""}`);
32
62
  break;
33
63
  }
34
64
 
@@ -93,7 +123,7 @@ function run(args) {
93
123
  }
94
124
 
95
125
  default:
96
- console.log("Usage: open-claudia pack [list|show <dir>|match \"<text>\"|migrate|remove <dir>|reindex]");
126
+ console.log("Usage: open-claudia pack [list|show <dir>|match \"<text>\"|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
97
127
  }
98
128
  }
99
129
 
package/core/dream.js CHANGED
@@ -14,6 +14,7 @@ const path = require("path");
14
14
  const cron = require("node-cron");
15
15
 
16
16
  const CONFIG_DIR = require("../config-dir");
17
+ const { config } = require("./config");
17
18
  const packs = require("./packs");
18
19
  const entities = require("./entities");
19
20
  const persona = require("./persona");
@@ -23,7 +24,13 @@ const DREAM_MODEL = process.env.DREAM_MODEL || "sonnet";
23
24
  const DREAM_CRON = process.env.DREAM_CRON || "0 4 * * *";
24
25
  const MAX_PACK_CHARS = 2500;
25
26
  const MAX_ENTITY_CHARS = 900;
26
- const LIMITS = { merges: 3, umbrellas: 2, parents: 8, retag: 8, entity_merges: 3, entity_notes: 4 };
27
+ const LIMITS = { merges: 3, umbrellas: 2, parents: 8, retag: 8, entity_merges: 3, entity_notes: 4, archive: 5 };
28
+
29
+ // Retirement guard: even if the model proposes archiving, only act when a pack
30
+ // is genuinely cold — old enough, idle long enough, and rarely used. Protects
31
+ // freshly-created packs the user just made.
32
+ const ARCHIVE_IDLE_DAYS = Number(process.env.DREAM_ARCHIVE_IDLE_DAYS || 30);
33
+ const ARCHIVE_MAX_USAGE = Number(process.env.DREAM_ARCHIVE_MAX_USAGE || 3);
27
34
 
28
35
  let _dreaming = false;
29
36
 
@@ -31,6 +38,15 @@ function enabled() {
31
38
  return (process.env.DREAM || "on").toLowerCase() !== "off";
32
39
  }
33
40
 
41
+ // Whether to post the post-dream summary into the owner's chat. On by
42
+ // default; toggled with /dreamsummary (persisted to .env, read back from
43
+ // config on restart, with the live process.env taking precedence so the
44
+ // toggle takes effect without a restart).
45
+ function summaryEnabled() {
46
+ const v = process.env.DREAM_SUMMARY ?? config.DREAM_SUMMARY ?? "on";
47
+ return String(v).toLowerCase() !== "off";
48
+ }
49
+
34
50
  function clip(s, n) {
35
51
  const t = String(s || "");
36
52
  return t.length > n ? t.slice(0, n) + "\n…[truncated]" : t;
@@ -45,8 +61,8 @@ function buildDreamPrompt() {
45
61
  `name: ${p.name}`,
46
62
  `description: ${p.description}`,
47
63
  `tags: ${p.tags.join(", ")}`,
48
- `last_used: ${p.last_used || "never"}`,
49
- `Stance: ${p.sections.Stance}`,
64
+ `last_used: ${p.last_used || "never"} (used ${p.usage_count || 0}×)`,
65
+ `Stance${packs.provenanceOf(p.dir, "Stance") === "user" ? " (USER-AUTHORED — do not rewrite)" : ""}: ${p.sections.Stance}`,
50
66
  `Procedure: ${p.sections.Procedure}`,
51
67
  `State: ${p.sections.State}`,
52
68
  `Journal:\n${p.sections.Journal}`,
@@ -77,19 +93,20 @@ ${persona.loadPersona()}
77
93
 
78
94
  Your job — decide what consolidation, if any, is warranted:
79
95
 
80
- 1. merges: packs that are clearly the SAME topic under different names get merged into one. Supply the merged Stance/Procedure/State (synthesised, not concatenated; null leaves the target's section alone) and a one-sentence journal note. Be conservative: when in doubt, do not merge.
96
+ 1. merges: packs that are clearly the SAME topic under different names get merged into one. Supply the merged Stance/Procedure/State (synthesised, not concatenated; null leaves the target's section alone) and a one-sentence journal note. Be conservative: when in doubt, do not merge. A Stance marked USER-AUTHORED is off-limits — leave its stance null; the system preserves it verbatim regardless.
81
97
  2. umbrellas: when 3+ packs are siblings under one theme, create an umbrella pack whose State is a 3-6 line map of the family ("for X see pack Y"), and list its children. The umbrella is a router, not a duplicate.
82
98
  3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything.
83
99
  4. retag: tighten descriptions and tags. The router FTS-matches incoming messages against name/description/tags, so generic words there cause false matches. Descriptions should be one specific line; tags specific nouns.
84
100
  5. entity_merges: the same real-world entity recorded twice gets merged (the better slug wins).
85
101
  6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name.
86
- 7. persona: evolve the persona GENTLYkeep its structure and length (under 2200 chars), adjust only what recent work justifies (a new habit, a sharpened quirk). Most dreams should return null here.
87
- 8. report: a short chat message to the owner, written AS Open Claudia in first person warm, a little playful, a few emojis, mobile-friendly (2-6 short lines). Say what you tidied and why it helps. If you changed nothing, say the memory is in good shape, charmingly.
102
+ 7. archive: retire packs that have gone cold long unused AND rarely used over their life (consult last_used and the usage count). Archiving moves a pack out of the live index (reversibly, backed up) so it stops adding recall noise. ONLY propose packs idle ≥${ARCHIVE_IDLE_DAYS} days and used ≤${ARCHIVE_MAX_USAGE}× total; never an umbrella/parent pack that still has children; when in doubt, leave it. Give a one-line reason.
103
+ 8. persona: evolve the persona GENTLY keep its structure and length (under 2200 chars), adjust only what recent work justifies (a new habit, a sharpened quirk). Most dreams should return null here.
104
+ 9. report: a short chat message to the owner, written AS Open Claudia in first person — warm, a little playful, a few emojis, mobile-friendly (2-6 short lines). Say what you tidied and why it helps. If you changed nothing, say the memory is in good shape, charmingly.
88
105
 
89
106
  Hard rules:
90
107
  - Never invent packs/entities not listed above; reference them by exact dir/slug.
91
108
  - Never store secrets, tokens, passwords, or credentials anywhere.
92
- - Limits: ≤${LIMITS.merges} merges, ≤${LIMITS.umbrellas} umbrellas, ≤${LIMITS.parents} parents, ≤${LIMITS.retag} retags, ≤${LIMITS.entity_merges} entity merges, ≤${LIMITS.entity_notes} entity note rewrites.
109
+ - Limits: ≤${LIMITS.merges} merges, ≤${LIMITS.umbrellas} umbrellas, ≤${LIMITS.parents} parents, ≤${LIMITS.retag} retags, ≤${LIMITS.entity_merges} entity merges, ≤${LIMITS.entity_notes} entity note rewrites, ≤${LIMITS.archive} archives.
93
110
  - An empty decision is a perfectly good decision.
94
111
 
95
112
  Reply with ONLY a JSON object, no prose, no code fences:
@@ -99,6 +116,7 @@ Reply with ONLY a JSON object, no prose, no code fences:
99
116
  "retag": [{"pack": "<dir>", "description": "<one specific line>", "tags": ["..."]}],
100
117
  "entity_merges": [{"into": "<slug>", "from": ["<slug>"], "notes": "<merged Notes or null>"}],
101
118
  "entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes>"}],
119
+ "archive": [{"pack": "<dir>", "reason": "<one line: why it's cold>"}],
102
120
  "persona": null,
103
121
  "report": "<chat message>"}`;
104
122
  }
@@ -118,6 +136,7 @@ function parseDream(text) {
118
136
  retag: arr("retag"),
119
137
  entity_merges: arr("entity_merges"),
120
138
  entity_notes: arr("entity_notes"),
139
+ archive: arr("archive"),
121
140
  persona: typeof obj.persona === "string" ? obj.persona : null,
122
141
  report: typeof obj.report === "string" ? obj.report.trim() : "",
123
142
  };
@@ -161,18 +180,19 @@ function applyDream(decision, backupRoot) {
161
180
  const into = m?.into && packs.readPack(m.into);
162
181
  const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
163
182
  if (!into || from.length === 0) continue;
164
- packs.updatePack(m.into, {
183
+ const mr = packs.updatePack(m.into, {
165
184
  stance: typeof m.stance === "string" ? m.stance : "",
166
185
  procedure: typeof m.procedure === "string" ? m.procedure : "",
167
186
  state: typeof m.state === "string" ? m.state : "",
168
187
  journal: m.journal || `absorbed ${from.join(", ")}`,
169
- });
188
+ }, "dream");
170
189
  for (const d of from) {
171
190
  backupPack(d, backupRoot);
172
191
  packs.removePack(d);
173
192
  gone.add(d);
174
193
  }
175
194
  lines.push(`📦 Merged ${from.join(" + ")} into ${m.into}`);
195
+ if (mr.protectedStance) lines.push(`🛡️ Kept ${m.into}'s user-authored Stance as-is`);
176
196
  } catch (e) { console.warn(`[dream] merge failed: ${e.message}`); }
177
197
  }
178
198
 
@@ -190,7 +210,7 @@ function applyDream(decision, backupRoot) {
190
210
  tags: Array.isArray(u.tags) ? u.tags.slice(0, 6) : [],
191
211
  state: u.state || "",
192
212
  journal: `umbrella created over ${children.join(", ")}`,
193
- });
213
+ }, "dream");
194
214
  }
195
215
  for (const c of children) {
196
216
  const child = packs.readPack(c);
@@ -222,7 +242,7 @@ function applyDream(decision, backupRoot) {
222
242
  const desc = typeof r.description === "string" ? r.description.trim() : "";
223
243
  const tags = Array.isArray(r.tags) ? r.tags.map((t) => String(t).trim()).filter(Boolean).slice(0, 6) : [];
224
244
  if (!desc && tags.length === 0) continue;
225
- packs.updatePack(r.pack, { description: desc, tags });
245
+ packs.updatePack(r.pack, { description: desc, tags }, "dream");
226
246
  lines.push(`🏷️ Sharpened ${r.pack}'s description/tags`);
227
247
  } catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
228
248
  }
@@ -259,6 +279,27 @@ function applyDream(decision, backupRoot) {
259
279
  } catch (e) { console.warn(`[dream] entity notes failed: ${e.message}`); }
260
280
  }
261
281
 
282
+ if (decision.archive && decision.archive.length) {
283
+ const allPacks = packs.listPacks();
284
+ for (const a of decision.archive) {
285
+ try {
286
+ const dir = a?.pack;
287
+ if (!dir || gone.has(dir)) continue;
288
+ const pack = packs.readPack(dir);
289
+ if (!pack) continue;
290
+ if (allPacks.some((p) => p.parent === dir)) continue; // never orphan children
291
+ const ageDays = pack.created ? (Date.now() - Date.parse(pack.created)) / 86400000 : Infinity;
292
+ const idleDays = pack.last_used ? (Date.now() - Date.parse(pack.last_used)) / 86400000 : ageDays;
293
+ const uses = Number(pack.usage_count) || 0;
294
+ if (ageDays < ARCHIVE_IDLE_DAYS || idleDays < ARCHIVE_IDLE_DAYS || uses > ARCHIVE_MAX_USAGE) continue;
295
+ backupPack(dir, backupRoot);
296
+ packs.archivePack(dir);
297
+ gone.add(dir);
298
+ lines.push(`🗃 Archived ${dir} — ${a.reason || `idle ${Math.round(idleDays)}d, used ${uses}×`}`);
299
+ } catch (e) { console.warn(`[dream] archive failed: ${e.message}`); }
300
+ }
301
+ }
302
+
262
303
  if (decision.persona) {
263
304
  try {
264
305
  if (persona.personaExists()) {
@@ -305,8 +346,9 @@ async function runDream({ trigger = "manual" } = {}) {
305
346
  }
306
347
 
307
348
  // In-bot scheduler: one quiet-hours cron, reporting to the owner's
308
- // Telegram chat. Silent (console only) when the dream changed nothing
309
- // a nightly "all tidy" ping would be noise.
349
+ // Telegram chat. When the dream summary is on (default) it posts a short
350
+ // note after every run — including the charming "all tidy" line when
351
+ // nothing changed. /dreamsummary off mutes it to console only.
310
352
  function initDream(adapters) {
311
353
  if (!enabled()) return;
312
354
  if (!cron.validate(DREAM_CRON)) {
@@ -318,7 +360,8 @@ function initDream(adapters) {
318
360
  const res = await runDream({ trigger: "cron" });
319
361
  if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return; }
320
362
  console.log(`dream: applied ${res.applied.length} change(s)`);
321
- if (res.applied.length === 0) return;
363
+ if (!summaryEnabled()) { console.log("dream: summary muted (DREAM_SUMMARY=off)"); return; }
364
+ if (!res.message) return;
322
365
  const { CHAT_ID } = require("./config");
323
366
  const tg = (adapters || []).find((a) => a.type === "telegram");
324
367
  if (tg && CHAT_ID) await tg.send(CHAT_ID, res.message);
@@ -329,4 +372,4 @@ function initDream(adapters) {
329
372
  console.log(`dream: scheduled (${DREAM_CRON}, model ${DREAM_MODEL})`);
330
373
  }
331
374
 
332
- module.exports = { runDream, initDream, buildDreamPrompt, parseDream, applyDream, enabled, DREAM_CRON, DREAM_MODEL };
375
+ module.exports = { runDream, initDream, buildDreamPrompt, parseDream, applyDream, enabled, summaryEnabled, DREAM_CRON, DREAM_MODEL };
package/core/handlers.js CHANGED
@@ -125,7 +125,7 @@ register({
125
125
  "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode",
126
126
  "Identity: /whoami /link",
127
127
  "Team: /people /intros /auth (owner)",
128
- "Automation: /cron /vault /soul",
128
+ "Automation: /cron /vault /soul /dreamsummary",
129
129
  "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
130
130
  "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
131
131
  "System: /doctor /requirements /restart /upgrade",
@@ -947,6 +947,24 @@ register({
947
947
  },
948
948
  });
949
949
 
950
+ register({
951
+ name: "dreamsummary", description: "Toggle the post-dream memory summary in chat", args: "[on|off]",
952
+ handler: async (env, { tail }) => {
953
+ if (!authorized(env)) return;
954
+ const dream = require("./dream");
955
+ const arg = (tail || "").trim().toLowerCase();
956
+ if (arg !== "on" && arg !== "off") {
957
+ const on = dream.summaryEnabled();
958
+ return send(`Dream summary is ${on ? "on" : "off"}.\n\nAfter each nightly memory consolidation I ${on ? "post a short summary here" : "tidy up silently"}.\n\nToggle: /dreamsummary on · /dreamsummary off`);
959
+ }
960
+ saveEnvKey("DREAM_SUMMARY", arg);
961
+ process.env.DREAM_SUMMARY = arg;
962
+ return send(arg === "on"
963
+ ? "Dream summary on — I'll drop a short note here after each nightly memory tidy-up. 💤"
964
+ : "Dream summary off — I'll consolidate memory silently. Backups are still kept, so nothing is lost.");
965
+ },
966
+ });
967
+
950
968
  // ── Learned skills (now backed by context packs) ────────────────────
951
969
  // Skills migrated into context packs (~/.open-claudia/packs). /skills is a
952
970
  // thin window onto that store; any stragglers still in the legacy
@@ -984,6 +1002,10 @@ register({
984
1002
  }
985
1003
  const lines = packs.map((p) => `• ${p.dir} — ${p.description || p.name || "(no description)"}`);
986
1004
  let msg = `Skills / context packs (${packs.length}):\n\n${lines.join("\n")}`;
1005
+ const archived = packsLib.listArchived();
1006
+ if (archived.length) {
1007
+ msg += `\n\n${archived.length} archived (cold packs retired by the nightly dream) — open-claudia pack archived to view, pack restore <dir> to bring one back.`;
1008
+ }
987
1009
  if (legacy.length) {
988
1010
  msg += `\n\n${legacy.length} legacy skill(s) still in ~/.claude/skills — run open-claudia pack migrate to fold them in.`;
989
1011
  }
@@ -129,18 +129,18 @@ function applyAction(a) {
129
129
  if (a.action === "update" && a.pack) {
130
130
  const existing = packs.readPack(a.pack);
131
131
  if (!existing) return null;
132
- packs.updatePack(a.pack, {
132
+ const r = packs.updatePack(a.pack, {
133
133
  journal: a.journal || "",
134
134
  state: typeof a.state === "string" ? a.state : "",
135
135
  stance: typeof a.stance === "string" ? a.stance : "",
136
136
  procedure: typeof a.procedure === "string" ? a.procedure : "",
137
- });
138
- return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated" };
137
+ }, "reviewer");
138
+ return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated", protectedStance: !!r.protectedStance };
139
139
  }
140
140
  if (a.action === "create" && (a.dir || a.name)) {
141
141
  const dir = packs.slugify(a.dir || a.name);
142
142
  if (packs.readPack(dir)) {
143
- packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" });
143
+ packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" }, "reviewer");
144
144
  return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
145
145
  }
146
146
  const pack = packs.createPack({
@@ -152,7 +152,7 @@ function applyAction(a) {
152
152
  procedure: a.procedure || "",
153
153
  state: a.state || "",
154
154
  journal: a.journal || "",
155
- });
155
+ }, "reviewer");
156
156
  return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "" };
157
157
  }
158
158
  return null;
package/core/packs.js CHANGED
@@ -70,8 +70,10 @@ function serialize(pack) {
70
70
  `created: ${pack.created || new Date().toISOString()}`,
71
71
  `updated: ${pack.updated || new Date().toISOString()}`,
72
72
  `last_used: ${pack.last_used || ""}`,
73
- "---",
73
+ `usage_count: ${pack.usage_count || 0}`,
74
74
  );
75
+ if (pack.archived) fmLines.push(`archived: ${pack.archived}`);
76
+ fmLines.push("---");
75
77
  const sections = SECTIONS
76
78
  .map((s) => `## ${s}\n\n${(pack.sections?.[s] || "").trim()}`)
77
79
  .join("\n\n");
@@ -99,6 +101,8 @@ function readPack(dir) {
99
101
  created: fm.created || "",
100
102
  updated: fm.updated || (stat ? stat.mtime.toISOString() : ""),
101
103
  last_used: fm.last_used || "",
104
+ usage_count: Number(fm.usage_count || 0) || 0,
105
+ archived: fm.archived || "",
102
106
  sections,
103
107
  };
104
108
  }
@@ -133,7 +137,7 @@ function writePack(pack) {
133
137
  return pack.dir;
134
138
  }
135
139
 
136
- function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent }) {
140
+ function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent }, origin = "user") {
137
141
  const d = slugify(dir || name);
138
142
  if (!d) throw new Error("pack needs a name");
139
143
  if (readPack(d)) throw new Error(`pack ${d} already exists`);
@@ -152,6 +156,7 @@ function createPack({ dir, name, description, tags, stance, procedure, state, jo
152
156
  },
153
157
  };
154
158
  writePack(pack);
159
+ setProvenance(d, SECTIONS.filter((s) => (pack.sections[s] || "").trim()), origin);
155
160
  return pack;
156
161
  }
157
162
 
@@ -159,23 +164,98 @@ function today() {
159
164
  return new Date().toISOString().slice(0, 10);
160
165
  }
161
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // Provenance: a sidecar .provenance.json per pack records which origin
169
+ // (user | reviewer | dream) last authored each section. Sections we never
170
+ // explicitly stamped default to "user" — anything not proven to be automated
171
+ // is protected as if you wrote it. This is what lets the reviewer and dream
172
+ // rewrite their own output freely while never silently clobbering your words.
173
+ function provFile(dir) {
174
+ return path.join(PACKS_DIR, dir, ".provenance.json");
175
+ }
176
+
177
+ function readProvenance(dir) {
178
+ let data = {};
179
+ try { data = JSON.parse(fs.readFileSync(provFile(dir), "utf-8")) || {}; } catch (e) {}
180
+ const sections = data.sections && typeof data.sections === "object" ? data.sections : {};
181
+ return { sections, lastWriter: data.lastWriter || "", ts: data.ts || "" };
182
+ }
183
+
184
+ function provenanceOf(dir, section) {
185
+ return readProvenance(dir).sections[section] || "user";
186
+ }
187
+
188
+ function setProvenance(dir, sectionNames, origin) {
189
+ if (!origin) return;
190
+ const names = [].concat(sectionNames || []).filter(Boolean);
191
+ if (!names.length) return;
192
+ const cur = readProvenance(dir);
193
+ for (const n of names) cur.sections[n] = origin;
194
+ cur.lastWriter = origin;
195
+ cur.ts = new Date().toISOString();
196
+ try {
197
+ fs.mkdirSync(path.join(PACKS_DIR, dir), { recursive: true, mode: 0o700 });
198
+ fs.writeFileSync(provFile(dir), JSON.stringify(cur, null, 2), { mode: 0o600 });
199
+ } catch (e) {}
200
+ }
201
+
202
+ // A foreground Write/Edit to a PACK.md is you (or me acting on what you told
203
+ // me), so stamp the touched section(s) "user" — this also resets a section the
204
+ // reviewer/dream previously claimed back to user-authored once you edit it.
205
+ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
206
+ try {
207
+ if (tool === "Write" && typeof content === "string") {
208
+ const { body } = parseFrontmatter(content);
209
+ const { sections } = parseSections(body);
210
+ const applied = SECTIONS.filter((s) => sections[s] && sections[s].trim());
211
+ setProvenance(dir, applied.length ? applied : SECTIONS, "user");
212
+ return;
213
+ }
214
+ if (tool === "Edit" && typeof oldString === "string" && oldString) {
215
+ const cur = fs.readFileSync(packFile(dir), "utf-8");
216
+ const idx = cur.indexOf(oldString);
217
+ if (idx < 0) { setProvenance(dir, SECTIONS, "user"); return; }
218
+ let found = null;
219
+ for (const s of SECTIONS) {
220
+ const h = cur.indexOf(`## ${s}`);
221
+ if (h >= 0 && h <= idx && (found === null || h > found.pos)) found = { sec: s, pos: h };
222
+ }
223
+ setProvenance(dir, found ? [found.sec] : SECTIONS, "user");
224
+ }
225
+ } catch (e) {}
226
+ }
227
+
162
228
  // Apply a reviewer mutation. Only supplied fields change; journal entries
163
229
  // append (capped); state replaces (it represents "current truth").
164
- function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}) {
230
+ function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}, origin = "user") {
165
231
  const pack = readPack(dir);
166
232
  if (!pack) throw new Error(`no pack: ${dir}`);
167
233
  pack.updated = new Date().toISOString();
168
234
  if (description) pack.description = description;
169
235
  if (Array.isArray(tags) && tags.length) pack.tags = tags;
170
- if (typeof stance === "string" && stance.trim()) pack.sections.Stance = stance.trim();
171
- if (typeof procedure === "string" && procedure.trim()) pack.sections.Procedure = procedure.trim();
172
- if (typeof state === "string" && state.trim()) pack.sections.State = state.trim();
236
+ const applied = [];
237
+ let protectedStance = false;
238
+ if (typeof stance === "string" && stance.trim()) {
239
+ // Stance shapes behaviour, so a user-authored Stance is never silently
240
+ // overwritten by an automated writer — your intent wins.
241
+ if (origin !== "user" && provenanceOf(dir, "Stance") === "user") {
242
+ protectedStance = true;
243
+ } else {
244
+ pack.sections.Stance = stance.trim();
245
+ applied.push("Stance");
246
+ }
247
+ }
248
+ if (typeof procedure === "string" && procedure.trim()) { pack.sections.Procedure = procedure.trim(); applied.push("Procedure"); }
249
+ if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
173
250
  if (typeof journal === "string" && journal.trim()) {
174
251
  const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
175
252
  entries.push(`- [${today()}] ${journal.trim().replace(/\n+/g, " ")}`);
176
253
  pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
254
+ applied.push("Journal");
177
255
  }
178
256
  writePack(pack);
257
+ if (applied.length) setProvenance(dir, applied, origin);
258
+ pack.protectedStance = protectedStance;
179
259
  return pack;
180
260
  }
181
261
 
@@ -193,10 +273,68 @@ function touchUsed(dirs) {
193
273
  const pack = readPack(dir);
194
274
  if (!pack) continue;
195
275
  pack.last_used = now;
276
+ pack.usage_count = (Number(pack.usage_count) || 0) + 1;
196
277
  try { writePack(pack); } catch (e) {}
197
278
  }
198
279
  }
199
280
 
281
+ // ---------------------------------------------------------------------------
282
+ // Lifecycle: archive stale packs to PACKS_DIR/.archived/<dir>. listPacks and
283
+ // the FTS index skip dot-dirs, so an archived pack stops matching but the file
284
+ // is kept and fully restorable. This is the brake on an index that only grows.
285
+
286
+ const ARCHIVE_DIRNAME = ".archived";
287
+ function archiveRoot() { return path.join(PACKS_DIR, ARCHIVE_DIRNAME); }
288
+
289
+ function archivePack(nameOrDir) {
290
+ const pack = findPack(nameOrDir);
291
+ if (!pack) return null;
292
+ ensureDir();
293
+ fs.mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
294
+ pack.archived = new Date().toISOString();
295
+ try { fs.writeFileSync(packFile(pack.dir), serialize(pack), { mode: 0o600 }); } catch (e) {}
296
+ const dest = path.join(archiveRoot(), pack.dir);
297
+ fs.rmSync(dest, { recursive: true, force: true });
298
+ fs.renameSync(path.join(PACKS_DIR, pack.dir), dest);
299
+ markIndexDirty();
300
+ return pack;
301
+ }
302
+
303
+ function listArchived() {
304
+ let entries;
305
+ try { entries = fs.readdirSync(archiveRoot()); } catch (e) { return []; }
306
+ const out = [];
307
+ for (const name of entries) {
308
+ if (name.startsWith(".")) continue;
309
+ let content;
310
+ try { content = fs.readFileSync(path.join(archiveRoot(), name, "PACK.md"), "utf-8"); }
311
+ catch (e) { continue; }
312
+ const { fm } = parseFrontmatter(content);
313
+ out.push({
314
+ dir: name,
315
+ name: fm.name || name,
316
+ description: fm.description || "",
317
+ last_used: fm.last_used || "",
318
+ usage_count: Number(fm.usage_count || 0) || 0,
319
+ archived: fm.archived || "",
320
+ });
321
+ }
322
+ return out.sort((a, b) => a.dir.localeCompare(b.dir));
323
+ }
324
+
325
+ function restorePack(nameOrDir) {
326
+ const needle = String(nameOrDir || "").trim().toLowerCase();
327
+ if (!needle) return null;
328
+ const found = listArchived().find((p) => p.dir.toLowerCase() === needle || p.name.toLowerCase() === needle);
329
+ if (!found) return null;
330
+ if (readPack(found.dir)) throw new Error(`an active pack ${found.dir} already exists`);
331
+ fs.renameSync(path.join(archiveRoot(), found.dir), path.join(PACKS_DIR, found.dir));
332
+ const pack = readPack(found.dir);
333
+ if (pack) { pack.archived = ""; pack.updated = new Date().toISOString(); try { writePack(pack); } catch (e) {} }
334
+ markIndexDirty();
335
+ return pack;
336
+ }
337
+
200
338
  // Recognise a Write/Edit aimed at a pack file (for chat announcements).
201
339
  function packNameFromPath(filePath) {
202
340
  if (!filePath) return null;
@@ -333,4 +471,6 @@ module.exports = {
333
471
  PACKS_DIR, SECTIONS, slugify,
334
472
  listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
335
473
  touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
474
+ archivePack, restorePack, listArchived,
475
+ readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
336
476
  };
package/core/runner.js CHANGED
@@ -806,6 +806,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
806
806
  if (packDir) {
807
807
  if (packsLib.readPack(packDir)) notifySkill(`pack:${packDir}`, `✏️ Updating my notes on ${packDir}…`);
808
808
  else notifySkill(`pack:${packDir}`, `📦 Starting a new pack: ${packDir} — open-claudia pack show ${packDir} to peek.`);
809
+ try {
810
+ packsLib.recordForegroundWrite(packDir, {
811
+ tool: toolName,
812
+ oldString: input?.old_string ?? input?.oldString,
813
+ content: input?.content,
814
+ });
815
+ } catch (e) {}
809
816
  return;
810
817
  }
811
818
  const entSlug = entitiesLib.entityNameFromPath(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.23",
3
+ "version": "2.6.25",
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": {