auxilo-mcp 0.9.2 → 0.9.3
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/bin/auxilo-cli.js +205 -31
- package/lib/installer.js +392 -12
- package/mcp-server.js +5 -5
- package/package.json +5 -2
- package/scripts/extract-local.js +99 -14
- package/scripts/review-notice.js +145 -0
- package/scripts/runner.js +161 -44
- package/scripts/sources/cline.js +160 -0
- package/scripts/sources/continue.js +140 -0
- package/scripts/sources/roo-code.js +35 -0
package/scripts/extract-local.js
CHANGED
|
@@ -19,7 +19,24 @@ const os = require('os');
|
|
|
19
19
|
|
|
20
20
|
const CATEGORIES = ['data-processing', 'web-interaction', 'code-execution', 'communication', 'storage-state', 'content-generation', 'payment-financial', 'monitoring'];
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* SPEC3 slice A1 gate — score-at-extraction, BUILT BUT DARK by default.
|
|
24
|
+
*
|
|
25
|
+
* ┌─ CRITICAL SEQUENCING CONSTRAINT (SPEC3-BUILDER-REVIEW-LOOP §3.1/§8) ──────┐
|
|
26
|
+
* │ Under a server WITHOUT the B1 extraction-channel hold, a clean /learn │
|
|
27
|
+
* │ submission carrying a floor-passing quality_self_assessment publishes │
|
|
28
|
+
* │ IMMEDIATELY (seamlessEligible). Turning this gate on against such a │
|
|
29
|
+
* │ server silently flips hook extraction from "everything held" to "clean │
|
|
30
|
+
* │ items auto-publish" — an unrecorded consent change (2026-06-10 class). │
|
|
31
|
+
* │ Do NOT set AUXILO_SCORE_EXTRACTION=1 until the server holds │
|
|
32
|
+
* │ submission_channel:'extraction' items behind standing consent (B1). │
|
|
33
|
+
* └───────────────────────────────────────────────────────────────────────────┘
|
|
34
|
+
*/
|
|
35
|
+
function scoreExtractionEnabled(env = process.env) {
|
|
36
|
+
return env.AUXILO_SCORE_EXTRACTION === '1';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EXTRACTION_PROMPT_BASE = `You are extracting reusable OPERATIONAL LEARNINGS from an AI agent's session transcript, to publish to a PUBLIC knowledge marketplace read by other AI agents.
|
|
23
40
|
|
|
24
41
|
Extract 0 to 5 GENUINE learnings: non-obvious solutions, workarounds, API quirks, error root-causes, integration gotchas — the kind of thing that cost real debugging or combined multiple sources. SKIP trivial lookups, well-documented standard approaches, opinions, and conversation.
|
|
25
42
|
|
|
@@ -31,12 +48,36 @@ Output STRICT JSON ONLY — an array (possibly empty []) of objects with these k
|
|
|
31
48
|
"category": one of ${JSON.stringify(CATEGORIES)}
|
|
32
49
|
"tags": array of lowercase keyword strings
|
|
33
50
|
"task_context": one sentence describing the task
|
|
34
|
-
"outcome": one of "success","partial","failure","workaround"
|
|
51
|
+
"outcome": one of "success","partial","failure","workaround"`;
|
|
52
|
+
|
|
53
|
+
/** A1: rubric addendum — appended ONLY when scoreExtractionEnabled(). */
|
|
54
|
+
const QUALITY_RUBRIC_ADDENDUM = `
|
|
55
|
+
"quality_self_assessment": an object scoring the learning honestly on four
|
|
56
|
+
dimensions, each an INTEGER 1-5: "specificity" (precise and detailed, not
|
|
57
|
+
vague), "actionability" (another agent can directly use it), "novelty"
|
|
58
|
+
(non-obvious; an LLM would likely get it wrong), "completeness" (context,
|
|
59
|
+
reproduction steps, caveats), plus "total" (the exact sum of the four).
|
|
60
|
+
A learning worth publishing scores at least 14/20 with no dimension below 3.
|
|
61
|
+
If a learning honestly scores below that bar, DROP it from the array rather
|
|
62
|
+
than inflating the numbers.`;
|
|
63
|
+
|
|
64
|
+
const PROMPT_SUFFIX = `
|
|
35
65
|
No prose, no explanation, no markdown code fences — just the raw JSON array.
|
|
36
66
|
|
|
37
67
|
TRANSCRIPT:
|
|
38
68
|
`;
|
|
39
69
|
|
|
70
|
+
/** Build the extraction prompt for the current (or injected) gate state. */
|
|
71
|
+
function buildExtractionPrompt(opts = {}) {
|
|
72
|
+
const withScore = opts.scoreExtraction !== undefined
|
|
73
|
+
? Boolean(opts.scoreExtraction)
|
|
74
|
+
: scoreExtractionEnabled();
|
|
75
|
+
return EXTRACTION_PROMPT_BASE + (withScore ? QUALITY_RUBRIC_ADDENDUM : '') + PROMPT_SUFFIX;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Back-compat export: the default (gate-evaluated-at-call) prompt. */
|
|
79
|
+
const EXTRACTION_PROMPT = buildExtractionPrompt({ scoreExtraction: false });
|
|
80
|
+
|
|
40
81
|
/** Resolve the `claude` binary — hook/launchd env may have a minimal PATH. */
|
|
41
82
|
function resolveClaudeBin() {
|
|
42
83
|
const candidates = [
|
|
@@ -63,7 +104,7 @@ function resolveClaudeBin() {
|
|
|
63
104
|
*/
|
|
64
105
|
function extractWithClaudeCode(transcript) {
|
|
65
106
|
const bin = resolveClaudeBin();
|
|
66
|
-
const input =
|
|
107
|
+
const input = buildExtractionPrompt() + String(transcript).slice(0, 200000);
|
|
67
108
|
// Do NOT pass ANTHROPIC_API_KEY through — we want the user's logged-in Claude
|
|
68
109
|
// subscription (OAuth), not an API key (which would bill someone). Delete it.
|
|
69
110
|
const childEnv = { ...process.env, AUXILO_EXTRACTING: '1' };
|
|
@@ -79,8 +120,41 @@ function extractWithClaudeCode(transcript) {
|
|
|
79
120
|
return { ok: true, out, reason: null };
|
|
80
121
|
}
|
|
81
122
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
123
|
+
/**
|
|
124
|
+
* A1: validate a model-emitted quality_self_assessment. Returns the normalized
|
|
125
|
+
* object, or null when malformed/missing — the caller then OMITS the field
|
|
126
|
+
* entirely (never fabricate; the server's `awaiting_quality` hold is the
|
|
127
|
+
* correct fallback and is deliberately not a 400 — AUD19-6 quarantines rather
|
|
128
|
+
* than bounces).
|
|
129
|
+
*/
|
|
130
|
+
const QUALITY_DIMENSIONS = ['specificity', 'actionability', 'novelty', 'completeness'];
|
|
131
|
+
function validateQualityAssessment(qa) {
|
|
132
|
+
if (!qa || typeof qa !== 'object' || Array.isArray(qa)) return null;
|
|
133
|
+
const out = {};
|
|
134
|
+
let sum = 0;
|
|
135
|
+
for (const dim of QUALITY_DIMENSIONS) {
|
|
136
|
+
const v = qa[dim];
|
|
137
|
+
if (!Number.isInteger(v) || v < 1 || v > 5) return null;
|
|
138
|
+
out[dim] = v;
|
|
139
|
+
sum += v;
|
|
140
|
+
}
|
|
141
|
+
if (!Number.isInteger(qa.total) || qa.total !== sum) return null;
|
|
142
|
+
out.total = sum;
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Defensively parse a JSON array of learnings out of model output.
|
|
148
|
+
* A1: `quality_self_assessment` is attached ONLY when (a) the score gate is on
|
|
149
|
+
* (opts.scoreExtraction, default = env AUXILO_SCORE_EXTRACTION) AND (b) the
|
|
150
|
+
* assessment validates. Gate OFF strips the field even if the model emits one,
|
|
151
|
+
* so a dark 0.9.3 client can never arm seamless publish (see the sequencing
|
|
152
|
+
* constraint at scoreExtractionEnabled).
|
|
153
|
+
*/
|
|
154
|
+
function parseLearnings(raw, opts = {}) {
|
|
155
|
+
const withScore = opts.scoreExtraction !== undefined
|
|
156
|
+
? Boolean(opts.scoreExtraction)
|
|
157
|
+
: scoreExtractionEnabled();
|
|
84
158
|
let s = String(raw || '').trim();
|
|
85
159
|
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
86
160
|
if (fence) s = fence[1].trim();
|
|
@@ -92,14 +166,21 @@ function parseLearnings(raw) {
|
|
|
92
166
|
if (!Array.isArray(arr)) return [];
|
|
93
167
|
return arr
|
|
94
168
|
.filter(l => l && typeof l.title === 'string' && typeof l.body === 'string' && l.title.length >= 10 && l.body.length >= 50)
|
|
95
|
-
.map(l =>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
169
|
+
.map(l => {
|
|
170
|
+
const out = {
|
|
171
|
+
title: l.title,
|
|
172
|
+
body: l.body,
|
|
173
|
+
category: CATEGORIES.includes(l.category) ? l.category : 'code-execution',
|
|
174
|
+
tags: Array.isArray(l.tags) ? l.tags.slice(0, 8).map(String) : [],
|
|
175
|
+
task_context: typeof l.task_context === 'string' ? l.task_context : '',
|
|
176
|
+
outcome: ['success', 'partial', 'failure', 'workaround'].includes(l.outcome) ? l.outcome : 'success',
|
|
177
|
+
};
|
|
178
|
+
if (withScore) {
|
|
179
|
+
const qa = validateQualityAssessment(l.quality_self_assessment);
|
|
180
|
+
if (qa) out.quality_self_assessment = qa;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
});
|
|
103
184
|
}
|
|
104
185
|
|
|
105
186
|
/**
|
|
@@ -116,4 +197,8 @@ async function extractLocally(transcript, sourceType) {
|
|
|
116
197
|
return { learnings: parseLearnings(out) };
|
|
117
198
|
}
|
|
118
199
|
|
|
119
|
-
module.exports = {
|
|
200
|
+
module.exports = {
|
|
201
|
+
extractLocally, parseLearnings, resolveClaudeBin, CATEGORIES,
|
|
202
|
+
EXTRACTION_PROMPT, buildExtractionPrompt, scoreExtractionEnabled,
|
|
203
|
+
validateQualityAssessment, QUALITY_DIMENSIONS,
|
|
204
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/review-notice.js — SessionStart held-count notice (LW-18 layer 1b)
|
|
5
|
+
*
|
|
6
|
+
* Invoked by the Claude Code SessionStart hook (structured entry written by
|
|
7
|
+
* `auxilo setup` → lib/installer.js registerClaudeCodeSessionStartNotice via
|
|
8
|
+
* the ~/.auxilo/bin/auxilo-review-notice.sh shim). Whatever this prints to
|
|
9
|
+
* stdout is added to the session's context — so the contract is strict:
|
|
10
|
+
*
|
|
11
|
+
* COUNT ONLY. Never titles, never bodies, never flags. Pending bodies are
|
|
12
|
+
* adversarial by assumption (some are held BECAUSE injection-flagged —
|
|
13
|
+
* LW-18 threat model); surfacing content to a live agent at session start
|
|
14
|
+
* would be a confused-deputy channel. One count + two command names, that
|
|
15
|
+
* is all.
|
|
16
|
+
*
|
|
17
|
+
* Behavior:
|
|
18
|
+
* - FAIL-SILENT: every non-happy path exits 0 with NO output. A broken
|
|
19
|
+
* notice must never break (or noise up) a session.
|
|
20
|
+
* - Recursion guard: AUXILO_EXTRACTING=1 (the headless `claude -p`
|
|
21
|
+
* extraction child fires SessionStart too) → silent exit.
|
|
22
|
+
* - Suppression: at most ONE notice per 4 hours, tracked in
|
|
23
|
+
* ~/.auxilo/review-notice-state.json (LW-18 spec).
|
|
24
|
+
* - Count source: GET /account/pending/summary with the credentials from
|
|
25
|
+
* ~/.auxilo/credentials.json; 3.5s abort so session start is never held
|
|
26
|
+
* hostage by a slow network.
|
|
27
|
+
*
|
|
28
|
+
* Self-contained (fs/path/os + global fetch) — ships in RUNNER_STACK to
|
|
29
|
+
* ~/.auxilo/bin/scripts/ and must not require anything outside that layout
|
|
30
|
+
* except node builtins.
|
|
31
|
+
*
|
|
32
|
+
* @module review-notice
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const os = require('os');
|
|
40
|
+
|
|
41
|
+
/** ≤ 1 notice per 4h (LW-18 spec). */
|
|
42
|
+
const NOTICE_SUPPRESSION_MS = 4 * 60 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
/** Network budget: SessionStart hooks block session start. */
|
|
45
|
+
const FETCH_TIMEOUT_MS = 3500;
|
|
46
|
+
|
|
47
|
+
function auxiloDir(homeDir) {
|
|
48
|
+
return path.join(homeDir || os.homedir(), '.auxilo');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function statePath(homeDir) {
|
|
52
|
+
return path.join(auxiloDir(homeDir), 'review-notice-state.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read suppression state; malformed/absent → {} (fail-open to notifying). */
|
|
56
|
+
function readState(homeDir) {
|
|
57
|
+
try {
|
|
58
|
+
const s = JSON.parse(fs.readFileSync(statePath(homeDir), 'utf-8'));
|
|
59
|
+
return s && typeof s === 'object' ? s : {};
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pure suppression decision: notify iff no prior notice, or the last one is
|
|
67
|
+
* older than the suppression window (bad timestamps read as "no prior").
|
|
68
|
+
*/
|
|
69
|
+
function shouldNotify(state, now = Date.now(), suppressionMs = NOTICE_SUPPRESSION_MS) {
|
|
70
|
+
const last = state && state.last_notice_at ? Date.parse(state.last_notice_at) : NaN;
|
|
71
|
+
if (!Number.isFinite(last)) return true;
|
|
72
|
+
return now - last >= suppressionMs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Persist the suppression stamp (best-effort — failure must not throw). */
|
|
76
|
+
function writeState(homeDir, now = Date.now()) {
|
|
77
|
+
try {
|
|
78
|
+
const p = statePath(homeDir);
|
|
79
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
80
|
+
fs.writeFileSync(p, JSON.stringify({ last_notice_at: new Date(now).toISOString() }, null, 2) + '\n');
|
|
81
|
+
} catch { /* fail-silent */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The one line. Count only — see the header contract. */
|
|
85
|
+
function renderNotice(count) {
|
|
86
|
+
return `Auxilo: ${count} learning(s) held for your review — run auxilo_review (MCP) or \`npx auxilo review\`.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Load credentials; null when absent/malformed/keyless. */
|
|
90
|
+
function readCredentials(homeDir) {
|
|
91
|
+
try {
|
|
92
|
+
const creds = JSON.parse(fs.readFileSync(path.join(auxiloDir(homeDir), 'credentials.json'), 'utf-8'));
|
|
93
|
+
return creds && typeof creds === 'object' && creds.api_key ? creds : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchPendingCount(creds, fetchImpl = fetch) {
|
|
100
|
+
const baseUrl = String(creds.base_url || 'https://auxilo.io').replace(/\/+$/, '');
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetchImpl(`${baseUrl}/account/pending/summary`, {
|
|
105
|
+
headers: { 'X-API-Key': creds.api_key },
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) return null;
|
|
109
|
+
const body = await res.json();
|
|
110
|
+
const n = body && body.pending_count;
|
|
111
|
+
return Number.isFinite(n) ? n : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function main() {
|
|
120
|
+
// Recursion guard: extraction children must never notice (or burn the
|
|
121
|
+
// suppression window).
|
|
122
|
+
if (process.env.AUXILO_EXTRACTING === '1') return;
|
|
123
|
+
|
|
124
|
+
const homeDir = os.homedir();
|
|
125
|
+
const creds = readCredentials(homeDir);
|
|
126
|
+
if (!creds) return;
|
|
127
|
+
|
|
128
|
+
if (!shouldNotify(readState(homeDir))) return;
|
|
129
|
+
|
|
130
|
+
const count = await fetchPendingCount(creds);
|
|
131
|
+
if (count == null || count <= 0) return;
|
|
132
|
+
|
|
133
|
+
process.stdout.write(renderNotice(count) + '\n');
|
|
134
|
+
writeState(homeDir);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
shouldNotify, renderNotice, readState, writeState, readCredentials,
|
|
139
|
+
fetchPendingCount, NOTICE_SUPPRESSION_MS, FETCH_TIMEOUT_MS,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (require.main === module) {
|
|
143
|
+
// FAIL-SILENT: exit 0 on every path, including internal errors.
|
|
144
|
+
main().then(() => process.exit(0)).catch(() => process.exit(0));
|
|
145
|
+
}
|
package/scripts/runner.js
CHANGED
|
@@ -36,11 +36,9 @@ const fs = require('fs');
|
|
|
36
36
|
const path = require('path');
|
|
37
37
|
const os = require('os');
|
|
38
38
|
const crypto = require('crypto');
|
|
39
|
+
const { spawn } = require('child_process');
|
|
39
40
|
const { scanText, SENSITIVITY_FILTER_VERSION } = require('../lib/sensitivity-filter.js');
|
|
40
|
-
const {
|
|
41
|
-
const { OpenClawSource } = require('./sources/openclaw.js');
|
|
42
|
-
const { GeminiCliSource } = require('./sources/gemini-cli.js');
|
|
43
|
-
const { AntigravitySource } = require('./sources/antigravity.js');
|
|
41
|
+
const { TranscriptSource } = require('./sources/source.interface.js');
|
|
44
42
|
const { GenericJsonlSource } = require('./sources/generic-jsonl.js');
|
|
45
43
|
|
|
46
44
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
@@ -87,14 +85,42 @@ const PENDING_DIR = path.join(AUXILO_DIR, 'pending-learnings');
|
|
|
87
85
|
const LEDGER_PATH = path.join(AUXILO_DIR, 'ledger.json');
|
|
88
86
|
const LOG_PATH = path.join(AUXILO_DIR, 'extract.log');
|
|
89
87
|
|
|
90
|
-
// ─── Source Registry (§4.4)
|
|
88
|
+
// ─── Source Registry (§4.4 / UC-3 dynamic) ──────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// UC-3: the registry is built by enumerating scripts/sources/*.js — a new
|
|
91
|
+
// adapter self-registers by dropping a file in that exports a TranscriptSource
|
|
92
|
+
// subclass with a static `id`. Excluded by name: the abstract interface, and
|
|
93
|
+
// generic-jsonl (the config-instantiated single-file FALLBACK, not a
|
|
94
|
+
// discoverable source — its detect() is deliberately inert).
|
|
95
|
+
//
|
|
96
|
+
// Per-file try/catch: one broken adapter must never kill the runner
|
|
97
|
+
// (fail-silent degradation, UC §5). Duplicate ids: first file wins
|
|
98
|
+
// (alphabetical), so a rogue copy can't shadow an established adapter.
|
|
91
99
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
];
|
|
100
|
+
const SOURCE_REGISTRY_EXCLUDE = new Set(['source.interface.js', 'generic-jsonl.js']);
|
|
101
|
+
|
|
102
|
+
function loadSources() {
|
|
103
|
+
const dir = path.join(__dirname, 'sources');
|
|
104
|
+
const classes = [];
|
|
105
|
+
let files = [];
|
|
106
|
+
try {
|
|
107
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.js') && !SOURCE_REGISTRY_EXCLUDE.has(f)).sort();
|
|
108
|
+
} catch { return classes; }
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
let mod;
|
|
111
|
+
try { mod = require(`./sources/${f}`); } catch { continue; } // broken adapter — skip silently
|
|
112
|
+
for (const exported of Object.values(mod || {})) {
|
|
113
|
+
if (typeof exported !== 'function') continue;
|
|
114
|
+
if (!(exported.prototype instanceof TranscriptSource)) continue;
|
|
115
|
+
if (!exported.id || exported.id === 'abstract') continue;
|
|
116
|
+
if (classes.some(c => c.id === exported.id)) continue;
|
|
117
|
+
classes.push(exported);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return classes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const SOURCES = loadSources();
|
|
98
124
|
|
|
99
125
|
async function enumerateActiveSources(filter) {
|
|
100
126
|
const active = [];
|
|
@@ -233,32 +259,43 @@ function listPendingFiles() {
|
|
|
233
259
|
|
|
234
260
|
// ─── Upload ─────────────────────────────────────────────────────────────────
|
|
235
261
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Submit finished learnings to POST /learn, classifying each response
|
|
264
|
+
* truthfully (P1-13b / SPEC3 A2):
|
|
265
|
+
* 2xx + status 'approved' → published (seamless)
|
|
266
|
+
* 2xx + status 'pending_review' → held (the contributor review queue)
|
|
267
|
+
* anything else / network error → rejected
|
|
268
|
+
*
|
|
269
|
+
* Every submission carries `submission_channel: 'extraction'` UNCONDITIONALLY.
|
|
270
|
+
* SPEC3 §3.2 trust analysis: the marker is a BRAKE, never a gas pedal — a
|
|
271
|
+
* pre-B1 server destructures known fields only and ignores it; a post-B1
|
|
272
|
+
* server uses it to HOLD extraction-channel items behind standing consent.
|
|
273
|
+
* Shipping it dark means the brake is in the field before scoring ever arms
|
|
274
|
+
* (see the AUXILO_SCORE_EXTRACTION sequencing constraint in extract-local.js).
|
|
275
|
+
*
|
|
276
|
+
* `quality_self_assessment` passes through spread-if-present — extract-local
|
|
277
|
+
* only attaches it when the A1 score gate is on.
|
|
278
|
+
*
|
|
279
|
+
* @param {Array<object>} learnings
|
|
280
|
+
* @param {string} sourceType
|
|
281
|
+
* @param {object} [opts] { fetchImpl, baseUrl, apiKey } — injectable for tests
|
|
282
|
+
* @returns {Promise<{published:number, held:number, rejected:number}>}
|
|
283
|
+
*/
|
|
284
|
+
async function submitLearnings(learnings, sourceType, opts = {}) {
|
|
285
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
286
|
+
const baseUrl = opts.baseUrl || BASE_URL;
|
|
287
|
+
const apiKey = opts.apiKey !== undefined ? opts.apiKey : API_KEY;
|
|
252
288
|
|
|
253
289
|
let published = 0;
|
|
290
|
+
let held = 0;
|
|
254
291
|
let rejected = 0;
|
|
255
292
|
for (const l of learnings) {
|
|
256
293
|
try {
|
|
257
|
-
const res = await
|
|
294
|
+
const res = await fetchImpl(`${baseUrl}/learn`, {
|
|
258
295
|
method: 'POST',
|
|
259
296
|
headers: {
|
|
260
297
|
'Content-Type': 'application/json',
|
|
261
|
-
'X-API-Key':
|
|
298
|
+
'X-API-Key': apiKey,
|
|
262
299
|
'Idempotency-Key': crypto.randomUUID(),
|
|
263
300
|
},
|
|
264
301
|
body: JSON.stringify({
|
|
@@ -269,16 +306,69 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
|
|
|
269
306
|
task_context: l.task_context,
|
|
270
307
|
outcome: l.outcome,
|
|
271
308
|
contributor_agent: `auxilo-hook/${sourceType}`,
|
|
309
|
+
submission_channel: 'extraction',
|
|
310
|
+
...(l.quality_self_assessment && { quality_self_assessment: l.quality_self_assessment }),
|
|
272
311
|
}),
|
|
273
312
|
});
|
|
274
|
-
if (res.ok)
|
|
275
|
-
|
|
313
|
+
if (!res.ok) { rejected += 1; continue; }
|
|
314
|
+
let body = {};
|
|
315
|
+
try { body = await res.json(); } catch { /* tolerate non-JSON 2xx */ }
|
|
316
|
+
if (body && body.status === 'pending_review') held += 1;
|
|
317
|
+
else published += 1;
|
|
276
318
|
} catch (_) {
|
|
277
319
|
rejected += 1;
|
|
278
320
|
}
|
|
279
321
|
}
|
|
280
|
-
|
|
281
|
-
|
|
322
|
+
return { published, held, rejected };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
|
|
326
|
+
// CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) — Auxilo
|
|
327
|
+
// does not pay to extract. The local model (via `claude -p`) extracts + self-screens
|
|
328
|
+
// the already-client-scrubbed transcript, and we submit finished learnings to /learn.
|
|
329
|
+
const { extractLocally } = require('./extract-local.js');
|
|
330
|
+
let learnings;
|
|
331
|
+
let skipped;
|
|
332
|
+
try {
|
|
333
|
+
({ learnings, skipped } = await extractLocally(transcript, sourceType));
|
|
334
|
+
} catch (err) {
|
|
335
|
+
throw new Error(`Local extraction failed: ${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
if (skipped) {
|
|
338
|
+
log(`[runner] ${skipped}`);
|
|
339
|
+
return { learnings_published: 0, learnings_held: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { published, held, rejected } = await submitLearnings(learnings, sourceType);
|
|
343
|
+
// NOTE: token-free prose — the digest-parsed `published=`/`held=`/`rejected=`
|
|
344
|
+
// tokens live ONLY on the per-run caller log lines. This line previously
|
|
345
|
+
// carried `published=` too and the digest double-counted every extraction
|
|
346
|
+
// (its dedup only drops byte-identical lines). P1-13b.
|
|
347
|
+
log(`[runner] client-side extraction: ${learnings.length} candidate(s) → ${published} live, ${held} held for review, ${rejected} rejected`);
|
|
348
|
+
return { learnings_published: published, learnings_held: held, learnings_rejected: rejected, extraction_id: `client-${sessionId}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Held-items local notification (LW-18 layer 2) ──────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* macOS notification when a run holds items for review: COUNT ONLY, no titles,
|
|
355
|
+
* no content (pending bodies are adversarial by assumption — LW-18 threat
|
|
356
|
+
* model). Fail-silent: darwin-only, detached osascript spawn, every error
|
|
357
|
+
* swallowed; a broken notifier must never fail the run. AUXILO_NO_NOTIFY=1
|
|
358
|
+
* disables (tests, headless CI).
|
|
359
|
+
*/
|
|
360
|
+
function notifyHeld(count) {
|
|
361
|
+
try {
|
|
362
|
+
if (!count || count <= 0) return;
|
|
363
|
+
if (process.platform !== 'darwin') return;
|
|
364
|
+
if (process.env.AUXILO_NO_NOTIFY === '1') return;
|
|
365
|
+
const msg = `${count} learning(s) held for your review — run npx auxilo review`;
|
|
366
|
+
const child = spawn('/usr/bin/osascript', [
|
|
367
|
+
'-e', `display notification ${JSON.stringify(msg)} with title "Auxilo"`,
|
|
368
|
+
], { stdio: 'ignore', detached: true });
|
|
369
|
+
child.unref();
|
|
370
|
+
child.on('error', () => { /* fail-silent */ });
|
|
371
|
+
} catch { /* fail-silent */ }
|
|
282
372
|
}
|
|
283
373
|
|
|
284
374
|
// ─── Install Hooks (B15) ────────────────────────────────────────────────────
|
|
@@ -362,19 +452,38 @@ function installHooks() {
|
|
|
362
452
|
// Renamed 2026-07-15: the original label carried a dead pre-Fly host prefix (legacy-label purge).
|
|
363
453
|
const SWEEPER_LABEL = 'io.auxilo.sweeper';
|
|
364
454
|
|
|
455
|
+
/**
|
|
456
|
+
* The sweeper copy manifest. Sources rows are DERIVED by enumerating
|
|
457
|
+
* scripts/sources/*.js (UC-3): a new adapter file is automatically installed —
|
|
458
|
+
* the d8c7099 bug class (adapter added, manifest not, installed sweepers crash
|
|
459
|
+
* with MODULE_NOT_FOUND) is now structurally impossible. Exported for the
|
|
460
|
+
* manifest-closure test in test/wave3-client-funnel.test.js.
|
|
461
|
+
*/
|
|
462
|
+
function sweeperManifest(repoRoot = path.resolve(__dirname, '..')) {
|
|
463
|
+
const sourceRows = [];
|
|
464
|
+
try {
|
|
465
|
+
for (const f of fs.readdirSync(path.join(repoRoot, 'scripts', 'sources')).filter(f => f.endsWith('.js')).sort()) {
|
|
466
|
+
sourceRows.push([`scripts/sources/${f}`, `scripts/sources/${f}`, 0o644]);
|
|
467
|
+
}
|
|
468
|
+
} catch { /* missing dir surfaces as missing-file errors below */ }
|
|
469
|
+
return [
|
|
470
|
+
['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
|
|
471
|
+
['scripts/runner.js', 'scripts/runner.js', 0o755],
|
|
472
|
+
...sourceRows,
|
|
473
|
+
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
474
|
+
// Client-side extraction (2026-07-02) — required by the sweep path since /extract went 410.
|
|
475
|
+
// Missing from this manifest until 2026-07-19: installed sweepers crashed with
|
|
476
|
+
// "Cannot find module './extract-local.js'" while queue files were retained.
|
|
477
|
+
['scripts/extract-local.js', 'scripts/extract-local.js', 0o644],
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
|
|
365
481
|
function installSweeper() {
|
|
366
482
|
const repoRoot = path.resolve(__dirname, '..');
|
|
367
483
|
const binRoot = path.join(AUXILO_DIR, 'bin');
|
|
368
484
|
|
|
369
485
|
// 1. Copy executable surface, preserving relative layout so requires resolve.
|
|
370
|
-
const filesToCopy =
|
|
371
|
-
['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
|
|
372
|
-
['scripts/runner.js', 'scripts/runner.js', 0o755],
|
|
373
|
-
['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
|
|
374
|
-
['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
|
|
375
|
-
['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
|
|
376
|
-
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
377
|
-
];
|
|
486
|
+
const filesToCopy = sweeperManifest(repoRoot);
|
|
378
487
|
for (const [src, dest, mode] of filesToCopy) {
|
|
379
488
|
const srcPath = path.join(repoRoot, src);
|
|
380
489
|
const destPath = path.join(binRoot, dest);
|
|
@@ -734,7 +843,8 @@ async function main() {
|
|
|
734
843
|
|
|
735
844
|
try {
|
|
736
845
|
const result = await postExtract(cleaned, sessionId, sourceType, report);
|
|
737
|
-
log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
846
|
+
log(`[runner] ✓ published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
847
|
+
notifyHeld(result.learnings_held || 0); // LW-18 layer 2: count-only, fail-silent
|
|
738
848
|
// Mark only after a successful upload so a failed POST can be retried.
|
|
739
849
|
const ledger = loadLedger();
|
|
740
850
|
ledgerMark(ledger, sourceType, sessionId, contentSha, sessionRef.mtime);
|
|
@@ -751,13 +861,15 @@ async function main() {
|
|
|
751
861
|
const pending = listPendingFiles();
|
|
752
862
|
log(`[runner] Flushing ${pending.length} pending queue file(s)...`);
|
|
753
863
|
let flushed = 0;
|
|
864
|
+
let flushHeld = 0;
|
|
754
865
|
for (const qf of pending) {
|
|
755
866
|
try {
|
|
756
867
|
const payload = JSON.parse(fs.readFileSync(qf, 'utf-8'));
|
|
757
868
|
const result = await postExtract(
|
|
758
869
|
payload.transcript, payload.sessionId, payload.source, payload.scrubReport
|
|
759
870
|
);
|
|
760
|
-
log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
|
|
871
|
+
log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
|
|
872
|
+
flushHeld += result.learnings_held || 0;
|
|
761
873
|
deleteQueueFile(qf);
|
|
762
874
|
flushed++;
|
|
763
875
|
} catch (err) {
|
|
@@ -765,6 +877,7 @@ async function main() {
|
|
|
765
877
|
}
|
|
766
878
|
}
|
|
767
879
|
log(`[runner] Flush complete: ${flushed}/${pending.length} succeeded`);
|
|
880
|
+
notifyHeld(flushHeld); // LW-18 layer 2: count-only, fail-silent
|
|
768
881
|
process.exit(0);
|
|
769
882
|
}
|
|
770
883
|
|
|
@@ -780,6 +893,7 @@ async function main() {
|
|
|
780
893
|
let totalProcessed = 0;
|
|
781
894
|
let totalSkipped = 0;
|
|
782
895
|
let totalFailed = 0;
|
|
896
|
+
let totalHeld = 0;
|
|
783
897
|
|
|
784
898
|
for (const source of sources) {
|
|
785
899
|
log(`[runner] Discovering sessions from ${source.label} (${source.type})...`);
|
|
@@ -878,7 +992,8 @@ async function main() {
|
|
|
878
992
|
|
|
879
993
|
try {
|
|
880
994
|
const result = await postExtract(cleaned, sessionRef.sessionId, source.type, report);
|
|
881
|
-
log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
995
|
+
log(`[runner] ✓ published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
996
|
+
totalHeld += result.learnings_held || 0;
|
|
882
997
|
ledgerMark(ledger, source.type, sessionRef.sessionId, sha, sessionRef.mtime);
|
|
883
998
|
deleteQueueFile(queueFile);
|
|
884
999
|
saveLedger(ledger);
|
|
@@ -893,6 +1008,7 @@ async function main() {
|
|
|
893
1008
|
|
|
894
1009
|
saveLedger(ledger);
|
|
895
1010
|
log(`[runner] Summary: ${totalDiscovered} discovered, ${totalProcessed} processed, ${totalSkipped} skipped, ${totalFailed} failed`);
|
|
1011
|
+
notifyHeld(totalHeld); // LW-18 layer 2: one notification per sweep, count-only
|
|
896
1012
|
process.exit(totalFailed > 0 ? 1 : 0);
|
|
897
1013
|
}
|
|
898
1014
|
|
|
@@ -902,6 +1018,7 @@ module.exports = {
|
|
|
902
1018
|
parseArgs, writeQueueFile, deleteQueueFile, listPendingFiles,
|
|
903
1019
|
loadLedger, saveLedger, ledgerHighWater, ledgerHas, ledgerMark,
|
|
904
1020
|
installHooks, installSweeper, installDigest, printStatus, scrubAndVerify, enumerateActiveSources,
|
|
1021
|
+
loadSources, SOURCES, sweeperManifest, submitLearnings, notifyHeld,
|
|
905
1022
|
KILL_SWITCH_PATH, PENDING_DIR, LEDGER_PATH,
|
|
906
1023
|
};
|
|
907
1024
|
|