@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 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
- Each fire wakes the agent in the same channel and resumes the selected provider
19
- session captured when the schedule was created. Provider/project switches made
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
- getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
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" && !getClaudeOAuthToken().value) {
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. Claude is cheap (token presence is what the bot relies
397
- // on). Codex spawns `codex login status` once — fine for a setup screen.
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: !!getClaudeOAuthToken().value,
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
  };