@inetafrica/open-claudia 3.0.35 → 3.0.40
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 +21 -0
- package/bin/cli.js +24 -0
- package/bin/pack.js +19 -2
- package/bin/schedule.js +71 -2
- package/core/actions.js +2 -2
- package/core/auth-flow.js +5 -3
- package/core/credential-detect.js +55 -0
- package/core/handlers.js +73 -0
- package/core/loopback.js +52 -0
- package/core/packs.js +51 -1
- package/core/recall/discoverer.js +123 -20
- package/core/recall/kpi.js +23 -2
- package/core/recall/tuning.js +18 -0
- package/core/router.js +53 -9
- package/core/scheduler.js +105 -5
- package/core/system-prompt.js +71 -27
- package/package.json +9 -3
- package/test-cokept-warming.js +117 -0
- package/test-credential-capture.js +109 -0
- package/test-extractive-injection.js +70 -0
- package/test-pack-search.js +79 -0
- package/test-provider-language.js +28 -9
- package/test-provider-scheduler.js +90 -0
- package/test-recall-evolution.js +9 -1
- package/test-score-gate.js +104 -0
- package/test-walker-score.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.40 — Sharper recall: surfaces the actual matched facts, drops weakly-relevant ones (third cut of the Pass-4 release)
|
|
4
|
+
|
|
5
|
+
- **On-open pack/entity injection is now extractive, not a headline.** Opening a pack used to inject its one-line description, hiding the facts that actually matched in State/Procedure/Journal. It now injects the Stance verbatim plus the specific fuzzy-matched lines (section-tagged), so the model sees the real matched content rather than a summary of it. Toggle with `RECALL_EXTRACTIVE` (default on).
|
|
6
|
+
- **A walker score-gate drops weakly-relevant nodes.** The cheap recall walker is a pure router now — it emits `{id, why, score 0-100}` and never paraphrases. Nodes it keeps below the relevance floor (`RECALL_MIN_RELEVANCE_SCORE`, default 55) are gated out on full-tier walks, and the cascade is tightened so sticky auto-keeps are routed back to the judge instead of leaking in. Fails open (a garbled walk bypasses the gate).
|
|
7
|
+
- **A KPI ruler stamps each version.** Per-turn recall records now carry kept-node scores, gate activity, and warmings; the KPI table/HTML report aggregate them per version so each release freezes as a comparable baseline (`relevance`, `gate`, `warm` columns).
|
|
8
|
+
- **Co-kept Hebbian warming ships dark (default OFF).** A weaker-than-co-open reinforcement for pairs the walker co-injected, guarded by score floor + a message-grounded endpoint + a top-N cap. It writes persistent graph state so it can't be validated in one session — left off (`RECALL_COKEPT_WARMING`) pending real-traffic evaluation.
|
|
9
|
+
- **Escape hatch + measurement caveat.** `/engine classic` reverts to the prior behaviour if anything regresses. Two reviewer-side measurement refinements (used-the-right-fact line-level adjudication, and shadow-delta scoring) were deferred to the separate in-flight shadow-delta task — they change how recall is *measured*, not what ships here.
|
|
10
|
+
- **Agent-space / OpenClaw:** no new deps, no new env required (all `RECALL_*` knobs have defaults), no schema change; Docker image builds FROM the existing `:base`. Covered by new `test-pack-search.js`, `test-walker-score.js`, `test-extractive-injection.js`, `test-score-gate.js`, and `test-cokept-warming.js`. Existing pods pick this up on their next approved upgrade. (Two earlier cuts failed CI before publishing anything: v3.0.38 bumped the version but not the release-pin guard in `test-provider-language.js`; v3.0.39 fixed that but `test-cokept-warming.js` asserted a sqlite-graph write that no-ops on CI's `node:20` — it now skips that path when `node:sqlite` is absent, like `test-recall-graph.js`. Both were verified locally by running the suite with `node:sqlite` simulated absent, reproducing the CI environment.)
|
|
11
|
+
|
|
12
|
+
## v3.0.37 — Silent background watches, and pasted secrets are accepted on any provider
|
|
13
|
+
|
|
14
|
+
- **You can now arm a watch: a background poll that stays out of the chat and only wakes me when its condition is actually met.** A watch is just a cron carrying a shell `check` command — each tick runs the check with output discarded, and *only* an exit 0 wakes the agent (with a `🔔 Watch "<label>" triggered` notice and a normal reply). A non-zero exit is the idle case: nothing hits the chat, the poll's activity goes to an append-only `watch.log`, and the job stays pending for the next tick. Watches default to **one-shot** (fire once, then remove) so a matched condition can't spam you; add `--repeat` to keep polling. Create/manage from the CLI (`open-claudia watch-add "<check>" "<wake-prompt>" [--every "<cron>"] [--repeat]`, `watch-list`, `watch-remove`), and the owner can flip the global master switch with **`/watch on|off`** (watches are owner-gated because the check is raw shell). Rides the migration-verified jobs store untouched — `check`/`repeat` survive as extra fields, zero schema change.
|
|
15
|
+
- **Pasting a credential into the chat now stores it out-of-band regardless of which provider is active.** Codex refuses secrets in its prompt, so a pasted key under the Codex backend used to be rejected — and being told to re-paste it through keyring commands *in the same chat* was no safer and plain annoying. A loose paste is now classified (`core/credential-detect.js`) and captured **before** the turn reaches the model: a Claude OAuth token → `.env`+vault, an OpenAI key → handed to the Codex CLI, a recognised service token → the operational keyring, an ambiguous long token → scrubbed from chat. Every captured secret is registered for redaction and the original message deleted. The capture is provider-agnostic and never forwards the secret to any model.
|
|
16
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds FROM the existing `:base`. Covered by new `test-credential-capture.js` and watch probes in `test-provider-scheduler.js`. Existing pods pick this up on their next approved upgrade.
|
|
17
|
+
|
|
18
|
+
## v3.0.36 — Picking a Claude model no longer forces a re-login
|
|
19
|
+
|
|
20
|
+
- **Selecting any Claude model claimed Claude wasn't connected and started a login — even while Claude was working fine.** The model-button handler (and the `/login` picker) decided "connected?" from a shallow token probe (`getClaudeOAuthToken().value`) that only sees an OAuth token in `.env`, the process env, or an unlocked vault. Anyone whose Claude login lives in the Claude CLI's own credential store (the normal `claude` login, no bot token) therefore read as *disconnected*, so every model tap answered "Claude isn't connected yet. Starting login…". The re-login it launched (`claude auth login --claudeai`) never stores a bot token, so the shallow check stayed false and the prompt returned on the very next tap — an unbreakable loop.
|
|
21
|
+
- **Both surfaces now resolve Claude's real auth state via `claudeProviderAuthStatus()`** — the same probe agent runs rely on, which counts a bot OAuth token *and* a `claude auth status` CLI-store login as authenticated. A model tap only starts a login when Claude is genuinely unauthenticated, mirroring the Codex branch beside it. Codex was never affected (it already checked `codex login status`). Cost: one `claude auth status` (~1s) on a model tap when no bot token exists — fine for a settings screen, and skipped entirely when a bot token is present.
|
|
22
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds FROM the existing `:base`. Existing pods pick this up on their next approved upgrade.
|
|
23
|
+
|
|
3
24
|
## v3.0.35 — Spaces comments are readable rich text, not a run-on wall
|
|
4
25
|
|
|
5
26
|
- **The bot's Spaces replies rendered as unreadable walls.** `buildCommentContent` posted the entire reply as one text node inside one paragraph. Spaces comments are TipTap/ProseMirror docs: inside a single text node newlines don't render, so paragraphs, numbered lists and `---` separators collapsed into one run-on blob — and the model's markdown (`**bold**`, `*italic*`, backticks) printed its asterisks literally.
|
package/bin/cli.js
CHANGED
|
@@ -306,6 +306,26 @@ switch (command) {
|
|
|
306
306
|
require("./schedule").runCronRemove(args.slice(1));
|
|
307
307
|
break;
|
|
308
308
|
}
|
|
309
|
+
case "watch-add": {
|
|
310
|
+
require("./schedule").runWatchAdd(args.slice(1));
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "watch-list": {
|
|
314
|
+
require("./schedule").runWatchList();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "watch-remove": {
|
|
318
|
+
require("./schedule").runWatchRemove(args.slice(1));
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "watch-on": {
|
|
322
|
+
require("./schedule").runWatchToggle(true);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "watch-off": {
|
|
326
|
+
require("./schedule").runWatchToggle(false);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
309
329
|
|
|
310
330
|
case "task": {
|
|
311
331
|
require("./task").run(args.slice(1));
|
|
@@ -440,6 +460,10 @@ Background work (only inside an active bot-spawned task):
|
|
|
440
460
|
open-claudia cron-add "<sched>" "<prompt>" Recurring job pinned to the selected provider session
|
|
441
461
|
open-claudia cron-list List all wakeups + crons on this channel
|
|
442
462
|
open-claudia cron-remove <id> Remove a scheduled job
|
|
463
|
+
open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake only when it exits 0
|
|
464
|
+
open-claudia watch-list List watches on this channel + master switch state
|
|
465
|
+
open-claudia watch-remove <id> Remove a watch
|
|
466
|
+
open-claudia watch-on | watch-off Global master switch for all watches (owner)
|
|
443
467
|
open-claudia task add|list|start|done|remove Per-channel todo (survives restarts)
|
|
444
468
|
open-claudia agent "<prompt>" [--role X] [--provider active|claude|codex] Spawn a provider-isolated sub-agent
|
|
445
469
|
|
package/bin/pack.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// CLI: inspect and manage context packs (living skills+memory docs).
|
|
2
2
|
// open-claudia pack list
|
|
3
3
|
// open-claudia pack show <dir>
|
|
4
|
-
// open-claudia pack match "<text>" — debug the router
|
|
4
|
+
// open-claudia pack match "<text>" — debug the router (whole-pack)
|
|
5
|
+
// open-claudia pack search "<query>" — per-line extractive search
|
|
5
6
|
// open-claudia pack migrate — fold ~/.claude/skills into packs
|
|
6
7
|
// open-claudia pack remove <dir>
|
|
7
8
|
// open-claudia pack reindex
|
|
@@ -124,6 +125,22 @@ function run(args) {
|
|
|
124
125
|
break;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
case "search": {
|
|
129
|
+
let dir = null, k = 8;
|
|
130
|
+
const words = [];
|
|
131
|
+
for (let i = 0; i < rest.length; i++) {
|
|
132
|
+
if (rest[i] === "--pack") { dir = rest[++i]; continue; }
|
|
133
|
+
if (rest[i] === "--k") { k = Math.max(1, parseInt(rest[++i], 10) || 8); continue; }
|
|
134
|
+
words.push(rest[i]);
|
|
135
|
+
}
|
|
136
|
+
const query = words.join(" ");
|
|
137
|
+
if (!query) { console.error('Usage: pack search "<query>" [--pack <dir>] [--k N]'); process.exitCode = 1; return; }
|
|
138
|
+
const hits = packs.searchLines(query, { dir, k });
|
|
139
|
+
if (hits.length === 0) return console.log("No matching lines.");
|
|
140
|
+
for (const h of hits) console.log(`${String(h.score).padStart(3)} ${h.dir}/${h.section}:${h.lineno} ${h.text}`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
case "remove": {
|
|
128
145
|
const removed = packs.removePack(rest[0]);
|
|
129
146
|
if (!removed) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
|
|
@@ -176,7 +193,7 @@ function run(args) {
|
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
default:
|
|
179
|
-
console.log("Usage: open-claudia pack [list|skills|abilities|skill <dir> [on|off]|kind <dir> <ability|context>|show <dir>|match \"<text>\"|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
|
|
196
|
+
console.log("Usage: open-claudia pack [list|skills|abilities|skill <dir> [on|off]|kind <dir> <ability|context>|show <dir>|match \"<text>\"|search \"<query>\" [--pack <dir>] [--k N]|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
|
|
180
197
|
}
|
|
181
198
|
}
|
|
182
199
|
|
package/bin/schedule.js
CHANGED
|
@@ -15,8 +15,19 @@ Crons (recurring):
|
|
|
15
15
|
open-claudia cron-list # all wakeups + crons for this channel
|
|
16
16
|
open-claudia cron-remove <id> # remove by id from cron-list
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Watches (silent background monitor — polls a shell check, wakes you only on a hit):
|
|
19
|
+
open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat]
|
|
20
|
+
<check-cmd>: a shell command polled on a schedule (default every minute).
|
|
21
|
+
exit 0 = condition met → wake the agent with <wake-prompt>.
|
|
22
|
+
non-zero = still idle → nothing happens, nothing hits the chat.
|
|
23
|
+
--repeat: keep polling after it fires (default: fire once, then remove).
|
|
24
|
+
open-claudia watch-list # watches on this channel + master switch state
|
|
25
|
+
open-claudia watch-remove <id> # remove a watch by id
|
|
26
|
+
open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
|
|
27
|
+
|
|
28
|
+
The poll itself never touches the chat — only a real trigger wakes the agent
|
|
29
|
+
and produces a normal reply. Each fire resumes the selected provider session
|
|
30
|
+
captured when the schedule/watch was created. Provider/project switches made
|
|
20
31
|
later do not retarget already-scheduled work.
|
|
21
32
|
`;
|
|
22
33
|
|
|
@@ -123,10 +134,68 @@ async function runCronRemove(args) {
|
|
|
123
134
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
async function runWatchAdd(args) {
|
|
138
|
+
if (args.length < 2 || args[0] === "--help") { console.log(HELP); process.exit(args[0] === "--help" ? 0 : 2); }
|
|
139
|
+
const rest = args.slice();
|
|
140
|
+
const label = takeFlag(rest, "label");
|
|
141
|
+
const schedule = takeFlag(rest, "every");
|
|
142
|
+
const project = takeFlag(rest, "project");
|
|
143
|
+
const repeatIdx = rest.indexOf("--repeat");
|
|
144
|
+
const repeat = repeatIdx >= 0;
|
|
145
|
+
if (repeat) rest.splice(repeatIdx, 1);
|
|
146
|
+
const check = rest[0];
|
|
147
|
+
const prompt = rest.slice(1).join(" ");
|
|
148
|
+
if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat]"); process.exit(2); }
|
|
149
|
+
try {
|
|
150
|
+
const request = { check, prompt, repeat, label: label || prompt.slice(0, 60) };
|
|
151
|
+
if (schedule) request.schedule = schedule;
|
|
152
|
+
if (project) request.project = project;
|
|
153
|
+
const res = await postJson("watch-add", request);
|
|
154
|
+
console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"). Silent until the check passes.`);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runWatchList() {
|
|
160
|
+
try {
|
|
161
|
+
const res = await postJson("watch-list", {});
|
|
162
|
+
const list = res.watches || [];
|
|
163
|
+
console.log(`Watches: master switch is ${res.enabled ? "ON" : "OFF"}.`);
|
|
164
|
+
if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
|
|
165
|
+
for (const w of list) {
|
|
166
|
+
const mode = w.repeat ? "repeating" : "one-shot";
|
|
167
|
+
console.log(`${w.id} ${mode} polls "${w.schedule}" check: ${w.check} → ${w.label || "(no label)"}`);
|
|
168
|
+
}
|
|
169
|
+
process.exit(0);
|
|
170
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function runWatchRemove(args) {
|
|
174
|
+
const id = args[0];
|
|
175
|
+
if (!id) { console.error("Usage: watch-remove <id>"); process.exit(2); }
|
|
176
|
+
try {
|
|
177
|
+
const res = await postJson("watch-remove", { id });
|
|
178
|
+
console.log(res.ok ? `Removed watch ${id}.` : `Not found: ${id}.`);
|
|
179
|
+
process.exit(res.ok ? 0 : 1);
|
|
180
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runWatchToggle(enabled) {
|
|
184
|
+
try {
|
|
185
|
+
const res = await postJson("watch-toggle", { enabled });
|
|
186
|
+
console.log(`Watches ${res.enabled ? "ENABLED" : "DISABLED"} globally.`);
|
|
187
|
+
process.exit(0);
|
|
188
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
189
|
+
}
|
|
190
|
+
|
|
126
191
|
module.exports = {
|
|
127
192
|
HELP,
|
|
128
193
|
runScheduleWakeup,
|
|
129
194
|
runCronAdd,
|
|
130
195
|
runCronList,
|
|
131
196
|
runCronRemove,
|
|
197
|
+
runWatchAdd,
|
|
198
|
+
runWatchList,
|
|
199
|
+
runWatchRemove,
|
|
200
|
+
runWatchToggle,
|
|
132
201
|
};
|
package/core/actions.js
CHANGED
|
@@ -28,7 +28,7 @@ const jobs = require("./jobs");
|
|
|
28
28
|
const scheduler = require("./scheduler");
|
|
29
29
|
const { sideChatCoordinator } = require("./side-chat");
|
|
30
30
|
const {
|
|
31
|
-
|
|
31
|
+
claudeProviderAuthStatus, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
|
|
32
32
|
} = require("./auth-flow");
|
|
33
33
|
|
|
34
34
|
const REMOVED_PROVIDER_CALLBACK_MESSAGE = "This button is from an older Open Claudia release. Cursor Agent support has been removed. Open /backend to choose Claude Code or OpenAI Codex.";
|
|
@@ -335,7 +335,7 @@ async function handleAction(envelope) {
|
|
|
335
335
|
const beLabel = provider.label;
|
|
336
336
|
// Auth-aware: if the chosen provider isn't connected, start its login flow
|
|
337
337
|
// now. The model is already set, so once auth completes the user is on it.
|
|
338
|
-
if (be === "claude" &&
|
|
338
|
+
if (be === "claude" && claudeProviderAuthStatus().state !== "authenticated") {
|
|
339
339
|
if (!isChatOwner(envelope.channelId)) { await send("Claude isn't connected and only the owner can sign it in."); return; }
|
|
340
340
|
await send(`Set to ${model}, but Claude isn't connected yet. Starting login — open the URL I send, then paste the code here.`);
|
|
341
341
|
await runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
|
package/core/auth-flow.js
CHANGED
|
@@ -393,12 +393,14 @@ function codexAuthHelp(reason = "Codex authentication failed") {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
// Quick "is this provider usable non-interactively" check for the unified
|
|
396
|
-
// /login model picker.
|
|
397
|
-
//
|
|
396
|
+
// /login model picker. Each provider resolves its real auth state once (Claude
|
|
397
|
+
// via a bot token or the CLI credential store; Codex via `codex login status`)
|
|
398
|
+
// — fine for a setup screen, and it stops a CLI-store login from looking
|
|
399
|
+
// disconnected.
|
|
398
400
|
function providerAuthStatus() {
|
|
399
401
|
const binary = codexExecutable();
|
|
400
402
|
return {
|
|
401
|
-
claude:
|
|
403
|
+
claude: claudeProviderAuthStatus().state === "authenticated",
|
|
402
404
|
codex: binary ? runCodexLoginStatus().ok : false,
|
|
403
405
|
};
|
|
404
406
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Pure classification of a pasted chat message into a credential disposition.
|
|
4
|
+
// No side effects — the router performs the actual store/delete/confirm. Kept
|
|
5
|
+
// standalone (no requires) so it can be unit-tested without loading the
|
|
6
|
+
// router's dependency graph, and so the ordering rules live in one place.
|
|
7
|
+
//
|
|
8
|
+
// Disposition kinds:
|
|
9
|
+
// claude — a Claude Code OAuth token → saved to .env + vault
|
|
10
|
+
// openai — an OpenAI API key → handed to the Codex CLI
|
|
11
|
+
// keyring — another recognised secret → operational keyring (name provided)
|
|
12
|
+
// scrub — an ambiguous long token → delete from chat but still answer
|
|
13
|
+
// none — ordinary text — leave the message alone
|
|
14
|
+
//
|
|
15
|
+
// A recognised service prefix is claimed BEFORE the generic long-opaque Claude
|
|
16
|
+
// heuristic, otherwise a long GitHub/GitLab token would be mistaken for a
|
|
17
|
+
// Claude token. sk-ant- is claimed before the generic OpenAI sk- shape.
|
|
18
|
+
|
|
19
|
+
function classifyPastedCredential(raw) {
|
|
20
|
+
const text = String(raw || "").trim();
|
|
21
|
+
if (!text) return { kind: "none" };
|
|
22
|
+
|
|
23
|
+
if (/^sk-ant-[A-Za-z0-9._-]+$/.test(text)) return { kind: "claude" };
|
|
24
|
+
if (/^sk-(?:proj-)?[A-Za-z0-9._-]{20,}$/.test(text)) return { kind: "openai" };
|
|
25
|
+
|
|
26
|
+
const byPrefix = [
|
|
27
|
+
[/^glpat-[A-Za-z0-9._-]{8,}$/, "gitlab_token"],
|
|
28
|
+
[/^(?:ghp_|gho_|github_pat_)[A-Za-z0-9._-]{8,}$/, "github_token"],
|
|
29
|
+
[/^(?:xoxb-|xoxp-)[A-Za-z0-9-]{8,}$/, "slack_token"],
|
|
30
|
+
[/^AKIA[0-9A-Z]{8,}$/, "aws_access_key_id"],
|
|
31
|
+
[/^AIza[0-9A-Za-z._-]{10,}$/, "google_api_key"],
|
|
32
|
+
];
|
|
33
|
+
for (const [re, name] of byPrefix) if (re.test(text)) return { kind: "keyring", name, value: text };
|
|
34
|
+
|
|
35
|
+
// A labelled secret ("token: <value>", "Bearer <value>") — only when the
|
|
36
|
+
// value is a single secret-shaped token, so "key: the answer is 42" is left
|
|
37
|
+
// as ordinary text rather than swallowed.
|
|
38
|
+
const labeled = text.match(/^(Bearer)\s+(\S{12,})$/i)
|
|
39
|
+
|| text.match(/^(token|key|secret|password)\s*:\s*(\S{12,})$/i);
|
|
40
|
+
if (labeled) {
|
|
41
|
+
const name = labeled[1].toLowerCase() === "bearer" ? "bearer_token" : labeled[1].toLowerCase();
|
|
42
|
+
return { kind: "keyring", name, value: labeled[2] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Long opaque token with no sk- prefix → Claude OAuth token shape.
|
|
46
|
+
if (!/^sk-/.test(text) && text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text)) return { kind: "claude" };
|
|
47
|
+
|
|
48
|
+
// Ambiguous long single token: scrub it from the chat (it may be a secret)
|
|
49
|
+
// but let the turn proceed so the agent can still respond.
|
|
50
|
+
if (/^[A-Za-z0-9_-]{20,}$/.test(text)) return { kind: "scrub" };
|
|
51
|
+
|
|
52
|
+
return { kind: "none" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { classifyPastedCredential };
|
package/core/handlers.js
CHANGED
|
@@ -2105,6 +2105,79 @@ register({
|
|
|
2105
2105
|
},
|
|
2106
2106
|
});
|
|
2107
2107
|
|
|
2108
|
+
// ── Watch (silent background monitor) ──────────────────────────────
|
|
2109
|
+
// A watch is a cron carrying a shell `check`. The scheduler polls the check
|
|
2110
|
+
// silently — nothing reaches the chat — and wakes the agent with `prompt`
|
|
2111
|
+
// only when the check exits 0. Owner-only: the check is arbitrary shell on
|
|
2112
|
+
// the bot host.
|
|
2113
|
+
|
|
2114
|
+
register({
|
|
2115
|
+
name: "watch", description: "Silent background monitor: poll a shell check, ping you only on a hit", args: "[add \"<check>\" \"<prompt>\" [repeat] | remove <#> | on | off]", ownerOnly: true,
|
|
2116
|
+
handler: async (env, { tail }) => {
|
|
2117
|
+
if (!ownerEnv(env)) return send("Owner only — a watch runs shell commands on my host.");
|
|
2118
|
+
const channelWatches = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => !!j.check);
|
|
2119
|
+
|
|
2120
|
+
if (!tail) {
|
|
2121
|
+
const list = channelWatches();
|
|
2122
|
+
const master = scheduler.watchesEnabled() ? "ON" : "OFF";
|
|
2123
|
+
if (list.length === 0) {
|
|
2124
|
+
return send([
|
|
2125
|
+
`No watches. Master switch: ${master}.`,
|
|
2126
|
+
"",
|
|
2127
|
+
"A watch silently polls a shell check and only pings you when it passes — nothing hits the chat until then.",
|
|
2128
|
+
"",
|
|
2129
|
+
'Add: /watch add "<check-cmd>" "<wake-prompt>"',
|
|
2130
|
+
'e.g. /watch add "curl -sf https://myapp/health || exit 1" "myapp health check failed — take a look"',
|
|
2131
|
+
"Add `repeat` at the end to keep polling after it fires (default: one-shot).",
|
|
2132
|
+
].join("\n"));
|
|
2133
|
+
}
|
|
2134
|
+
return send(
|
|
2135
|
+
`Watches (master switch: ${master}):\n\n` +
|
|
2136
|
+
list.map((w, i) => `${i + 1}. ${w.label} — polls "${w.schedule}"${w.repeat ? " (repeating)" : " (one-shot)"}\n check: ${w.check}`).join("\n") +
|
|
2137
|
+
"\n\nRemove: /watch remove <#>\nToggle all: /watch on | /watch off",
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
if (/^on$/i.test(tail)) {
|
|
2142
|
+
scheduler.setWatchesEnabled(true);
|
|
2143
|
+
return send("✅ Watches ENABLED. Existing watches resume polling.");
|
|
2144
|
+
}
|
|
2145
|
+
if (/^off$/i.test(tail)) {
|
|
2146
|
+
scheduler.setWatchesEnabled(false);
|
|
2147
|
+
return send("⏸️ Watches DISABLED globally. Polling is paused and nothing will fire until /watch on. Your watches are kept.");
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
const addMatch = tail.match(/^add\s+"([^"]+)"\s+"([^"]+)"(?:\s+(repeat))?$/i);
|
|
2151
|
+
if (addMatch) {
|
|
2152
|
+
const job = scheduler.addWatch({
|
|
2153
|
+
adapter: env.adapter.id,
|
|
2154
|
+
adapterType: env.adapter.type,
|
|
2155
|
+
channelId: String(env.channelId),
|
|
2156
|
+
canonicalUserId: env.canonicalUserId,
|
|
2157
|
+
project: "Workspace",
|
|
2158
|
+
projectDir: WORKSPACE,
|
|
2159
|
+
check: addMatch[1],
|
|
2160
|
+
prompt: addMatch[2],
|
|
2161
|
+
label: addMatch[2].slice(0, 50),
|
|
2162
|
+
source: "user",
|
|
2163
|
+
repeat: !!addMatch[3],
|
|
2164
|
+
});
|
|
2165
|
+
return send(`👁️ Watching (${addMatch[3] ? "repeating" : "one-shot"}, polls "${job.schedule}"). I'll stay quiet until the check passes, then ping you.`);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
const removeMatch = tail.match(/^remove\s+(\d+)$/);
|
|
2169
|
+
if (removeMatch) {
|
|
2170
|
+
const list = channelWatches();
|
|
2171
|
+
const idx = parseInt(removeMatch[1], 10) - 1;
|
|
2172
|
+
if (idx < 0 || idx >= list.length) return send("Invalid number.");
|
|
2173
|
+
const removed = scheduler.removeJob(list[idx].id);
|
|
2174
|
+
return send(`Removed watch: ${removed ? removed.label : "(unknown)"}`);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
send('Usage: /watch | /watch add "<check-cmd>" "<wake-prompt>" [repeat] | /watch remove <#> | /watch on | /watch off');
|
|
2178
|
+
},
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2108
2181
|
// ── Channels ───────────────────────────────────────────────────────
|
|
2109
2182
|
|
|
2110
2183
|
register({
|
package/core/loopback.js
CHANGED
|
@@ -74,6 +74,7 @@ function readBodyToFile(req, dest) {
|
|
|
74
74
|
const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
|
|
75
75
|
const JSON_KINDS = new Set([
|
|
76
76
|
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
77
|
+
"watch-add", "watch-list", "watch-remove", "watch-toggle",
|
|
77
78
|
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
|
|
78
79
|
"people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
|
|
79
80
|
"people-remove", "people-set-primary",
|
|
@@ -238,6 +239,57 @@ async function handleJson(req, res, url, kind) {
|
|
|
238
239
|
return reply(res, 200, { ok: true, jobs: list, archived });
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
// A watch is a cron carrying a shell `check`; the scheduler polls the check
|
|
243
|
+
// silently and only wakes the agent (with `prompt`) when it exits 0.
|
|
244
|
+
if (kind === "watch-add") {
|
|
245
|
+
if (!payload.check || !payload.prompt) return reply(res, 400, { error: "missing check/prompt" });
|
|
246
|
+
const job = scheduler.addWatch({
|
|
247
|
+
adapter: adapterId,
|
|
248
|
+
adapterType: adapter.type,
|
|
249
|
+
channelId,
|
|
250
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
251
|
+
userId: payload.userId || null,
|
|
252
|
+
provider: payload.provider || null,
|
|
253
|
+
providerSettings: payload.providerSettings || {},
|
|
254
|
+
project: payload.project || null,
|
|
255
|
+
projectDir: payload.projectDir || null,
|
|
256
|
+
check: String(payload.check),
|
|
257
|
+
prompt: String(payload.prompt),
|
|
258
|
+
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
259
|
+
schedule: payload.schedule ? String(payload.schedule) : undefined,
|
|
260
|
+
repeat: !!payload.repeat,
|
|
261
|
+
source: payload.source || "agent",
|
|
262
|
+
sessionId: payload.sessionId || null,
|
|
263
|
+
previousSessionId: payload.previousSessionId || null,
|
|
264
|
+
originRunId: payload.originRunId || null,
|
|
265
|
+
sessionLineage: {
|
|
266
|
+
originRunId: payload.originRunId || null,
|
|
267
|
+
originSessionId: payload.sessionId || null,
|
|
268
|
+
previousSessionId: payload.previousSessionId || null,
|
|
269
|
+
originProject: payload.originProject || payload.project || null,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
return reply(res, 200, { ok: true, job });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (kind === "watch-list") {
|
|
276
|
+
const list = jobsStore.listForChannel(adapterId, channelId).filter((job) => !!job.check);
|
|
277
|
+
return reply(res, 200, { ok: true, watches: list, enabled: scheduler.watchesEnabled() });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (kind === "watch-remove") {
|
|
281
|
+
const id = payload.id || url.searchParams.get("id");
|
|
282
|
+
if (!id) return reply(res, 400, { error: "missing id" });
|
|
283
|
+
const removed = scheduler.removeJob(id);
|
|
284
|
+
return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (kind === "watch-toggle") {
|
|
288
|
+
if (typeof payload.enabled !== "boolean") return reply(res, 400, { error: "missing enabled boolean" });
|
|
289
|
+
const enabled = scheduler.setWatchesEnabled(payload.enabled);
|
|
290
|
+
return reply(res, 200, { ok: true, enabled });
|
|
291
|
+
}
|
|
292
|
+
|
|
241
293
|
if (kind === "task-add") {
|
|
242
294
|
if (!payload.content) return reply(res, 400, { error: "missing content" });
|
|
243
295
|
try {
|
package/core/packs.js
CHANGED
|
@@ -641,11 +641,61 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
|
|
|
641
641
|
.map(([dir, score]) => ({ dir, name: names.get(dir) || dir, score }));
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
// Extractive per-line search. Where matchPacks routes whole packs, this
|
|
645
|
+
// returns the individual lines that best match the query, tagged by section,
|
|
646
|
+
// so the injector can surface a verbatim fact from anywhere in a pack — not
|
|
647
|
+
// just Stance. Deliberately JS-side (no second FTS table) so it can never
|
|
648
|
+
// disturb the load-bearing whole-pack index in reindex(). Fuzz comes from
|
|
649
|
+
// substring matching: term "deploy" also hits "deployment"/"deployed".
|
|
650
|
+
function searchLines(query, { dir = null, k = 8, minScore = 1 } = {}) {
|
|
651
|
+
const terms = queryTerms(query);
|
|
652
|
+
if (terms.length === 0) return [];
|
|
653
|
+
let packs;
|
|
654
|
+
if (dir) {
|
|
655
|
+
const p = readPack(dir);
|
|
656
|
+
packs = p ? [p] : [];
|
|
657
|
+
} else {
|
|
658
|
+
packs = listPacks().filter((p) => !p.archived);
|
|
659
|
+
}
|
|
660
|
+
const hits = [];
|
|
661
|
+
for (const p of packs) {
|
|
662
|
+
for (const section of SECTIONS) {
|
|
663
|
+
const body = p.sections?.[section] || "";
|
|
664
|
+
if (!body) continue;
|
|
665
|
+
const lines = body.split("\n");
|
|
666
|
+
for (let i = 0; i < lines.length; i++) {
|
|
667
|
+
const trimmed = lines[i].trim();
|
|
668
|
+
if (trimmed.length < 3) continue; // skip blanks / stray punctuation
|
|
669
|
+
const low = trimmed.toLowerCase();
|
|
670
|
+
let distinct = 0;
|
|
671
|
+
let total = 0;
|
|
672
|
+
for (const t of terms) {
|
|
673
|
+
let idx = low.indexOf(t);
|
|
674
|
+
if (idx === -1) continue;
|
|
675
|
+
distinct++;
|
|
676
|
+
let occ = 0;
|
|
677
|
+
while (idx !== -1 && occ < 3) { occ++; idx = low.indexOf(t, idx + t.length); }
|
|
678
|
+
total += occ;
|
|
679
|
+
}
|
|
680
|
+
if (distinct < 1) continue;
|
|
681
|
+
const score = distinct * 10 + Math.min(total - distinct, 5);
|
|
682
|
+
if (score < minScore) continue;
|
|
683
|
+
hits.push({ dir: p.dir, section, lineno: i + 1, text: trimmed, score });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
hits.sort((a, b) => b.score - a.score
|
|
688
|
+
|| a.dir.localeCompare(b.dir)
|
|
689
|
+
|| SECTIONS.indexOf(a.section) - SECTIONS.indexOf(b.section)
|
|
690
|
+
|| a.lineno - b.lineno);
|
|
691
|
+
return hits.slice(0, Math.max(1, Math.min(50, k)));
|
|
692
|
+
}
|
|
693
|
+
|
|
644
694
|
module.exports = {
|
|
645
695
|
PACKS_DIR, SECTIONS, slugify,
|
|
646
696
|
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack, isNearDupJournal,
|
|
647
697
|
listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
|
|
648
|
-
touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
|
|
698
|
+
touchUsed, packNameFromPath, matchPacks, searchLines, reindex, markIndexDirty,
|
|
649
699
|
archivePack, restorePack, listArchived,
|
|
650
700
|
readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
|
|
651
701
|
};
|