@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
|
@@ -217,14 +217,18 @@ function excerptFor(id, packsLib, entitiesLib) {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
const WALKER_SYSTEM = [
|
|
220
|
-
"You are the recall
|
|
220
|
+
"You are the recall ROUTER for an assistant's long-term memory.",
|
|
221
221
|
"You are given the user's message, recent conversation, and candidate memory nodes (some matched by keyword, some pulled in by graph links).",
|
|
222
|
-
"
|
|
222
|
+
"Your job is to route: decide which nodes GENUINELY bear on what the user is doing now, and how strongly.",
|
|
223
|
+
"You are a router, NOT a summarizer: never paraphrase, rewrite, or invent the contents of a node. The verbatim fact will be surfaced separately; your why is only a pointer to WHY the node is relevant.",
|
|
223
224
|
"Keep graph-linked nodes ONLY if they actually bear on the task (e.g. a shared theme/commit-style that governs the thing being worked on). Drop incidental keyword overlaps.",
|
|
224
225
|
"Some candidates are reusable tools (id starts `tool:`). Keep a tool ONLY if running it would actually help with THIS task; drop tools merely related by keyword.",
|
|
225
226
|
"Some candidates are past-conversation episodes (id starts `episode:`). Keep one ONLY if that past exchange holds a decision, method, or outcome directly reusable for the current message — not mere topic overlap.",
|
|
226
|
-
"For each kept node
|
|
227
|
-
|
|
227
|
+
"For each kept node give three fields:",
|
|
228
|
+
" - id: the candidate's id, verbatim.",
|
|
229
|
+
" - why: a terse pointer (≤12 words) to why it's relevant — quote a concrete anchor verbatim, never a paraphrase of the whole node.",
|
|
230
|
+
" - score: an integer 0–100 for how strongly this node bears on THIS message. 100 = the message is directly about it; 60 = plausibly useful; below 50 = weak/incidental.",
|
|
231
|
+
'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app","score":85}]. Use [] if none.',
|
|
228
232
|
].join("\n");
|
|
229
233
|
|
|
230
234
|
// Run the walker model on the prompt. Prefer the warm (reused) process for
|
|
@@ -276,7 +280,7 @@ async function runWalker(prompt, options = {}) {
|
|
|
276
280
|
items: {
|
|
277
281
|
type: "object",
|
|
278
282
|
required: ["id"],
|
|
279
|
-
properties: { id: { type: "string" }, why: { type: "string" } },
|
|
283
|
+
properties: { id: { type: "string" }, why: { type: "string" }, score: { type: "number" } },
|
|
280
284
|
},
|
|
281
285
|
},
|
|
282
286
|
timeoutMs: WALKER_TIMEOUT_MS,
|
|
@@ -291,11 +295,47 @@ async function runWalker(prompt, options = {}) {
|
|
|
291
295
|
};
|
|
292
296
|
}
|
|
293
297
|
|
|
294
|
-
//
|
|
295
|
-
//
|
|
298
|
+
// A walker score is an integer 0–100. Missing/garbage → 60: a neutral pass so
|
|
299
|
+
// a parse hiccup never silently buries a node the walker meant to keep.
|
|
300
|
+
function clampScore(v) {
|
|
301
|
+
const n = Number(v);
|
|
302
|
+
if (!Number.isFinite(n)) return 60;
|
|
303
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// WS5: which co-kept pairs earn a weak warming bump. Co-KEPT (walker co-injected
|
|
307
|
+
// them this turn) is noisier than co-OPEN (the model actually opened them), so
|
|
308
|
+
// it warms only under three guards: both endpoints scored ≥ minScore (high-
|
|
309
|
+
// confidence keeps), the pair contains ≥1 message-grounded (user-origin) seed —
|
|
310
|
+
// never let two pure graph hops reinforce each other into a phantom edge — and
|
|
311
|
+
// only the top `maxPairs` strongest pairs warm. Pack/entity graph nodes only
|
|
312
|
+
// (tools and episodes are not graph nodes). Pure + deterministic, so it is
|
|
313
|
+
// unit-tested without touching the graph. Returns [[a, b], ...].
|
|
314
|
+
function cokeptPairs(keptIds, scoreById, groundedIds, { minScore = 60, maxPairs = 3 } = {}) {
|
|
315
|
+
if (!scoreById || !(scoreById.size >= 0)) return [];
|
|
316
|
+
const grounded = groundedIds instanceof Set ? groundedIds : new Set(groundedIds || []);
|
|
317
|
+
const eligible = [...keptIds].filter((id) =>
|
|
318
|
+
(id.startsWith("pack:") || id.startsWith("entity:")) &&
|
|
319
|
+
scoreById.has(id) && scoreById.get(id) >= minScore);
|
|
320
|
+
const pairs = [];
|
|
321
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
322
|
+
for (let j = i + 1; j < eligible.length; j++) {
|
|
323
|
+
const a = eligible[i], b = eligible[j];
|
|
324
|
+
if (!grounded.has(a) && !grounded.has(b)) continue; // ≥1 message-grounded endpoint
|
|
325
|
+
pairs.push({ a, b, strength: scoreById.get(a) + scoreById.get(b) });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
pairs.sort((x, y) => y.strength - x.strength || (x.a + "" + x.b).localeCompare(y.a + "" + y.b));
|
|
329
|
+
return pairs.slice(0, Math.max(0, maxPairs)).map((p) => [p.a, p.b]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Judge candidates. Returns { kept: Map|null, scores: Map|null, costUsd, source,
|
|
333
|
+
// walkerMs } — kept/scores null means fail-open (walker off/failed/unparseable).
|
|
334
|
+
// kept is id→why(string); scores is the parallel id→0-100 relevance from the
|
|
335
|
+
// router, consumed by the injection score-gate and asymmetric warming.
|
|
296
336
|
async function walk(userText, contextText, candidates, options = {}) {
|
|
297
337
|
if (!WALKER_ENABLED || candidates.length === 0) {
|
|
298
|
-
return { kept: null, costUsd: null, source: "off", walkerMs: 0, provider: options.provider || null, model: null };
|
|
338
|
+
return { kept: null, scores: null, costUsd: null, source: "off", walkerMs: 0, provider: options.provider || null, model: null };
|
|
299
339
|
}
|
|
300
340
|
const lines = candidates.map((c) => {
|
|
301
341
|
const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
|
|
@@ -309,26 +349,28 @@ async function walk(userText, contextText, candidates, options = {}) {
|
|
|
309
349
|
"Candidate memory nodes:",
|
|
310
350
|
...lines,
|
|
311
351
|
"",
|
|
312
|
-
'Reply ONLY with the JSON array of kept nodes
|
|
352
|
+
'Reply ONLY with the JSON array of kept nodes, each with id, why, and score (0–100), e.g. [{"id":"' + candidates[0].id + '","why":"...","score":80}].',
|
|
313
353
|
].join("\n");
|
|
314
354
|
const t0 = Date.now();
|
|
315
355
|
try {
|
|
316
356
|
const { text, costUsd, source, provider, model } = await runWalker(prompt, options);
|
|
317
357
|
const walkerMs = Date.now() - t0;
|
|
318
358
|
const match = String(text || "").match(/\[[\s\S]*\]/);
|
|
319
|
-
if (!match) return { kept: null, costUsd, source, walkerMs, provider, model };
|
|
359
|
+
if (!match) return { kept: null, scores: null, costUsd, source, walkerMs, provider, model };
|
|
320
360
|
const arr = JSON.parse(match[0]);
|
|
321
|
-
if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs, provider, model };
|
|
361
|
+
if (!Array.isArray(arr)) return { kept: null, scores: null, costUsd, source, walkerMs, provider, model };
|
|
322
362
|
const known = new Set(candidates.map((c) => c.id));
|
|
323
363
|
const out = new Map();
|
|
364
|
+
const scores = new Map();
|
|
324
365
|
for (const item of arr) {
|
|
325
366
|
if (item && typeof item.id === "string" && known.has(item.id)) {
|
|
326
367
|
out.set(item.id, String(item.why || "").slice(0, 120));
|
|
368
|
+
scores.set(item.id, clampScore(item.score));
|
|
327
369
|
}
|
|
328
370
|
}
|
|
329
|
-
return { kept: out, costUsd, source, walkerMs, provider, model };
|
|
371
|
+
return { kept: out, scores, costUsd, source, walkerMs, provider, model };
|
|
330
372
|
} catch (e) {
|
|
331
|
-
return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0, provider: options.provider || null, model: null };
|
|
373
|
+
return { kept: null, scores: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0, provider: options.provider || null, model: null };
|
|
332
374
|
}
|
|
333
375
|
}
|
|
334
376
|
|
|
@@ -491,6 +533,14 @@ async function run(ctx) {
|
|
|
491
533
|
// the judge's verdict wins. Top-3 ranked candidates and episodes (no stable
|
|
492
534
|
// history) always go to the judge.
|
|
493
535
|
const cascadeOn = tier === "full" && tuning.get("cascade") !== "off";
|
|
536
|
+
// Score-gate config (WS4). When a relevance floor is set, the walker's real
|
|
537
|
+
// per-turn score decides injection — so the cascade must NOT auto-KEEP from
|
|
538
|
+
// history: a sticky pack's history score is always high and would re-leak the
|
|
539
|
+
// very irrelevant-recall defect the gate exists to fix. Auto-DROP still fires
|
|
540
|
+
// (dead nodes cost tokens and never help); would-be-keeps are routed to the
|
|
541
|
+
// judge instead, where they earn a real current-turn score and face the gate.
|
|
542
|
+
const minScore = tier === "full" ? tuning.get("minRelevanceScore") : 0;
|
|
543
|
+
const gateConfigured = minScore > 0;
|
|
494
544
|
const autoKept = new Map(); // id -> canned why (honest provenance)
|
|
495
545
|
const autoDropped = [];
|
|
496
546
|
const shadowVerdicts = new Map(); // id -> auto verdict, also sent to judge
|
|
@@ -505,13 +555,16 @@ async function run(ctx) {
|
|
|
505
555
|
const v = cascadeVerdict(st);
|
|
506
556
|
if (!v) { judgeCands.push(c); continue; }
|
|
507
557
|
if (Math.random() < SHADOW_RATE) { shadowVerdicts.set(c.id, v); judgeCands.push(c); continue; }
|
|
508
|
-
if (v === "keep")
|
|
509
|
-
|
|
558
|
+
if (v === "keep") {
|
|
559
|
+
if (gateConfigured) { judgeCands.push(c); continue; } // no history auto-keep under the gate — the judge scores it
|
|
560
|
+
autoKept.set(c.id, `kept ${st.kept}/${st.seen} recent similar turns`);
|
|
561
|
+
} else autoDropped.push(c.id);
|
|
510
562
|
}
|
|
511
563
|
}
|
|
512
564
|
|
|
513
|
-
const walkRes = await walk(userText, contextText || fullContext, judgeCands, { provider: provider || null });
|
|
565
|
+
const walkRes = await walk(userText, contextText || fullContext, judgeCands, { provider: provider || null, ...(ctx.walkerOptions || {}) });
|
|
514
566
|
const whyById = walkRes.kept;
|
|
567
|
+
const scoreById = walkRes.scores; // parallel id→0-100 router relevance (null on fail-open)
|
|
515
568
|
|
|
516
569
|
let shadowChecks = 0, shadowAgree = 0;
|
|
517
570
|
if (whyById && shadowVerdicts.size) {
|
|
@@ -529,6 +582,19 @@ async function run(ctx) {
|
|
|
529
582
|
// once kept 15 unjudged seeds including WiFi tools for a dream question). The
|
|
530
583
|
// seeds tier keeps its classic contract: all user-origin keyword seeds.
|
|
531
584
|
let whyMap = whyById ? new Map(whyById) : null;
|
|
585
|
+
// Score-gate on injection (WS4 keystone): drop any walker-kept node scoring
|
|
586
|
+
// below the relevance floor, so a weakly-linked node no longer gets injected.
|
|
587
|
+
// Only fires when the walker produced real scores (full tier, successful walk);
|
|
588
|
+
// fail-open (scores null) skips the gate. A successful walk that gates every
|
|
589
|
+
// node leaves an empty (non-null) whyMap → inject nothing, which is the
|
|
590
|
+
// correct signal "nothing was relevant" (NOT the fail-open seed fallback,
|
|
591
|
+
// which is reserved for a walk that never produced a verdict at all).
|
|
592
|
+
const gatedOut = [];
|
|
593
|
+
if (whyMap && gateConfigured && scoreById) {
|
|
594
|
+
for (const id of [...whyMap.keys()]) {
|
|
595
|
+
if (scoreById.has(id) && scoreById.get(id) < minScore) { whyMap.delete(id); gatedOut.push(id); }
|
|
596
|
+
}
|
|
597
|
+
}
|
|
532
598
|
if (whyMap) for (const [id, why] of autoKept) whyMap.set(id, why);
|
|
533
599
|
let keptIds;
|
|
534
600
|
if (whyMap) {
|
|
@@ -570,8 +636,23 @@ async function run(ctx) {
|
|
|
570
636
|
const finalPacks = packMatches.slice(0, packLimit);
|
|
571
637
|
const finalEnts = entityMatches.slice(0, 4);
|
|
572
638
|
|
|
573
|
-
// 5: render via shared builders, then weave in the why-bullets.
|
|
574
|
-
|
|
639
|
+
// 5: render via shared builders, then weave in the why-bullets. Extractive
|
|
640
|
+
// mode (default on) surfaces the verbatim lines that matched this message from
|
|
641
|
+
// anywhere in each pack — the router already scored relevance; here we hand
|
|
642
|
+
// the model the actual facts, not just a Stance headline (Defect-A fix). Line
|
|
643
|
+
// hits come from the WS1 per-line search, scoped per final pack.
|
|
644
|
+
const extractiveOn = tuning.get("extractive") !== "off" && typeof packsLib.searchLines === "function";
|
|
645
|
+
let matchedLines = null;
|
|
646
|
+
if (extractiveOn && finalPacks.length) {
|
|
647
|
+
matchedLines = new Map();
|
|
648
|
+
for (const m of finalPacks) {
|
|
649
|
+
try {
|
|
650
|
+
const hits = packsLib.searchLines(userText, { dir: m.dir, k: 8 });
|
|
651
|
+
if (hits && hits.length) matchedLines.set(m.dir, hits);
|
|
652
|
+
} catch (e) { /* a pack with no line hits just shows Stance */ }
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
let packBlock = buildPackBlock(finalPacks, budget, extractiveOn ? { extractive: true, matchedLines } : {});
|
|
575
656
|
const entityBlock = buildEntityBlock(finalEnts, budget);
|
|
576
657
|
if (packBlock && whyMap) {
|
|
577
658
|
const bullets = finalPacks
|
|
@@ -631,6 +712,25 @@ async function run(ctx) {
|
|
|
631
712
|
episodeBlock = `\n\n## Past work that looks related\nFrom earlier conversations — verify before relying on it:\n${lines.join("\n")}\n`;
|
|
632
713
|
}
|
|
633
714
|
|
|
715
|
+
// WS5: asymmetric co-kept warming (dark-launch OFF). Feed a weak Hebbian
|
|
716
|
+
// signal from the walker's co-injected, high-confidence, message-grounded
|
|
717
|
+
// keeps — a fraction of a real co-use bump. Best-effort: never breaks recall.
|
|
718
|
+
let cokeptWarmed = [];
|
|
719
|
+
if (tier === "full" && tuning.get("cokeptWarming") === "on" && scoreById && keptIds.size > 1) {
|
|
720
|
+
try {
|
|
721
|
+
const grounded = new Set([
|
|
722
|
+
...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
|
|
723
|
+
...entSeeds.filter((m) => m.origin === "user").map((m) => `entity:${m.slug}`),
|
|
724
|
+
]);
|
|
725
|
+
cokeptWarmed = cokeptPairs(keptIds, scoreById, grounded, {
|
|
726
|
+
minScore: tuning.get("warmMinScore"),
|
|
727
|
+
maxPairs: tuning.get("cokeptMaxPairs"),
|
|
728
|
+
});
|
|
729
|
+
const amount = tuning.get("cokeptAmount");
|
|
730
|
+
for (const [a, b] of cokeptWarmed) graph.reinforce(a, b, amount);
|
|
731
|
+
} catch (e) { cokeptWarmed = []; }
|
|
732
|
+
}
|
|
733
|
+
|
|
634
734
|
metrics.logTurn({
|
|
635
735
|
engine: "discoverer",
|
|
636
736
|
provider: walkRes.provider || provider || "",
|
|
@@ -640,10 +740,13 @@ async function run(ctx) {
|
|
|
640
740
|
seeds: seedNodes,
|
|
641
741
|
activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
|
|
642
742
|
cand: candidates.map((c) => ({ id: c.id, act: !!c.activated })),
|
|
643
|
-
kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "" })),
|
|
743
|
+
kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "", score: scoreById && scoreById.has(id) ? scoreById.get(id) : null })),
|
|
644
744
|
auto: (autoKept.size || autoDropped.length || shadowChecks)
|
|
645
745
|
? { kept: [...autoKept.keys()], dropped: autoDropped, shadow: { checks: shadowChecks, agree: shadowAgree } }
|
|
646
746
|
: null,
|
|
747
|
+
gate: gateConfigured ? { min: minScore, out: gatedOut } : undefined,
|
|
748
|
+
extracted: matchedLines && matchedLines.size ? [...matchedLines].map(([dir, hits]) => ({ dir, n: hits.length })) : undefined,
|
|
749
|
+
warmed: cokeptWarmed.length ? cokeptWarmed : undefined,
|
|
647
750
|
gated: false,
|
|
648
751
|
latencyMs: Date.now() - started,
|
|
649
752
|
seedMs,
|
|
@@ -660,4 +763,4 @@ async function run(ctx) {
|
|
|
660
763
|
};
|
|
661
764
|
}
|
|
662
765
|
|
|
663
|
-
module.exports = { name: "discoverer", run, runWalker, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
|
|
766
|
+
module.exports = { name: "discoverer", run, runWalker, needsRecall, recallTier, walk, cascadeVerdict, cokeptPairs, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
|
package/core/recall/kpi.js
CHANGED
|
@@ -16,6 +16,9 @@ function emptyBucket(v) {
|
|
|
16
16
|
rescueTurns: 0, rescues: 0, walkerFailed: 0,
|
|
17
17
|
latencyMsTotal: 0, costUsd: 0, autoKept: 0, autoDropped: 0,
|
|
18
18
|
opens: 0, reviewerUses: 0, byTier: {},
|
|
19
|
+
// Pass-4 signals (WS0): walker relevance scores on kept nodes, score-gate
|
|
20
|
+
// activity, and co-kept warmings — the ruler for the accuracy/precision work.
|
|
21
|
+
keptScoreSum: 0, keptScoreN: 0, gateActiveTurns: 0, gateFiredTurns: 0, gatedNodes: 0, warmings: 0,
|
|
19
22
|
};
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -54,6 +57,14 @@ function scanLog() {
|
|
|
54
57
|
const seedIds = new Set((rec.seeds || []).map((s) => s.id));
|
|
55
58
|
const rescues = kept.filter((k) => !seedIds.has(k.id)).length;
|
|
56
59
|
if (rescues > 0) { b.rescueTurns++; b.rescues += rescues; }
|
|
60
|
+
for (const k of kept) if (typeof k.score === "number") { b.keptScoreSum += k.score; b.keptScoreN++; }
|
|
61
|
+
if (rec.gate) {
|
|
62
|
+
b.gateActiveTurns++;
|
|
63
|
+
const out = (rec.gate.out || []).length;
|
|
64
|
+
b.gatedNodes += out;
|
|
65
|
+
if (out > 0) b.gateFiredTurns++;
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(rec.warmed)) b.warmings += rec.warmed.length;
|
|
57
68
|
if (rec.auto) {
|
|
58
69
|
b.autoKept += (rec.auto.kept || []).length;
|
|
59
70
|
b.autoDropped += (rec.auto.dropped || []).length;
|
|
@@ -91,6 +102,10 @@ function aggregate() {
|
|
|
91
102
|
walkerFailPct: pct(b.walkerFailed, b.turns - b.gated),
|
|
92
103
|
autoKept: b.autoKept,
|
|
93
104
|
autoDropped: b.autoDropped,
|
|
105
|
+
avgKeptScore: b.keptScoreN ? Math.round(b.keptScoreSum / b.keptScoreN) : null,
|
|
106
|
+
gatedNodes: b.gatedNodes,
|
|
107
|
+
gateFiredPct: pct(b.gateFiredTurns, b.gateActiveTurns),
|
|
108
|
+
warmings: b.warmings,
|
|
94
109
|
});
|
|
95
110
|
}
|
|
96
111
|
rows.sort((a, b) => (a.from || "").localeCompare(b.from || ""));
|
|
@@ -107,6 +122,9 @@ function renderTable(rows) {
|
|
|
107
122
|
["turns", (r) => String(r.turns)],
|
|
108
123
|
["rescue", (r) => (r.rescuePct == null ? "—" : r.rescuePct + "%")],
|
|
109
124
|
["noise", (r) => (r.noisePct == null ? "—" : r.noisePct + "%")],
|
|
125
|
+
["relevance", (r) => (r.avgKeptScore == null ? "—" : String(r.avgKeptScore))],
|
|
126
|
+
["gate", (r) => (r.gateFiredPct == null ? "—" : `${r.gateFiredPct}% / ${r.gatedNodes}`)],
|
|
127
|
+
["warm", (r) => String(r.warmings || 0)],
|
|
110
128
|
["avg", (r) => (r.avgLatencyMs / 1000).toFixed(1) + "s"],
|
|
111
129
|
["gated", (r) => (r.gatedPct == null ? "—" : r.gatedPct + "%")],
|
|
112
130
|
["$ / turn", (r) => "$" + r.costPerTurnUsd.toFixed(3)],
|
|
@@ -177,6 +195,8 @@ function htmlReport() {
|
|
|
177
195
|
barChart("Average recall latency", rows.map((r) => ({ label: label(r), value: r.avgLatencyMs / 1000 })), { fmt: (v) => v.toFixed(1) + "s" }),
|
|
178
196
|
barChart("Recall cost per turn", rows.map((r) => ({ label: label(r), value: r.costPerTurnUsd })), { fmt: (v) => "$" + v.toFixed(3) }),
|
|
179
197
|
barChart("Gated turns — pleasantries skipped for free", rows.map((r) => ({ label: label(r), value: r.gatedPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
198
|
+
barChart("Average relevance of kept memories — walker score 0–100 (higher = the model kept more clearly-relevant nodes)",
|
|
199
|
+
rows.map((r) => ({ label: label(r), value: r.avgKeptScore })), { fmt: (v) => Math.round(v), max: 100 }),
|
|
180
200
|
barChart("Walker failure rate", rows.map((r) => ({ label: label(r), value: r.walkerFailPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
181
201
|
].filter(Boolean).map((s) => `<div class="card">${s}</div>`).join("\n");
|
|
182
202
|
|
|
@@ -191,8 +211,9 @@ function htmlReport() {
|
|
|
191
211
|
|
|
192
212
|
const tierRows = rows.map((r) => {
|
|
193
213
|
const t = Object.entries(r.byTier).map(([k, v]) => `${k}: ${v.turns}× @ ${(v.avgMs / 1000).toFixed(1)}s`).join(" · ") || "—";
|
|
214
|
+
const rel = `${r.avgKeptScore == null ? "—" : r.avgKeptScore} / ${r.gateFiredPct == null ? "—" : r.gateFiredPct + "%·" + r.gatedNodes} / ${r.warmings || 0}`;
|
|
194
215
|
return `<tr><td>${esc(r.v)}</td><td>${esc(r.from)}→${esc(r.to)}</td><td>${r.turns}</td><td>${t}</td>` +
|
|
195
|
-
`<td>${r.opens} / ${r.reviewerUses}</td><td>${r.autoKept} / ${r.autoDropped}</td><td>${esc(r.models.join(", ") || "—")}</td></tr>`;
|
|
216
|
+
`<td>${r.opens} / ${r.reviewerUses}</td><td>${r.autoKept} / ${r.autoDropped}</td><td>${esc(rel)}</td><td>${esc(r.models.join(", ") || "—")}</td></tr>`;
|
|
196
217
|
}).join("\n");
|
|
197
218
|
|
|
198
219
|
return `<!doctype html>
|
|
@@ -212,7 +233,7 @@ th{color:#8b98a5;font-weight:600}
|
|
|
212
233
|
${charts}
|
|
213
234
|
${trendChart ? `<div class="card">${trendChart}</div>` : ""}
|
|
214
235
|
<div class="card"><table>
|
|
215
|
-
<tr><th>version</th><th>window</th><th>turns</th><th>latency by tier</th><th>opens / reviewer</th><th>cascade kept / dropped</th><th>walker model</th></tr>
|
|
236
|
+
<tr><th>version</th><th>window</th><th>turns</th><th>latency by tier</th><th>opens / reviewer</th><th>cascade kept / dropped</th><th>relevance / gate·nodes / warm</th><th>walker model</th></tr>
|
|
216
237
|
${tierRows}
|
|
217
238
|
</table></div>
|
|
218
239
|
</body></html>`;
|
package/core/recall/tuning.js
CHANGED
|
@@ -18,6 +18,24 @@ const KNOBS = {
|
|
|
18
18
|
seedsTierMaxWords: { def: 4, min: 2, max: 6, env: "RECALL_SEEDS_TIER_MAX_WORDS" },
|
|
19
19
|
decayHalfLifeDays: { def: 60, min: 10, max: 90, env: "RECALL_DECAY_HALF_LIFE_DAYS" },
|
|
20
20
|
cascade: { def: "on", values: ["on", "off"], env: "RECALL_CASCADE" },
|
|
21
|
+
// Extractive on-open injection: surface Stance verbatim + the actual lines
|
|
22
|
+
// that matched this message (from anywhere in the pack), not just a headline.
|
|
23
|
+
extractive: { def: "on", values: ["on", "off"], env: "RECALL_EXTRACTIVE" },
|
|
24
|
+
// Injection score-gate (keystone): a walker-kept node scoring below this floor
|
|
25
|
+
// (0–100) is not injected. Set 0 to disable the gate entirely (and restore
|
|
26
|
+
// history-based cascade auto-keep). Default 55 = drop weak/incidental links.
|
|
27
|
+
minRelevanceScore: { def: 55, min: 0, max: 100, env: "RECALL_MIN_RELEVANCE_SCORE" },
|
|
28
|
+
// Asymmetric co-kept warming (WS5). Co-OPEN (the model actually opened the
|
|
29
|
+
// nodes) warms at full strength; co-KEPT (the walker co-injected them) is a
|
|
30
|
+
// weaker, noisier signal. Dark-launched OFF: it writes persistent graph state,
|
|
31
|
+
// so it can't be validated in a single test session — flip it on deliberately.
|
|
32
|
+
cokeptWarming: { def: "off", values: ["on", "off"], env: "RECALL_COKEPT_WARMING" },
|
|
33
|
+
// How much a co-kept pair bumps its `related` edge (vs 1.0 for a real co-use).
|
|
34
|
+
cokeptAmount: { def: 0.15, min: 0, max: 1, env: "RECALL_COKEPT_AMOUNT" },
|
|
35
|
+
// Only co-kept nodes scoring at/above this floor warm — high-confidence keeps.
|
|
36
|
+
warmMinScore: { def: 60, min: 0, max: 100, env: "RECALL_WARM_MIN_SCORE" },
|
|
37
|
+
// Cap on how many (strongest) co-kept pairs warm per turn.
|
|
38
|
+
cokeptMaxPairs: { def: 3, min: 1, max: 10, env: "RECALL_COKEPT_MAX_PAIRS" },
|
|
21
39
|
};
|
|
22
40
|
|
|
23
41
|
let _cache = null;
|
package/core/router.js
CHANGED
|
@@ -22,8 +22,10 @@ const {
|
|
|
22
22
|
clearPendingClaudeAuth, clearPendingCodexAuth,
|
|
23
23
|
sendClaudeAuthStatusSummary, sendCodexAuthStatusSummary,
|
|
24
24
|
} = require("./auth-flow");
|
|
25
|
-
const { redactSensitive } = require("./redact");
|
|
25
|
+
const { redactSensitive, registerSecrets } = require("./redact");
|
|
26
26
|
const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
|
|
27
|
+
const keyring = require("./keyring");
|
|
28
|
+
const { classifyPastedCredential } = require("./credential-detect");
|
|
27
29
|
const channelWizard = require("./channel-wizard");
|
|
28
30
|
require("./handlers"); // side-effect: register slash commands
|
|
29
31
|
|
|
@@ -272,6 +274,51 @@ async function handleDocument(envelope) {
|
|
|
272
274
|
} catch (err) { await send(`Failed: ${err.message}`); }
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
// Provider-agnostic capture of a credential pasted into chat with no auth
|
|
278
|
+
// flow active. Before v3.0.37 such a paste was merely deleted and then still
|
|
279
|
+
// forwarded to the model — which, under Codex, refused it as a policy
|
|
280
|
+
// violation. Now the router classifies it and stores it out-of-band (Claude
|
|
281
|
+
// token → .env/vault, OpenAI key → Codex CLI, other recognised secrets →
|
|
282
|
+
// operational keyring), deletes the message, confirms, and stops. The model
|
|
283
|
+
// never sees the secret. Returns true when it fully handled the message
|
|
284
|
+
// (caller must stop), false to let the turn proceed normally.
|
|
285
|
+
async function captureLooseCredential(text, envelope) {
|
|
286
|
+
const disposition = classifyPastedCredential(text);
|
|
287
|
+
switch (disposition.kind) {
|
|
288
|
+
case "claude": {
|
|
289
|
+
await deleteMessage(envelope.messageId);
|
|
290
|
+
registerSecrets(text);
|
|
291
|
+
saveClaudeOAuthToken(text);
|
|
292
|
+
await send(`Saved your Claude token to .env${vault.isUnlocked() ? " and vault" : ""} and deleted your message so it isn't left in chat. Restart with /restart so I pick it up.`);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
case "openai": {
|
|
296
|
+
await deleteMessage(envelope.messageId);
|
|
297
|
+
registerSecrets(text);
|
|
298
|
+
const result = await saveCodexApiKeyWithCli(text);
|
|
299
|
+
await send(result.ok
|
|
300
|
+
? "Saved your OpenAI API key via the Codex CLI and deleted your message. I never printed it."
|
|
301
|
+
: `Deleted your message, but the Codex CLI couldn't store that key: ${redactSensitive(result.output).slice(-400)}`);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
case "keyring": {
|
|
305
|
+
await deleteMessage(envelope.messageId);
|
|
306
|
+
registerSecrets(disposition.value);
|
|
307
|
+
keyring.set(disposition.name, disposition.value);
|
|
308
|
+
await send(`Stored that secret in the operational keyring as \`${disposition.name}\` and deleted your message. Manage it with /keyring.`);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
case "scrub": {
|
|
312
|
+
// Ambiguous long token: strip it from the chat but let the turn proceed
|
|
313
|
+
// (it may be an id/arg the agent legitimately needs to act on).
|
|
314
|
+
await deleteMessage(envelope.messageId);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
default:
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
275
322
|
async function handleText(envelope) {
|
|
276
323
|
if (isDuplicate(envelope)) return;
|
|
277
324
|
const state = currentState();
|
|
@@ -448,17 +495,14 @@ async function handleText(envelope) {
|
|
|
448
495
|
}
|
|
449
496
|
|
|
450
497
|
const text = envelope.text || "";
|
|
498
|
+
|
|
499
|
+
// Loose credential paste with no auth flow active: store it out-of-band and
|
|
500
|
+
// stop, instead of forwarding a secret to the model (Codex refuses those).
|
|
501
|
+
if (await captureLooseCredential(text.trim(), envelope)) return;
|
|
502
|
+
|
|
451
503
|
const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
|
|
452
504
|
const attachments = [];
|
|
453
505
|
const imagePaths = [];
|
|
454
|
-
const credPatterns = [
|
|
455
|
-
/^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/,
|
|
456
|
-
/^[A-Za-z0-9_-]{20,}$/,
|
|
457
|
-
/^(Bearer |token:|key:|secret:|password:)/i,
|
|
458
|
-
];
|
|
459
|
-
if (credPatterns.some((p) => p.test(text.trim()))) {
|
|
460
|
-
await deleteMessage(envelope.messageId);
|
|
461
|
-
}
|
|
462
506
|
|
|
463
507
|
let prompt = text;
|
|
464
508
|
const reply = envelope.reply;
|
package/core/scheduler.js
CHANGED
|
@@ -10,6 +10,8 @@ const crypto = require("crypto");
|
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const cron = require("node-cron");
|
|
13
|
+
const { spawn } = require("child_process");
|
|
14
|
+
const CONFIG_DIR = require("../config-dir");
|
|
13
15
|
|
|
14
16
|
const {
|
|
15
17
|
CRONS_FILE,
|
|
@@ -34,6 +36,10 @@ const { redactSensitive } = require("./redact");
|
|
|
34
36
|
const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
|
|
35
37
|
const RETRY_DELAY_MS = 30 * 1000;
|
|
36
38
|
const BUSY_DELAY_MS = 30 * 1000;
|
|
39
|
+
// A watch's `check` command is capped so a hung poll can't wedge the tick.
|
|
40
|
+
const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
41
|
+
const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
|
|
42
|
+
const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
|
|
37
43
|
|
|
38
44
|
function uniqueId(prefix = "occ") {
|
|
39
45
|
return typeof crypto.randomUUID === "function"
|
|
@@ -53,6 +59,13 @@ function terminalRunSucceeded(result) {
|
|
|
53
59
|
return true;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
// A one-shot fires exactly once then is removed after a successful run: a
|
|
63
|
+
// wakeup, or a watch (a cron carrying a shell `check`) created without repeat
|
|
64
|
+
// polling. A repeat watch stays pending and keeps polling like any cron.
|
|
65
|
+
function isOneShot(job) {
|
|
66
|
+
return job.kind === "wakeup" || (!!job.check && !job.repeat);
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
function providerIsRunnable(status) {
|
|
57
70
|
if (!status || status.availability?.state !== "available") return false;
|
|
58
71
|
if (status.compatibility?.state === "incompatible") return false;
|
|
@@ -96,6 +109,30 @@ function createSchedulerService(dependencies = {}) {
|
|
|
96
109
|
const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
|
|
97
110
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
98
111
|
const timers = new Map();
|
|
112
|
+
const checksInFlight = new Set();
|
|
113
|
+
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
114
|
+
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
115
|
+
// dir with the process env, output discarded (exit code is the contract),
|
|
116
|
+
// hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
|
|
117
|
+
const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
|
|
118
|
+
const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (code, timedOut) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
try { clearTimer(timer); } catch (_) {}
|
|
124
|
+
resolve({ code, timedOut: !!timedOut });
|
|
125
|
+
};
|
|
126
|
+
let child;
|
|
127
|
+
try {
|
|
128
|
+
child = spawn("sh", ["-c", String(command)], { cwd, env: process.env, stdio: "ignore" });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return resolve({ code: 127, timedOut: false });
|
|
131
|
+
}
|
|
132
|
+
const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
|
|
133
|
+
child.on("close", (code) => finish(code == null ? 1 : code, false));
|
|
134
|
+
child.on("error", () => finish(127, false));
|
|
135
|
+
}));
|
|
99
136
|
let adapters = Array.isArray(dependencies.adapters) ? dependencies.adapters.slice() : [];
|
|
100
137
|
|
|
101
138
|
function setAdapters(list) {
|
|
@@ -186,7 +223,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
186
223
|
|
|
187
224
|
function wrappedPrompt(job, target) {
|
|
188
225
|
const firedAt = new Date(now()).toISOString();
|
|
189
|
-
const header = job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
226
|
+
const header = job.check ? "Watch triggered" : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
190
227
|
if (target.fallbackFrom) {
|
|
191
228
|
return [
|
|
192
229
|
`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
|
|
@@ -270,7 +307,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
270
307
|
catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
|
|
271
308
|
if (!marked) return { ok: false, status: "failed", error: { code: "JOB_PERSIST_FAILED", message: "Scheduled success marker was not persisted" }, result };
|
|
272
309
|
|
|
273
|
-
if (job
|
|
310
|
+
if (isOneShot(job)) {
|
|
274
311
|
try {
|
|
275
312
|
const removed = jobsStore.remove(job.id);
|
|
276
313
|
if (!removed) throw new Error("successful one-shot could not be removed after its durable success marker");
|
|
@@ -308,12 +345,34 @@ function createSchedulerService(dependencies = {}) {
|
|
|
308
345
|
if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
|
|
309
346
|
return { ok: false, status: "running", skipped: true, inFlight: true };
|
|
310
347
|
}
|
|
311
|
-
if (job.status === "succeeded" && job
|
|
348
|
+
if (job.status === "succeeded" && isOneShot(job)) {
|
|
312
349
|
try { jobsStore.remove(job.id); } catch (_) {}
|
|
313
350
|
unscheduleJob(job.id);
|
|
314
351
|
return { ok: true, status: "succeeded", finalized: true };
|
|
315
352
|
}
|
|
316
353
|
|
|
354
|
+
// Watch poll gate. A watch is a cron carrying a shell `check`; each tick
|
|
355
|
+
// runs the check and only wakes the agent when it exits 0. A non-zero exit
|
|
356
|
+
// is the normal idle case: skip silently, mark nothing, leave the job
|
|
357
|
+
// pending so the next tick polls again. Never marks failure; never chats.
|
|
358
|
+
if (job.check) {
|
|
359
|
+
if (!watchesEnabled()) {
|
|
360
|
+
logWatch(job, "skipped (watches disabled globally)");
|
|
361
|
+
return { ok: false, status: "watch-disabled", skipped: true };
|
|
362
|
+
}
|
|
363
|
+
if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
|
|
364
|
+
checksInFlight.add(job.id);
|
|
365
|
+
let check;
|
|
366
|
+
try { check = await runWatchCheck(job.check, job); }
|
|
367
|
+
catch (_) { check = { code: 1, timedOut: false }; }
|
|
368
|
+
finally { checksInFlight.delete(job.id); }
|
|
369
|
+
if (!check || check.code !== 0) {
|
|
370
|
+
logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
|
|
371
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
372
|
+
}
|
|
373
|
+
logWatch(job, "triggered (check passed) — waking agent");
|
|
374
|
+
}
|
|
375
|
+
|
|
317
376
|
const adapter = resolveAdapter(job);
|
|
318
377
|
if (!adapter) {
|
|
319
378
|
try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
|
|
@@ -385,7 +444,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
385
444
|
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
386
445
|
}
|
|
387
446
|
|
|
388
|
-
try { await send(`Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
|
|
447
|
+
try { await send(job.check ? `🔔 Watch "${job.label}" triggered` : `Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
|
|
389
448
|
let result;
|
|
390
449
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
391
450
|
catch (error) {
|
|
@@ -403,7 +462,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
403
462
|
function scheduleJob(job) {
|
|
404
463
|
unscheduleJob(job.id);
|
|
405
464
|
if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
|
|
406
|
-
if (job.status === "succeeded" && job
|
|
465
|
+
if (job.status === "succeeded" && isOneShot(job)) {
|
|
407
466
|
try { jobsStore.remove(job.id); } catch (_) {}
|
|
408
467
|
return false;
|
|
409
468
|
}
|
|
@@ -514,6 +573,41 @@ function createSchedulerService(dependencies = {}) {
|
|
|
514
573
|
return job;
|
|
515
574
|
}
|
|
516
575
|
|
|
576
|
+
// Global master switch for all watches (owner's /watch off). Absent file →
|
|
577
|
+
// enabled (default ON). Only the poll gate consults it; wakeups/crons are
|
|
578
|
+
// untouched.
|
|
579
|
+
function watchesEnabled() {
|
|
580
|
+
try { return fs.readFileSync(WATCHES_ENABLED_FILE, "utf8").trim() !== "off"; }
|
|
581
|
+
catch (_) { return true; }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function setWatchesEnabled(on) {
|
|
585
|
+
try { fs.writeFileSync(WATCHES_ENABLED_FILE, on ? "on" : "off"); } catch (_) {}
|
|
586
|
+
return !!on;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Append-only audit of watch poll activity. NEVER sent to chat — this is the
|
|
590
|
+
// whole point of a watch: the polling stays out of the conversation, only a
|
|
591
|
+
// real trigger wakes the agent and surfaces in chat.
|
|
592
|
+
function logWatch(job, event) {
|
|
593
|
+
const line = `[${new Date(now()).toISOString()}] job=${job.id} label=${JSON.stringify(job.label || "")} ${event}\n`;
|
|
594
|
+
try { fs.appendFileSync(WATCH_LOG_FILE, line); } catch (_) {}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// A watch is a cron carrying a shell `check`; it rides the migration-verified
|
|
598
|
+
// jobs store untouched (check/repeat survive normalizeRunnableJob as extra
|
|
599
|
+
// fields). Default schedule polls every minute; default is one-shot.
|
|
600
|
+
function addWatch(spec) {
|
|
601
|
+
return addJob({
|
|
602
|
+
...spec,
|
|
603
|
+
kind: "cron",
|
|
604
|
+
schedule: spec.schedule || "* * * * *",
|
|
605
|
+
check: String(spec.check || "").trim(),
|
|
606
|
+
repeat: !!spec.repeat,
|
|
607
|
+
label: spec.label || String(spec.prompt || "watch").slice(0, 60),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
517
611
|
function removeJob(id) {
|
|
518
612
|
unscheduleJob(id);
|
|
519
613
|
return jobsStore.remove(id);
|
|
@@ -529,6 +623,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
529
623
|
scheduleJob,
|
|
530
624
|
unscheduleJob,
|
|
531
625
|
addJob,
|
|
626
|
+
addWatch,
|
|
627
|
+
watchesEnabled,
|
|
628
|
+
setWatchesEnabled,
|
|
532
629
|
removeJob,
|
|
533
630
|
stopAll,
|
|
534
631
|
fireJob,
|
|
@@ -561,6 +658,9 @@ module.exports = {
|
|
|
561
658
|
scheduleJob: service.scheduleJob,
|
|
562
659
|
unscheduleJob: service.unscheduleJob,
|
|
563
660
|
addJob: service.addJob,
|
|
661
|
+
addWatch: service.addWatch,
|
|
662
|
+
watchesEnabled: service.watchesEnabled,
|
|
663
|
+
setWatchesEnabled: service.setWatchesEnabled,
|
|
564
664
|
removeJob: service.removeJob,
|
|
565
665
|
stopAll: service.stopAll,
|
|
566
666
|
fireJob: service.fireJob,
|