@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21

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 (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. package/subgraph-store.ts +109 -16
@@ -0,0 +1,381 @@
1
+ /**
2
+ * totalreclaw_report_qa_bug — RC-gated tool for agent-driven QA bug reports.
3
+ *
4
+ * Only registered when the plugin version contains `-rc.` (SemVer pre-release
5
+ * token); stable builds never expose this tool. Shipped in 3.3.1-rc.3 so
6
+ * agents running the `qa-totalreclaw` skill can file structured issues to
7
+ * `p-diogo/totalreclaw-internal` via direct GitHub REST API fetch (scanner-
8
+ * safe — no shelling out to CLIs) without the maintainer opening a fresh
9
+ * issue by hand for every RC finding.
10
+ *
11
+ * See `.github/ISSUE_TEMPLATE/qa-bug.yml` in the internal repo — the
12
+ * markdown body this module renders mirrors the form-template field
13
+ * names so future automation can parse either the form or the tool
14
+ * output identically.
15
+ *
16
+ * Security: all user-supplied strings (symptom / expected / repro / logs
17
+ * / environment) run through `redactSecrets()` fail-close before the
18
+ * POST. BIP-39 phrases, API keys, Telegram bot tokens, and bearer tokens
19
+ * in headers all become `<REDACTED>` in the posted issue. Refer to
20
+ * `redactSecrets()` for the exact rule set.
21
+ *
22
+ * Target repo safety: the default target is `p-diogo/totalreclaw-internal`.
23
+ * Operators can override via the `TOTALRECLAW_QA_REPO` env var, but only
24
+ * to another slug ending in `-internal`. Any other slug — including the
25
+ * public `p-diogo/totalreclaw` — is rejected with a loud error. rc.13 QA
26
+ * surfaced a repo-slug drift where QA findings leaked to the public
27
+ * tracker; rc.14 adds this fail-loud guard.
28
+ */
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // RC-gate detection
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * True when the given version string indicates a pre-release build
36
+ * (SemVer `-rc.` or PEP-440 `rc`). Used to gate the QA bug-report tool so
37
+ * stable users never see it.
38
+ *
39
+ * Accepts:
40
+ * - `3.3.1-rc.3` → SemVer pre-release (plugin)
41
+ * - `2.3.1rc3` → PEP-440 release-candidate (Hermes-style)
42
+ * - `1.0.0-rc.1` → SemVer
43
+ *
44
+ * Rejects:
45
+ * - `3.3.1` → stable
46
+ * - `3.3.1-beta.1` → pre-release but not RC (future: might unblock beta QA)
47
+ * - `"" / null` → empty defensive
48
+ */
49
+ export function isRcBuild(version: string | null | undefined): boolean {
50
+ if (!version || typeof version !== 'string') return false;
51
+ const v = version.toLowerCase();
52
+ // SemVer: `-rc.<N>`
53
+ if (/-rc\.\d+/.test(v)) return true;
54
+ // PEP-440: `rc<N>` (no dash)
55
+ if (/\d+rc\d+/.test(v)) return true;
56
+ return false;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Redaction — fail-close
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const REDACTED = '<REDACTED>';
64
+
65
+ /**
66
+ * Redact likely secrets from free-text fields before posting to GitHub.
67
+ * Runs a sequence of patterns; order matters (longer/more-specific first).
68
+ *
69
+ * Covered:
70
+ * - BIP-39 recovery phrases (12 or 24 lowercase words, space-separated)
71
+ * - OpenAI-style `sk-` keys, Anthropic `sk-ant-` keys
72
+ * - Google-style `AIzaSy...` keys
73
+ * - Telegram bot tokens (`\d+:[A-Za-z0-9_-]{35,}`)
74
+ * - Bearer tokens in `Authorization:` headers
75
+ * - Hex auth keys (>=32 chars of hex alone on a line or after `key=`)
76
+ *
77
+ * Unknown shapes may still leak. Fail-close on the patterns we DO match,
78
+ * fail-open on patterns we don't — the agent is also instructed (via the
79
+ * SKILL.md addendum) to not pass raw secrets.
80
+ */
81
+ export function redactSecrets(text: string): string {
82
+ if (!text || typeof text !== 'string') return '';
83
+ let out = text;
84
+
85
+ // BIP-39 mnemonic — 12 or 24 lowercase alpha words separated by single
86
+ // spaces. Some test vectors use 15/18/21 words, accept those too.
87
+ //
88
+ // CAVEAT: the regex is a shape check, not a dictionary check. A line of
89
+ // 12 random English words that happen to all be lowercase will also be
90
+ // redacted — acceptable over-redaction for a bug report field.
91
+ out = out.replace(
92
+ /\b(?:[a-z]{3,10}(?:\s+[a-z]{3,10}){11,23})\b/g,
93
+ REDACTED,
94
+ );
95
+
96
+ // OpenAI / Anthropic-style `sk-...` keys. `sk-ant-api03-...` gets caught
97
+ // by the broader `sk-[A-Za-z0-9_-]{20,}` pattern below.
98
+ out = out.replace(/\bsk-[A-Za-z0-9_-]{20,}/g, REDACTED);
99
+
100
+ // Google API key: `AIzaSy` prefix + ~33 trailing chars (total 39).
101
+ // We accept 30–45 trailing chars so accidental suffixes / URL-encoded
102
+ // variants don't escape.
103
+ out = out.replace(/\bAIza[0-9A-Za-z\-_]{30,45}\b/g, REDACTED);
104
+
105
+ // Telegram bot token: `\d+:[A-Za-z0-9_-]{35,}`.
106
+ out = out.replace(/\b\d{6,}:[A-Za-z0-9_-]{35,}\b/g, REDACTED);
107
+
108
+ // Bearer token in Authorization header (case-insensitive). Preserves the
109
+ // header name so the log remains recognizable.
110
+ out = out.replace(
111
+ /(authorization[:\s]*bearer\s+)[A-Za-z0-9._\-+/=]+/gi,
112
+ `$1${REDACTED}`,
113
+ );
114
+
115
+ // X-Api-Key / x-api-key style header.
116
+ out = out.replace(
117
+ /(x-api-key[:\s]*)[A-Za-z0-9._\-+/=]{20,}/gi,
118
+ `$1${REDACTED}`,
119
+ );
120
+
121
+ // Hex blobs 64+ chars (typical auth-key / private-key shape). Must not
122
+ // eat commit SHAs or contract addresses; gate on length 40+. Bump to 64
123
+ // to avoid eating regular addresses.
124
+ out = out.replace(/\b[a-fA-F0-9]{64,}\b/g, REDACTED);
125
+
126
+ // Private-key-style 0x-prefixed 64-hex.
127
+ out = out.replace(/\b0x[a-fA-F0-9]{64}\b/g, REDACTED);
128
+
129
+ // UUIDs that appear alongside `token=` or `secret=` qualifiers. Naked
130
+ // UUIDs are left alone (fact IDs are legitimate UUIDs).
131
+ out = out.replace(
132
+ /((?:token|secret|auth_key)\s*[=:]\s*)[A-Za-z0-9-]{20,}/gi,
133
+ `$1${REDACTED}`,
134
+ );
135
+
136
+ return out;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Tool interface
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export interface QaBugArgs {
144
+ integration: string;
145
+ rc_version: string;
146
+ severity: string;
147
+ title: string;
148
+ symptom: string;
149
+ expected: string;
150
+ repro: string;
151
+ logs: string;
152
+ environment: string;
153
+ }
154
+
155
+ export interface QaBugDeps {
156
+ /** GitHub personal-access token with `repo` scope. */
157
+ githubToken: string;
158
+ /**
159
+ * Repo to post to. Defaults to `resolveQaRepo(null)` → reads
160
+ * `TOTALRECLAW_QA_REPO` env var and falls back to
161
+ * `p-diogo/totalreclaw-internal`. Pass a slug (tests only) to
162
+ * bypass env-var lookup.
163
+ */
164
+ repo?: string;
165
+ /**
166
+ * Abstract fetch for testing — defaults to global `fetch`. Intentionally
167
+ * `unknown`-returning so the caller doesn't need to typecheck every
168
+ * GitHub response field.
169
+ */
170
+ fetchImpl?: typeof fetch;
171
+ /** Logger for non-fatal diagnostic lines. */
172
+ logger?: { info: (msg: string) => void; warn: (msg: string) => void };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Target repo guard — fail-loud on any repo that isn't the internal tracker.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export const DEFAULT_QA_REPO = 'p-diogo/totalreclaw-internal';
180
+
181
+ /**
182
+ * Known-public repo slugs that must never receive QA bug reports. The
183
+ * structural rule (`endsWith('-internal')`) below should already block
184
+ * these, but the explicit denylist is a belt-and-braces safety against
185
+ * a future rename that accidentally drops the `-internal` suffix.
186
+ */
187
+ export const PUBLIC_REPOS_DENYLIST: ReadonlySet<string> = new Set([
188
+ 'p-diogo/totalreclaw',
189
+ 'p-diogo/totalreclaw-website',
190
+ 'p-diogo/totalreclaw-relay',
191
+ 'p-diogo/totalreclaw-plugin',
192
+ 'p-diogo/totalreclaw-hermes',
193
+ ]);
194
+
195
+ /**
196
+ * Resolve the target repo for a QA bug filing.
197
+ *
198
+ * Precedence: explicit override → `TOTALRECLAW_QA_REPO` env → default.
199
+ * Throws if the slug is on the public denylist or does not end in
200
+ * `-internal`. rc.13 QA found agent-filed bug reports leaking to the
201
+ * public repo; this guard makes any such drift fail loudly rather than
202
+ * silently leak RC ship-stopper detail.
203
+ *
204
+ * `TOTALRECLAW_QA_REPO` is the documented override var. The env-var
205
+ * read lives in `config.ts` (CONFIG.qaRepoOverride) so this module
206
+ * never touches process environment directly — keeps the plugin
207
+ * scanner-sim clean because this file also performs a GitHub HTTPS
208
+ * request (env + network in the same file would trip OpenClaw's
209
+ * env-harvesting heuristic).
210
+ *
211
+ * Pass the env-resolved slug (or `null`/empty for default) as
212
+ * `override`. Tests can inject via the second arg.
213
+ */
214
+ export function resolveQaRepo(
215
+ override?: string | null,
216
+ env?: Record<string, string | undefined>,
217
+ ): string {
218
+ // `env` is only for test injection — production callers should
219
+ // pre-resolve the env value via CONFIG.qaRepoOverride and pass it as
220
+ // `override`. The env lookup is a last-resort fallback that works in
221
+ // Node but is NEVER the primary path in production.
222
+ const envOverride = env ? env.TOTALRECLAW_QA_REPO : undefined;
223
+ const raw = (override || envOverride || DEFAULT_QA_REPO).trim();
224
+ if (!raw || !raw.includes('/')) {
225
+ throw new Error(`invalid QA repo slug '${raw}': expected 'owner/name' format`);
226
+ }
227
+ if (PUBLIC_REPOS_DENYLIST.has(raw)) {
228
+ throw new Error(
229
+ `refusing to file QA bug to PUBLIC repo '${raw}'. ` +
230
+ 'QA bug reports contain RC ship-stopper detail that must not ' +
231
+ "leak to public. Set TOTALRECLAW_QA_REPO to a repo ending in " +
232
+ "'-internal' (e.g. p-diogo/totalreclaw-internal).",
233
+ );
234
+ }
235
+ if (!raw.endsWith('-internal')) {
236
+ throw new Error(
237
+ `refusing to file QA bug to repo '${raw}': slug must end in ` +
238
+ "'-internal' (structural safety rule). Override via " +
239
+ 'TOTALRECLAW_QA_REPO only to another internal fork.',
240
+ );
241
+ }
242
+ return raw;
243
+ }
244
+
245
+ const VALID_INTEGRATIONS = new Set([
246
+ 'plugin',
247
+ 'hermes',
248
+ 'nanoclaw',
249
+ 'mcp',
250
+ 'relay',
251
+ 'clawhub',
252
+ 'docs',
253
+ 'other',
254
+ ]);
255
+
256
+ // Internal → display-name mapping for the issue body. Matches the
257
+ // dropdown values in `.github/ISSUE_TEMPLATE/qa-bug.yml`.
258
+ const INTEGRATION_DISPLAY: Record<string, string> = {
259
+ plugin: 'OpenClaw plugin',
260
+ hermes: 'Hermes Python',
261
+ nanoclaw: 'NanoClaw skill',
262
+ mcp: 'MCP server',
263
+ relay: 'Relay (backend)',
264
+ clawhub: 'ClawHub publishing',
265
+ docs: 'Docs / setup guide',
266
+ other: 'Other',
267
+ };
268
+
269
+ const VALID_SEVERITIES = new Set(['blocker', 'high', 'medium', 'low']);
270
+
271
+ export function validateQaBugArgs(args: QaBugArgs): { ok: true } | { ok: false; error: string } {
272
+ if (!args || typeof args !== 'object') return { ok: false, error: 'args must be an object' };
273
+ const missing = ['integration', 'rc_version', 'severity', 'title', 'symptom', 'expected', 'repro', 'logs', 'environment']
274
+ .filter((f) => !args[f as keyof QaBugArgs] || typeof args[f as keyof QaBugArgs] !== 'string');
275
+ if (missing.length) {
276
+ return { ok: false, error: `missing or non-string fields: ${missing.join(', ')}` };
277
+ }
278
+ if (!VALID_INTEGRATIONS.has(args.integration)) {
279
+ return { ok: false, error: `invalid integration "${args.integration}"; expected one of ${[...VALID_INTEGRATIONS].join(', ')}` };
280
+ }
281
+ if (!VALID_SEVERITIES.has(args.severity)) {
282
+ return { ok: false, error: `invalid severity "${args.severity}"; expected one of ${[...VALID_SEVERITIES].join(', ')}` };
283
+ }
284
+ if (args.title.length > 60) {
285
+ return { ok: false, error: 'title must be <= 60 chars' };
286
+ }
287
+ return { ok: true };
288
+ }
289
+
290
+ /**
291
+ * Build the issue body mirroring the `.github/ISSUE_TEMPLATE/qa-bug.yml`
292
+ * layout. Runs every user-supplied string through `redactSecrets` before
293
+ * embedding. Exported for unit testing.
294
+ */
295
+ export function buildIssueBody(args: QaBugArgs): string {
296
+ const integrationDisplay = INTEGRATION_DISPLAY[args.integration] ?? args.integration;
297
+ const header = [
298
+ '_Filed automatically by the TotalReclaw RC bug-report tool._',
299
+ '',
300
+ '### Integration',
301
+ integrationDisplay,
302
+ '',
303
+ '### RC version',
304
+ '`' + redactSecrets(args.rc_version) + '`',
305
+ '',
306
+ '### Severity',
307
+ args.severity,
308
+ '',
309
+ '### What happened',
310
+ redactSecrets(args.symptom),
311
+ '',
312
+ '### What was expected',
313
+ redactSecrets(args.expected),
314
+ '',
315
+ '### Reproduction steps',
316
+ redactSecrets(args.repro),
317
+ '',
318
+ '### Relevant logs / evidence',
319
+ '```',
320
+ redactSecrets(args.logs),
321
+ '```',
322
+ '',
323
+ '### Environment',
324
+ redactSecrets(args.environment),
325
+ '',
326
+ '---',
327
+ '> Reporter: LLM agent via `totalreclaw_report_qa_bug` (RC-gated tool)',
328
+ ].join('\n');
329
+ return header;
330
+ }
331
+
332
+ /**
333
+ * POST the bug to GitHub. Returns the issue URL on success; throws with a
334
+ * structured message on failure. The caller (tool handler) wraps the
335
+ * exception into a JSON tool response.
336
+ */
337
+ export async function postQaBugIssue(
338
+ args: QaBugArgs,
339
+ deps: QaBugDeps,
340
+ ): Promise<{ issue_url: string; issue_number: number }> {
341
+ const validation = validateQaBugArgs(args);
342
+ if ('error' in validation) throw new Error(`invalid args: ${validation.error}`);
343
+ if (!deps.githubToken) throw new Error('githubToken is required');
344
+
345
+ const repo = resolveQaRepo(deps.repo ?? null);
346
+ const url = `https://api.github.com/repos/${repo}/issues`;
347
+
348
+ const title = `[qa-bug] ${redactSecrets(args.title)}`;
349
+ const body = buildIssueBody(args);
350
+ const labels = [
351
+ 'qa-bug',
352
+ 'pending-triage',
353
+ `severity:${args.severity}`,
354
+ `component:${args.integration}`,
355
+ `rc:${args.rc_version.replace(/[^A-Za-z0-9.\-]/g, '_').slice(0, 40)}`,
356
+ ];
357
+
358
+ const fetchFn = deps.fetchImpl ?? fetch;
359
+ const res = await fetchFn(url, {
360
+ method: 'POST',
361
+ headers: {
362
+ Accept: 'application/vnd.github+json',
363
+ 'X-GitHub-Api-Version': '2022-11-28',
364
+ Authorization: `Bearer ${deps.githubToken}`,
365
+ 'Content-Type': 'application/json',
366
+ 'User-Agent': 'totalreclaw-plugin-qa-bug',
367
+ },
368
+ body: JSON.stringify({ title, body, labels }),
369
+ });
370
+
371
+ if (!res.ok) {
372
+ const text = await res.text().catch(() => '');
373
+ throw new Error(`GitHub API ${res.status}: ${text.slice(0, 200)}`);
374
+ }
375
+ const json = (await res.json()) as { html_url?: string; number?: number };
376
+ if (!json.html_url || typeof json.number !== 'number') {
377
+ throw new Error('GitHub API returned no html_url / number');
378
+ }
379
+ deps.logger?.info(`Filed QA bug #${json.number}: ${json.html_url}`);
380
+ return { issue_url: json.html_url, issue_number: json.number };
381
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared outbound-header helper for relay calls.
3
+ *
4
+ * Centralizes the common `X-TotalReclaw-*` headers so every fetch site
5
+ * consistently tags requests with:
6
+ * - `X-TotalReclaw-Client` — caller identity (defaults to `openclaw-plugin`).
7
+ * - `X-TotalReclaw-Session` — optional QA / observability tag from
8
+ * `TOTALRECLAW_SESSION_ID`. Used by Axiom log filters and the
9
+ * `qa-totalreclaw` skill to scope log searches per QA run.
10
+ *
11
+ * Pure function — no I/O, no network. Reads `getSessionId()` (which reads the
12
+ * env var via getter so harnesses that flip the env between calls pick up
13
+ * the new value).
14
+ *
15
+ * The session-id env var was accidentally placed in the v1 REMOVED_ENV_VARS
16
+ * list and silently warned-and-dropped, breaking Axiom traceability for QA
17
+ * runs (see internal#127). This helper is the canonical re-entry point for
18
+ * the variable.
19
+ */
20
+
21
+ import { getSessionId } from './config.js';
22
+
23
+ /** Default `X-TotalReclaw-Client` value. */
24
+ export const DEFAULT_CLIENT_ID = 'openclaw-plugin';
25
+
26
+ /**
27
+ * Build the standard outbound header set.
28
+ *
29
+ * @param overrides - merge-in additional headers (`Authorization`,
30
+ * `Content-Type`, etc.); these win over the defaults.
31
+ * @param clientId - override the `X-TotalReclaw-Client` value.
32
+ *
33
+ * Always includes `X-TotalReclaw-Client`. Includes `X-TotalReclaw-Session`
34
+ * only when `TOTALRECLAW_SESSION_ID` is set + non-empty.
35
+ */
36
+ export function buildRelayHeaders(
37
+ overrides: Record<string, string> = {},
38
+ clientId: string = DEFAULT_CLIENT_ID,
39
+ ): Record<string, string> {
40
+ const headers: Record<string, string> = {
41
+ 'X-TotalReclaw-Client': clientId,
42
+ };
43
+ const sessionId = getSessionId();
44
+ if (sessionId) {
45
+ headers['X-TotalReclaw-Session'] = sessionId;
46
+ }
47
+ // Caller-supplied headers (Authorization, Content-Type, Accept, etc.) take
48
+ // precedence over the defaults but should generally not stomp the X-* tags.
49
+ return { ...headers, ...overrides };
50
+ }
package/reranker.ts CHANGED
@@ -507,3 +507,76 @@ export function rerank(
507
507
  // Preserve rrfScore and cosineSimilarity through MMR
508
508
  return mmrResults as RerankerResult[];
509
509
  }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // Relevance gate (issue #116)
513
+ // ---------------------------------------------------------------------------
514
+
515
+ /**
516
+ * Decide whether reranked results clear the relevance gate for surfacing to
517
+ * the user (recall tool) or auto-injecting into agent context (hooks).
518
+ *
519
+ * Two-signal acceptance rule, addressing issue #116 (rc.18 finding F1):
520
+ *
521
+ * 1. **Cosine path** — at least one reranked result has cosine similarity
522
+ * with the query embedding >= `cosineThreshold`. This is the existing
523
+ * semantic-relevance gate and remains the primary signal.
524
+ *
525
+ * 2. **Lexical override** — when cosine is below threshold (e.g. short
526
+ * queries against the local Harrier-OSS-270m model produce embeddings
527
+ * with low cosine sim regardless of topical match), the gate ALSO
528
+ * passes when every meaningful query token (post stop-word removal)
529
+ * appears as a stem-prefix substring in the top reranked result's
530
+ * text. This is strong lexical evidence that the user is asking
531
+ * about a fact already stored, even when embedding sim is weak.
532
+ *
533
+ * Without (2), short queries like `"favorite color"` against the stored
534
+ * fact `"User's favorite color is cobalt blue"` were silently filtered
535
+ * even though every query token was present in the candidate. Hermes
536
+ * (Python client) does not apply any cosine gate, which is why it
537
+ * recalled the same fact for the same Smart Account in rc.18 QA.
538
+ *
539
+ * The lexical override is intentionally conservative:
540
+ * - Requires ALL non-stop-word query tokens to be present (any-of would
541
+ * over-trigger).
542
+ * - Uses 4-char-prefix substring match to be stem-tolerant ("favorite"
543
+ * stems to "favorit" in the stored fact's blind index, but the raw
544
+ * fact text contains the unstemmed word; the prefix check absorbs
545
+ * light morphology).
546
+ * - Token count must be >= 1 — empty/all-stop-word queries fall back
547
+ * to cosine path.
548
+ *
549
+ * @param query - the user's search query (raw string)
550
+ * @param reranked - reranked results (top first)
551
+ * @param cosineThreshold - the configured cosine cutoff (typically 0.15)
552
+ * @returns true if results should be surfaced; false to suppress
553
+ */
554
+ export function passesRelevanceGate(
555
+ query: string,
556
+ reranked: RerankerResult[],
557
+ cosineThreshold: number,
558
+ ): boolean {
559
+ if (reranked.length === 0) return false;
560
+
561
+ // Path 1: cosine clears threshold.
562
+ const maxCosine = Math.max(...reranked.map((r) => r.cosineSimilarity ?? 0));
563
+ if (maxCosine >= cosineThreshold) return true;
564
+
565
+ // Path 2: lexical override — every meaningful query token appears in
566
+ // the top reranked result's text.
567
+ const queryTokens = tokenize(query, /* removeStopWords */ true);
568
+ if (queryTokens.length === 0) return false;
569
+
570
+ const topText = (reranked[0]?.text ?? '').toLowerCase();
571
+ if (topText.length === 0) return false;
572
+
573
+ // 4-char prefix substring match: tolerates light stemming ("favorite"
574
+ // matches a fact text containing "favorite", "favorites", "favoring",
575
+ // etc., without re-running the WASM Porter stemmer client-side).
576
+ const PREFIX_LEN = 4;
577
+ for (const token of queryTokens) {
578
+ const probe = token.length >= PREFIX_LEN ? token.slice(0, PREFIX_LEN) : token;
579
+ if (!topText.includes(probe)) return false;
580
+ }
581
+ return true;
582
+ }
@@ -34,6 +34,7 @@ import {
34
34
  buildV1ClaimBlob,
35
35
  mapTypeToCategory,
36
36
  readV1Blob,
37
+ type PinStatus,
37
38
  } from './claims-helper.js';
38
39
  import {
39
40
  isValidMemoryType,
@@ -135,6 +136,12 @@ interface NormalizedFact {
135
136
  confidence: number;
136
137
  createdAt: string;
137
138
  expiresAt?: string;
139
+ /**
140
+ * v1.1 pin state. Preserved across retype / set_scope so that pinned facts
141
+ * remain pinned after a metadata edit. Issue #117 surfaced an adjacent gap
142
+ * where this field was silently dropped on the rewrite path.
143
+ */
144
+ pinStatus?: PinStatus;
138
145
  }
139
146
 
140
147
  function projectFromDecrypted(decrypted: string): NormalizedFact | null {
@@ -166,6 +173,7 @@ function projectFromDecrypted(decrypted: string): NormalizedFact | null {
166
173
  confidence: v1.confidence,
167
174
  createdAt: v1.createdAt,
168
175
  expiresAt: v1.expiresAt,
176
+ pinStatus: v1.pinStatus,
169
177
  };
170
178
  }
171
179
  }
@@ -267,6 +275,10 @@ async function rewriteWithMutation(
267
275
  confidence: next.confidence,
268
276
  createdAt: new Date().toISOString(),
269
277
  supersededBy: factId,
278
+ // Issue #117 follow-up: preserve pin_status so that retype / set_scope
279
+ // on a pinned fact does NOT silently un-pin it. Without this, a pinned
280
+ // fact loses its immunity to auto-supersede after any metadata edit.
281
+ pinStatus: next.pinStatus,
270
282
  });
271
283
  } catch (err) {
272
284
  return {
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { getSubgraphConfig } from './subgraph-store.js';
24
24
  import { CONFIG } from './config.js';
25
+ import { buildRelayHeaders } from './relay-headers.js';
25
26
 
26
27
  export interface SubgraphSearchFact {
27
28
  id: string;
@@ -48,13 +49,13 @@ async function gqlQuery<T>(
48
49
  authKeyHex?: string,
49
50
  ): Promise<T | null> {
50
51
  try {
51
- const headers: Record<string, string> = {
52
+ const overrides: Record<string, string> = {
52
53
  'Content-Type': 'application/json',
53
- 'X-TotalReclaw-Client': 'openclaw-plugin',
54
54
  };
55
55
  if (authKeyHex) {
56
- headers['Authorization'] = `Bearer ${authKeyHex}`;
56
+ overrides['Authorization'] = `Bearer ${authKeyHex}`;
57
57
  }
58
+ const headers = buildRelayHeaders(overrides);
58
59
  const response = await fetch(endpoint, {
59
60
  method: 'POST',
60
61
  headers,