@unblocklabs/unblock-memory 0.3.22 → 0.3.23

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.
@@ -1,9 +1,9 @@
1
1
  import { Type, type Static } from "typebox";
2
- export declare const PERSON_DOSSIER_SCHEMA: Type.TObject<{
2
+ declare const PERSON_DOSSIER_SCHEMA: Type.TObject<{
3
3
  schemaVersion: Type.TLiteral<1>;
4
4
  blurb: Type.TString;
5
5
  sections: Type.TArray<Type.TObject<{
6
- category: Type.TUnion<Type.TLiteral<"role" | "priorities" | "preferences" | "successCriteria" | "workingStyle" | "relationship" | "openLoops">[]>;
6
+ category: Type.TEnum<["role", "priorities", "preferences", "successCriteria", "workingStyle", "relationship", "openLoops"]>;
7
7
  claims: Type.TArray<Type.TObject<{
8
8
  statement: Type.TString;
9
9
  evidence: Type.TArray<Type.TObject<{
@@ -16,6 +16,23 @@ export declare const PERSON_DOSSIER_SCHEMA: Type.TObject<{
16
16
  }>>;
17
17
  }>>;
18
18
  }>;
19
+ export declare const PERSON_DOSSIER_WRITE_SCHEMA: Type.TObject<{
20
+ sections: Type.TArray<Type.TObject<{
21
+ category: Type.TEnum<["role", "relationship"]>;
22
+ claims: Type.TArray<Type.TObject<{
23
+ epistemicType: Type.TEnum<["observed", "reported"]>;
24
+ statement: Type.TString;
25
+ evidence: Type.TArray<Type.TObject<{
26
+ source: Type.TUnion<[Type.TLiteral<"session">, Type.TLiteral<"memory">, Type.TLiteral<"directory">, Type.TLiteral<"manual">]>;
27
+ locator: Type.TString;
28
+ observedAt: Type.TOptional<Type.TString>;
29
+ }>>;
30
+ confidence: Type.TOptional<Type.TUnion<[Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">]>>;
31
+ }>>;
32
+ }>>;
33
+ schemaVersion: Type.TLiteral<1>;
34
+ blurb: Type.TString;
35
+ }>;
19
36
  export type PersonDossier = Static<typeof PERSON_DOSSIER_SCHEMA>;
20
37
  export declare class DossierConflictError extends Error {
21
38
  constructor();
@@ -118,7 +135,23 @@ export declare class PeopleStore {
118
135
  listActivePeople(limit?: number, offset?: number): Person[];
119
136
  findIdentity(provider: string, accountScope: string, externalId: string): PersonIdentity | undefined;
120
137
  setInjection(personId: string, enabled: boolean): Person | undefined;
121
- validateDossier(input: unknown): PersonDossier;
138
+ validateDossier(input: unknown): {
139
+ schemaVersion: 1;
140
+ blurb: string;
141
+ sections: {
142
+ category: "role" | "relationship";
143
+ claims: {
144
+ confidence?: "low" | "medium" | "high" | undefined;
145
+ statement: string;
146
+ evidence: {
147
+ observedAt?: string | undefined;
148
+ source: "memory" | "manual" | "session" | "directory";
149
+ locator: string;
150
+ }[];
151
+ epistemicType: "observed" | "reported";
152
+ }[];
153
+ }[];
154
+ };
122
155
  getDossierRevision(personId: string): string | null;
123
156
  replaceDossier(personId: string, reasonInput: string, input: unknown, expectedRevision?: string | null): PersonDossier;
124
157
  deleteDossier(personId: string, reasonInput: string): boolean;
@@ -165,3 +198,4 @@ export declare class PeopleStores {
165
198
  get(agentId: string): PeopleStore;
166
199
  closeAll(): void;
167
200
  }
201
+ export {};
@@ -39,14 +39,25 @@ const claimSchema = Type.Object({
39
39
  ]),
40
40
  confidence: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")])),
41
41
  }, { additionalProperties: false });
42
- export const PERSON_DOSSIER_SCHEMA = Type.Object({
42
+ // Keep the broad schema for legacy dossier and history reads only.
43
+ const PERSON_DOSSIER_SCHEMA = Type.Object({
43
44
  schemaVersion: Type.Literal(1),
44
45
  blurb: Type.String({ minLength: 1, pattern: "\\S" }),
45
46
  sections: Type.Array(Type.Object({
46
- category: Type.Union(BASELINE_DOSSIER_CATEGORIES.map((category) => Type.Literal(category))),
47
+ category: Type.Enum(BASELINE_DOSSIER_CATEGORIES),
47
48
  claims: Type.Array(claimSchema, { minItems: 1, maxItems: 100 }),
48
49
  }, { additionalProperties: false }), { maxItems: BASELINE_DOSSIER_CATEGORIES.length }),
49
50
  }, { additionalProperties: false });
51
+ export const PERSON_DOSSIER_WRITE_SCHEMA = Type.Object({
52
+ ...PERSON_DOSSIER_SCHEMA.properties,
53
+ sections: Type.Array(Type.Object({
54
+ category: Type.Enum(["role", "relationship"]),
55
+ claims: Type.Array(Type.Object({
56
+ ...claimSchema.properties,
57
+ epistemicType: Type.Enum(["observed", "reported"]),
58
+ }, { additionalProperties: false }), { minItems: 1, maxItems: 100 }),
59
+ }, { additionalProperties: false }), { maxItems: 2 }),
60
+ }, { additionalProperties: false });
50
61
  export class DossierConflictError extends Error {
51
62
  constructor() { super("Dossier or person changed during review; inspect again before retrying"); }
52
63
  }
@@ -243,6 +254,15 @@ export class PeopleStore {
243
254
  WHERE provider = ? AND account_scope = ? AND external_id = ?
244
255
  `)
245
256
  .run(optional(input.displayName), optional(input.realName), optional(input.handle), optional(input.avatarUrl), optional(input.title), input.isBot === undefined ? null : Number(input.isBot), input.isDeactivated === undefined ? null : Number(input.isDeactivated), now, input.syncedAt ?? null, provider, accountScope, externalId);
257
+ const displayName = [optional(input.displayName), optional(input.realName)]
258
+ .find(name => name !== null && name !== externalId);
259
+ if (displayName) {
260
+ // Repair generated ID placeholders without renaming an established person.
261
+ this.#db.prepare(`
262
+ UPDATE people SET display_name = ?, updated_at = ?
263
+ WHERE id = ? AND display_name = ? AND preferred_name IS NULL
264
+ `).run(displayName, now, personId, externalId);
265
+ }
246
266
  if (!directorySync) {
247
267
  this.#db
248
268
  .prepare("UPDATE people SET last_seen_at = ?, updated_at = ? WHERE id = ?")
@@ -363,7 +383,7 @@ export class PeopleStore {
363
383
  return row ? person(row) : undefined;
364
384
  }
365
385
  validateDossier(input) {
366
- const dossier = Value.Parse(PERSON_DOSSIER_SCHEMA, input);
386
+ const dossier = Value.Parse(PERSON_DOSSIER_WRITE_SCHEMA, input);
367
387
  this.#validateDossier(dossier);
368
388
  serializeDossier(dossier);
369
389
  return dossier;
@@ -728,12 +748,6 @@ export class PeopleStore {
728
748
  if (backgroundWordCount(dossier.blurb) > PEOPLE_BACKGROUND_MAX_WORDS) {
729
749
  throw new Error(`dossier blurb must not exceed ${PEOPLE_BACKGROUND_MAX_WORDS} words`);
730
750
  }
731
- if (categories.some(category => category !== "role" && category !== "relationship")) {
732
- throw new Error("New dossiers support only role and relationship background; rewrite legacy behavioral profiles");
733
- }
734
- if (dossier.sections.some(section => section.claims.some(claim => claim.epistemicType === "inferred" || claim.epistemicType === "agent_assessment"))) {
735
- throw new Error("Background claims must be explicit observed or reported facts, not inferred profiles");
736
- }
737
751
  }
738
752
  #migrate() {
739
753
  this.#db.exec("BEGIN IMMEDIATE");
@@ -2,7 +2,7 @@ import { jsonResult } from "openclaw/plugin-sdk/agent-runtime";
2
2
  import { Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
  import { renderPeopleWhisper } from "./people-hooks.js";
5
- import { DossierConflictError, PERSON_DOSSIER_SCHEMA } from "./people-store.js";
5
+ import { DossierConflictError, PERSON_DOSSIER_WRITE_SCHEMA } from "./people-store.js";
6
6
  import { getContext } from "./tool-context.js";
7
7
  import { reviewPersonDossier } from "./people-dossier-review.js";
8
8
  import { createOpenClawSlackDirectory, syncSlackDirectory, } from "./slack-directory.js";
@@ -77,7 +77,7 @@ const updateParameters = Type.Union([
77
77
  Type.Object({
78
78
  action: Type.Literal("replace_dossier"),
79
79
  personId: nonEmpty,
80
- dossier: PERSON_DOSSIER_SCHEMA,
80
+ dossier: PERSON_DOSSIER_WRITE_SCHEMA,
81
81
  reason: Type.String({ pattern: "\\S", maxLength: 500 }),
82
82
  agentName: Type.Optional(Type.String({ pattern: "\\S", maxLength: 100 })),
83
83
  manualVerification: Type.Optional(Type.String({ pattern: "\\S", maxLength: 400,
@@ -144,7 +144,7 @@ function createInspectTool(stores, config, ctx) {
144
144
  return {
145
145
  name: "memory_people_inspect",
146
146
  label: "Inspect People Memory",
147
- description: "List active people, inspect one person, read dossier change history, or list actionable people todos.",
147
+ description: "List active people, inspect one person, read dossier change history, or list actionable people todos. injectionEligible is a record-level preview, not proof that global hooks or an already-served thread will inject.",
148
148
  parameters: inspectParameters,
149
149
  async execute(_toolCallId, raw) {
150
150
  const input = Value.Parse(inspectParameters, raw);
@@ -198,7 +198,7 @@ function createUpdateTool(stores, config, runtime, ctx) {
198
198
  return {
199
199
  name: "memory_people_update",
200
200
  label: "Update People Memory",
201
- description: "Replace a background-only dossier (blurb <=70 words, role/relationship sections, observed/reported facts). Automatically reviews the blurb against claim evidence qmd://path#Lstart-Lend before saving; blocked/unavailable reviews leave it unchanged. Use explicit manualVerification only after verifying original sources yourself. Also deletes dossiers or updates injection, company, todo and person status.",
201
+ description: "Replace a background-only dossier (blurb <=70 words, role/relationship sections, observed/reported facts). Uses peoplePrimer approval to review the blurb against claim evidence qmd://path#Lstart-Lend before saving; blocked/unavailable reviews leave it unchanged. Use explicit manualVerification only after verifying original sources yourself. Also deletes dossiers or updates injection, company, todo and person status. Restoring a person leaves injection off; dossier changes do not reset thread receipts.",
202
202
  parameters: updateParameters,
203
203
  async execute(_toolCallId, raw, signal) {
204
204
  const input = Value.Parse(updateParameters, raw);
@@ -271,7 +271,7 @@ function createSyncTool(stores, reader, ctx) {
271
271
  return {
272
272
  name: "memory_people_sync",
273
273
  label: "Sync Slack People",
274
- description: "Manually enrich this agent's people store from one OpenClaw-authenticated Slack directory account.",
274
+ description: "Manually enrich this agent's people store from one OpenClaw-authenticated Slack directory account (users:read). At most 200 entries from the directory start, without a continuation cursor. Skips unavailable people; deactivation disables the linked person and injection.",
275
275
  parameters: syncParameters,
276
276
  async execute(_toolCallId, raw) {
277
277
  const input = Value.Parse(syncParameters, raw);
@@ -16,26 +16,30 @@ import { registerReviewTools } from "./review-tools.js";
16
16
  import { registerResponseAudit } from "./response-runtime.js";
17
17
  const searchParameters = Type.Object({
18
18
  query: Type.String({ pattern: "\\S" }),
19
- corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), { minItems: 1 })),
19
+ corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), {
20
+ minItems: 1, description: 'Configured corpus names; default is all non-skill corpora. Use ["all"] alone for explicit all-corpora recall.',
21
+ })),
20
22
  sessionFilter: Type.Optional(Type.Object({
21
23
  startedFrom: Type.Optional(Type.String({
24
+ description: "Inclusive lower bound on session start time, not message or claim dates (ISO 8601).",
22
25
  pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$",
23
26
  })),
24
27
  startedTo: Type.Optional(Type.String({
28
+ description: "Inclusive upper bound on session start time, not message or claim dates (ISO 8601).",
25
29
  pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$",
26
30
  })),
27
31
  provider: Type.Optional(Type.String({ pattern: "\\S" })),
28
32
  chatType: Type.Optional(Type.Union([Type.Literal("channel"), Type.Literal("group"), Type.Literal("direct")])),
29
33
  accountId: Type.Optional(Type.String({ pattern: "\\S" })),
30
34
  conversationId: Type.Optional(Type.String({ pattern: "\\S" })),
31
- }, { additionalProperties: false })),
32
- maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
33
- minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
35
+ }, { additionalProperties: false, description: "Restricts session documents only; selected file corpora remain eligible. Not an audience access control." })),
36
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Maximum hits; default 5." })),
37
+ minScore: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum vector similarity; default 0.3. Not confidence in factual truth." })),
34
38
  }, { additionalProperties: false });
35
39
  const getParameters = Type.Object({
36
40
  path: Type.String({ pattern: "\\S" }),
37
- from: Type.Optional(Type.Integer({ minimum: 1 })),
38
- lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
41
+ from: Type.Optional(Type.Integer({ minimum: 1, description: "First source line, 1-based; default 1. Use nextFrom from a truncated read to continue." })),
42
+ lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000, description: "Requested lines; default 120, also bounded by 12,000 content characters." })),
39
43
  }, { additionalProperties: false });
40
44
  const syncSessionsParameters = Type.Object({
41
45
  force: Type.Optional(Type.Boolean()),
@@ -48,7 +52,7 @@ function createSearchTool(runtime, ctx) {
48
52
  return {
49
53
  name: "memory_search",
50
54
  label: "Memory Search",
51
- description: "Search configured memory corpora with semantic vector retrieval. The isolated skills corpus is never included.",
55
+ description: "Search this agent's configured memory corpora with local vector retrieval, not QMD's hybrid query. Skills are excluded. Results are evidence leads; inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
52
56
  parameters: searchParameters,
53
57
  async execute(_toolCallId, params, signal) {
54
58
  const { query: untrimmedQuery, corpora, sessionFilter, maxResults, minScore, } = Value.Parse(searchParameters, params);
@@ -86,7 +90,7 @@ function createGetTool(runtime, ctx) {
86
90
  return {
87
91
  name: "memory_get",
88
92
  label: "Memory Get",
89
- description: "Read an exact qmd:// path returned by memory_search.",
93
+ description: "Read an exact indexed qmd:// source path returned by memory tools. Defaults to 120 lines, bounded to 12,000 content characters. Check truncated/nextFrom and continue when present; not_found or unavailable is not a successful empty read.",
90
94
  parameters: getParameters,
91
95
  async execute(_toolCallId, params) {
92
96
  const { path: untrimmedPath, from, lines } = Value.Parse(getParameters, params);
@@ -427,7 +431,7 @@ export function registerUnblockMemory(api) {
427
431
  supportsPrivateTranscriptRecall: false,
428
432
  promptBuilder: ({ availableTools }) => availableTools.has("memory_search")
429
433
  ? [
430
- "Use memory_search for relevant past facts, then memory_get when more surrounding context is needed.",
434
+ "Use memory_search for relevant past facts, then memory_get to verify source context, attribution and dates. Follow read continuation when present. Empty search is not proof of absence; historical memory is not current authorization.",
431
435
  ]
432
436
  : [],
433
437
  flushPlanResolver: resolveFlushPlan,
@@ -95,7 +95,8 @@ export async function syncSlackDirectory(params) {
95
95
  }
96
96
  try {
97
97
  const existing = params.store.findIdentity("slack", params.accountId, externalId);
98
- if (existing && params.store.getPerson(existing.personId)?.status !== "active") {
98
+ const existingPerson = existing ? params.store.getPerson(existing.personId) : undefined;
99
+ if (existing && existingPerson?.status !== "active") {
99
100
  counts.skipped += 1;
100
101
  continue;
101
102
  }
@@ -118,7 +119,7 @@ export async function syncSlackDirectory(params) {
118
119
  });
119
120
  if (result.created)
120
121
  counts.created += 1;
121
- else if (changed)
122
+ else if (changed || result.person.displayName !== existingPerson?.displayName)
122
123
  counts.updated += 1;
123
124
  else
124
125
  counts.unchanged += 1;
@@ -0,0 +1,380 @@
1
+ # Configuration
2
+
3
+ [Overview](../README.md) · [Retrieval](retrieval.md) · [People](peoplesql.md) · [Response audit](response-audit.md)
4
+
5
+ All plugin settings below belong under
6
+ `plugins.entries.unblock-memory.config`, not at the top level of OpenClaw.
7
+ Unknown keys are rejected. Restart/reload the Gateway after changing plugin
8
+ settings; a rotated credential file is reread without a restart.
9
+
10
+ ## Feature gates and fallbacks
11
+
12
+ | Feature | Required settings/dependencies | TypeSafe disabled / no key | Provider or unreadable-key failure |
13
+ | --- | --- | --- | --- |
14
+ | Ordinary search/get | Installed/enabled memory slot; configured corpora | Unchanged local retrieval | Unchanged; its own indexing/embedding errors still matter |
15
+ | Skill Whisperer | `skillWhisperer.enabled`, skills corpus, host hooks | Best local vector candidate meeting `minScore` | No hint; does not fall back |
16
+ | Memory Whisperer | `memoryWhisperer.enabled`, explicit approved corpora, host hooks | No hints | No hints |
17
+ | Complementary hints | Enabled Memory Whisperer + `complementaryHints` | No additional judgment; base hints also require a key | Keep baseline hints unless the total deadline expires |
18
+ | People store/tools | `people.enabled` | Available; automatic save review needs primer/key or verified manual alternative | Storage/inspection still available |
19
+ | People Whisperer | People + `people.whisperer.enabled`, host hooks, eligible person/dossier, no prior thread receipt | Unchanged local lookup | Unchanged local lookup |
20
+ | People Primer / automatic dossier save review | People + `peoplePrimer.enabled`, approved corpora | No judgment; save requires source-specific manual verification | No automatic save; verify/retry instead |
21
+ | Quality audit / cluster review | `qualityAudit.enabled`, approved corpora; cluster review also needs fresh analysis | No judgment | Unavailable/partial; preserve evidence and retry as documented |
22
+ | Ordinary claim review | `evidenceReview.enabled`, approved corpora | No judgment | Unavailable; no claim verified |
23
+ | Response quality/sentiment | `responseAudit.enabled`, approved humans/chat types | No inference | Unavailable; successful assessment stages stay cached |
24
+ | Clustering | Configured local `analysis.executable` | Unchanged | Unchanged; worker failures do not disable ordinary search |
25
+
26
+ Whisperers, people storage, the primer and audits default off. `typesafe.enabled` defaults true but does not
27
+ enable any feature; `sentimentEnabled` defaults true only **within an enabled
28
+ response audit**. `peoplePrimer` controls automatic dossier-save review;
29
+ `evidenceReview` is a different advisory tool. Disabling people injection does
30
+ not disable people tools/storage or erase dossiers.
31
+
32
+ ## Shared TypeSafe credentials
33
+
34
+ All plugin TypeSafe features use `plugins.entries.unblock-memory.config.typesafe`:
35
+
36
+ ```json
37
+ {
38
+ "typesafe": {
39
+ "enabled": true,
40
+ "apiKeyFile": "/absolute/path/to/secrets/unblock-memory-typesafe.env",
41
+ "timeoutMs": 1500
42
+ }
43
+ }
44
+ ```
45
+
46
+ This is a **plugin config fragment**, not a top-level OpenClaw configuration.
47
+ The key file can contain a plaintext key or dotenv entries:
48
+
49
+ ```dotenv
50
+ TYPESAFE_API_KEY="YOUR_TYPESAFE_KEY"
51
+ ```
52
+
53
+ Credentials come from inline `typesafe.apiKey`, an absolute `typesafe.apiKeyFile`,
54
+ or (when neither is configured) the Gateway process's `TYPESAFE_API_KEY` environment
55
+ variable. Configure at most one of `apiKey` and `apiKeyFile`; prefer a private file
56
+ over a secret in config. Defaults are `enabled: true` and `timeoutMs: 1500`.
57
+
58
+ The file is reread when a feature resolves credentials, so replacing its contents
59
+ does not require a Gateway restart. Restart/reload the Gateway after changing the
60
+ configured path or other plugin settings. A dotenv file is not executed as shell
61
+ code and does not change the process environment. Workspace `.env` files are not
62
+ auto-discovered, and an interactive shell's exported key need not reach a managed
63
+ Gateway service.
64
+
65
+ Missing/empty files or dotenv files without `TYPESAFE_API_KEY` count as no key.
66
+ An explicit file never falls back to an unrelated environment key. Missing or
67
+ unreadable credentials do not break normal memory functionality or Gateway startup;
68
+ the feature-specific fallback/skip behavior above applies. **Unreadable files and
69
+ provider errors are not Skill Whisperer's no-key fallback:** they suppress its hint.
70
+ Use `memory_diagnostics` for credential availability; it does not verify provider
71
+ acceptance. Keep secret files mode `600`, secret directories mode `700`, and keys
72
+ out of Git, chat, shell arguments and logs.
73
+
74
+ The plugin reads only `TYPESAFE_API_KEY` from the environment. Standalone QMD also
75
+ supports `TYPESAFE_API_KEY_FILE`; these are separate credential resolvers.
76
+ `typesafe.timeoutMs` is not a universal total deadline: People Primer and dossier
77
+ save review use `peoplePrimer.timeoutMs` per request; Memory Whisperer has its
78
+ own overall budget.
79
+
80
+ ## Example profiles
81
+
82
+ These are **alternative plugin config fragments**, not additive whole-host files.
83
+ Merge only the intended settings. If supplying `corpora`, preserve every desired
84
+ existing entry: the array replaces the default, and exactly one `memory` is required.
85
+ Use the README's host wrapper and [host controls](#host-controls) separately.
86
+
87
+ ### Sessions, including DMs explicitly
88
+
89
+ ```json
90
+ {
91
+ "corpora": [
92
+ { "name": "memory", "kind": "files", "paths": ["MEMORY.md", "USER.md", "memory/**/*.md"] },
93
+ { "name": "sessions", "kind": "sessions", "chatTypes": ["channel", "group", "direct"], "syncIntervalMinutes": 60 }
94
+ ]
95
+ }
96
+ ```
97
+
98
+ Omit `direct` when DMs should not be indexed. Start `memory_sync_sessions({})`
99
+ and inspect `memory_sync_status({})` for an immediate refresh; the scheduled first
100
+ refresh waits an interval.
101
+
102
+ ### Skill Whisperer, local only
103
+
104
+ ```json
105
+ {
106
+ "corpora": [
107
+ { "name": "memory", "kind": "files", "paths": ["MEMORY.md", "USER.md", "memory/**/*.md"] },
108
+ { "name": "skills", "kind": "skills", "paths": ["skills/**/SKILL.md", ".agents/skills/**/SKILL.md", "~/.agents/skills/**/SKILL.md", "~/.openclaw/skills/**/SKILL.md", "~/.openclaw/plugin-skills/**/SKILL.md"] }
109
+ ],
110
+ "typesafe": { "enabled": false },
111
+ "skillWhisperer": { "enabled": true }
112
+ }
113
+ ```
114
+
115
+ Select only desired skill locations. To use TypeSafe selection instead, enable
116
+ `typesafe` and configure credentials; the selected skill still is not auto-invoked.
117
+
118
+ ### Memory Whisperer over approved knowledge
119
+
120
+ ```json
121
+ {
122
+ "corpora": [
123
+ { "name": "memory", "kind": "files", "paths": ["MEMORY.md", "USER.md", "memory/**/*.md"] },
124
+ { "name": "knowledge", "kind": "files", "paths": ["knowledge/**/*.md"] }
125
+ ],
126
+ "typesafe": { "apiKeyFile": "/absolute/path/to/secrets/unblock-memory-typesafe.env" },
127
+ "memoryWhisperer": { "enabled": true, "corpora": ["knowledge"] }
128
+ }
129
+ ```
130
+
131
+ Create the private key file first. Approve only knowledge suitable for every
132
+ audience of this agent. For exact-current-session hints, configure a sessions
133
+ corpus and add `sessions` to `memoryWhisperer.corpora`; missing session identity
134
+ excludes that corpus. This does not make ordinary search current-session-only.
135
+
136
+ ### People storage, without injection
137
+
138
+ ```json
139
+ { "people": { "enabled": true, "whisperer": { "enabled": false } } }
140
+ ```
141
+
142
+ ### People injection and optional evidence primer
143
+
144
+ ```json
145
+ {
146
+ "people": { "enabled": true, "whisperer": { "enabled": true } },
147
+ "peoplePrimer": { "enabled": true, "corpora": ["memory"] },
148
+ "typesafe": { "apiKeyFile": "/absolute/path/to/secrets/unblock-memory-typesafe.env" }
149
+ }
150
+ ```
151
+
152
+ The default memory corpus exists. This separately approves its evidence for
153
+ TypeSafe; it does not create a dossier or schedule maintenance. Use the
154
+ [people workflow](peoplesql.md). Adding `sessions` to primer approval, after
155
+ configuring that corpus, approves **all indexed sessions**, unlike Memory Whisperer.
156
+
157
+ ### Optional analysis worker
158
+
159
+ ```json
160
+ { "analysis": { "executable": "/absolute/path/to/unblock-cluster/bin/unblock-memory-analysis" } }
161
+ ```
162
+
163
+ Set this only after [installing the worker](retrieval.md#memory-analysis). No
164
+ clustering or curation schedule is created. Response-audit setup and a dry-run-first
165
+ workflow are in [its own guide](response-audit.md).
166
+
167
+ ## Settings reference
168
+
169
+ The tables show resolved defaults. For source-specific entries,
170
+ `corpora[sessions]` means the array entry with `name: "sessions"`, not a literal
171
+ configuration key. The manifest schema and config resolvers are the machine
172
+ contract; these tables explain their effects.
173
+
174
+ ## Sources and base runtime
175
+
176
+ | Setting | Default | Meaning / supported range |
177
+ | --- | --- | --- |
178
+ | `corpora` | One files corpus `memory`: `MEMORY.md`, `USER.md`, `memory/**/*.md` | Explicit array replaces defaults; must contain exactly one `memory`; `all` is a search selector, not a corpus name |
179
+ | `corpora[].name` | Required for explicit entries | Unique name; `sessions` and `skills` reserved for corresponding kinds |
180
+ | `corpora[].kind` | Required for explicit entries | `files`, `sessions`, or `skills` |
181
+ | `corpora[].paths` | Required for files/skills | Nonempty exact-file/directory/glob list; workspace-relative, absolute or `~/`; directory means recursive Markdown; does not grant host write trust |
182
+ | `corpora[sessions].chatTypes` | `['channel','group']` | Nonempty subset of channel/group/direct; DMs require `direct` |
183
+ | `corpora[sessions].maxExpandedTokens` | `500` | 1–10,000; use full turn/message when it fits, otherwise preserve full matched chunk; not a total result-size hard cap |
184
+ | `corpora[sessions].syncIntervalMinutes` | `60` | 0–1,440; zero manual-only; first scheduled sync after one interval; requires running Gateway |
185
+ | `keepEmbeddingModelWarm` | `true` | Retain embedding model/context after first use; false allows five-minute idle disposal |
186
+ | `analysis.executable` | Unset | Optional absolute local worker path; enables ability to recluster, not automatic scheduling |
187
+
188
+ ## TypeSafe and whisperers
189
+
190
+ | Setting | Default | Meaning / supported range |
191
+ | --- | --- | --- |
192
+ | `typesafe.enabled` | `true` | Shared plugin provider gate; no feature is opted in merely by adding a key |
193
+ | `typesafe.apiKey` | Unset | Explicit inline key; mutually exclusive with key file; prefer file |
194
+ | `typesafe.apiKeyFile` | Unset | Absolute raw-key or dotenv file, reread at credential resolution; explicit missing file never falls back to a different key |
195
+ | `typesafe.timeoutMs` | `1500` | 1–10,000 per request for Skill/Memory Whisperer, quality/claim/cluster review and response audit; **primer and dossier save use `peoplePrimer.timeoutMs` instead** |
196
+ | `skillWhisperer.enabled` | `false` | Requires explicit skills corpus and appropriate host hook access |
197
+ | `skillWhisperer.historyMessages` | `5` | Nonnegative integer; prior visible messages used for routing |
198
+ | `skillWhisperer.minScore` | `0.5` | 0–1, **local vector fallback only**, ignored for TypeSafe shortlist admission |
199
+ | `skillWhisperer.cooldownTurns` | `10` | Nonnegative user-turn count; no fallback to weaker cooling-down alternatives |
200
+ | `memoryWhisperer.enabled` | `false` | Requires explicit approved non-skill corpora, TypeSafe key and host hooks |
201
+ | `memoryWhisperer.corpora` | `[]` | Explicit known corpus names; required nonempty when enabled; no `all` or skills |
202
+ | `memoryWhisperer.historyMessages` | `5` | 0–50, retrieval history count, not judge-history limit |
203
+ | `memoryWhisperer.minUsefulness` | `0.9` | 0–1, minimum Noul yes-probability per candidate |
204
+ | `memoryWhisperer.maxHints` | `2` | 1–2 |
205
+ | `memoryWhisperer.cooldownTurns` | `10` | 0–1,000; recently injected evidence |
206
+ | `memoryWhisperer.timeoutMs` | `3000` | 1–10,000 total whisper deadline, not just the provider timeout |
207
+ | `memoryWhisperer.complementaryHints` | `false` | Optional extra redundancy judgment; does not expand retrieval or enable the feature |
208
+
209
+ No configured plugin credential means fallback to **`TYPESAFE_API_KEY` only** in
210
+ the Gateway environment. Unlike QMD's resolver, the plugin does not read a
211
+ `TYPESAFE_API_KEY_FILE` environment variable. Do not conflate these contracts.
212
+ Missing/empty key and unreadable/erroring key are different for Skill Whisperer:
213
+ the former allows vector fallback; the latter suppresses the hint.
214
+
215
+ ## People
216
+
217
+ | Setting | Default | Meaning / supported range |
218
+ | --- | --- | --- |
219
+ | `people.enabled` | `false` | Store, Slack identity observation, people tools; independent of injection |
220
+ | `people.whisperer.enabled` | `false` | Exact-identity prompt injection; requires people enabled |
221
+ | `people.whisperer.maxChars` | `1200` | 1–4,000; also the **stored new-dossier blurb character limit even when injection is off**; independent 70-word ceiling remains |
222
+ | `people.todos.maxOpen` | `1000` | 1–10,000; bounded open data-quality todos with overflow accounting |
223
+ | `peoplePrimer.enabled` | `false` | Requires people enabled + explicit approved corpora; controls evidence primer and automatic save review |
224
+ | `peoplePrimer.corpora` | `[]` | Explicit configured non-skill evidence approvals; sessions means all indexed sessions |
225
+ | `peoplePrimer.hitsPerQuestion` | `30` | 1–40 vector results for each of three questions, before provider grading |
226
+ | `peoplePrimer.minScore` | `0.35` | 0–1 vector admission threshold |
227
+ | `peoplePrimer.minUsefulness` | `0.8` | 0.5–1; all background eligibility dimensions must pass |
228
+ | `peoplePrimer.maxEvidencePerQuestion` | `3` | 1–10 selected evidence references per question, **not** a cap on grading work |
229
+ | `peoplePrimer.timeoutMs` | `30000` | 1–60,000 per provider request, also used for draft/save review; tool's overall limit is 120 seconds |
230
+
231
+ ## Audits and advisory reviews
232
+
233
+ | Setting | Default | Meaning / supported range |
234
+ | --- | --- | --- |
235
+ | `qualityAudit.enabled` | `false` | On-demand chunk-quality and sampled-cluster review |
236
+ | `qualityAudit.corpora` | `[]` | Explicit non-skill approval; nonempty when enabled; sessions means all indexed sessions |
237
+ | `qualityAudit.minNoise` | `0.8` | 0–1 threshold for model noise flags; deterministic empty/encoding indicators have separate rules |
238
+ | `evidenceReview.enabled` | `false` | Ordinary atomic-claim review tool; does not turn on dossier save review |
239
+ | `evidenceReview.corpora` | `[]` | Explicit non-skill approval; nonempty when enabled |
240
+ | `responseAudit.enabled` | `false` | Operator-only response evaluation; requires approved senders and TypeSafe |
241
+ | `responseAudit.sentimentEnabled` | `true` | Within opted-in audit; false removes emotion questions without disabling quality judgments |
242
+ | `responseAudit.senderIds` | `[]` | Up to 50 approved Slack sender IDs; nonempty when enabled; trusted human/owner metadata also required, explicit bots excluded |
243
+ | `responseAudit.chatTypes` | `['direct']` | Nonempty approved subset of direct/group/channel |
244
+ | `responseAudit.historyMessages` | `6` | 0–20 preceding visible messages |
245
+ | `responseAudit.lookbackDays` | `30` | 1–90 days |
246
+ | `responseAudit.maxEpisodes` | `20` | 1–100 per run; a run need not clear the backlog |
247
+ | `responseAudit.intervalMinutes` | `60` | 0–1,440; zero manual-only; persisted per-agent due time, bounded catch-up |
248
+ | `responseAudit.memoryCorpora` | `[]` | Optional file-only corpus approvals for current-index memory-gap investigation; separate from response transcript approval |
249
+
250
+
251
+ ## Host controls
252
+
253
+ These are **outside** `plugins.entries.unblock-memory.config`:
254
+
255
+ - `plugins.slots.memory: "unblock-memory"` selects the memory owner. Installation,
256
+ plugin enablement and any host allowlists remain separate.
257
+ - `plugins.entries.unblock-memory.hooks.allowConversationAccess` allows the
258
+ non-bundled plugin's conversation hooks. `allowPromptInjection` controls prompt
259
+ mutation. For whisperers, configure the plugin entry with this fragment:
260
+
261
+ ```json
262
+ {
263
+ "plugins": {
264
+ "entries": {
265
+ "unblock-memory": {
266
+ "hooks": { "allowConversationAccess": true, "allowPromptInjection": true }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ ```
272
+
273
+ Host hook timeouts may bound work independently of the plugin's internal deadline.
274
+ The flags do not enable any whisperer by themselves.
275
+
276
+ Optional `memory_people_sync` may need `tools.allow`; agent skill allowlists must
277
+ include `people-whisperer` and/or `memory-curator` when used. Indexing a skill for
278
+ routing neither authorizes nor installs it.
279
+
280
+ ### Compaction memory writes
281
+
282
+ The plugin supplies OpenClaw a pre-compaction memory-flush plan unless
283
+ `agents.defaults.compaction.memoryFlush.enabled` is false. This is a
284
+ **host-triggered agent write**, not an independent plugin timer. It is separate
285
+ from session sync, all whisperers and dossier maintenance.
286
+
287
+ Its prompt writes durable information only to `memory/YYYY-MM-DD.md`, appending
288
+ if the file exists, never overwriting it or bootstrap files. When nothing merits
289
+ storage, `NO_REPLY` is appropriate. The date uses
290
+ `agents.defaults.userTimezone`, otherwise the system timezone.
291
+
292
+ Supported host settings: `enabled`, `softThresholdTokens` (default 4,000),
293
+ `forceFlushTranscriptBytes` (default 2 MiB), and optional `model`. The plugin
294
+ plan has a fixed 20,000-token reserve floor and supplies its own prompts; custom
295
+ host `memoryFlush.prompt` / `systemPrompt` are not used by this resolver.
296
+
297
+ Disable this plan with this **host config fragment**:
298
+
299
+ ```json
300
+ { "agents": { "defaults": { "compaction": { "memoryFlush": { "enabled": false } } } } }
301
+ ```
302
+
303
+ Or customize its supported thresholds and date timezone:
304
+
305
+ ```json
306
+ {
307
+ "agents": {
308
+ "defaults": {
309
+ "userTimezone": "America/New_York",
310
+ "compaction": {
311
+ "memoryFlush": { "enabled": true, "softThresholdTokens": 6000, "forceFlushTranscriptBytes": "3mb" }
312
+ }
313
+ }
314
+ }
315
+ }
316
+ ```
317
+
318
+ ### Agent and audience scope
319
+
320
+ Per-person injection state, dossier existence, availability and thread receipts
321
+ are stored state, not config switches. See [people lifecycle](peoplesql.md#injection-and-person-state).
322
+
323
+ Normal memory tools can access the agent's configured non-skill corpora. Corpus
324
+ selectors are not audience ACLs. Per-feature TypeSafe approvals constrain that
325
+ feature's remote processing, not general retrieval access. This is an agent/fleet
326
+ boundary, not multi-tenant authorization. Approve sources for the agent's audiences.
327
+
328
+ ## TypeSafe data scope
329
+
330
+ | Feature | Evidence sent when explicitly enabled/approved |
331
+ | --- | --- |
332
+ | Skill selection | Bounded visible current/recent conversation + shortlisted skill names/descriptions; not skill procedures or source-path fields |
333
+ | Memory hints | Bounded visible conversation + up to 8 complete excerpts, corpus names and relevant session dates; exact current session only for session hits |
334
+ | Complementarity | Up to 4 already-qualified excerpts for pairwise redundancy checks |
335
+ | People primer | Person identity, agent name, approved retrieved excerpts and source/session metadata; all indexed sessions eligible if approved, not just the current chat |
336
+ | Dossier save/draft review | Proposed blurb, person/agent names and 1–3 exact approved indexed evidence ranges, at most 6,000 characters total; existing dossier is not evidence |
337
+ | Chunk quality | Up to 4 complete chunks of at most 6,000 characters each per request + source kinds; no conversation or source-path fields |
338
+ | Ordinary claim review | One proposed claim + up to 3 approved indexed ranges, at most 6,000 characters total |
339
+ | Cluster review | Up to 6 eligible complete sampled chunks, each at most 2,000 characters; conclusions only concern the sample |
340
+ | Response audit | Approved visible request/answer/context/feedback and bounded later response evidence, separated by assessment stage; optional current-index whole-short-document evidence from approved file corpora |
341
+ | Standalone QMD query | Query, optional intent, selected excerpts, source paths and evaluation time; separate process/SDK credentials and collection scope |
342
+
343
+ Omitting tool-result/thinking/system fields does not remove their content if it
344
+ was quoted in ordinary visible text. Provider judgments are advisory; probability
345
+ or score is not proof. Enabling a feature approves only that feature's documented processing.
346
+
347
+
348
+ ## Storage, upgrades and recovery
349
+
350
+ Each agent's state lives under the configured OpenClaw state directory, normally
351
+ `~/.openclaw/agents/<agentId>/unblock-memory/`. `index.sqlite` is rebuildable;
352
+ `unblock-memory.sqlite` holds durable people, curation and response-audit state.
353
+ Disabling a feature does not delete its data.
354
+
355
+ ### Durable database migration
356
+
357
+ Each agent has two active plugin databases: rebuildable `index.sqlite` and durable
358
+ `unblock-memory.sqlite`. The latter uses WAL, private permissions and component
359
+ schema versions. Store modules and tool access remain separate: consolidating files
360
+ does not expose operator response audits to memory searches or whisperers.
361
+
362
+ When upgrading from separate `curation.sqlite`, `people.sqlite` and
363
+ `response-audit.sqlite` files, **stop the Gateway and any plugin CLI writers first**.
364
+ On first durable-store access, the plugin imports all existing files, even for
365
+ disabled features, in one transaction. It includes committed WAL data, verifies
366
+ row counts/values and foreign keys, and records completion. Missing stores are
367
+ normal; unsupported or invalid data aborts the import without a partial cutover.
368
+ Restarting retries an incomplete import. QMD and transcript databases are untouched.
369
+
370
+ The old files remain untouched as **inert recovery copies**, not active stores.
371
+ Completed migration never reimports them or writes to them. Do not run old and new
372
+ plugin versions together: old writers can continue changing their separate files.
373
+ Back up the new database with SQLite's online backup API (or with all writers
374
+ stopped and WAL safely checkpointed); copying only a live `.sqlite` file is unsafe.
375
+
376
+ To roll back before any new writes, stop all writers, preserve the new database and
377
+ its WAL/SHM sidecars, and restore the old plugin against the retained legacy files.
378
+ **After new writes, those files are stale**: an old-version rollback requires an
379
+ explicit reverse data migration or accepting the loss of post-upgrade changes.
380
+ Keep recovery files until the upgrade has been verified; cleanup is a separate step.