@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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.5.0
4
+ - **Context packs: skills become living topic documents (skills + memory merged).** Each pack is `~/.open-claudia/packs/<dir>/PACK.md` with four sections — Stance (how to think about the topic: user preferences, hard rules), Procedure (verified how-to, what skills used to be), State (where work stands now; replaced wholesale as things move), Journal (dated one-line log of past sessions, capped at 30 entries).
5
+ - **Pre-turn router** (`core/packs.js` + `system-prompt.js`): the incoming message is FTS5-matched against all packs (porter-stemmed, field-weighted — name/description/tags hits count double, threshold keeps a single incidental body word from dragging a pack in; BM25 was unusable because its IDF collapses on small corpora). Matched packs are injected into the per-turn context block, once per (channel, session, pack-version) so re-injection only happens when a pack actually changed. Rides the user message, not the system prompt, to preserve the prompt-cache prefix.
6
+ - **Post-turn reviewer** (`core/pack-review.js`): after every substantial turn (>400 chars) a haiku subagent reviews the exchange and returns a strict-JSON decision — update a pack (journal line + State rewrite), create one for a new durable topic, or nothing. The model returns JSON only; all file writes are applied by bot code, so the reviewer needs no tools or permissions. Active by default (most working turns produce at least a journal line), `PACK_REVIEW=off` to disable, `PACK_REVIEW_MODEL` to change model. Every applied mutation is announced in chat (one line) — no silent learning.
7
+ - **In-turn writes announced**: `Write`/`Edit` aimed at a `PACK.md` triggers `Updating pack: X` / `New pack: X` chat lines, same as skills did.
8
+ - **`open-claudia pack list|show|match|migrate|remove|reindex`**: inspect packs, debug the router (`match` shows scores), and `pack migrate` folds existing `~/.claude/skills` into packs (body → Procedure section) with originals backed up to `~/.open-claudia/backup/skills-pre-packs/` — run it once after upgrading.
9
+ - `spawnSubagent` learned `model` and `systemPrompt` overrides (used by the reviewer; also available to `open-claudia agent`).
10
+ - **Entity memory (phase 2): contextual notes on people, places, projects, orgs and systems.** Each entity is a short file at `~/.open-claudia/entities/<slug>.md` — frontmatter (name, type, aliases, description) plus Notes (current truth, replaced wholesale) and Log (dated one-line observations, capped at 40).
11
+ - **Same router, second store** (`core/entities.js`): incoming messages are FTS5-matched against entities with the pack scoring scheme, except the strong fields are name/aliases — a single mention of "Emmanuel" injects the Emmanuel note, but one stray body word can't. Injected as a `## Known entities` block beside packs, deduped per (channel, session, entity-version).
12
+ - **Same reviewer, one call**: the post-turn haiku reviewer now also returns an `entities` array — it creates or updates entity notes (merge semantics: aliases union, Notes replace, Log appends) in the same pass that maintains packs, at no extra subagent cost. Entity changes are announced in chat like pack changes.
13
+ - **`open-claudia entity list|show|match|note|remove|reindex`**: inspect the store, debug the router, or jot a note manually (`entity note Emmanuel "owns inet-central org cleanup" --type person`).
14
+ - Direct `Write`/`Edit` to an entity file announces `Updating entity: X` / `New entity: X`, same as packs.
15
+ - This completes phase 2 of the packs system; "dream" consolidation (pack merging + skill trees + entity linking) is next.
16
+
17
+ ## v2.4.2
18
+ - Fix replies losing everything written before tool calls. The backend's final `result` event carries only the LAST text segment of a turn, but the stream parser assigned it over the accumulated `assistantText` — so a turn shaped "long explanation → tool calls → short closing line" delivered only the closing line, which read as nonsense without its context. `evt.result` is now a fallback used only when nothing was accumulated (some Cursor turns). Fixed in both `runClaude` and the auxiliary runner.
19
+
20
+ ## v2.4.1
21
+ - Fix Telegram messages occasionally arriving as raw HTML (`<b>`, `<code>` shown literally). Root cause: the Markdown→HTML pass could inject `<i>` tags *inside* model-authored `<code>` spans (two snake_case identifiers on one line pair their underscores as italics), producing unbalanced HTML that Telegram rejects — and the parse-failure fallback then resent the converted body verbatim, tags and all.
22
+ - `<code>`/`<pre>` spans are now stashed whole (content included) before Markdown conversion, so nothing can be injected inside them.
23
+ - The italic `_..._` rule only fires at word boundaries, so snake_case identifiers anywhere in the text can no longer pair up.
24
+ - Model-authored entities (`&lt;`, `&amp;`, `&#…;`) are preserved instead of double-escaped — `<code>&lt;name&gt;</code>` now renders as `<name>` instead of `&lt;name&gt;`.
25
+ - Last-resort fallback (send + edit) now strips tags and decodes entities (`htmlToPlain`) so a rejected message degrades to clean plain text, never raw markup.
26
+
3
27
  ## v2.4.0
4
28
  - **FTS5 transcript index (cross-session recall).** Project transcripts are now indexed in SQLite FTS5 via Node's built-in `node:sqlite` (no new dependency), turning "did we discuss X last week?" into a ~50ms ranked lookup instead of a linear grep over JSONL.
5
29
  - `core/transcript-index.js`: WAL-mode DB at `~/.open-claudia/transcripts/index.db` (0600), FTS5 table with porter tokenizer, per-file byte offsets for idempotent incremental indexing. Partial trailing lines wait for the next pass; a replaced/truncated transcript drops its stale rows and reindexes. Fail-soft: without `node:sqlite` every call no-ops and transcript-window remains the path.
package/bin/cli.js CHANGED
@@ -274,6 +274,16 @@ switch (command) {
274
274
  break;
275
275
  }
276
276
 
277
+ case "pack": {
278
+ require("./pack").run(args.slice(1));
279
+ break;
280
+ }
281
+
282
+ case "entity": {
283
+ require("./entity").run(args.slice(1));
284
+ break;
285
+ }
286
+
277
287
  case "agent": {
278
288
  require("./agent").run(args.slice(1));
279
289
  break;
@@ -320,6 +330,8 @@ Memory tools:
320
330
  (alias: ts; --all for every project; --help for options)
321
331
  open-claudia transcript-window <pattern> Search project transcript, show hits with context
322
332
  (alias: tw; --help for options)
333
+ open-claudia pack list|show|match|migrate Context packs: living topic docs (skills + memory)
334
+ open-claudia entity list|show|match|note Entity notes: people/places/projects memory
323
335
 
324
336
  Background work (only inside an active bot-spawned task):
325
337
  open-claudia schedule-wakeup <when> "<prompt>" One-shot future wake-up; resumes session
package/bin/entity.js ADDED
@@ -0,0 +1,77 @@
1
+ // CLI: inspect and manage entity notes (people/places/projects memory).
2
+ // open-claudia entity list
3
+ // open-claudia entity show <slug-or-name>
4
+ // open-claudia entity match "<text>" — debug the router
5
+ // open-claudia entity note <name> "<text>" [--type person|place|project|org|system]
6
+ // open-claudia entity remove <slug-or-name>
7
+ // open-claudia entity reindex
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const entities = require("../core/entities");
12
+
13
+ function run(args) {
14
+ const cmd = (args[0] || "list").toLowerCase();
15
+ const rest = args.slice(1);
16
+
17
+ switch (cmd) {
18
+ case "list": {
19
+ const all = entities.listEntities();
20
+ if (all.length === 0) return console.log(`No entities yet (${entities.ENTITIES_DIR}).`);
21
+ for (const e of all) {
22
+ const seen = e.last_seen ? ` last-seen ${e.last_seen.slice(0, 10)}` : "";
23
+ const aka = e.aliases.length ? ` aka ${e.aliases.join(", ")}` : "";
24
+ console.log(`${e.slug} (${e.type})${aka}${seen}\n ${e.description}`);
25
+ }
26
+ break;
27
+ }
28
+
29
+ case "show": {
30
+ const e = entities.findEntity(rest.join(" "));
31
+ if (!e) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
32
+ console.log(fs.readFileSync(path.join(entities.ENTITIES_DIR, e.slug + ".md"), "utf-8"));
33
+ break;
34
+ }
35
+
36
+ case "match": {
37
+ const text = rest.join(" ");
38
+ if (!text) { console.error('Usage: entity match "<text>"'); process.exitCode = 1; return; }
39
+ const hits = entities.matchEntities(text, { limit: 5 });
40
+ if (hits.length === 0) return console.log("No matching entities.");
41
+ for (const h of hits) console.log(`${h.score.toFixed(2)} ${h.slug} — ${h.name}`);
42
+ break;
43
+ }
44
+
45
+ case "note": {
46
+ const typeIdx = rest.indexOf("--type");
47
+ let type = null;
48
+ if (typeIdx !== -1) {
49
+ type = rest[typeIdx + 1] || null;
50
+ rest.splice(typeIdx, 2);
51
+ }
52
+ const [name, ...noteParts] = rest;
53
+ const note = noteParts.join(" ");
54
+ if (!name || !note) { console.error('Usage: entity note <name> "<text>" [--type <type>]'); process.exitCode = 1; return; }
55
+ const { entity, created } = entities.upsertEntity({ name, type, log: note });
56
+ console.log(`${created ? "Created" : "Updated"} entity ${entity.slug} (${entity.type}).`);
57
+ break;
58
+ }
59
+
60
+ case "remove": {
61
+ const removed = entities.removeEntity(rest.join(" "));
62
+ if (!removed) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
63
+ console.log(`Removed entity ${removed.slug}.`);
64
+ break;
65
+ }
66
+
67
+ case "reindex": {
68
+ console.log(entities.reindex() ? "Reindexed." : "Index unavailable (node:sqlite missing?).");
69
+ break;
70
+ }
71
+
72
+ default:
73
+ console.log("Usage: open-claudia entity [list|show <slug>|match \"<text>\"|note <name> \"<text>\"|remove <slug>|reindex]");
74
+ }
75
+ }
76
+
77
+ module.exports = { run };
package/bin/pack.js ADDED
@@ -0,0 +1,100 @@
1
+ // CLI: inspect and manage context packs (living skills+memory docs).
2
+ // open-claudia pack list
3
+ // open-claudia pack show <dir>
4
+ // open-claudia pack match "<text>" — debug the router
5
+ // open-claudia pack migrate — fold ~/.claude/skills into packs
6
+ // open-claudia pack remove <dir>
7
+ // open-claudia pack reindex
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const packs = require("../core/packs");
12
+
13
+ function run(args) {
14
+ const cmd = (args[0] || "list").toLowerCase();
15
+ const rest = args.slice(1);
16
+
17
+ switch (cmd) {
18
+ case "list": {
19
+ const all = packs.listPacks();
20
+ if (all.length === 0) return console.log(`No packs yet (${packs.PACKS_DIR}).`);
21
+ for (const p of all) {
22
+ const used = p.last_used ? ` last-used ${p.last_used.slice(0, 10)}` : "";
23
+ console.log(`${p.dir} — ${p.name}${p.tags.length ? ` [${p.tags.join(", ")}]` : ""}${used}\n ${p.description}`);
24
+ }
25
+ break;
26
+ }
27
+
28
+ case "show": {
29
+ const p = packs.findPack(rest[0]);
30
+ if (!p) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
31
+ console.log(fs.readFileSync(path.join(packs.PACKS_DIR, p.dir, "PACK.md"), "utf-8"));
32
+ break;
33
+ }
34
+
35
+ case "match": {
36
+ const text = rest.join(" ");
37
+ if (!text) { console.error('Usage: pack match "<text>"'); process.exitCode = 1; return; }
38
+ const hits = packs.matchPacks(text, { limit: 5 });
39
+ if (hits.length === 0) return console.log("No matching packs.");
40
+ for (const h of hits) console.log(`${h.score.toFixed(2)} ${h.dir} — ${h.name}`);
41
+ break;
42
+ }
43
+
44
+ case "remove": {
45
+ const removed = packs.removePack(rest[0]);
46
+ if (!removed) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
47
+ console.log(`Removed pack ${removed.dir}.`);
48
+ break;
49
+ }
50
+
51
+ case "reindex": {
52
+ console.log(packs.reindex() ? "Reindexed." : "Index unavailable (node:sqlite missing?).");
53
+ break;
54
+ }
55
+
56
+ case "migrate": {
57
+ const skills = require("../core/skills");
58
+ const backupDir = path.join(require("../config-dir"), "backup", "skills-pre-packs");
59
+ const all = skills.listSkills();
60
+ if (all.length === 0) return console.log("No skills to migrate.");
61
+ fs.mkdirSync(backupDir, { recursive: true });
62
+ let migrated = 0, skipped = 0;
63
+ for (const s of all) {
64
+ const dir = packs.slugify(s.dir);
65
+ if (packs.readPack(dir)) {
66
+ console.log(`skip ${s.dir} (pack exists)`);
67
+ skipped++;
68
+ continue;
69
+ }
70
+ const content = fs.readFileSync(s.file, "utf-8");
71
+ const body = content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
72
+ packs.createPack({
73
+ dir,
74
+ name: s.name,
75
+ description: s.description,
76
+ tags: [],
77
+ procedure: body,
78
+ journal: "Migrated from ~/.claude/skills.",
79
+ });
80
+ // Fold: move the original skill out of ~/.claude/skills so the
81
+ // pack is the single source of truth. Backup kept, never deleted.
82
+ const skillDir = path.join(skills.SKILLS_DIR, s.dir);
83
+ const dest = path.join(backupDir, s.dir);
84
+ fs.rmSync(dest, { recursive: true, force: true });
85
+ fs.cpSync(skillDir, dest, { recursive: true });
86
+ fs.rmSync(skillDir, { recursive: true, force: true });
87
+ console.log(`migrated ${s.dir} -> pack ${dir}`);
88
+ migrated++;
89
+ }
90
+ packs.reindex();
91
+ console.log(`Done: ${migrated} migrated, ${skipped} skipped. Originals backed up at ${backupDir}.`);
92
+ break;
93
+ }
94
+
95
+ default:
96
+ console.log("Usage: open-claudia pack [list|show <dir>|match \"<text>\"|migrate|remove <dir>|reindex]");
97
+ }
98
+ }
99
+
100
+ module.exports = { run };
@@ -10,7 +10,7 @@ const TelegramBot = require("node-telegram-bot-api");
10
10
  const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
11
11
  const { canonicalForChannel } = require("../../core/identity");
12
12
  const { portableToInlineKeyboard } = require("../types");
13
- const { telegramHtml } = require("./format");
13
+ const { telegramHtml, htmlToPlain } = require("./format");
14
14
 
15
15
  class TelegramAdapter {
16
16
  constructor({ id = "telegram", token, ownerChatId, chatIds }) {
@@ -205,7 +205,7 @@ class TelegramAdapter {
205
205
 
206
206
  async send(channelId, text, opts = {}) {
207
207
  const o = {};
208
- const body = opts.parseMode === "HTML" ? telegramHtml(text) : text;
208
+ let body = opts.parseMode === "HTML" ? telegramHtml(text) : text;
209
209
  if (opts.parseMode) o.parse_mode = opts.parseMode;
210
210
  const kb = this._normalizeKeyboard(opts.keyboard);
211
211
  if (kb) o.reply_markup = kb;
@@ -229,7 +229,9 @@ class TelegramAdapter {
229
229
  continue;
230
230
  }
231
231
  if (opts.parseMode && o.parse_mode) {
232
+ console.error("Send: HTML rejected, falling back to plain:", errMsg);
232
233
  delete o.parse_mode;
234
+ body = htmlToPlain(body);
233
235
  continue;
234
236
  }
235
237
  console.error("Send error:", errMsg);
@@ -255,7 +257,7 @@ class TelegramAdapter {
255
257
  if (errMsg.includes("message is not modified")) return;
256
258
  if (opts.parseMode && o.parse_mode) {
257
259
  delete o.parse_mode;
258
- try { await this.bot.editMessageText(text, o); return; } catch (e2) {}
260
+ try { await this.bot.editMessageText(htmlToPlain(body), o); return; } catch (e2) {}
259
261
  }
260
262
  if (!errMsg.includes("message to edit not found")) console.error("Edit error:", errMsg);
261
263
  }
@@ -14,6 +14,15 @@ function escapeHtml(text) {
14
14
  .replace(/>/g, "&gt;");
15
15
  }
16
16
 
17
+ // Like escapeHtml, but keeps entities the model already wrote (&lt; &amp; &#123;)
18
+ // instead of double-escaping them into visible "&lt;" text.
19
+ function escapeHtmlPreservingEntities(text) {
20
+ return String(text || "")
21
+ .replace(/&(?!(?:amp|lt|gt|quot|#\d+|#x[0-9a-fA-F]+);)/g, "&amp;")
22
+ .replace(/</g, "&lt;")
23
+ .replace(/>/g, "&gt;");
24
+ }
25
+
17
26
  function stripMarkdown(text) {
18
27
  return String(text || "")
19
28
  .replace(/[*_`~]/g, "")
@@ -48,6 +57,13 @@ function stashAllowedHtmlTags(text) {
48
57
  };
49
58
  let out = text;
50
59
 
60
+ // Stash whole model-authored <code>/<pre> spans, content included, so
61
+ // Markdown conversion can never inject tags inside them (e.g. two
62
+ // snake_case identifiers on one line becoming a stray <i> pair).
63
+ out = out.replace(/&lt;(code|pre)&gt;([\s\S]*?)&lt;\/\1&gt;/gi, (_, tag, inner) => {
64
+ return stash(`<${tag.toLowerCase()}>${inner}</${tag.toLowerCase()}>`);
65
+ });
66
+
51
67
  // Hide model-authored Telegram HTML tags from Markdown conversion. This keeps
52
68
  // underscores in hrefs such as wan2_2_video from becoming bogus <i> tags.
53
69
  for (const tag of ["b", "strong", "i", "em", "u", "s", "strike", "del", "code", "pre", "blockquote"]) {
@@ -83,8 +99,9 @@ function convertMarkdownToHtml(text) {
83
99
  out = out.replace(/\*\*([^*\n][\s\S]*?[^*\n])\*\*/g, "<b>$1</b>");
84
100
  out = out.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, "<b>$1</b>");
85
101
 
86
- // Italic. Keep conservative to avoid mangling snake_case identifiers.
87
- out = out.replace(/(?<!_)_([^_\n]+)_(?!_)/g, "<i>$1</i>");
102
+ // Italic. Only fire when the underscores sit at word boundaries, so
103
+ // snake_case identifiers (skill_manage ... skill_manage) never pair up.
104
+ out = out.replace(/(?<![A-Za-z0-9_])_([^_\n]+)_(?![A-Za-z0-9_])/g, "<i>$1</i>");
88
105
 
89
106
  // Strikethrough and Telegram spoiler syntax.
90
107
  out = out.replace(/~~([^~\n]+)~~/g, "<s>$1</s>");
@@ -95,7 +112,7 @@ function convertMarkdownToHtml(text) {
95
112
 
96
113
  function telegramHtml(text) {
97
114
  const { text: withoutCode, blocks } = stashCode(text);
98
- let out = escapeHtml(withoutCode);
115
+ let out = escapeHtmlPreservingEntities(withoutCode);
99
116
  const { text: withoutHtmlTags, tags } = stashAllowedHtmlTags(out);
100
117
  out = convertMarkdownToHtml(withoutHtmlTags);
101
118
 
@@ -110,4 +127,16 @@ function telegramHtml(text) {
110
127
  return out;
111
128
  }
112
129
 
113
- module.exports = { escapeHtml, stripMarkdown, telegramHtml };
130
+ // Last-resort plain text for when Telegram rejects the HTML: drop tags,
131
+ // decode entities, so the user never sees raw <b>/<code> markup.
132
+ function htmlToPlain(html) {
133
+ return String(html || "")
134
+ .replace(/<\/?[a-zA-Z][^>]*>/g, "")
135
+ .replace(/&lt;/g, "<")
136
+ .replace(/&gt;/g, ">")
137
+ .replace(/&quot;/g, '"')
138
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
139
+ .replace(/&amp;/g, "&");
140
+ }
141
+
142
+ module.exports = { escapeHtml, stripMarkdown, telegramHtml, htmlToPlain };
@@ -0,0 +1,329 @@
1
+ // Entity memory: short living notes on the people, places, projects,
2
+ // orgs and systems that come up in conversation (a Honcho-style store,
3
+ // our own version). Each entity is ~/.open-claudia/entities/<slug>.md
4
+ // with frontmatter and two sections:
5
+ // ## Notes — current truth about the entity (replaces wholesale)
6
+ // ## Log — dated one-line observations, newest last
7
+ // The pre-turn router injects matched entities alongside context packs;
8
+ // the post-turn reviewer extracts/updates them after each turn.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+
13
+ let DatabaseSync = null;
14
+ try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node — matching disabled */ }
15
+
16
+ const CONFIG_DIR = require("../config-dir");
17
+ const ENTITIES_DIR = process.env.ENTITIES_DIR ? path.resolve(process.env.ENTITIES_DIR) : path.join(CONFIG_DIR, "entities");
18
+
19
+ const TYPES = ["person", "place", "project", "org", "system", "thing"];
20
+ const MAX_LOG_ENTRIES = 40;
21
+
22
+ function ensureDir() {
23
+ fs.mkdirSync(ENTITIES_DIR, { recursive: true, mode: 0o700 });
24
+ }
25
+
26
+ function slugify(name) {
27
+ return String(name || "").toLowerCase().trim()
28
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
29
+ }
30
+
31
+ function normalizeType(type) {
32
+ const t = String(type || "").toLowerCase().trim();
33
+ return TYPES.includes(t) ? t : "thing";
34
+ }
35
+
36
+ function parseFrontmatter(content) {
37
+ const m = String(content || "").match(/^---\n([\s\S]*?)\n---/);
38
+ if (!m) return { fm: {}, body: String(content || "") };
39
+ const fm = {};
40
+ for (const line of m[1].split("\n")) {
41
+ const kv = line.match(/^([a-z_]+):\s*(.*)$/);
42
+ if (kv) fm[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
43
+ }
44
+ return { fm, body: content.slice(m[0].length).replace(/^\n+/, "") };
45
+ }
46
+
47
+ function parseSections(body) {
48
+ const out = { Notes: "", Log: "" };
49
+ let current = null;
50
+ for (const line of String(body || "").split("\n")) {
51
+ const h = line.match(/^##\s+(Notes|Log)\s*$/i);
52
+ if (h) {
53
+ current = h[1].toLowerCase() === "notes" ? "Notes" : "Log";
54
+ continue;
55
+ }
56
+ if (current) out[current] += line + "\n";
57
+ }
58
+ out.Notes = out.Notes.trim();
59
+ out.Log = out.Log.trim();
60
+ return out;
61
+ }
62
+
63
+ function serialize(ent) {
64
+ return [
65
+ "---",
66
+ `name: ${ent.name}`,
67
+ `type: ${ent.type}`,
68
+ `aliases: ${(ent.aliases || []).join(", ")}`,
69
+ `description: ${ent.description || ""}`,
70
+ `created: ${ent.created || new Date().toISOString()}`,
71
+ `updated: ${ent.updated || new Date().toISOString()}`,
72
+ `last_seen: ${ent.last_seen || ""}`,
73
+ "---",
74
+ "",
75
+ `## Notes`,
76
+ "",
77
+ (ent.sections?.Notes || "").trim(),
78
+ "",
79
+ `## Log`,
80
+ "",
81
+ (ent.sections?.Log || "").trim(),
82
+ "",
83
+ ].join("\n");
84
+ }
85
+
86
+ function entityFile(slug) {
87
+ return path.join(ENTITIES_DIR, slug + ".md");
88
+ }
89
+
90
+ function readEntity(slug) {
91
+ let content;
92
+ try { content = fs.readFileSync(entityFile(slug), "utf-8"); }
93
+ catch (e) { return null; }
94
+ const { fm, body } = parseFrontmatter(content);
95
+ return {
96
+ slug,
97
+ name: fm.name || slug,
98
+ type: normalizeType(fm.type),
99
+ aliases: (fm.aliases || "").split(",").map((a) => a.trim()).filter(Boolean),
100
+ description: fm.description || "",
101
+ created: fm.created || "",
102
+ updated: fm.updated || "",
103
+ last_seen: fm.last_seen || "",
104
+ sections: parseSections(body),
105
+ };
106
+ }
107
+
108
+ function listEntities() {
109
+ let names;
110
+ try { names = fs.readdirSync(ENTITIES_DIR); } catch (e) { return []; }
111
+ const out = [];
112
+ for (const f of names) {
113
+ if (!f.endsWith(".md") || f.startsWith(".")) continue;
114
+ const e = readEntity(f.slice(0, -3));
115
+ if (e) out.push(e);
116
+ }
117
+ out.sort((a, b) => a.slug.localeCompare(b.slug));
118
+ return out;
119
+ }
120
+
121
+ function findEntity(nameOrSlug) {
122
+ const needle = String(nameOrSlug || "").trim().toLowerCase();
123
+ if (!needle) return null;
124
+ const direct = readEntity(slugify(needle));
125
+ if (direct) return direct;
126
+ return listEntities().find((e) =>
127
+ e.slug === needle ||
128
+ e.name.toLowerCase() === needle ||
129
+ e.aliases.some((a) => a.toLowerCase() === needle)
130
+ ) || null;
131
+ }
132
+
133
+ function writeEntity(ent) {
134
+ ensureDir();
135
+ fs.writeFileSync(entityFile(ent.slug), serialize(ent), { mode: 0o600 });
136
+ markIndexDirty();
137
+ return ent.slug;
138
+ }
139
+
140
+ function today() {
141
+ return new Date().toISOString().slice(0, 10);
142
+ }
143
+
144
+ // Create-or-merge. Notes replace (current truth); log lines append
145
+ // (capped); aliases union; description/type update when supplied.
146
+ function upsertEntity({ name, type, aliases, description, notes, log } = {}) {
147
+ const cleanName = String(name || "").trim();
148
+ if (!cleanName) throw new Error("entity needs a name");
149
+ const existing = findEntity(cleanName);
150
+ const ent = existing || {
151
+ slug: slugify(cleanName),
152
+ name: cleanName,
153
+ type: normalizeType(type),
154
+ aliases: [],
155
+ description: "",
156
+ created: new Date().toISOString(),
157
+ sections: { Notes: "", Log: "" },
158
+ };
159
+ if (!ent.slug) throw new Error("entity needs a name");
160
+ ent.updated = new Date().toISOString();
161
+ if (type) ent.type = normalizeType(type);
162
+ if (description) ent.description = String(description).trim();
163
+ for (const a of [].concat(aliases || [])) {
164
+ const alias = String(a).trim();
165
+ if (alias && alias.toLowerCase() !== ent.name.toLowerCase() &&
166
+ !ent.aliases.some((x) => x.toLowerCase() === alias.toLowerCase())) {
167
+ ent.aliases.push(alias);
168
+ }
169
+ }
170
+ ent.aliases = ent.aliases.slice(0, 8);
171
+ if (typeof notes === "string" && notes.trim()) ent.sections.Notes = notes.trim();
172
+ if (typeof log === "string" && log.trim()) {
173
+ const entries = ent.sections.Log.split("\n").filter((l) => l.trim());
174
+ entries.push(`- [${today()}] ${log.trim().replace(/\n+/g, " ")}`);
175
+ ent.sections.Log = entries.slice(-MAX_LOG_ENTRIES).join("\n");
176
+ }
177
+ writeEntity(ent);
178
+ return { entity: ent, created: !existing };
179
+ }
180
+
181
+ function removeEntity(nameOrSlug) {
182
+ const ent = findEntity(nameOrSlug);
183
+ if (!ent) return null;
184
+ fs.rmSync(entityFile(ent.slug), { force: true });
185
+ markIndexDirty();
186
+ return ent;
187
+ }
188
+
189
+ function touchSeen(slugs) {
190
+ const now = new Date().toISOString();
191
+ for (const slug of [].concat(slugs || [])) {
192
+ const ent = readEntity(slug);
193
+ if (!ent) continue;
194
+ ent.last_seen = now;
195
+ try { writeEntity(ent); } catch (e) {}
196
+ }
197
+ }
198
+
199
+ // Recognise a Write/Edit aimed at an entity file (for chat announcements).
200
+ function entityNameFromPath(filePath) {
201
+ if (!filePath) return null;
202
+ const resolved = path.resolve(String(filePath));
203
+ const rel = path.relative(ENTITIES_DIR, resolved);
204
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
205
+ if (rel.includes(path.sep) || !rel.endsWith(".md")) return null;
206
+ return rel.slice(0, -3);
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // FTS matching — same field-weighted per-term scoring as packs (BM25 is
211
+ // unusable on a small corpus): a hit on name/aliases counts 2, anything
212
+ // else 1. Threshold 2 means a single name mention is enough to inject,
213
+ // but one stray body word is not.
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(ENTITIES_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 entities USING fts5(
232
+ name, aliases, description, content, slug 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(ENTITIES_DIR); } catch (e) { return 0; }
246
+ for (const f of names) {
247
+ if (!f.endsWith(".md")) continue;
248
+ try {
249
+ const m = fs.statSync(path.join(ENTITIES_DIR, f)).mtimeMs;
250
+ if (m > latest) latest = m;
251
+ } catch (e) {}
252
+ }
253
+ return latest;
254
+ }
255
+
256
+ function reindex() {
257
+ const db = openDb();
258
+ if (!db) return false;
259
+ const all = listEntities();
260
+ db.exec("BEGIN");
261
+ try {
262
+ db.exec("DELETE FROM entities");
263
+ const insert = db.prepare("INSERT INTO entities (name, aliases, description, content, slug) VALUES (?, ?, ?, ?, ?)");
264
+ for (const e of all) {
265
+ insert.run(e.name, e.aliases.join(" "), e.description, [e.sections.Notes, e.sections.Log].join("\n"), e.slug);
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
+ function matchEntities(text, { limit = 4, threshold = null } = {}) {
299
+ const db = openDb();
300
+ if (!db) return [];
301
+ ensureIndex();
302
+ const terms = queryTerms(text);
303
+ if (terms.length === 0) return [];
304
+ const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
305
+ const scores = new Map();
306
+ let stmt;
307
+ try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return []; }
308
+ for (const t of terms) {
309
+ const quoted = `"${t.replace(/"/g, '""')}"`;
310
+ let strong = [];
311
+ let any = [];
312
+ try { strong = stmt.all(`{name aliases} : ${quoted}`); } catch (e) {}
313
+ try { any = stmt.all(quoted); } catch (e) {}
314
+ const strongSet = new Set(strong.map((r) => r.slug));
315
+ for (const r of any) scores.set(r.slug, (scores.get(r.slug) || 0) + (strongSet.has(r.slug) ? 2 : 1));
316
+ }
317
+ const names = new Map(listEntities().map((e) => [e.slug, e.name]));
318
+ return [...scores.entries()]
319
+ .filter(([, s]) => s >= minScore)
320
+ .sort((a, b) => b[1] - a[1])
321
+ .slice(0, Math.max(1, Math.min(10, limit)))
322
+ .map(([slug, score]) => ({ slug, name: names.get(slug) || slug, score }));
323
+ }
324
+
325
+ module.exports = {
326
+ ENTITIES_DIR, TYPES, slugify,
327
+ listEntities, findEntity, readEntity, writeEntity, upsertEntity, removeEntity,
328
+ touchSeen, entityNameFromPath, matchEntities, reindex, markIndexDirty,
329
+ };