openclaw-hybrid-memory 2026.4.21 → 2026.4.30

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.
Files changed (51) hide show
  1. package/README.md +32 -3
  2. package/api/memory-plugin-api.ts +2 -0
  3. package/api/plugin-runtime.ts +2 -0
  4. package/backends/facts-db/entity-layer.ts +359 -0
  5. package/backends/facts-db.ts +56 -0
  6. package/backends/migrations/facts-migrations.ts +22 -0
  7. package/cli/cmd-config.ts +52 -13
  8. package/cli/cmd-install.ts +242 -7
  9. package/cli/cmd-verify.ts +64 -15
  10. package/cli/manage.ts +106 -31
  11. package/cli/register.ts +6 -0
  12. package/cli/types.ts +27 -2
  13. package/cli/verify.ts +25 -0
  14. package/config/parsers/retrieval.ts +12 -0
  15. package/config/types/retrieval.ts +2 -0
  16. package/config/utils.ts +2 -2
  17. package/index.ts +2 -0
  18. package/lifecycle/resolve-agent-id.ts +40 -3
  19. package/lifecycle/session-state.ts +23 -11
  20. package/lifecycle/stage-injection.ts +21 -0
  21. package/lifecycle/stage-recall.ts +127 -17
  22. package/lifecycle/stage-setup.ts +5 -2
  23. package/lifecycle/types.ts +4 -1
  24. package/npm-shrinkwrap.json +395 -322
  25. package/openclaw.plugin.json +38 -2
  26. package/package.json +5 -2
  27. package/services/bootstrap.ts +16 -0
  28. package/services/chat.ts +3 -131
  29. package/services/cost-feature-labels.ts +5 -0
  30. package/services/cross-agent-learning.ts +2 -1
  31. package/services/embedding-migration.ts +2 -2
  32. package/services/embeddings/factory.ts +23 -2
  33. package/services/embeddings/openai-provider.ts +11 -2
  34. package/services/embeddings/shared.ts +2 -9
  35. package/services/entity-enrichment-cli.ts +47 -0
  36. package/services/entity-enrichment.ts +186 -0
  37. package/services/identity-reflection.ts +2 -1
  38. package/services/llm-rate-limit-headers.ts +124 -0
  39. package/services/post-compaction-recall.ts +118 -0
  40. package/services/recall-pipeline.ts +85 -14
  41. package/services/recall-timing.ts +73 -0
  42. package/services/reflection.ts +4 -3
  43. package/setup/cli-context.ts +10 -0
  44. package/setup/init-databases.ts +8 -2
  45. package/setup/register-hooks.ts +24 -2
  46. package/skills/hybrid-memory/SKILL.md +67 -0
  47. package/skills/hybrid-memory/references/memory-optimization.md +128 -0
  48. package/tools/credential-tools.ts +1 -0
  49. package/tools/memory-tools.ts +153 -33
  50. package/utils/openclaw-agent-defaults.ts +21 -0
  51. package/workspace-snippets/TOOLS-hybrid-memory-body.md +7 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenClaw memory-hybrid plugin
2
2
 
3
- Your OpenClaw agent forgets after each session. This plugin gives it **lasting memory**: structured facts (SQLite + FTS5) and semantic search (LanceDB), with auto-capture, auto-recall, TTL-based decay, **dynamic memory tiering (hot/warm/cold)**, LLM auto-classification, graph-based spreading activation for zero-LLM recall, and an optional credential vault. **Progressive disclosure** lets you inject a lightweight memory index instead of full texts—the agent uses `memory_recall` to fetch only what it needs, saving tokens. One install, one config—then your agent remembers preferences, decisions, and context across conversations.
3
+ Your OpenClaw agent forgets after each session. This plugin gives it **lasting memory**: structured facts (SQLite + FTS5) and semantic search (LanceDB), with auto-capture, auto-recall, TTL-based decay, **dynamic memory tiering (hot/warm/cold)**, LLM auto-classification, graph-based spreading activation for zero-LLM recall, **contacts and organizations** (multilingual PERSON/ORG extraction with **franc** + LLM when graph is on; agent tool `memory_directory`), and an optional credential vault. **Progressive disclosure** lets you inject a lightweight memory index instead of full texts—the agent uses `memory_recall` to fetch only what it needs, saving tokens. One install, one config—then your agent remembers preferences, decisions, and context across conversations.
4
4
 
5
5
  Part of the [OpenClaw Hybrid Memory](https://github.com/markus-lassfolk/openclaw-hybrid-memory) v3 deployment.
6
6
 
@@ -43,7 +43,7 @@ Or with npm directly: `npm i openclaw-hybrid-memory` in your OpenClaw extensions
43
43
 
44
44
  **Manual install from a `.tgz`:** npm packages never ship `node_modules`; after `tar -xzf` you must run **`npm install --omit=dev`** or **`npm ci --omit=dev`** (plain `npm ci` also installs devDependencies). The published artifact includes **`npm-shrinkwrap.json`** (npm strips `package-lock.json` from published tarballs by design; `npm ci` uses the shrinkwrap the same way). Let the command finish—**`postinstall`** installs and rebuilds **`@lancedb/lancedb`** for your platform.
45
45
 
46
- **2. Configure.** Set your OpenAI API key and enable the plugin. Easiest: run `openclaw hybrid-mem install` to merge full defaults (memory slot, compaction prompts, nightly session-distillation job) into `~/.openclaw/openclaw.json`, then set `plugins.entries["openclaw-hybrid-memory"].config.embedding.apiKey` to your key.
46
+ **2. Configure.** Set your OpenAI API key and enable the plugin. Easiest: run `openclaw hybrid-mem install` to merge full defaults (memory slot, compaction prompts, nightly session-distillation job) into `~/.openclaw/openclaw.json`, then set `plugins.entries["openclaw-hybrid-memory"].config.embedding.apiKey` to your key. The same command copies the bundled **hybrid-memory AgentSkill** folder to `{workspace}/skills/hybrid-memory/` (`SKILL.md` plus `references/`, e.g. memory optimization guide—OpenClaw’s highest-precedence skill location; see [Skills](https://docs.openclaw.ai/tools/skills)) and merges a **managed section** into `{workspace}/TOOLS.md` (guidance only—[Agent workspace](https://docs.openclaw.ai/concepts/agent-workspace)). The TOOLS block is delimited by HTML comments so `install` / `upgrade` can refresh it without overwriting your other notes. Workspace root is `OPENCLAW_WORKSPACE` (if set to a valid path), `agents.defaults.workspace`, `agent.workspace`, or `~/.openclaw/workspace` by default.
47
47
 
48
48
  **3. Restart the gateway** and run **`openclaw hybrid-mem verify [--fix]`** to confirm SQLite, LanceDB, and the embedding API. Use `--fix` to add any missing config (e.g. embedding block, nightly job) and to normalize isolated `hybrid-mem:*` cron jobs by removing explicit top-level `sessionKey` values so OpenClaw uses per-job `cron:<jobId>` session isolation. Verify also warns if **`hybrid-mem:*` cron job models** disagree with **`agents.defaults.model.primary`** (see [SESSION-DISTILLATION.md](../../docs/SESSION-DISTILLATION.md) § *Maintenance cron session isolation and model alignment*).
49
49
 
@@ -63,7 +63,11 @@ Or with npm directly: `npm i openclaw-hybrid-memory` in your OpenClaw extensions
63
63
 
64
64
  ## Agent tool names
65
65
 
66
- Every tool this plugin registers uses **underscore** names (for example `memory_store`, `memory_recall`, `memory_record_episode`). LLM providers that validate tool definitions (notably **Anthropic**) require names to match `^[a-zA-Z0-9_-]{1,128}$` — **periods are not allowed**. Do not document or prompt for dotted aliases such as `memory.store`; they are not valid in those APIs.
66
+ Every tool this plugin registers uses **underscore** names (for example `memory_store`, `memory_recall`, `memory_directory`, `memory_record_episode`). LLM providers that validate tool definitions (notably **Anthropic**) require names to match `^[a-zA-Z0-9_-]{1,128}$` — **periods are not allowed**. Do not document or prompt for dotted aliases such as `memory.store`; they are not valid in those APIs.
67
+
68
+ ## Entity layer (contacts, organizations, NER)
69
+
70
+ When **`graph.enabled`** is true, new facts are enriched asynchronously with **PERSON** and **ORG** mentions (language hint via **franc**, extraction via LLM). Data lives in SQLite (`organizations`, `contacts`, `fact_entity_mentions`, `org_fact_links`). The **`memory_directory`** tool exposes **`list_contacts`** and **`org_view`** for structured lists—use **`memory_recall`** for ranked semantic search. Backfill older facts with **`openclaw hybrid-mem enrich-entities`**. See [GRAPH-MEMORY.md](../../docs/GRAPH-MEMORY.md#person-and-organization-enrichment-entity-layer) and [MULTILINGUAL-SUPPORT.md](../../docs/MULTILINGUAL-SUPPORT.md).
67
71
 
68
72
  ## Event Bus
69
73
 
@@ -144,6 +148,31 @@ Installing at this level means Node's module resolution finds it by traversing u
144
148
 
145
149
  Then set `embedding.provider: "onnx"` in your plugin config. Models are auto-downloaded from HuggingFace on first use. `onnxruntime-node` is not listed as a dependency of this package — it is a ~513 MB optional native binary that most users do not need. The plugin detects its absence and shows a clear error if you configure the `onnx` provider without installing it.
146
150
 
151
+ ## Recall Timing Diagnostics
152
+
153
+ Set `autoRecall.recallTiming` to:
154
+
155
+ - `off` (default): no structured recall timing events
156
+ - `basic`: completed events with duration/counters
157
+ - `verbose`: started+completed events plus ISO timestamps
158
+
159
+ Example:
160
+
161
+ ```json
162
+ "autoRecall": {
163
+ "enabled": true,
164
+ "recallTiming": "basic"
165
+ }
166
+ ```
167
+
168
+ Operator workflow:
169
+
170
+ ```bash
171
+ openclaw logs --follow | rg 'memory-hybrid: recall span='
172
+ ```
173
+
174
+ The recall logs include a shared `span` plus `phase`, `event`, `duration_ms`, and counts (for example `hits`, `fts_rows`, `merged_rows`) so you can attribute latency across FTS, embedding, LanceDB/vector search, merge, and stage-level orchestration.
175
+
147
176
  ## Credits
148
177
 
149
178
  Based on the design in **[Give Your Clawdbot Permanent Memory](https://clawdboss.ai/posts/give-your-clawdbot-permanent-memory)** (Clawdboss.ai). The plugin has since been extended with auto-capture, auto-recall, decay/TTL, auto-classify, token caps, consolidation, verify/uninstall CLI, and more — see the [repo README](../../README.md) and [docs/](../../docs/).
@@ -108,6 +108,8 @@ export interface MemoryPluginAPI {
108
108
  // --- Refs (lifecycle / degradation) ---
109
109
  restartPendingClearedRef: { value: boolean };
110
110
  recallInFlightRef: { value: number };
111
+ /** Last prompt used for before_agent_start recall; used to re-match memories after compaction (#957). */
112
+ lastAutoRecallPromptRef: { value: string | null };
111
113
 
112
114
  // --- WAL & search (raw; caller binds wal where needed) ---
113
115
  walWrite: WalWriteFn;
@@ -97,6 +97,8 @@ export interface PluginRuntime {
97
97
  restartPendingClearedRef: { value: boolean };
98
98
  /** Count of in-flight recall operations (degradation / back-pressure). */
99
99
  recallInFlightRef: { value: number };
100
+ /** Last user prompt used for interactive auto-recall (issue #957 post-compaction reinjection). */
101
+ lastAutoRecallPromptRef: { value: string | null };
100
102
  /** Last progressive index fact IDs (1-based position → fact id). */
101
103
  lastProgressiveIndexIds: string[];
102
104
 
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Organizations, contacts, and NER mention persistence (#985–#987).
3
+ */
4
+
5
+ import { randomUUID } from "node:crypto";
6
+ import type { DatabaseSync } from "node:sqlite";
7
+
8
+ import { createTransaction } from "../../utils/sqlite-transaction.js";
9
+
10
+ export type EntityMentionLabel = "PERSON" | "ORG";
11
+
12
+ type FactEntityMentionRow = {
13
+ id: string;
14
+ factId: string;
15
+ label: EntityMentionLabel;
16
+ surfaceText: string;
17
+ normalizedSurface: string;
18
+ startOffset: number;
19
+ endOffset: number;
20
+ confidence: number;
21
+ detectedLang: string | null;
22
+ source: string;
23
+ contactId: string | null;
24
+ organizationId: string | null;
25
+ };
26
+
27
+ export type OrganizationRow = {
28
+ id: string;
29
+ canonicalKey: string;
30
+ displayName: string;
31
+ aliasesJson: string | null;
32
+ };
33
+
34
+ export type ContactRow = {
35
+ id: string;
36
+ normalizedKey: string;
37
+ displayName: string;
38
+ email: string | null;
39
+ notes: string | null;
40
+ aliasesJson: string | null;
41
+ primaryOrgId: string | null;
42
+ };
43
+
44
+ export function normalizeEntityKey(name: string): string {
45
+ return name.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim();
46
+ }
47
+
48
+ /** Escape `%`, `_`, and `\` for SQLite `LIKE ... ESCAPE '\'` literal matching. */
49
+ export function escapeLikeLiteralForBackslashEscape(s: string): string {
50
+ return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
51
+ }
52
+
53
+ export function migrateEntityLayerTables(db: DatabaseSync): void {
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS organizations (
56
+ id TEXT PRIMARY KEY,
57
+ canonical_key TEXT NOT NULL UNIQUE,
58
+ display_name TEXT NOT NULL,
59
+ aliases_json TEXT,
60
+ created_at INTEGER NOT NULL,
61
+ updated_at INTEGER NOT NULL
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_org_canonical ON organizations(canonical_key);
64
+ `);
65
+
66
+ db.exec(`
67
+ CREATE TABLE IF NOT EXISTS contacts (
68
+ id TEXT PRIMARY KEY,
69
+ normalized_key TEXT NOT NULL,
70
+ display_name TEXT NOT NULL,
71
+ email TEXT,
72
+ notes TEXT,
73
+ aliases_json TEXT,
74
+ primary_org_id TEXT REFERENCES organizations(id) ON DELETE SET NULL,
75
+ created_at INTEGER NOT NULL,
76
+ updated_at INTEGER NOT NULL
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_contacts_org ON contacts(primary_org_id);
79
+ `);
80
+ // One row per normalized display key (upsertContact assumes uniqueness).
81
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_normalized_key_unique ON contacts(normalized_key)");
82
+
83
+ db.exec(`
84
+ CREATE TABLE IF NOT EXISTS fact_entity_mentions (
85
+ id TEXT PRIMARY KEY,
86
+ fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
87
+ label TEXT NOT NULL,
88
+ surface_text TEXT NOT NULL,
89
+ normalized_surface TEXT NOT NULL,
90
+ start_offset INTEGER NOT NULL,
91
+ end_offset INTEGER NOT NULL,
92
+ confidence REAL NOT NULL DEFAULT 0.8,
93
+ detected_lang TEXT,
94
+ source TEXT NOT NULL DEFAULT 'llm',
95
+ contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
96
+ organization_id TEXT REFERENCES organizations(id) ON DELETE SET NULL,
97
+ created_at INTEGER NOT NULL
98
+ );
99
+ CREATE INDEX IF NOT EXISTS idx_fem_fact ON fact_entity_mentions(fact_id);
100
+ CREATE INDEX IF NOT EXISTS idx_fem_org ON fact_entity_mentions(organization_id);
101
+ CREATE INDEX IF NOT EXISTS idx_fem_contact ON fact_entity_mentions(contact_id);
102
+ CREATE INDEX IF NOT EXISTS idx_fem_label ON fact_entity_mentions(label);
103
+ `);
104
+
105
+ db.exec(`
106
+ CREATE TABLE IF NOT EXISTS org_fact_links (
107
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
108
+ fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
109
+ reason TEXT NOT NULL DEFAULT 'ner_mention',
110
+ created_at INTEGER NOT NULL,
111
+ PRIMARY KEY (org_id, fact_id, reason)
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_org_fact_org ON org_fact_links(org_id);
114
+ CREATE INDEX IF NOT EXISTS idx_org_fact_fact ON org_fact_links(fact_id);
115
+ `);
116
+ }
117
+
118
+ function upsertOrganization(db: DatabaseSync, displayName: string): { id: string; created: boolean } | null {
119
+ const canonicalKey = normalizeEntityKey(displayName);
120
+ if (!canonicalKey) {
121
+ return null;
122
+ }
123
+ const existing = db.prepare("SELECT id FROM organizations WHERE canonical_key = ?").get(canonicalKey) as
124
+ | { id: string }
125
+ | undefined;
126
+ if (existing) {
127
+ const now = Math.floor(Date.now() / 1000);
128
+ db.prepare("UPDATE organizations SET display_name = ?, updated_at = ? WHERE id = ?").run(
129
+ displayName.trim(),
130
+ now,
131
+ existing.id,
132
+ );
133
+ return { id: existing.id, created: false };
134
+ }
135
+ const id = randomUUID();
136
+ const now = Math.floor(Date.now() / 1000);
137
+ db.prepare(
138
+ `INSERT INTO organizations (id, canonical_key, display_name, aliases_json, created_at, updated_at)
139
+ VALUES (?, ?, ?, NULL, ?, ?)`,
140
+ ).run(id, canonicalKey, displayName.trim(), now, now);
141
+ return { id, created: true };
142
+ }
143
+
144
+ function upsertContact(
145
+ db: DatabaseSync,
146
+ displayName: string,
147
+ primaryOrgId: string | null,
148
+ ): { id: string; created: boolean } | null {
149
+ const nk = normalizeEntityKey(displayName);
150
+ if (!nk) {
151
+ return null;
152
+ }
153
+ const existing = db.prepare("SELECT id, primary_org_id FROM contacts WHERE normalized_key = ? LIMIT 1").get(nk) as
154
+ | { id: string; primary_org_id: string | null }
155
+ | undefined;
156
+ const now = Math.floor(Date.now() / 1000);
157
+ if (existing) {
158
+ if (primaryOrgId && !existing.primary_org_id) {
159
+ db.prepare("UPDATE contacts SET primary_org_id = ?, updated_at = ?, display_name = ? WHERE id = ?").run(
160
+ primaryOrgId,
161
+ now,
162
+ displayName.trim(),
163
+ existing.id,
164
+ );
165
+ } else {
166
+ db.prepare("UPDATE contacts SET updated_at = ?, display_name = ? WHERE id = ?").run(
167
+ now,
168
+ displayName.trim(),
169
+ existing.id,
170
+ );
171
+ }
172
+ return { id: existing.id, created: false };
173
+ }
174
+ const id = randomUUID();
175
+ db.prepare(
176
+ `INSERT INTO contacts (id, normalized_key, display_name, email, notes, aliases_json, primary_org_id, created_at, updated_at)
177
+ VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, ?)`,
178
+ ).run(id, nk, displayName.trim(), primaryOrgId, now, now);
179
+ return { id, created: true };
180
+ }
181
+
182
+ export function replaceFactEntityMentions(
183
+ db: DatabaseSync,
184
+ factId: string,
185
+ mentions: Array<{
186
+ label: EntityMentionLabel;
187
+ surfaceText: string;
188
+ normalizedSurface: string;
189
+ startOffset: number;
190
+ endOffset: number;
191
+ confidence: number;
192
+ detectedLang: string | null;
193
+ source: string;
194
+ }>,
195
+ ): void {
196
+ const tx = createTransaction(db, () => {
197
+ db.prepare("DELETE FROM fact_entity_mentions WHERE fact_id = ?").run(factId);
198
+ db.prepare("DELETE FROM org_fact_links WHERE fact_id = ? AND reason = 'ner_mention'").run(factId);
199
+
200
+ const now = Math.floor(Date.now() / 1000);
201
+ const ins = db.prepare(
202
+ `INSERT INTO fact_entity_mentions (
203
+ id, fact_id, label, surface_text, normalized_surface, start_offset, end_offset,
204
+ confidence, detected_lang, source, contact_id, organization_id, created_at
205
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
206
+ );
207
+ const insOrgLink = db.prepare(
208
+ `INSERT OR IGNORE INTO org_fact_links (org_id, fact_id, reason, created_at) VALUES (?, ?, 'ner_mention', ?)`,
209
+ );
210
+
211
+ const orgIds: string[] = [];
212
+ const personRows: Array<{ surface: string; contactId: string }> = [];
213
+
214
+ for (const m of mentions) {
215
+ let contactId: string | null = null;
216
+ let organizationId: string | null = null;
217
+
218
+ if (m.label === "ORG") {
219
+ const org = upsertOrganization(db, m.surfaceText);
220
+ if (org) {
221
+ organizationId = org.id;
222
+ orgIds.push(org.id);
223
+ insOrgLink.run(org.id, factId, now);
224
+ }
225
+ } else if (m.label === "PERSON") {
226
+ const con = upsertContact(db, m.surfaceText, null);
227
+ if (con) {
228
+ contactId = con.id;
229
+ personRows.push({ surface: m.surfaceText, contactId: con.id });
230
+ }
231
+ }
232
+
233
+ ins.run(
234
+ randomUUID(),
235
+ factId,
236
+ m.label,
237
+ m.surfaceText,
238
+ m.normalizedSurface,
239
+ m.startOffset,
240
+ m.endOffset,
241
+ m.confidence,
242
+ m.detectedLang,
243
+ m.source,
244
+ contactId,
245
+ organizationId,
246
+ now,
247
+ );
248
+ }
249
+
250
+ // If same fact mentions both a person and an org, set primary_org on contacts (weak v1 heuristic).
251
+ if (orgIds.length > 0 && personRows.length > 0) {
252
+ const primaryOrg = orgIds[0];
253
+ for (const p of personRows) {
254
+ db.prepare("UPDATE contacts SET primary_org_id = COALESCE(primary_org_id, ?), updated_at = ? WHERE id = ?").run(
255
+ primaryOrg,
256
+ now,
257
+ p.contactId,
258
+ );
259
+ }
260
+ }
261
+
262
+ db.prepare("UPDATE facts SET entity_enrichment_at = ? WHERE id = ?").run(now, factId);
263
+ });
264
+ tx();
265
+ }
266
+
267
+ export function getOrganizationByKeyOrName(db: DatabaseSync, query: string): OrganizationRow | null {
268
+ const nk = normalizeEntityKey(query);
269
+ if (!nk) return null;
270
+ const byKey = db.prepare("SELECT * FROM organizations WHERE canonical_key = ?").get(nk) as
271
+ | Record<string, unknown>
272
+ | undefined;
273
+ if (byKey) return rowToOrg(byKey);
274
+ const like = `%${escapeLikeLiteralForBackslashEscape(nk)}%`;
275
+ const byName = db
276
+ .prepare(
277
+ "SELECT * FROM organizations WHERE canonical_key LIKE ? ESCAPE '\\' ORDER BY length(display_name) ASC LIMIT 1",
278
+ )
279
+ .get(like) as Record<string, unknown> | undefined;
280
+ return byName ? rowToOrg(byName) : null;
281
+ }
282
+
283
+ function rowToOrg(row: Record<string, unknown>): OrganizationRow {
284
+ return {
285
+ id: row.id as string,
286
+ canonicalKey: row.canonical_key as string,
287
+ displayName: row.display_name as string,
288
+ aliasesJson: (row.aliases_json as string | null) ?? null,
289
+ };
290
+ }
291
+
292
+ export function listContactsForOrg(db: DatabaseSync, orgId: string, limit: number): ContactRow[] {
293
+ const rows = db
294
+ .prepare(
295
+ `SELECT * FROM contacts
296
+ WHERE primary_org_id = ?
297
+ ORDER BY display_name COLLATE NOCASE
298
+ LIMIT ?`,
299
+ )
300
+ .all(orgId, limit) as Array<Record<string, unknown>>;
301
+ return rows.map(rowToContact);
302
+ }
303
+
304
+ export function listContactsByNamePrefix(db: DatabaseSync, prefix: string, limit: number): ContactRow[] {
305
+ const p = prefix.trim().toLowerCase();
306
+ if (!p) {
307
+ const rows = db.prepare(`SELECT * FROM contacts ORDER BY display_name COLLATE NOCASE LIMIT ?`).all(limit) as Array<
308
+ Record<string, unknown>
309
+ >;
310
+ return rows.map(rowToContact);
311
+ }
312
+ const esc = escapeLikeLiteralForBackslashEscape(p);
313
+ const pat = `${esc}%`;
314
+ const normalizedPrefix = normalizeEntityKey(prefix);
315
+ const escNormalized = escapeLikeLiteralForBackslashEscape(normalizedPrefix);
316
+ const patNormalized = `${escNormalized}%`;
317
+ const rows = db
318
+ .prepare(
319
+ `SELECT * FROM contacts
320
+ WHERE lower(display_name) LIKE ? ESCAPE '\\' OR normalized_key LIKE ? ESCAPE '\\'
321
+ ORDER BY display_name COLLATE NOCASE
322
+ LIMIT ?`,
323
+ )
324
+ .all(pat, patNormalized, limit) as Array<Record<string, unknown>>;
325
+ return rows.map(rowToContact);
326
+ }
327
+
328
+ function rowToContact(row: Record<string, unknown>): ContactRow {
329
+ return {
330
+ id: row.id as string,
331
+ normalizedKey: row.normalized_key as string,
332
+ displayName: row.display_name as string,
333
+ email: (row.email as string | null) ?? null,
334
+ notes: (row.notes as string | null) ?? null,
335
+ aliasesJson: (row.aliases_json as string | null) ?? null,
336
+ primaryOrgId: (row.primary_org_id as string | null) ?? null,
337
+ };
338
+ }
339
+
340
+ export function listFactIdsForOrg(db: DatabaseSync, orgId: string, limit: number): string[] {
341
+ const rows = db
342
+ .prepare(`SELECT DISTINCT fact_id FROM org_fact_links WHERE org_id = ? ORDER BY created_at DESC LIMIT ?`)
343
+ .all(orgId, limit) as Array<{ fact_id: string }>;
344
+ return rows.map((r) => r.fact_id);
345
+ }
346
+
347
+ export function listFactsNeedingEnrichment(db: DatabaseSync, limit: number, minTextLen: number): string[] {
348
+ const rows = db
349
+ .prepare(
350
+ `SELECT f.id FROM facts f
351
+ WHERE f.superseded_at IS NULL
352
+ AND length(f.text) >= ?
353
+ AND f.entity_enrichment_at IS NULL
354
+ ORDER BY f.created_at DESC
355
+ LIMIT ?`,
356
+ )
357
+ .all(minTextLen, limit) as Array<{ id: string }>;
358
+ return rows.map((r) => r.id);
359
+ }
@@ -63,6 +63,17 @@ import {
63
63
  migrateScanCursorsTable as migrateScanCursorsTableHelper,
64
64
  updateScanCursor as updateScanCursorHelper,
65
65
  } from "./facts-db/scan-cursors.js";
66
+ import {
67
+ replaceFactEntityMentions,
68
+ getOrganizationByKeyOrName as lookupOrganizationByKeyOrName,
69
+ listContactsForOrg as entityLayerListContactsForOrg,
70
+ listContactsByNamePrefix as entityLayerListContactsByNamePrefix,
71
+ listFactIdsForOrg as entityLayerListFactIdsForOrg,
72
+ listFactsNeedingEnrichment as entityLayerListFactsNeedingEnrichment,
73
+ type ContactRow,
74
+ type OrganizationRow,
75
+ } from "./facts-db/entity-layer.js";
76
+ import type { ExtractedMention } from "../services/entity-enrichment.js";
66
77
  export {
67
78
  MEMORY_LINK_TYPES,
68
79
  type MemoryLinkType,
@@ -5441,4 +5452,49 @@ export class FactsDB extends BaseSqliteStore {
5441
5452
  return 0;
5442
5453
  }
5443
5454
  }
5455
+
5456
+ // --- Entity layer: NER mentions, organizations, contacts (#985–#987) ---
5457
+
5458
+ /** Replace stored NER rows for a fact (typically after LLM extraction). */
5459
+ applyEntityEnrichment(factId: string, mentions: ExtractedMention[], detectedLang: string): void {
5460
+ replaceFactEntityMentions(
5461
+ this.liveDb,
5462
+ factId,
5463
+ mentions.map((m) => ({
5464
+ label: m.label,
5465
+ surfaceText: m.surfaceText,
5466
+ normalizedSurface: m.normalizedSurface,
5467
+ startOffset: m.startOffset,
5468
+ endOffset: m.endOffset,
5469
+ confidence: m.confidence,
5470
+ detectedLang,
5471
+ source: "llm",
5472
+ })),
5473
+ );
5474
+ }
5475
+
5476
+ /** Resolve an organization by canonical key or fuzzy display name. */
5477
+ lookupOrganization(query: string): OrganizationRow | null {
5478
+ return lookupOrganizationByKeyOrName(this.liveDb, query);
5479
+ }
5480
+
5481
+ /** Contacts with primary_org_id = org. */
5482
+ listContactsForOrganization(orgId: string, limit: number): ContactRow[] {
5483
+ return entityLayerListContactsForOrg(this.liveDb, orgId, limit);
5484
+ }
5485
+
5486
+ /** List contacts by optional name prefix (empty = recent alphabetical cap). */
5487
+ listContactsByNamePrefix(prefix: string, limit: number): ContactRow[] {
5488
+ return entityLayerListContactsByNamePrefix(this.liveDb, prefix, limit);
5489
+ }
5490
+
5491
+ /** Fact ids linked to an org via NER/org_fact_links. */
5492
+ listFactIdsLinkedToOrg(orgId: string, limit: number): string[] {
5493
+ return entityLayerListFactIdsForOrg(this.liveDb, orgId, limit);
5494
+ }
5495
+
5496
+ /** Facts not yet processed by entity enrichment (see `facts.entity_enrichment_at`). */
5497
+ listFactIdsNeedingEntityEnrichment(limit: number, minTextLen = 24): string[] {
5498
+ return entityLayerListFactsNeedingEnrichment(this.liveDb, limit, minTextLen);
5499
+ }
5444
5500
  }
@@ -1,6 +1,7 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
2
  import { createTransaction } from "../../utils/sqlite-transaction.js";
3
3
  import { normalizedHash } from "../../utils/tags.js";
4
+ import { migrateEntityLayerTables } from "../facts-db/entity-layer.js";
4
5
  /**
5
6
  * Procedure feedback loop — version tracking and failure logging (#782).
6
7
  * procedure_versions: per-version success/failure counts and avoidance notes.
@@ -1101,6 +1102,27 @@ export function runFactsMigrations(db: DatabaseSync): void {
1101
1102
 
1102
1103
  // Token budget trim: index-backed ordering (Issue #838)
1103
1104
  migrateTrimBudgetIndex(db);
1105
+
1106
+ // Contacts, organizations, NER mentions (#985–#987)
1107
+ migrateEntityLayerTables(db);
1108
+ migrateFactsEntityEnrichmentAt(db);
1109
+ }
1110
+
1111
+ /** Track completion of NER/contact-org enrichment per fact (avoids re-LLM when zero entities). */
1112
+ function migrateFactsEntityEnrichmentAt(db: DatabaseSync): void {
1113
+ const cols = db.prepare("PRAGMA table_info(facts)").all() as Array<{ name: string }>;
1114
+ if (!cols.some((c) => c.name === "entity_enrichment_at")) {
1115
+ db.exec("ALTER TABLE facts ADD COLUMN entity_enrichment_at INTEGER");
1116
+ }
1117
+ db.exec(`
1118
+ UPDATE facts
1119
+ SET entity_enrichment_at = COALESCE(
1120
+ (SELECT MIN(created_at) FROM fact_entity_mentions WHERE fact_id = facts.id),
1121
+ created_at
1122
+ )
1123
+ WHERE entity_enrichment_at IS NULL
1124
+ AND EXISTS (SELECT 1 FROM fact_entity_mentions m WHERE m.fact_id = facts.id)
1125
+ `);
1104
1126
  }
1105
1127
 
1106
1128
  /** Supports SQL ORDER BY for trimToBudget without full in-memory sort of all facts (Issue #838). */
package/cli/cmd-config.ts CHANGED
@@ -12,7 +12,13 @@ import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { homedir } from "node:os";
14
14
 
15
- import { hybridConfigSchema, PRESET_OVERRIDES, type ConfigMode } from "../config.js";
15
+ import {
16
+ getCronModelConfig,
17
+ getLLMModelPreference,
18
+ hybridConfigSchema,
19
+ PRESET_OVERRIDES,
20
+ type ConfigMode,
21
+ } from "../config.js";
16
22
  import { capturePluginError } from "../services/error-reporter.js";
17
23
  import { PLUGIN_ID, getRestartPendingPath } from "../utils/constants.js";
18
24
  import type { HandlerContext } from "./handlers.js";
@@ -73,18 +79,29 @@ function setNested(obj: Record<string, unknown>, path: string, value: unknown):
73
79
  if (last === "__proto__" || last === "constructor" || last === "prototype") {
74
80
  return false;
75
81
  }
76
- const v =
77
- value === "true" || value === "enabled"
78
- ? true
79
- : value === "false" || value === "disabled"
80
- ? false
81
- : value === "null"
82
- ? null
83
- : /^-?\d+$/.test(String(value))
84
- ? Number.parseInt(String(value), 10)
85
- : /^-?\d*\.\d+$/.test(String(value))
86
- ? Number.parseFloat(String(value))
87
- : value;
82
+ const rawStr = String(value).trim();
83
+ let v: unknown = value;
84
+ if ((rawStr.startsWith("[") && rawStr.endsWith("]")) || (rawStr.startsWith("{") && rawStr.endsWith("}"))) {
85
+ try {
86
+ v = JSON.parse(rawStr) as unknown;
87
+ } catch {
88
+ v = value;
89
+ }
90
+ }
91
+ if (v === value && typeof v === "string") {
92
+ v =
93
+ v === "true" || v === "enabled"
94
+ ? true
95
+ : v === "false" || v === "disabled"
96
+ ? false
97
+ : v === "null"
98
+ ? null
99
+ : /^-?\d+$/.test(v)
100
+ ? Number.parseInt(v, 10)
101
+ : /^-?\d*\.\d+$/.test(v)
102
+ ? Number.parseFloat(v)
103
+ : v;
104
+ }
88
105
  (cur as any)[last] = v;
89
106
  return true;
90
107
  }
@@ -173,6 +190,27 @@ export function runConfigViewForCli(ctx: HandlerContext, sink: VerifyCliSink): v
173
190
  log(` Cost tracking: ${on(rawEnabled("costTracking", cfg.costTracking?.enabled ?? false))}`);
174
191
  log("");
175
192
 
193
+ log("LLM tiers (keep cheap models first; heavy only for quality-critical steps)");
194
+ try {
195
+ const cronCfg = getCronModelConfig(cfg);
196
+ const nano = getLLMModelPreference(cronCfg, "nano");
197
+ const def = getLLMModelPreference(cronCfg, "default");
198
+ const heavy = getLLMModelPreference(cronCfg, "heavy");
199
+ const fmt = (arr: string[]) =>
200
+ arr.length === 0 ? "—" : arr.length === 1 ? arr[0] : `${arr[0]} (+${arr.length - 1} more)`;
201
+ log(` nano (HyDE, classify, summarize): ${fmt(nano)}`);
202
+ log(` default (maintenance, dream cycle if nightlyCycle.model unset): ${fmt(def)}`);
203
+ log(` heavy (distill, self-correction, hard tasks): ${fmt(heavy)}`);
204
+ if (cfg.nightlyCycle?.model?.trim()) {
205
+ log(` nightlyCycle.model (overrides dream / MEMORY_INDEX LLM): ${cfg.nightlyCycle.model.trim()}`);
206
+ }
207
+ const extTier = cfg.distill?.extractionModelTier ?? "default";
208
+ log(` distill.extractionModelTier (session extraction): ${extTier}`);
209
+ } catch {
210
+ log(" (could not resolve tiers — check plugin config)");
211
+ }
212
+ log("");
213
+
176
214
  log("Advanced");
177
215
  log(` Query expansion: ${on(cfg.queryExpansion.enabled)}`);
178
216
  log(` Retrieval directives: ${on(cfg.autoRecall.retrievalDirectives?.enabled ?? false)}`);
@@ -191,6 +229,7 @@ export function runConfigViewForCli(ctx: HandlerContext, sink: VerifyCliSink): v
191
229
 
192
230
  log("To change a setting: openclaw hybrid-mem config-set <key> <value>");
193
231
  log("Example (toggle): openclaw hybrid-mem config-set nightlyCycle enabled");
232
+ log("Example (LLM tier lists as JSON): openclaw hybrid-mem config-set llm.nano '[\"azure-foundry/gpt-4.1-nano\"]'");
194
233
  log("Help for a key: openclaw hybrid-mem help config-set <key>");
195
234
  }
196
235