@inetafrica/open-claudia 2.4.0 → 2.5.0
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 +24 -0
- package/bin/cli.js +12 -0
- package/bin/entity.js +77 -0
- package/bin/pack.js +100 -0
- package/channels/telegram/adapter.js +5 -3
- package/channels/telegram/format.js +33 -4
- package/core/entities.js +329 -0
- package/core/pack-review.js +199 -0
- package/core/packs.js +334 -0
- package/core/runner.js +33 -2
- package/core/subagent.js +3 -2
- package/core/system-prompt.js +106 -14
- package/package.json +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Post-turn pack reviewer (Hermes-inspired, selective-but-active).
|
|
2
|
+
// After each substantial turn a cheap model reviews the exchange and
|
|
3
|
+
// decides whether a context pack should be created or mutated. The model
|
|
4
|
+
// only RETURNS a JSON decision — all file writes are applied here by bot
|
|
5
|
+
// code, so the reviewer needs no tools and no permissions. Every applied
|
|
6
|
+
// mutation is announced in chat (one line), per the no-silent-learning
|
|
7
|
+
// rule.
|
|
8
|
+
|
|
9
|
+
const { spawnSubagent } = require("./subagent");
|
|
10
|
+
const packs = require("./packs");
|
|
11
|
+
const entities = require("./entities");
|
|
12
|
+
const { redactSensitive } = require("./redact");
|
|
13
|
+
|
|
14
|
+
const MIN_TURN_CHARS = 400;
|
|
15
|
+
const MAX_TEXT_CHARS = 7000;
|
|
16
|
+
const REVIEW_MODEL = process.env.PACK_REVIEW_MODEL || "haiku";
|
|
17
|
+
const MAX_ACTIONS = 2;
|
|
18
|
+
const MAX_ENTITY_ACTIONS = 3;
|
|
19
|
+
|
|
20
|
+
function enabled() {
|
|
21
|
+
return (process.env.PACK_REVIEW || "on").toLowerCase() !== "off";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clip(text, n = MAX_TEXT_CHARS) {
|
|
25
|
+
const s = String(text || "");
|
|
26
|
+
return s.length > n ? s.slice(0, n) + "\n…[truncated]" : s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildReviewPrompt(userText, assistantText) {
|
|
30
|
+
const index = packs.listPacks().map((p) =>
|
|
31
|
+
`- ${p.dir}: ${p.name} — ${p.description}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}`
|
|
32
|
+
).join("\n") || "(none yet)";
|
|
33
|
+
|
|
34
|
+
const entityIndex = entities.listEntities().map((e) =>
|
|
35
|
+
`- ${e.slug}: ${e.name} (${e.type})${e.aliases.length ? ` aka ${e.aliases.join(", ")}` : ""} — ${e.description}`
|
|
36
|
+
).join("\n") || "(none yet)";
|
|
37
|
+
|
|
38
|
+
return `You are the memory reviewer for a personal AI assistant. After each conversation turn you decide whether the assistant's long-term "context packs" and "entity notes" should change.
|
|
39
|
+
|
|
40
|
+
A context pack is a living document about ONE topic (a project, a system, a recurring task, a domain). It has four sections:
|
|
41
|
+
- Stance: how to think about the topic — preferences, framing, hard rules the user has expressed.
|
|
42
|
+
- Procedure: verified how-to steps and commands.
|
|
43
|
+
- State: where work on the topic stands NOW — decisions made, open questions, next steps. Replaces wholesale.
|
|
44
|
+
- Journal: one-line dated log of what happened each session.
|
|
45
|
+
|
|
46
|
+
An entity note is a short memory file about ONE specific named entity — a person, place, project, org, or system. It has:
|
|
47
|
+
- Notes: current truth about the entity (who/what it is, role, preferences, relationships). Replaces wholesale.
|
|
48
|
+
- Log: one-line dated observations, appended.
|
|
49
|
+
|
|
50
|
+
Existing packs:
|
|
51
|
+
${index}
|
|
52
|
+
|
|
53
|
+
Known entities:
|
|
54
|
+
${entityIndex}
|
|
55
|
+
|
|
56
|
+
The turn to review:
|
|
57
|
+
|
|
58
|
+
<user_message>
|
|
59
|
+
${clip(userText)}
|
|
60
|
+
</user_message>
|
|
61
|
+
|
|
62
|
+
<assistant_reply>
|
|
63
|
+
${clip(assistantText)}
|
|
64
|
+
</assistant_reply>
|
|
65
|
+
|
|
66
|
+
Decide. Rules:
|
|
67
|
+
- 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.
|
|
68
|
+
- 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.
|
|
69
|
+
- CREATE a pack when the turn worked on a durable topic no existing pack covers. Topics recur more than you expect — a named system or project is durable by default. Do not create packs for one-off trivia, and prefer update when an existing pack fits.
|
|
70
|
+
- Never store secrets, tokens, passwords, or credentials.
|
|
71
|
+
- State should be concise (under 150 words). Journal entries one sentence. Never start a journal or log sentence with a date — dates are prepended automatically.
|
|
72
|
+
- At most ${MAX_ACTIONS} pack actions.
|
|
73
|
+
- Entities: add an item when the turn revealed something durable about a specific named person, place, project, org, or system — their role, status, preferences, or relationship to other work. Use the existing entity name when one matches (check aliases). Skip entities mentioned only in passing with nothing learned. Notes under 100 words; "notes" null means leave Notes unchanged. At most ${MAX_ENTITY_ACTIONS} entity items.
|
|
74
|
+
- Packs and entities are independent — a turn can update both, either, or neither. Do not duplicate the same fact in a pack AND an entity unless it genuinely belongs to both.
|
|
75
|
+
|
|
76
|
+
Reply with ONLY a JSON object, no prose, no code fences:
|
|
77
|
+
{"actions": [
|
|
78
|
+
{"action": "update", "pack": "<existing dir>", "journal": "<one sentence>", "state": "<full new State or null>", "stance": null, "procedure": null}
|
|
79
|
+
| {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "journal": "<one sentence>"}
|
|
80
|
+
],
|
|
81
|
+
"entities": [
|
|
82
|
+
{"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>"}
|
|
83
|
+
]}
|
|
84
|
+
or {"actions": [], "entities": []}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseDecision(text) {
|
|
88
|
+
const raw = String(text || "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
|
|
89
|
+
const start = raw.indexOf("{");
|
|
90
|
+
const end = raw.lastIndexOf("}");
|
|
91
|
+
if (start === -1 || end <= start) return null;
|
|
92
|
+
try {
|
|
93
|
+
const obj = JSON.parse(raw.slice(start, end + 1));
|
|
94
|
+
const actions = Array.isArray(obj.actions) ? obj.actions.slice(0, MAX_ACTIONS) : (obj.action ? [obj] : []);
|
|
95
|
+
const entityActions = Array.isArray(obj.entities) ? obj.entities.slice(0, MAX_ENTITY_ACTIONS) : [];
|
|
96
|
+
return { actions, entities: entityActions };
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function applyAction(a) {
|
|
103
|
+
if (!a || typeof a !== "object") return null;
|
|
104
|
+
if (a.action === "update" && a.pack) {
|
|
105
|
+
const existing = packs.readPack(a.pack);
|
|
106
|
+
if (!existing) return null;
|
|
107
|
+
packs.updatePack(a.pack, {
|
|
108
|
+
journal: a.journal || "",
|
|
109
|
+
state: typeof a.state === "string" ? a.state : "",
|
|
110
|
+
stance: typeof a.stance === "string" ? a.stance : "",
|
|
111
|
+
procedure: typeof a.procedure === "string" ? a.procedure : "",
|
|
112
|
+
});
|
|
113
|
+
return { kind: "update", dir: a.pack, name: existing.name, note: a.journal || "state updated" };
|
|
114
|
+
}
|
|
115
|
+
if (a.action === "create" && (a.dir || a.name)) {
|
|
116
|
+
const dir = packs.slugify(a.dir || a.name);
|
|
117
|
+
if (packs.readPack(dir)) {
|
|
118
|
+
packs.updatePack(dir, { journal: a.journal || "", state: a.state || "" });
|
|
119
|
+
return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
|
|
120
|
+
}
|
|
121
|
+
const pack = packs.createPack({
|
|
122
|
+
dir,
|
|
123
|
+
name: a.name,
|
|
124
|
+
description: a.description,
|
|
125
|
+
tags: Array.isArray(a.tags) ? a.tags.slice(0, 6) : [],
|
|
126
|
+
stance: a.stance || "",
|
|
127
|
+
procedure: a.procedure || "",
|
|
128
|
+
state: a.state || "",
|
|
129
|
+
journal: a.journal || "",
|
|
130
|
+
});
|
|
131
|
+
return { kind: "create", dir: pack.dir, name: pack.name, note: a.description || "" };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyEntityAction(e) {
|
|
137
|
+
if (!e || typeof e !== "object" || !String(e.name || "").trim()) return null;
|
|
138
|
+
const { entity, created } = entities.upsertEntity({
|
|
139
|
+
name: e.name,
|
|
140
|
+
type: e.type,
|
|
141
|
+
aliases: Array.isArray(e.aliases) ? e.aliases.slice(0, 5) : [],
|
|
142
|
+
description: e.description,
|
|
143
|
+
notes: typeof e.notes === "string" ? e.notes : "",
|
|
144
|
+
log: e.log || "",
|
|
145
|
+
});
|
|
146
|
+
return { kind: created ? "create" : "update", slug: entity.slug, name: entity.name, type: entity.type, note: e.log || e.description || "" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fire-and-forget. `announce` is an async (text) => void bound to the
|
|
150
|
+
// originating channel; failures are logged, never thrown into the turn.
|
|
151
|
+
function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
152
|
+
if (!enabled()) return;
|
|
153
|
+
const combined = (String(userText || "") + String(assistantText || "")).trim();
|
|
154
|
+
if (combined.length < MIN_TURN_CHARS) return;
|
|
155
|
+
|
|
156
|
+
const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")));
|
|
157
|
+
|
|
158
|
+
spawnSubagent(prompt, {
|
|
159
|
+
model: REVIEW_MODEL,
|
|
160
|
+
channelId,
|
|
161
|
+
timeoutMs: 3 * 60 * 1000,
|
|
162
|
+
systemPrompt: "You are a background memory reviewer. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
|
|
163
|
+
}).then(({ text }) => {
|
|
164
|
+
const decision = parseDecision(text);
|
|
165
|
+
if (!decision || (decision.actions.length === 0 && decision.entities.length === 0)) return;
|
|
166
|
+
const lines = [];
|
|
167
|
+
for (const a of decision.actions) {
|
|
168
|
+
try {
|
|
169
|
+
const r = applyAction(a);
|
|
170
|
+
if (r) {
|
|
171
|
+
lines.push(r.kind === "create"
|
|
172
|
+
? `New pack: ${r.name} — ${clip(r.note, 100)}`
|
|
173
|
+
: `Pack updated: ${r.name} — ${clip(r.note, 100)}`);
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.warn(`[pack-review] apply failed: ${e.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const ea of decision.entities) {
|
|
180
|
+
try {
|
|
181
|
+
const r = applyEntityAction(ea);
|
|
182
|
+
if (r) {
|
|
183
|
+
lines.push(r.kind === "create"
|
|
184
|
+
? `New entity: ${r.name} (${r.type}) — ${clip(r.note, 100)}`
|
|
185
|
+
: `Entity noted: ${r.name} — ${clip(r.note, 100)}`);
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.warn(`[pack-review] entity apply failed: ${e.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (lines.length > 0 && typeof announce === "function") {
|
|
192
|
+
announce(lines.join("\n")).catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
}).catch((e) => {
|
|
195
|
+
console.warn(`[pack-review] reviewer failed: ${e.message}`);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { reviewTurn, parseDecision, applyAction, applyEntityAction, buildReviewPrompt };
|
package/core/packs.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Context packs: living per-topic documents that merge skills + memory.
|
|
2
|
+
// Each pack is ~/.open-claudia/packs/<dir>/PACK.md with YAML-ish
|
|
3
|
+
// frontmatter and four sections:
|
|
4
|
+
// ## Stance — how to think about the topic (a topic-scoped sub-soul)
|
|
5
|
+
// ## Procedure — how to do the thing (what skills used to be)
|
|
6
|
+
// ## State — where we left off: decisions, open questions
|
|
7
|
+
// ## Journal — dated recaps of past sessions, newest last
|
|
8
|
+
// A pre-turn router FTS-matches the incoming message against packs and
|
|
9
|
+
// injects hits into the user-message context block; a post-turn reviewer
|
|
10
|
+
// (cheap model) mutates packs after each substantial turn.
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
let DatabaseSync = null;
|
|
16
|
+
try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node — matching disabled */ }
|
|
17
|
+
|
|
18
|
+
const CONFIG_DIR = require("../config-dir");
|
|
19
|
+
const PACKS_DIR = process.env.PACKS_DIR ? path.resolve(process.env.PACKS_DIR) : path.join(CONFIG_DIR, "packs");
|
|
20
|
+
|
|
21
|
+
const SECTIONS = ["Stance", "Procedure", "State", "Journal"];
|
|
22
|
+
const MAX_JOURNAL_ENTRIES = 30;
|
|
23
|
+
|
|
24
|
+
function ensureDir() {
|
|
25
|
+
fs.mkdirSync(PACKS_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slugify(name) {
|
|
29
|
+
return String(name || "").toLowerCase().trim()
|
|
30
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseFrontmatter(content) {
|
|
34
|
+
const m = String(content || "").match(/^---\n([\s\S]*?)\n---/);
|
|
35
|
+
if (!m) return { fm: {}, body: String(content || "") };
|
|
36
|
+
const fm = {};
|
|
37
|
+
for (const line of m[1].split("\n")) {
|
|
38
|
+
const kv = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
39
|
+
if (kv) fm[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
40
|
+
}
|
|
41
|
+
return { fm, body: content.slice(m[0].length).replace(/^\n+/, "") };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseSections(body) {
|
|
45
|
+
const out = { Stance: "", Procedure: "", State: "", Journal: "" };
|
|
46
|
+
let current = null;
|
|
47
|
+
const pre = [];
|
|
48
|
+
for (const line of String(body || "").split("\n")) {
|
|
49
|
+
const h = line.match(/^##\s+(Stance|Procedure|State|Journal)\s*$/i);
|
|
50
|
+
if (h) {
|
|
51
|
+
current = SECTIONS.find((s) => s.toLowerCase() === h[1].toLowerCase());
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (current) out[current] += line + "\n";
|
|
55
|
+
else pre.push(line);
|
|
56
|
+
}
|
|
57
|
+
for (const s of SECTIONS) out[s] = out[s].trim();
|
|
58
|
+
return { sections: out, preamble: pre.join("\n").trim() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function serialize(pack) {
|
|
62
|
+
const fmLines = [
|
|
63
|
+
"---",
|
|
64
|
+
`name: ${pack.name}`,
|
|
65
|
+
`description: ${pack.description || ""}`,
|
|
66
|
+
`tags: ${(pack.tags || []).join(", ")}`,
|
|
67
|
+
];
|
|
68
|
+
if (pack.parent) fmLines.push(`parent: ${pack.parent}`);
|
|
69
|
+
fmLines.push(
|
|
70
|
+
`created: ${pack.created || new Date().toISOString()}`,
|
|
71
|
+
`updated: ${pack.updated || new Date().toISOString()}`,
|
|
72
|
+
`last_used: ${pack.last_used || ""}`,
|
|
73
|
+
"---",
|
|
74
|
+
);
|
|
75
|
+
const sections = SECTIONS
|
|
76
|
+
.map((s) => `## ${s}\n\n${(pack.sections?.[s] || "").trim()}`)
|
|
77
|
+
.join("\n\n");
|
|
78
|
+
return fmLines.join("\n") + "\n\n" + sections + "\n";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function packFile(dir) {
|
|
82
|
+
return path.join(PACKS_DIR, dir, "PACK.md");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readPack(dir) {
|
|
86
|
+
let content;
|
|
87
|
+
try { content = fs.readFileSync(packFile(dir), "utf-8"); }
|
|
88
|
+
catch (e) { return null; }
|
|
89
|
+
const { fm, body } = parseFrontmatter(content);
|
|
90
|
+
const { sections } = parseSections(body);
|
|
91
|
+
let stat = null;
|
|
92
|
+
try { stat = fs.statSync(packFile(dir)); } catch (e) {}
|
|
93
|
+
return {
|
|
94
|
+
dir,
|
|
95
|
+
name: fm.name || dir,
|
|
96
|
+
description: fm.description || "",
|
|
97
|
+
tags: (fm.tags || "").split(",").map((t) => t.trim()).filter(Boolean),
|
|
98
|
+
parent: fm.parent || null,
|
|
99
|
+
created: fm.created || "",
|
|
100
|
+
updated: fm.updated || (stat ? stat.mtime.toISOString() : ""),
|
|
101
|
+
last_used: fm.last_used || "",
|
|
102
|
+
sections,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function listPacks() {
|
|
107
|
+
let entries;
|
|
108
|
+
try { entries = fs.readdirSync(PACKS_DIR); } catch (e) { return []; }
|
|
109
|
+
const packs = [];
|
|
110
|
+
for (const name of entries) {
|
|
111
|
+
if (name.startsWith(".")) continue;
|
|
112
|
+
try { if (!fs.statSync(path.join(PACKS_DIR, name)).isDirectory()) continue; }
|
|
113
|
+
catch (e) { continue; }
|
|
114
|
+
const p = readPack(name);
|
|
115
|
+
if (p) packs.push(p);
|
|
116
|
+
}
|
|
117
|
+
packs.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
118
|
+
return packs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function findPack(nameOrDir) {
|
|
122
|
+
const needle = String(nameOrDir || "").trim().toLowerCase();
|
|
123
|
+
if (!needle) return null;
|
|
124
|
+
return listPacks().find((p) => p.dir.toLowerCase() === needle || p.name.toLowerCase() === needle) || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writePack(pack) {
|
|
128
|
+
ensureDir();
|
|
129
|
+
const dir = path.join(PACKS_DIR, pack.dir);
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
131
|
+
fs.writeFileSync(packFile(pack.dir), serialize(pack), { mode: 0o600 });
|
|
132
|
+
markIndexDirty();
|
|
133
|
+
return pack.dir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createPack({ dir, name, description, tags, stance, procedure, state, journal, parent }) {
|
|
137
|
+
const d = slugify(dir || name);
|
|
138
|
+
if (!d) throw new Error("pack needs a name");
|
|
139
|
+
if (readPack(d)) throw new Error(`pack ${d} already exists`);
|
|
140
|
+
const pack = {
|
|
141
|
+
dir: d,
|
|
142
|
+
name: name || d,
|
|
143
|
+
description: description || "",
|
|
144
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
145
|
+
parent: parent || null,
|
|
146
|
+
created: new Date().toISOString(),
|
|
147
|
+
sections: {
|
|
148
|
+
Stance: stance || "",
|
|
149
|
+
Procedure: procedure || "",
|
|
150
|
+
State: state || "",
|
|
151
|
+
Journal: journal ? `- [${today()}] ${journal}` : "",
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
writePack(pack);
|
|
155
|
+
return pack;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function today() {
|
|
159
|
+
return new Date().toISOString().slice(0, 10);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Apply a reviewer mutation. Only supplied fields change; journal entries
|
|
163
|
+
// append (capped); state replaces (it represents "current truth").
|
|
164
|
+
function updatePack(dir, { description, tags, stance, procedure, state, journal } = {}) {
|
|
165
|
+
const pack = readPack(dir);
|
|
166
|
+
if (!pack) throw new Error(`no pack: ${dir}`);
|
|
167
|
+
pack.updated = new Date().toISOString();
|
|
168
|
+
if (description) pack.description = description;
|
|
169
|
+
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();
|
|
173
|
+
if (typeof journal === "string" && journal.trim()) {
|
|
174
|
+
const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
|
|
175
|
+
entries.push(`- [${today()}] ${journal.trim().replace(/\n+/g, " ")}`);
|
|
176
|
+
pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
|
|
177
|
+
}
|
|
178
|
+
writePack(pack);
|
|
179
|
+
return pack;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function removePack(nameOrDir) {
|
|
183
|
+
const pack = findPack(nameOrDir);
|
|
184
|
+
if (!pack) return null;
|
|
185
|
+
fs.rmSync(path.join(PACKS_DIR, pack.dir), { recursive: true, force: true });
|
|
186
|
+
markIndexDirty();
|
|
187
|
+
return pack;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function touchUsed(dirs) {
|
|
191
|
+
const now = new Date().toISOString();
|
|
192
|
+
for (const dir of [].concat(dirs || [])) {
|
|
193
|
+
const pack = readPack(dir);
|
|
194
|
+
if (!pack) continue;
|
|
195
|
+
pack.last_used = now;
|
|
196
|
+
try { writePack(pack); } catch (e) {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Recognise a Write/Edit aimed at a pack file (for chat announcements).
|
|
201
|
+
function packNameFromPath(filePath) {
|
|
202
|
+
if (!filePath) return null;
|
|
203
|
+
const resolved = path.resolve(String(filePath));
|
|
204
|
+
const rel = path.relative(PACKS_DIR, resolved);
|
|
205
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
206
|
+
const parts = rel.split(path.sep);
|
|
207
|
+
if (parts.length !== 2 || parts[1] !== "PACK.md") return null;
|
|
208
|
+
return parts[0];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// FTS matching. Packs are few and small, so the index is rebuilt from
|
|
213
|
+
// scratch whenever any pack changes — no incremental bookkeeping.
|
|
214
|
+
|
|
215
|
+
let _db = null;
|
|
216
|
+
let _indexedAt = 0;
|
|
217
|
+
let _dirty = true;
|
|
218
|
+
|
|
219
|
+
function markIndexDirty() { _dirty = true; }
|
|
220
|
+
|
|
221
|
+
function openDb() {
|
|
222
|
+
if (!DatabaseSync) return null;
|
|
223
|
+
if (_db) return _db;
|
|
224
|
+
try {
|
|
225
|
+
ensureDir();
|
|
226
|
+
const dbPath = path.join(PACKS_DIR, "index.db");
|
|
227
|
+
const db = new DatabaseSync(dbPath);
|
|
228
|
+
try { fs.chmodSync(dbPath, 0o600); } catch (e) {}
|
|
229
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
230
|
+
db.exec("PRAGMA busy_timeout=3000");
|
|
231
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS packs USING fts5(
|
|
232
|
+
name, description, tags, content, dir UNINDEXED,
|
|
233
|
+
tokenize='porter unicode61'
|
|
234
|
+
)`);
|
|
235
|
+
_db = db;
|
|
236
|
+
return db;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function latestMtime() {
|
|
243
|
+
let latest = 0;
|
|
244
|
+
let names = [];
|
|
245
|
+
try { names = fs.readdirSync(PACKS_DIR); } catch (e) { return 0; }
|
|
246
|
+
for (const name of names) {
|
|
247
|
+
try {
|
|
248
|
+
const m = fs.statSync(packFile(name)).mtimeMs;
|
|
249
|
+
if (m > latest) latest = m;
|
|
250
|
+
} catch (e) {}
|
|
251
|
+
}
|
|
252
|
+
return latest;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function reindex() {
|
|
256
|
+
const db = openDb();
|
|
257
|
+
if (!db) return false;
|
|
258
|
+
const packs = listPacks();
|
|
259
|
+
db.exec("BEGIN");
|
|
260
|
+
try {
|
|
261
|
+
db.exec("DELETE FROM packs");
|
|
262
|
+
const insert = db.prepare("INSERT INTO packs (name, description, tags, content, dir) VALUES (?, ?, ?, ?, ?)");
|
|
263
|
+
for (const p of packs) {
|
|
264
|
+
const content = [p.sections.Stance, p.sections.Procedure, p.sections.State, p.sections.Journal].join("\n");
|
|
265
|
+
insert.run(p.name, p.description, p.tags.join(" "), content, p.dir);
|
|
266
|
+
}
|
|
267
|
+
db.exec("COMMIT");
|
|
268
|
+
} catch (e) {
|
|
269
|
+
try { db.exec("ROLLBACK"); } catch (e2) {}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
_dirty = false;
|
|
273
|
+
_indexedAt = Date.now();
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ensureIndex() {
|
|
278
|
+
if (_dirty || latestMtime() > _indexedAt) reindex();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const STOPWORDS = new Set(("a an and are as at be but by can did do for from had has have how i if in is it its " +
|
|
282
|
+
"me my no not of on or our so that the their then there this to up us was we what when where which who why " +
|
|
283
|
+
"will with you your yes yeah ok okay please thanks just like dont don't im i'm its it's").split(" "));
|
|
284
|
+
|
|
285
|
+
function queryTerms(text) {
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
const terms = [];
|
|
288
|
+
for (const raw of String(text || "").toLowerCase().split(/[^a-z0-9_.-]+/)) {
|
|
289
|
+
const t = raw.replace(/^[.-]+|[.-]+$/g, "");
|
|
290
|
+
if (t.length < 3 || STOPWORDS.has(t) || seen.has(t)) continue;
|
|
291
|
+
seen.add(t);
|
|
292
|
+
terms.push(t);
|
|
293
|
+
if (terms.length >= 40) break;
|
|
294
|
+
}
|
|
295
|
+
return terms;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Match a free-text message against packs. BM25 is unusable here — with
|
|
299
|
+
// a small corpus its IDF term collapses to ~0 — so score per term
|
|
300
|
+
// instead: a hit in name/description/tags counts 2 (porter-stemmed via
|
|
301
|
+
// FTS), a body-only hit counts 1; packs at/above the threshold win. A
|
|
302
|
+
// single incidental body word (score 1) never drags a pack in.
|
|
303
|
+
function matchPacks(text, { limit = 3, threshold = null } = {}) {
|
|
304
|
+
const db = openDb();
|
|
305
|
+
if (!db) return [];
|
|
306
|
+
ensureIndex();
|
|
307
|
+
const terms = queryTerms(text);
|
|
308
|
+
if (terms.length === 0) return [];
|
|
309
|
+
const minScore = threshold ?? Number(process.env.PACK_MATCH_THRESHOLD || 2);
|
|
310
|
+
const scores = new Map();
|
|
311
|
+
let stmt;
|
|
312
|
+
try { stmt = db.prepare("SELECT dir FROM packs WHERE packs MATCH ?"); } catch (e) { return []; }
|
|
313
|
+
for (const t of terms) {
|
|
314
|
+
const quoted = `"${t.replace(/"/g, '""')}"`;
|
|
315
|
+
let strong = [];
|
|
316
|
+
let any = [];
|
|
317
|
+
try { strong = stmt.all(`{name description tags} : ${quoted}`); } catch (e) {}
|
|
318
|
+
try { any = stmt.all(quoted); } catch (e) {}
|
|
319
|
+
const strongSet = new Set(strong.map((r) => r.dir));
|
|
320
|
+
for (const r of any) scores.set(r.dir, (scores.get(r.dir) || 0) + (strongSet.has(r.dir) ? 2 : 1));
|
|
321
|
+
}
|
|
322
|
+
const names = new Map(listPacks().map((p) => [p.dir, p.name]));
|
|
323
|
+
return [...scores.entries()]
|
|
324
|
+
.filter(([, s]) => s >= minScore)
|
|
325
|
+
.sort((a, b) => b[1] - a[1])
|
|
326
|
+
.slice(0, Math.max(1, Math.min(10, limit)))
|
|
327
|
+
.map(([dir, score]) => ({ dir, name: names.get(dir) || dir, score }));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = {
|
|
331
|
+
PACKS_DIR, SECTIONS, slugify,
|
|
332
|
+
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
|
|
333
|
+
touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
|
|
334
|
+
};
|
package/core/runner.js
CHANGED
|
@@ -25,6 +25,9 @@ const {
|
|
|
25
25
|
const { getClaudeOAuthToken, claudeAuthRecoveryMessage, isClaudeAuthErrorText, claudeUsageLimitMessage, isClaudeUsageLimitText, runClaudeAuthStatusDiagnostic, claudeSubprocessEnv } = require("./auth-flow");
|
|
26
26
|
const loopback = require("./loopback");
|
|
27
27
|
const skillsLib = require("./skills");
|
|
28
|
+
const packsLib = require("./packs");
|
|
29
|
+
const entitiesLib = require("./entities");
|
|
30
|
+
const packReview = require("./pack-review");
|
|
28
31
|
|
|
29
32
|
function telegramHtmlOpts(extra = {}) {
|
|
30
33
|
const adapter = currentAdapter();
|
|
@@ -500,7 +503,10 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
|
|
|
500
503
|
if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
|
|
501
504
|
saveState();
|
|
502
505
|
}
|
|
503
|
-
|
|
506
|
+
// evt.result only carries the FINAL text segment of the turn. Using it
|
|
507
|
+
// as anything but a fallback would clobber text streamed before tool
|
|
508
|
+
// calls (the "long reply, then tools, then short closing line" shape).
|
|
509
|
+
if (evt.type === "result" && evt.result && !assistantText.trim()) assistantText = evt.result;
|
|
504
510
|
}
|
|
505
511
|
});
|
|
506
512
|
proc.stderr.on("data", (d) => { stderrBuffer += d.toString(); });
|
|
@@ -618,6 +624,18 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
618
624
|
}
|
|
619
625
|
const filePath = input?.file_path || input?.filePath;
|
|
620
626
|
if ((toolName === "Write" || toolName === "Edit") && filePath) {
|
|
627
|
+
const packDir = packsLib.packNameFromPath(filePath);
|
|
628
|
+
if (packDir) {
|
|
629
|
+
if (packsLib.readPack(packDir)) notifySkill(`pack:${packDir}`, `Updating pack: ${packDir}`);
|
|
630
|
+
else notifySkill(`pack:${packDir}`, `New pack: ${packDir} — open-claudia pack show ${packDir} to inspect.`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const entSlug = entitiesLib.entityNameFromPath(filePath);
|
|
634
|
+
if (entSlug) {
|
|
635
|
+
if (entitiesLib.readEntity(entSlug)) notifySkill(`entity:${entSlug}`, `Updating entity: ${entSlug}`);
|
|
636
|
+
else notifySkill(`entity:${entSlug}`, `New entity: ${entSlug} — open-claudia entity show ${entSlug} to inspect.`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
621
639
|
const dir = skillsLib.skillNameFromPath(filePath);
|
|
622
640
|
if (!dir) return;
|
|
623
641
|
// The tool_use event precedes the actual write, so existence now
|
|
@@ -803,7 +821,9 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
803
821
|
if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
|
|
804
822
|
saveState();
|
|
805
823
|
}
|
|
806
|
-
|
|
824
|
+
// Fallback only: evt.result is just the final text segment, and assigning
|
|
825
|
+
// it unconditionally wiped everything the model said before tool calls.
|
|
826
|
+
if (evt.type === "result" && evt.result && !assistantText.trim()) assistantText = evt.result;
|
|
807
827
|
}
|
|
808
828
|
});
|
|
809
829
|
|
|
@@ -877,6 +897,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
877
897
|
if (settings.budget) settings.budget = null;
|
|
878
898
|
state.statusMessageId = null;
|
|
879
899
|
|
|
900
|
+
// Post-turn pack review: fire-and-forget on a cheap model; never
|
|
901
|
+
// blocks queue drain or the next turn.
|
|
902
|
+
if ((code === 0 || code === null) && assistantText.trim()) {
|
|
903
|
+
packReview.reviewTurn({
|
|
904
|
+
userText: prompt,
|
|
905
|
+
assistantText,
|
|
906
|
+
channelId,
|
|
907
|
+
announce: (text) => chatContext.run(store, () => send(text)),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
880
911
|
if (state.lastSessionId && state.currentSession) {
|
|
881
912
|
const title = state.isFirstMessage ? (prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt) : null;
|
|
882
913
|
recordSession(state.userId, state.currentSession.name, state.lastSessionId, title);
|
package/core/subagent.js
CHANGED
|
@@ -59,10 +59,11 @@ async function spawnSubagent(prompt, opts = {}) {
|
|
|
59
59
|
"-p",
|
|
60
60
|
"--output-format", opts.json ? "json" : "text",
|
|
61
61
|
"--verbose",
|
|
62
|
-
"--append-system-prompt", buildSubagentSystemPrompt(role),
|
|
62
|
+
"--append-system-prompt", opts.systemPrompt || buildSubagentSystemPrompt(role),
|
|
63
63
|
"--dangerously-skip-permissions",
|
|
64
|
-
prompt,
|
|
65
64
|
];
|
|
65
|
+
if (opts.model) args.push("--model", opts.model);
|
|
66
|
+
args.push(prompt);
|
|
66
67
|
const env = { ...botSubprocessEnv(), ...claudeSubprocessEnv() };
|
|
67
68
|
const proc = spawn(CLAUDE_PATH, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
68
69
|
|