@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 +1 -1
- package/bin/pack.js +32 -2
- package/core/dream.js +58 -15
- package/core/handlers.js +23 -1
- package/core/pack-review.js +5 -5
- package/core/packs.js +146 -6
- package/core/runner.js +7 -0
- package/package.json +1 -1
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|
|
|
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.
|
|
87
|
-
8.
|
|
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.
|
|
309
|
-
//
|
|
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 (
|
|
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
|
}
|
package/core/pack-review.js
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
172
|
-
if (typeof
|
|
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