@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.
- package/CHANGELOG.md +330 -0
- package/SKILL.md +50 -83
- package/api-client.ts +18 -11
- package/config.ts +117 -3
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +606 -0
- package/dist/config.js +280 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedding.js +86 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +563 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5348 -0
- package/dist/llm-client.js +686 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +542 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +442 -0
- package/dist/retype-setscope.js +348 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedding.ts +32 -9
- package/fs-helpers.ts +124 -0
- package/gateway-url.ts +57 -9
- package/index.ts +586 -357
- package/llm-client.ts +211 -23
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +19 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/qa-bug-report.ts +381 -0
- package/relay-headers.ts +50 -0
- package/reranker.ts +73 -0
- package/retype-setscope.ts +12 -0
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +109 -16
package/qa-bug-report.ts
ADDED
|
@@ -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
|
+
}
|
package/relay-headers.ts
ADDED
|
@@ -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
|
+
}
|
package/retype-setscope.ts
CHANGED
|
@@ -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 {
|
package/subgraph-search.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|