@inetafrica/open-claudia 2.6.24 → 2.6.26
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/pack.js +3 -0
- package/core/dream.js +25 -6
- package/core/pack-guard.js +82 -0
- package/core/pack-review.js +27 -6
- package/core/packs.js +82 -5
- package/core/runner.js +7 -0
- package/core/system-prompt.js +27 -0
- package/package.json +1 -1
package/bin/pack.js
CHANGED
|
@@ -56,6 +56,9 @@ function run(args) {
|
|
|
56
56
|
const p = packs.findPack(rest[0]);
|
|
57
57
|
if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
|
|
58
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})` : ""}`);
|
|
59
62
|
break;
|
|
60
63
|
}
|
|
61
64
|
|
package/core/dream.js
CHANGED
|
@@ -17,6 +17,7 @@ const CONFIG_DIR = require("../config-dir");
|
|
|
17
17
|
const { config } = require("./config");
|
|
18
18
|
const packs = require("./packs");
|
|
19
19
|
const entities = require("./entities");
|
|
20
|
+
const packGuard = require("./pack-guard");
|
|
20
21
|
const persona = require("./persona");
|
|
21
22
|
const { spawnSubagent } = require("./subagent");
|
|
22
23
|
|
|
@@ -62,7 +63,7 @@ function buildDreamPrompt() {
|
|
|
62
63
|
`description: ${p.description}`,
|
|
63
64
|
`tags: ${p.tags.join(", ")}`,
|
|
64
65
|
`last_used: ${p.last_used || "never"} (used ${p.usage_count || 0}×)`,
|
|
65
|
-
`Stance: ${p.sections.Stance}`,
|
|
66
|
+
`Stance${packs.provenanceOf(p.dir, "Stance") === "user" ? " (USER-AUTHORED — do not rewrite)" : ""}: ${p.sections.Stance}`,
|
|
66
67
|
`Procedure: ${p.sections.Procedure}`,
|
|
67
68
|
`State: ${p.sections.State}`,
|
|
68
69
|
`Journal:\n${p.sections.Journal}`,
|
|
@@ -93,7 +94,7 @@ ${persona.loadPersona()}
|
|
|
93
94
|
|
|
94
95
|
Your job — decide what consolidation, if any, is warranted:
|
|
95
96
|
|
|
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.
|
|
97
|
+
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.
|
|
97
98
|
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.
|
|
98
99
|
3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything.
|
|
99
100
|
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.
|
|
@@ -180,18 +181,25 @@ function applyDream(decision, backupRoot) {
|
|
|
180
181
|
const into = m?.into && packs.readPack(m.into);
|
|
181
182
|
const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
|
|
182
183
|
if (!into || from.length === 0) continue;
|
|
183
|
-
|
|
184
|
+
const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
|
|
185
|
+
if (mGuard.flagged) {
|
|
186
|
+
console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
|
|
187
|
+
lines.push(`🛡️ Skipped merge into ${m.into} — content looked like an injected instruction (${mGuard.kind})`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const mr = packs.updatePack(m.into, {
|
|
184
191
|
stance: typeof m.stance === "string" ? m.stance : "",
|
|
185
192
|
procedure: typeof m.procedure === "string" ? m.procedure : "",
|
|
186
193
|
state: typeof m.state === "string" ? m.state : "",
|
|
187
194
|
journal: m.journal || `absorbed ${from.join(", ")}`,
|
|
188
|
-
});
|
|
195
|
+
}, "dream");
|
|
189
196
|
for (const d of from) {
|
|
190
197
|
backupPack(d, backupRoot);
|
|
191
198
|
packs.removePack(d);
|
|
192
199
|
gone.add(d);
|
|
193
200
|
}
|
|
194
201
|
lines.push(`📦 Merged ${from.join(" + ")} into ${m.into}`);
|
|
202
|
+
if (mr.protectedStance) lines.push(`🛡️ Kept ${m.into}'s user-authored Stance as-is`);
|
|
195
203
|
} catch (e) { console.warn(`[dream] merge failed: ${e.message}`); }
|
|
196
204
|
}
|
|
197
205
|
|
|
@@ -201,6 +209,12 @@ function applyDream(decision, backupRoot) {
|
|
|
201
209
|
if (!dir || gone.has(dir)) continue;
|
|
202
210
|
const children = [].concat(u?.children || []).filter((d) => d && d !== dir && !gone.has(d) && packs.readPack(d));
|
|
203
211
|
if (children.length < 2) continue;
|
|
212
|
+
const uGuard = packGuard.scanSections({ description: u.description, state: u.state });
|
|
213
|
+
if (uGuard.flagged) {
|
|
214
|
+
console.warn(`[dream] guard blocked umbrella ${dir} (${uGuard.kind} in ${uGuard.section})`);
|
|
215
|
+
lines.push(`🛡️ Skipped umbrella ${dir} — content looked like an injected instruction (${uGuard.kind})`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
204
218
|
if (!packs.readPack(dir)) {
|
|
205
219
|
packs.createPack({
|
|
206
220
|
dir,
|
|
@@ -209,7 +223,7 @@ function applyDream(decision, backupRoot) {
|
|
|
209
223
|
tags: Array.isArray(u.tags) ? u.tags.slice(0, 6) : [],
|
|
210
224
|
state: u.state || "",
|
|
211
225
|
journal: `umbrella created over ${children.join(", ")}`,
|
|
212
|
-
});
|
|
226
|
+
}, "dream");
|
|
213
227
|
}
|
|
214
228
|
for (const c of children) {
|
|
215
229
|
const child = packs.readPack(c);
|
|
@@ -241,7 +255,12 @@ function applyDream(decision, backupRoot) {
|
|
|
241
255
|
const desc = typeof r.description === "string" ? r.description.trim() : "";
|
|
242
256
|
const tags = Array.isArray(r.tags) ? r.tags.map((t) => String(t).trim()).filter(Boolean).slice(0, 6) : [];
|
|
243
257
|
if (!desc && tags.length === 0) continue;
|
|
244
|
-
|
|
258
|
+
const rGuard = packGuard.scanForInjection(desc, { strict: true });
|
|
259
|
+
if (rGuard.flagged) {
|
|
260
|
+
console.warn(`[dream] guard blocked retag of ${r.pack} (${rGuard.kind})`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
packs.updatePack(r.pack, { description: desc, tags }, "dream");
|
|
245
264
|
lines.push(`🏷️ Sharpened ${r.pack}'s description/tags`);
|
|
246
265
|
} catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
|
|
247
266
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Content threat-scan for memory writes (spec #4).
|
|
2
|
+
//
|
|
3
|
+
// Threat model: prompt-injection rides in via tool output or a pasted doc,
|
|
4
|
+
// gets echoed in the assistant reply, and the background reviewer (or dream)
|
|
5
|
+
// faithfully writes "ignore previous instructions / always run X / exfiltrate
|
|
6
|
+
// to URL" into a pack section — which then auto-injects every session as if it
|
|
7
|
+
// were the user's own rule. Stance/Procedure/State auto-inject, so they are the
|
|
8
|
+
// dangerous sections; Journal is shown on demand, so it is scanned leniently
|
|
9
|
+
// (exfil only). We never import marketplace packs, so this is the whole surface.
|
|
10
|
+
//
|
|
11
|
+
// This is a heuristic gate, not a classifier. It is deliberately conservative:
|
|
12
|
+
// it only fires on phrasings that have no legitimate reason to appear in a
|
|
13
|
+
// memory section a model wrote about a past turn. On a hit we SKIP the write —
|
|
14
|
+
// a missed memory is cheap, a poisoned auto-injected rule is not.
|
|
15
|
+
|
|
16
|
+
// Imperative override / instruction-hijack phrasing.
|
|
17
|
+
const OVERRIDE_PATTERNS = [
|
|
18
|
+
/\bignore\s+(all\s+)?(previous|prior|earlier|above|the\s+following)\s+(instructions?|prompts?|rules?|context)/i,
|
|
19
|
+
/\bdisregard\s+(all\s+)?(previous|prior|earlier|above|your)\s+(instructions?|prompts?|rules?|training)/i,
|
|
20
|
+
/\bforget\s+(everything|all|your\s+(instructions?|rules?|prompt))/i,
|
|
21
|
+
/\boverride\s+(your|the|all)\s+(instructions?|rules?|system\s+prompt|guardrails?)/i,
|
|
22
|
+
/\b(you\s+(must|should|will)\s+always|always\s+remember\s+to)\b.*\b(run|execute|send|post|upload|reveal|disclose|delete|drop)\b/i,
|
|
23
|
+
/\bfrom\s+now\s+on\b.*\b(ignore|disregard|always|never)\b/i,
|
|
24
|
+
/\bdo\s+not\s+(tell|inform|ask|notify)\s+(the\s+)?(user|owner|sumeet)\b/i,
|
|
25
|
+
/\bsystem\s+prompt\b.*\b(ignore|reveal|print|output|leak)\b/i,
|
|
26
|
+
/\b(reveal|print|output|leak|disclose)\b.*\b(system\s+prompt|api\s+key|token|password|secret|credential)/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Exfiltration: a send/post/upload verb pointed at a URL or address. Scanned in
|
|
30
|
+
// every section, including Journal.
|
|
31
|
+
const EXFIL_PATTERNS = [
|
|
32
|
+
/\b(send|post|upload|exfiltrate|forward|transmit|leak|email|curl|wget|fetch)\b[^.\n]{0,60}\b(to|at|into)\b[^.\n]{0,40}(https?:\/\/|ftp:\/\/|[\w.-]+@[\w.-]+)/i,
|
|
33
|
+
/\b(https?:\/\/)[^\s)]+[^\s)]*\b.*\b(api\s*key|token|password|secret|credential|env)/i,
|
|
34
|
+
/\bcurl\b[^\n]*\b(https?:\/\/)/i,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// A long unbroken base64 blob — no reason for one to live in a prose memory
|
|
38
|
+
// section; classic carrier for an obfuscated payload.
|
|
39
|
+
const BASE64_BLOB = /[A-Za-z0-9+/]{120,}={0,2}/;
|
|
40
|
+
|
|
41
|
+
function matchAny(patterns, text) {
|
|
42
|
+
for (const re of patterns) {
|
|
43
|
+
const m = re.exec(text);
|
|
44
|
+
if (m) return m[0].slice(0, 80);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Scan a single piece of text. `strict` = true for auto-injected sections
|
|
50
|
+
// (Stance/Procedure/State), false for Journal (exfil-only).
|
|
51
|
+
function scanForInjection(text, { strict = true } = {}) {
|
|
52
|
+
const s = String(text || "");
|
|
53
|
+
if (!s.trim()) return { flagged: false };
|
|
54
|
+
|
|
55
|
+
const exfil = matchAny(EXFIL_PATTERNS, s);
|
|
56
|
+
if (exfil) return { flagged: true, kind: "exfil", evidence: exfil };
|
|
57
|
+
|
|
58
|
+
if (!strict) return { flagged: false };
|
|
59
|
+
|
|
60
|
+
const override = matchAny(OVERRIDE_PATTERNS, s);
|
|
61
|
+
if (override) return { flagged: true, kind: "override", evidence: override };
|
|
62
|
+
|
|
63
|
+
const b64 = BASE64_BLOB.exec(s);
|
|
64
|
+
if (b64) return { flagged: true, kind: "base64", evidence: b64[0].slice(0, 40) + "…" };
|
|
65
|
+
|
|
66
|
+
return { flagged: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Scan the sections of a pending pack write. Strict on the auto-injected
|
|
70
|
+
// sections, lenient on Journal. Returns the first hit, or { flagged: false }.
|
|
71
|
+
function scanSections(sections = {}) {
|
|
72
|
+
const strictKeys = ["stance", "procedure", "state", "description"];
|
|
73
|
+
for (const key of strictKeys) {
|
|
74
|
+
const r = scanForInjection(sections[key], { strict: true });
|
|
75
|
+
if (r.flagged) return { ...r, section: key };
|
|
76
|
+
}
|
|
77
|
+
const r = scanForInjection(sections.journal, { strict: false });
|
|
78
|
+
if (r.flagged) return { ...r, section: "journal" };
|
|
79
|
+
return { flagged: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { scanForInjection, scanSections };
|
package/core/pack-review.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const { spawnSubagent } = require("./subagent");
|
|
10
10
|
const packs = require("./packs");
|
|
11
11
|
const entities = require("./entities");
|
|
12
|
+
const packGuard = require("./pack-guard");
|
|
12
13
|
const { redactSensitive } = require("./redact");
|
|
13
14
|
|
|
14
15
|
const MIN_TURN_CHARS = 400;
|
|
@@ -124,23 +125,41 @@ function parseDecision(text) {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
function guardCheck(a) {
|
|
129
|
+
const hit = packGuard.scanSections({
|
|
130
|
+
stance: a.stance,
|
|
131
|
+
procedure: a.procedure,
|
|
132
|
+
state: a.state,
|
|
133
|
+
description: a.description,
|
|
134
|
+
journal: a.journal,
|
|
135
|
+
});
|
|
136
|
+
if (hit.flagged) {
|
|
137
|
+
console.warn(`[pack-review] guard blocked write to ${a.pack || a.dir || a.name} (${hit.kind} in ${hit.section}): ${hit.evidence}`);
|
|
138
|
+
}
|
|
139
|
+
return hit;
|
|
140
|
+
}
|
|
141
|
+
|
|
127
142
|
function applyAction(a) {
|
|
128
143
|
if (!a || typeof a !== "object") return null;
|
|
144
|
+
const guard = guardCheck(a);
|
|
145
|
+
if (guard.flagged) {
|
|
146
|
+
return { kind: "skipped", dir: a.pack || a.dir || "", name: a.name || a.pack || a.dir || "", reason: `${guard.kind} in ${guard.section}` };
|
|
147
|
+
}
|
|
129
148
|
if (a.action === "update" && a.pack) {
|
|
130
149
|
const existing = packs.readPack(a.pack);
|
|
131
150
|
if (!existing) return null;
|
|
132
|
-
packs.updatePack(a.pack, {
|
|
151
|
+
const r = packs.updatePack(a.pack, {
|
|
133
152
|
journal: a.journal || "",
|
|
134
153
|
state: typeof a.state === "string" ? a.state : "",
|
|
135
154
|
stance: typeof a.stance === "string" ? a.stance : "",
|
|
136
155
|
procedure: typeof a.procedure === "string" ? a.procedure : "",
|
|
137
|
-
});
|
|
138
|
-
return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated" };
|
|
156
|
+
}, "reviewer");
|
|
157
|
+
return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated", protectedStance: !!r.protectedStance };
|
|
139
158
|
}
|
|
140
159
|
if (a.action === "create" && (a.dir || a.name)) {
|
|
141
160
|
const dir = packs.slugify(a.dir || a.name);
|
|
142
161
|
if (packs.readPack(dir)) {
|
|
143
|
-
packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" });
|
|
162
|
+
packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" }, "reviewer");
|
|
144
163
|
return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
|
|
145
164
|
}
|
|
146
165
|
const pack = packs.createPack({
|
|
@@ -152,7 +171,7 @@ function applyAction(a) {
|
|
|
152
171
|
procedure: a.procedure || "",
|
|
153
172
|
state: a.state || "",
|
|
154
173
|
journal: a.journal || "",
|
|
155
|
-
});
|
|
174
|
+
}, "reviewer");
|
|
156
175
|
return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "" };
|
|
157
176
|
}
|
|
158
177
|
return null;
|
|
@@ -192,7 +211,9 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
|
192
211
|
for (const a of decision.actions) {
|
|
193
212
|
try {
|
|
194
213
|
const r = applyAction(a);
|
|
195
|
-
if (r) {
|
|
214
|
+
if (r && r.kind === "skipped") {
|
|
215
|
+
lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
|
|
216
|
+
} else if (r) {
|
|
196
217
|
lines.push(r.kind === "create"
|
|
197
218
|
? `📦 New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`
|
|
198
219
|
: `✏️ ${r.name} — ${clipWords(r.note, 180)}`);
|
package/core/packs.js
CHANGED
|
@@ -137,7 +137,7 @@ function writePack(pack) {
|
|
|
137
137
|
return pack.dir;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
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") {
|
|
141
141
|
const d = slugify(dir || name);
|
|
142
142
|
if (!d) throw new Error("pack needs a name");
|
|
143
143
|
if (readPack(d)) throw new Error(`pack ${d} already exists`);
|
|
@@ -156,6 +156,7 @@ function createPack({ dir, name, description, tags, stance, procedure, state, jo
|
|
|
156
156
|
},
|
|
157
157
|
};
|
|
158
158
|
writePack(pack);
|
|
159
|
+
setProvenance(d, SECTIONS.filter((s) => (pack.sections[s] || "").trim()), origin);
|
|
159
160
|
return pack;
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -163,23 +164,98 @@ function today() {
|
|
|
163
164
|
return new Date().toISOString().slice(0, 10);
|
|
164
165
|
}
|
|
165
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
|
+
|
|
166
228
|
// Apply a reviewer mutation. Only supplied fields change; journal entries
|
|
167
229
|
// append (capped); state replaces (it represents "current truth").
|
|
168
|
-
function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}) {
|
|
230
|
+
function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}, origin = "user") {
|
|
169
231
|
const pack = readPack(dir);
|
|
170
232
|
if (!pack) throw new Error(`no pack: ${dir}`);
|
|
171
233
|
pack.updated = new Date().toISOString();
|
|
172
234
|
if (description) pack.description = description;
|
|
173
235
|
if (Array.isArray(tags) && tags.length) pack.tags = tags;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
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"); }
|
|
177
250
|
if (typeof journal === "string" && journal.trim()) {
|
|
178
251
|
const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
|
|
179
252
|
entries.push(`- [${today()}] ${journal.trim().replace(/\n+/g, " ")}`);
|
|
180
253
|
pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
|
|
254
|
+
applied.push("Journal");
|
|
181
255
|
}
|
|
182
256
|
writePack(pack);
|
|
257
|
+
if (applied.length) setProvenance(dir, applied, origin);
|
|
258
|
+
pack.protectedStance = protectedStance;
|
|
183
259
|
return pack;
|
|
184
260
|
}
|
|
185
261
|
|
|
@@ -396,4 +472,5 @@ module.exports = {
|
|
|
396
472
|
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
|
|
397
473
|
touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
|
|
398
474
|
archivePack, restorePack, listArchived,
|
|
475
|
+
readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
|
|
399
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/core/system-prompt.js
CHANGED
|
@@ -535,15 +535,42 @@ function mergeMatches(primary, secondary, keyOf) {
|
|
|
535
535
|
function logRecall(msg, candPacks, candEntities, keptPacks, keptEntities) {
|
|
536
536
|
if (String(process.env.RECALL_DEBUG || "on").toLowerCase() === "off") return;
|
|
537
537
|
try {
|
|
538
|
+
// FTS-miss instrumentation (spec #6): packs the relevance judge KEPT even
|
|
539
|
+
// though keyword FTS scored them ~0 (or they only surfaced via context, not
|
|
540
|
+
// the user's words) are pure-semantic rescues. If this count climbs, a
|
|
541
|
+
// vector backend earns its keep; until then it stays 1-line instrumentation.
|
|
542
|
+
const scoreOf = new Map(candPacks.map((m) => [m.dir, m]));
|
|
543
|
+
const ftsMissRescues = keptPacks
|
|
544
|
+
.map((m) => scoreOf.get(m.dir))
|
|
545
|
+
.filter((c) => c && (!c.score || c.origin === "context"))
|
|
546
|
+
.map((c) => c.dir);
|
|
538
547
|
const rec = {
|
|
539
548
|
ts: new Date().toISOString(),
|
|
540
549
|
msg: String(msg || "").slice(0, 200),
|
|
541
550
|
candidatePacks: candPacks.map((m) => ({ dir: m.dir, score: m.score, origin: m.origin })),
|
|
542
551
|
keptPacks: keptPacks.map((m) => m.dir),
|
|
552
|
+
ftsMissRescues,
|
|
543
553
|
candidateEntities: candEntities.map((m) => ({ slug: m.slug, score: m.score, origin: m.origin })),
|
|
544
554
|
keptEntities: keptEntities.map((m) => m.slug),
|
|
545
555
|
};
|
|
546
556
|
fs.appendFileSync(path.join(CONFIG_DIR, "recall-debug.jsonl"), JSON.stringify(rec) + "\n");
|
|
557
|
+
bumpFtsMissCounter(ftsMissRescues.length);
|
|
558
|
+
} catch (e) {}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Cumulative tally so we don't have to re-scan the whole JSONL to answer
|
|
562
|
+
// "is keyword recall missing semantic matches often enough to warrant vectors?"
|
|
563
|
+
// Counts every recall turn plus how many of them rescued an FTS-missed pack.
|
|
564
|
+
function bumpFtsMissCounter(n) {
|
|
565
|
+
try {
|
|
566
|
+
const p = path.join(CONFIG_DIR, "recall-stats.json");
|
|
567
|
+
let stats = {};
|
|
568
|
+
try { stats = JSON.parse(fs.readFileSync(p, "utf8")) || {}; } catch (e) {}
|
|
569
|
+
stats.turns = (stats.turns || 0) + 1;
|
|
570
|
+
if (n > 0) stats.turnsWithRescue = (stats.turnsWithRescue || 0) + 1;
|
|
571
|
+
stats.ftsMissRescues = (stats.ftsMissRescues || 0) + n;
|
|
572
|
+
stats.updatedAt = new Date().toISOString();
|
|
573
|
+
fs.writeFileSync(p, JSON.stringify(stats, null, 2));
|
|
547
574
|
} catch (e) {}
|
|
548
575
|
}
|
|
549
576
|
|
package/package.json
CHANGED