auxilo-mcp 0.9.4 → 0.9.7
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/README.md +8 -6
- package/bin/auxilo-cli.js +172 -41
- package/config/near-duplicate.json +9 -0
- package/lib/extraction-index.js +462 -0
- package/lib/hook-status.js +24 -0
- package/lib/installer.js +14 -3
- package/lib/review.js +70 -16
- package/lib/similarity.js +275 -0
- package/mcp-server.js +1 -1
- package/package.json +5 -1
- package/scripts/extract-local.js +442 -25
- package/scripts/runner.js +53 -5
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Client-side extraction memory for SPEC3-F2.
|
|
5
|
+
*
|
|
6
|
+
* The index is local, append-only, and advisory. Every read or write failure
|
|
7
|
+
* fails open: extraction/submission continues without prompt memory or lexical
|
|
8
|
+
* filtering. The only shared detector is lib/similarity.js.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { findNearDuplicate, scoreChannels } = require('./similarity.js');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_INDEX_PATH = path.join(os.homedir(), '.auxilo', 'extracted-index.jsonl');
|
|
18
|
+
const HYDRATION_PAGE_SIZE = 500;
|
|
19
|
+
const PROMPT_MEMORY_MAX_TOKENS = 1200;
|
|
20
|
+
const PROMPT_MEMORY_MAX_ROWS = 40;
|
|
21
|
+
const VALID_STATUSES = new Set(['approved', 'rejected', 'pending_review']);
|
|
22
|
+
|
|
23
|
+
const MEMORY_INSTRUCTIONS = `PREVIOUSLY CAPTURED LESSONS
|
|
24
|
+
The following lessons were already submitted by this account.
|
|
25
|
+
A candidate that re-states any listed lesson — reworded, or a different facet of the same operational insight — is DROPPED, not relabeled and not "improved."
|
|
26
|
+
Extract new facts about a listed lesson ONLY when the new fact would change what another agent does: it must create a behavioral difference, not merely a wording difference.
|
|
27
|
+
Never discard a memory match invisibly. Put the complete candidate plus the matched lesson id/title in dedup_drops so the local runner can write its audit trail.`;
|
|
28
|
+
|
|
29
|
+
const CATEGORY_HINTS = Object.freeze({
|
|
30
|
+
'data-processing': ['data', 'parse', 'parser', 'json', 'csv', 'transform', 'pipeline'],
|
|
31
|
+
'web-interaction': ['api', 'http', 'browser', 'gmail', 'webhook', 'request', 'response'],
|
|
32
|
+
'code-execution': ['code', 'script', 'python', 'node', 'shell', 'command', 'runtime'],
|
|
33
|
+
'storage-state': ['state', 'queue', 'database', 'storage', 'file', 'commit', 'cache'],
|
|
34
|
+
'payment-financial': ['payment', 'stripe', 'usdc', 'wallet', 'settlement', 'withdraw'],
|
|
35
|
+
monitoring: ['monitor', 'alert', 'health', 'log', 'metric', 'observability', 'incident'],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function loud(log, message) {
|
|
39
|
+
try {
|
|
40
|
+
(log || console.error)(`[extraction-index] ${message}`);
|
|
41
|
+
} catch {
|
|
42
|
+
// A broken logger must not turn advisory dedup into a submission blocker.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeTags(tags) {
|
|
47
|
+
return Array.isArray(tags) ? tags.slice(0, 8).map(String) : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validIndexRow(row) {
|
|
51
|
+
return Boolean(
|
|
52
|
+
row &&
|
|
53
|
+
typeof row === 'object' &&
|
|
54
|
+
typeof row.title === 'string' &&
|
|
55
|
+
row.title.trim() &&
|
|
56
|
+
typeof row.category === 'string'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read an append-only JSONL index. A malformed line is skipped and logged, but
|
|
62
|
+
* marks the read unusable so extraction fails open instead of trusting a
|
|
63
|
+
* partially corrupt memory/filter corpus.
|
|
64
|
+
*/
|
|
65
|
+
function readExtractionIndex(opts = {}) {
|
|
66
|
+
const indexPath = opts.indexPath || DEFAULT_INDEX_PATH;
|
|
67
|
+
const fsImpl = opts.fsImpl || fs;
|
|
68
|
+
let exists;
|
|
69
|
+
try {
|
|
70
|
+
exists = fsImpl.existsSync(indexPath);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
loud(opts.log, `index existence check failed; dedup disabled for this run: ${error.message}`);
|
|
73
|
+
return { state: 'unreadable', usable: false, rows: [], bad_lines: 0 };
|
|
74
|
+
}
|
|
75
|
+
if (!exists) return { state: 'missing', usable: false, rows: [], bad_lines: 0 };
|
|
76
|
+
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = fsImpl.readFileSync(indexPath, 'utf8');
|
|
80
|
+
} catch (error) {
|
|
81
|
+
loud(opts.log, `index read failed; dedup disabled for this run: ${error.message}`);
|
|
82
|
+
return { state: 'unreadable', usable: false, rows: [], bad_lines: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rows = [];
|
|
86
|
+
let badLines = 0;
|
|
87
|
+
const lines = String(raw).split(/\r?\n/);
|
|
88
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
89
|
+
const line = lines[index].trim();
|
|
90
|
+
if (!line) continue;
|
|
91
|
+
try {
|
|
92
|
+
const row = JSON.parse(line);
|
|
93
|
+
if (!validIndexRow(row)) throw new Error('missing title/category');
|
|
94
|
+
rows.push(row);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
badLines += 1;
|
|
97
|
+
loud(opts.log, `skipping corrupt line ${index + 1} in ${indexPath}: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (badLines > 0) {
|
|
102
|
+
loud(opts.log, `index contains ${badLines} corrupt line(s); prompt memory and lexical dedup are disabled for this run`);
|
|
103
|
+
return { state: 'corrupt', usable: false, rows, bad_lines: badLines };
|
|
104
|
+
}
|
|
105
|
+
return { state: 'ready', usable: true, rows, bad_lines: 0 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendJsonlRows(rows, opts = {}) {
|
|
109
|
+
const indexPath = opts.indexPath || DEFAULT_INDEX_PATH;
|
|
110
|
+
const fsImpl = opts.fsImpl || fs;
|
|
111
|
+
try {
|
|
112
|
+
fsImpl.mkdirSync(path.dirname(indexPath), { recursive: true, mode: 0o700 });
|
|
113
|
+
if (!rows.length) {
|
|
114
|
+
const fd = fsImpl.openSync(indexPath, 'a', 0o600);
|
|
115
|
+
fsImpl.closeSync(fd);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
const payload = rows.map((row) => JSON.stringify(row)).join('\n') + '\n';
|
|
119
|
+
fsImpl.appendFileSync(indexPath, payload, { encoding: 'utf8', mode: 0o600 });
|
|
120
|
+
return true;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
loud(opts.log, `index append failed; extraction/submission continues: ${error.message}`);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function localIndexRow(learning, response = {}, opts = {}) {
|
|
128
|
+
const nowValue = typeof opts.now === 'function'
|
|
129
|
+
? opts.now()
|
|
130
|
+
: (opts.now || new Date().toISOString());
|
|
131
|
+
const body = String(learning.body || '');
|
|
132
|
+
return {
|
|
133
|
+
title: String(learning.title || ''),
|
|
134
|
+
category: String(learning.category || ''),
|
|
135
|
+
tags: normalizeTags(learning.tags),
|
|
136
|
+
body_hash: crypto.createHash('sha256').update(body).digest('hex'),
|
|
137
|
+
body,
|
|
138
|
+
submitted_at: String(nowValue),
|
|
139
|
+
...(typeof response.id === 'string' && response.id && { learning_id: response.id }),
|
|
140
|
+
...(typeof response.status === 'string' && VALID_STATUSES.has(response.status) && {
|
|
141
|
+
status: response.status,
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Append only after POST /learn returns 2xx. A network/4xx failure remains
|
|
148
|
+
* retryable and is not allowed to poison the local dedup memory.
|
|
149
|
+
*/
|
|
150
|
+
function appendSubmittedLearning(learning, response = {}, opts = {}) {
|
|
151
|
+
if (!learning || typeof learning !== 'object') return false;
|
|
152
|
+
return appendJsonlRows([localIndexRow(learning, response, opts)], opts);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hydratedIndexRow(learning) {
|
|
156
|
+
return {
|
|
157
|
+
title: String(learning.title || ''),
|
|
158
|
+
category: String(learning.category || ''),
|
|
159
|
+
tags: normalizeTags(learning.tags),
|
|
160
|
+
body_hash: null,
|
|
161
|
+
submitted_at: learning.created_at || null,
|
|
162
|
+
...(typeof learning.id === 'string' && learning.id && { learning_id: learning.id }),
|
|
163
|
+
...(typeof learning.status === 'string' && VALID_STATUSES.has(learning.status) && {
|
|
164
|
+
status: learning.status,
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Hydrate only a missing index from the caller's own metadata endpoint. The
|
|
171
|
+
* endpoint intentionally carries no body, so hydrated rows inform the prompt
|
|
172
|
+
* only; lexical filtering is limited to locally appended rows with a body.
|
|
173
|
+
*/
|
|
174
|
+
async function hydrateExtractionIndex(opts = {}) {
|
|
175
|
+
const indexPath = opts.indexPath || DEFAULT_INDEX_PATH;
|
|
176
|
+
const fsImpl = opts.fsImpl || fs;
|
|
177
|
+
try {
|
|
178
|
+
if (fsImpl.existsSync(indexPath)) return { hydrated: false, reason: 'not_fresh' };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
loud(opts.log, `fresh-index check failed; hydration skipped: ${error.message}`);
|
|
181
|
+
return { hydrated: false, reason: 'unreadable' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
185
|
+
const baseUrl = String(opts.baseUrl || '').replace(/\/+$/, '');
|
|
186
|
+
const apiKey = opts.apiKey;
|
|
187
|
+
if (typeof fetchImpl !== 'function' || !baseUrl || !apiKey) {
|
|
188
|
+
loud(opts.log, 'fresh-machine hydration unavailable (missing fetch, base URL, or API key); extracting without memory');
|
|
189
|
+
return { hydrated: false, reason: 'unavailable' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const hydratedRows = [];
|
|
193
|
+
let offset = 0;
|
|
194
|
+
try {
|
|
195
|
+
for (let page = 0; page < 10000; page += 1) {
|
|
196
|
+
const response = await fetchImpl(
|
|
197
|
+
`${baseUrl}/account/learnings?limit=${HYDRATION_PAGE_SIZE}&offset=${offset}`,
|
|
198
|
+
{ headers: { 'X-API-Key': apiKey } }
|
|
199
|
+
);
|
|
200
|
+
if (!response || !response.ok) {
|
|
201
|
+
const status = response && Number.isFinite(response.status) ? response.status : 'unknown';
|
|
202
|
+
throw new Error(`GET /account/learnings returned ${status}`);
|
|
203
|
+
}
|
|
204
|
+
const payload = await response.json();
|
|
205
|
+
if (!payload || !Array.isArray(payload.learnings)) {
|
|
206
|
+
throw new Error('GET /account/learnings returned an invalid payload');
|
|
207
|
+
}
|
|
208
|
+
for (const row of payload.learnings) {
|
|
209
|
+
const hydrated = hydratedIndexRow(row);
|
|
210
|
+
if (!validIndexRow(hydrated)) {
|
|
211
|
+
loud(opts.log, 'skipping malformed hydration row from GET /account/learnings');
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
hydratedRows.push(hydrated);
|
|
215
|
+
}
|
|
216
|
+
offset += payload.learnings.length;
|
|
217
|
+
const total = Number.isFinite(payload.total) ? payload.total : null;
|
|
218
|
+
if (payload.learnings.length < HYDRATION_PAGE_SIZE ||
|
|
219
|
+
(total !== null && offset >= total)) break;
|
|
220
|
+
if (payload.learnings.length === 0) break;
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
loud(opts.log, `fresh-machine hydration failed; extracting without memory: ${error.message}`);
|
|
224
|
+
return { hydrated: false, reason: 'request_failed' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!appendJsonlRows(hydratedRows, { indexPath, fsImpl, log: opts.log })) {
|
|
228
|
+
return { hydrated: false, reason: 'write_failed' };
|
|
229
|
+
}
|
|
230
|
+
loud(opts.log, `hydrated ${hydratedRows.length} own learning metadata row(s) into ${indexPath}`);
|
|
231
|
+
return { hydrated: true, count: hydratedRows.length };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function loadIndexForExtraction(opts = {}) {
|
|
235
|
+
const first = readExtractionIndex(opts);
|
|
236
|
+
if (first.state !== 'missing') return first;
|
|
237
|
+
const hydration = await hydrateExtractionIndex(opts);
|
|
238
|
+
if (!hydration.hydrated) return first;
|
|
239
|
+
return readExtractionIndex(opts);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function estimatePromptTokens(text) {
|
|
243
|
+
return Math.ceil(Buffer.byteLength(String(text || ''), 'utf8') / 4);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function inferCategoryHints(transcript) {
|
|
247
|
+
const text = String(transcript || '').toLowerCase();
|
|
248
|
+
const scores = [];
|
|
249
|
+
for (const [category, words] of Object.entries(CATEGORY_HINTS)) {
|
|
250
|
+
let score = 0;
|
|
251
|
+
for (const word of words) {
|
|
252
|
+
const matches = text.match(new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
|
|
253
|
+
score += matches ? matches.length : 0;
|
|
254
|
+
}
|
|
255
|
+
if (score > 0) scores.push({ category, score });
|
|
256
|
+
}
|
|
257
|
+
scores.sort((a, b) => b.score - a.score || a.category.localeCompare(b.category));
|
|
258
|
+
if (!scores.length) return [];
|
|
259
|
+
const top = scores[0].score;
|
|
260
|
+
return scores.filter((entry) => entry.score === top).map((entry) => entry.category);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function submittedTime(row) {
|
|
264
|
+
const parsed = Date.parse(row.submitted_at || row.created_at || '');
|
|
265
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function oneLineGist(row) {
|
|
269
|
+
if (typeof row.body === 'string' && row.body.trim()) {
|
|
270
|
+
const compact = row.body.replace(/\s+/g, ' ').trim();
|
|
271
|
+
const sentence = compact.match(/^(.{1,220}?[.!?])(?:\s|$)/);
|
|
272
|
+
return (sentence ? sentence[1] : compact.slice(0, 220)).trim();
|
|
273
|
+
}
|
|
274
|
+
const tags = normalizeTags(row.tags).filter(Boolean);
|
|
275
|
+
if (tags.length) return `Previously captured ${row.category} lesson tagged ${tags.join(', ')}.`;
|
|
276
|
+
return `Previously captured ${row.category} lesson.`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function indexRowId(row, index = 0) {
|
|
280
|
+
return String(
|
|
281
|
+
row.learning_id ||
|
|
282
|
+
row.id ||
|
|
283
|
+
row.body_hash ||
|
|
284
|
+
`local-index-${index}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function promptMemoryLine(row, index) {
|
|
289
|
+
const title = String(row.title || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
290
|
+
const gist = oneLineGist(row).slice(0, 240);
|
|
291
|
+
return `- [id:${indexRowId(row, index)} category:${row.category}] ${title} — ${gist}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Budget strategy: inferred same-category rows first, then most recent rows,
|
|
296
|
+
* with deterministic title/ID tie-breaking. The instruction header and rows
|
|
297
|
+
* together may never exceed maxTokens.
|
|
298
|
+
*/
|
|
299
|
+
function buildPromptMemory(rows, opts = {}) {
|
|
300
|
+
const maxTokens = Number.isFinite(opts.maxTokens)
|
|
301
|
+
? Math.max(0, Math.floor(opts.maxTokens))
|
|
302
|
+
: PROMPT_MEMORY_MAX_TOKENS;
|
|
303
|
+
const maxRows = Number.isFinite(opts.maxRows)
|
|
304
|
+
? Math.max(0, Math.floor(opts.maxRows))
|
|
305
|
+
: PROMPT_MEMORY_MAX_ROWS;
|
|
306
|
+
const hints = new Set(opts.categoryHints || inferCategoryHints(opts.transcript));
|
|
307
|
+
const eligible = (Array.isArray(rows) ? rows : [])
|
|
308
|
+
.filter(validIndexRow)
|
|
309
|
+
.slice()
|
|
310
|
+
.sort((a, b) => {
|
|
311
|
+
const aMatch = hints.has(a.category) ? 1 : 0;
|
|
312
|
+
const bMatch = hints.has(b.category) ? 1 : 0;
|
|
313
|
+
return bMatch - aMatch ||
|
|
314
|
+
submittedTime(b) - submittedTime(a) ||
|
|
315
|
+
String(a.title).localeCompare(String(b.title)) ||
|
|
316
|
+
String(a.learning_id || '').localeCompare(String(b.learning_id || ''));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!eligible.length || maxRows === 0 || estimatePromptTokens(MEMORY_INSTRUCTIONS) > maxTokens) {
|
|
320
|
+
return {
|
|
321
|
+
section: '',
|
|
322
|
+
estimated_tokens: 0,
|
|
323
|
+
included_count: 0,
|
|
324
|
+
included_rows: [],
|
|
325
|
+
category_hints: [...hints],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const lines = [];
|
|
330
|
+
const includedRows = [];
|
|
331
|
+
for (let index = 0; index < eligible.length; index += 1) {
|
|
332
|
+
const row = eligible[index];
|
|
333
|
+
if (lines.length >= maxRows) break;
|
|
334
|
+
const candidateLines = [...lines, promptMemoryLine(row, index)];
|
|
335
|
+
const candidate = `${MEMORY_INSTRUCTIONS}\n${candidateLines.join('\n')}\n\n`;
|
|
336
|
+
if (estimatePromptTokens(candidate) > maxTokens) break;
|
|
337
|
+
lines.push(candidateLines[candidateLines.length - 1]);
|
|
338
|
+
includedRows.push({
|
|
339
|
+
id: indexRowId(row, index),
|
|
340
|
+
title: row.title,
|
|
341
|
+
category: row.category,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (!lines.length) {
|
|
345
|
+
return {
|
|
346
|
+
section: '',
|
|
347
|
+
estimated_tokens: 0,
|
|
348
|
+
included_count: 0,
|
|
349
|
+
included_rows: [],
|
|
350
|
+
category_hints: [...hints],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const section = `${MEMORY_INSTRUCTIONS}\n${lines.join('\n')}\n\n`;
|
|
354
|
+
return {
|
|
355
|
+
section,
|
|
356
|
+
estimated_tokens: estimatePromptTokens(section),
|
|
357
|
+
included_count: lines.length,
|
|
358
|
+
included_rows: includedRows,
|
|
359
|
+
category_hints: [...hints],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Rank index rows against one extracted candidate with the shared F1 scoring
|
|
365
|
+
* implementation. No flag threshold is applied: the score is used only for a
|
|
366
|
+
* deterministic candidate-anchored top-K list for the local judge.
|
|
367
|
+
*/
|
|
368
|
+
function rankIndexRowsForCandidate(candidate, rows, opts = {}) {
|
|
369
|
+
const topK = Number.isFinite(opts.topK)
|
|
370
|
+
? Math.max(0, Math.floor(opts.topK))
|
|
371
|
+
: 10;
|
|
372
|
+
if (!candidate || topK === 0) return [];
|
|
373
|
+
return (Array.isArray(rows) ? rows : [])
|
|
374
|
+
.filter(validIndexRow)
|
|
375
|
+
.map((row, index) => {
|
|
376
|
+
const ranked = {
|
|
377
|
+
id: indexRowId(row, index),
|
|
378
|
+
title: row.title,
|
|
379
|
+
category: row.category,
|
|
380
|
+
body: typeof row.body === 'string' ? row.body : '',
|
|
381
|
+
};
|
|
382
|
+
const channels = scoreChannels(candidate, ranked);
|
|
383
|
+
return {
|
|
384
|
+
id: ranked.id,
|
|
385
|
+
title: ranked.title,
|
|
386
|
+
category: ranked.category,
|
|
387
|
+
similarity: channels.composite,
|
|
388
|
+
channels,
|
|
389
|
+
};
|
|
390
|
+
})
|
|
391
|
+
.sort((a, b) =>
|
|
392
|
+
b.similarity - a.similarity ||
|
|
393
|
+
a.id.localeCompare(b.id) ||
|
|
394
|
+
String(a.title).localeCompare(String(b.title))
|
|
395
|
+
)
|
|
396
|
+
.slice(0, topK);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Filter candidates against locally appended rows only. Hydrated metadata has
|
|
401
|
+
* no body and therefore never participates in lexical detection.
|
|
402
|
+
*/
|
|
403
|
+
function filterIndexedNearDuplicates(candidates, indexState, opts = {}) {
|
|
404
|
+
const input = Array.isArray(candidates) ? candidates : [];
|
|
405
|
+
if (!indexState || !indexState.usable) {
|
|
406
|
+
return { kept: input.slice(), dropped: [], disabled: true };
|
|
407
|
+
}
|
|
408
|
+
const localRows = indexState.rows
|
|
409
|
+
.filter((row) => typeof row.body === 'string' && row.body.trim())
|
|
410
|
+
.map((row, index) => ({
|
|
411
|
+
id: row.learning_id || `local-index-${index}`,
|
|
412
|
+
title: row.title,
|
|
413
|
+
body: row.body,
|
|
414
|
+
category: row.category,
|
|
415
|
+
status: row.status || null,
|
|
416
|
+
}));
|
|
417
|
+
if (!localRows.length) return { kept: input.slice(), dropped: [], disabled: false };
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const kept = [];
|
|
421
|
+
const dropped = [];
|
|
422
|
+
for (const candidate of input) {
|
|
423
|
+
const result = findNearDuplicate(candidate, localRows);
|
|
424
|
+
if (result.verdict === 'flag') {
|
|
425
|
+
const matchedRow = localRows.find((row) => row.id === result.match.id);
|
|
426
|
+
dropped.push({
|
|
427
|
+
candidate,
|
|
428
|
+
match: {
|
|
429
|
+
...result.match,
|
|
430
|
+
title: matchedRow ? matchedRow.title : null,
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
} else {
|
|
434
|
+
kept.push(candidate);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { kept, dropped, disabled: false };
|
|
438
|
+
} catch (error) {
|
|
439
|
+
loud(opts.log, `lexical dedup failed; keeping all extracted candidates: ${error.message}`);
|
|
440
|
+
return { kept: input.slice(), dropped: [], disabled: true };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
module.exports = {
|
|
445
|
+
DEFAULT_INDEX_PATH,
|
|
446
|
+
HYDRATION_PAGE_SIZE,
|
|
447
|
+
PROMPT_MEMORY_MAX_TOKENS,
|
|
448
|
+
PROMPT_MEMORY_MAX_ROWS,
|
|
449
|
+
MEMORY_INSTRUCTIONS,
|
|
450
|
+
readExtractionIndex,
|
|
451
|
+
appendSubmittedLearning,
|
|
452
|
+
hydrateExtractionIndex,
|
|
453
|
+
loadIndexForExtraction,
|
|
454
|
+
buildPromptMemory,
|
|
455
|
+
estimatePromptTokens,
|
|
456
|
+
inferCategoryHints,
|
|
457
|
+
filterIndexedNearDuplicates,
|
|
458
|
+
rankIndexRowsForCandidate,
|
|
459
|
+
indexRowId,
|
|
460
|
+
localIndexRow,
|
|
461
|
+
hydratedIndexRow,
|
|
462
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when a Claude Code SessionEnd collection contains an Auxilo extraction
|
|
5
|
+
* hook. Accepts both the legacy bare-command string and the current matcher
|
|
6
|
+
* group shape ({ hooks: [{ type: 'command', command }] }).
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} sessionEnd
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
function hasAuxiloSessionEndHook(sessionEnd) {
|
|
12
|
+
if (!Array.isArray(sessionEnd)) return false;
|
|
13
|
+
|
|
14
|
+
return sessionEnd.some((entry) => {
|
|
15
|
+
if (typeof entry === 'string') return entry.includes('auxilo-extract');
|
|
16
|
+
if (!entry || typeof entry !== 'object' || !Array.isArray(entry.hooks)) return false;
|
|
17
|
+
return entry.hooks.some((hook) =>
|
|
18
|
+
hook && typeof hook === 'object' &&
|
|
19
|
+
typeof hook.command === 'string' &&
|
|
20
|
+
hook.command.includes('auxilo-extract'));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { hasAuxiloSessionEndHook };
|
package/lib/installer.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
|
+
const { hasAuxiloSessionEndHook } = require('./hook-status.js');
|
|
27
28
|
|
|
28
29
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
29
30
|
|
|
@@ -73,6 +74,10 @@ const RUNNER_STACK = Object.freeze([
|
|
|
73
74
|
['scripts/review-notice.js', 'scripts/review-notice.js', 0o755],
|
|
74
75
|
...sourceAdapterRows(),
|
|
75
76
|
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
77
|
+
['lib/extraction-index.js', 'lib/extraction-index.js', 0o644],
|
|
78
|
+
['lib/similarity.js', 'lib/similarity.js', 0o644],
|
|
79
|
+
['lib/hook-status.js', 'lib/hook-status.js', 0o644],
|
|
80
|
+
['config/near-duplicate.json', 'config/near-duplicate.json', 0o644],
|
|
76
81
|
]);
|
|
77
82
|
|
|
78
83
|
// ─── Client registry + detection (spec §LW-12 step 1; UC-0 expansion) ───────
|
|
@@ -1556,16 +1561,21 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
1556
1561
|
// 4. Sentinel + runner + hook install state
|
|
1557
1562
|
const hookPath = hookScriptPathFor(homeDir);
|
|
1558
1563
|
const runnerInstalled = fs.existsSync(path.join(binRootFor(homeDir), 'scripts', 'runner.js'));
|
|
1564
|
+
let hookInstalled = false;
|
|
1559
1565
|
let hookRegistered = false;
|
|
1560
1566
|
try {
|
|
1561
1567
|
const settings = JSON.parse(
|
|
1562
1568
|
fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf-8')
|
|
1563
1569
|
);
|
|
1570
|
+
const sessionEnd = settings.hooks && settings.hooks.SessionEnd;
|
|
1571
|
+
// SPEC3-A3: installation detection accepts both the legacy string and the
|
|
1572
|
+
// current object shape through the same helper runner --status uses.
|
|
1573
|
+
hookInstalled = hasAuxiloSessionEndHook(sessionEnd);
|
|
1564
1574
|
// LW-17: only STRUCTURED entries count — Claude Code silently ignores
|
|
1565
1575
|
// bare-string entries (the 0.8.1 dead-hook bug), so reporting one as
|
|
1566
1576
|
// "registered" would mask exactly the failure this status exists to catch.
|
|
1567
|
-
hookRegistered = Array.isArray(
|
|
1568
|
-
|
|
1577
|
+
hookRegistered = Array.isArray(sessionEnd) &&
|
|
1578
|
+
sessionEnd.some((h) =>
|
|
1569
1579
|
h && typeof h === 'object' && Array.isArray(h.hooks) && h.hooks.some(isAuxiloCommandHook));
|
|
1570
1580
|
} catch { /* no settings */ }
|
|
1571
1581
|
|
|
@@ -1589,7 +1599,8 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
1589
1599
|
accountMode,
|
|
1590
1600
|
sentinel: sentinelPresent(homeDir),
|
|
1591
1601
|
runnerInstalled,
|
|
1592
|
-
hookInstalled
|
|
1602
|
+
hookInstalled,
|
|
1603
|
+
hookScriptInstalled: fs.existsSync(hookPath),
|
|
1593
1604
|
hookRegistered,
|
|
1594
1605
|
// LW-18 layer 1b: SessionStart held-count notice
|
|
1595
1606
|
noticeRegistered: sessionStartNoticeRegistered(homeDir),
|
package/lib/review.js
CHANGED
|
@@ -121,7 +121,7 @@ function formatFlags(l) {
|
|
|
121
121
|
|
|
122
122
|
// ── Review-seamless additions (2026-07-18) ──────────────────────────────────
|
|
123
123
|
//
|
|
124
|
-
// Summary + bulk network calls, plus the pure approve-
|
|
124
|
+
// Summary + bulk network calls, plus the pure approve-ready selection logic
|
|
125
125
|
// shared by the CLI, the MCP auxilo_review tool, and the triage report script.
|
|
126
126
|
// One selection implementation everywhere, so what a dry run PRINTS is exactly
|
|
127
127
|
// what a confirmed run DOES.
|
|
@@ -129,8 +129,12 @@ function formatFlags(l) {
|
|
|
129
129
|
/** Server-enforced max decisions per bulk call; clients chunk at this size. */
|
|
130
130
|
const BULK_CHUNK = 100;
|
|
131
131
|
|
|
132
|
-
/** Default
|
|
132
|
+
/** Default ready-to-publish quality threshold (repo quality gate: total >= 14/20). */
|
|
133
133
|
const DEFAULT_QUALITY_THRESHOLD = 14;
|
|
134
|
+
const LANE_READY = 'ready_to_publish';
|
|
135
|
+
const LANE_NEEDS_SCORE = 'needs_score';
|
|
136
|
+
const LANE_NEEDS_EYES = 'needs_your_eyes';
|
|
137
|
+
const REVIEW_LANES = new Set([LANE_READY, LANE_NEEDS_SCORE, LANE_NEEDS_EYES]);
|
|
134
138
|
|
|
135
139
|
/**
|
|
136
140
|
* GET /account/pending/summary: compact triage rows + counts (no bodies).
|
|
@@ -233,46 +237,95 @@ function qualityClears(row, minQuality) {
|
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
240
|
+
* Resolve the server lane on a summary row. Old servers did not return lane;
|
|
241
|
+
* those rows fall back to the legacy screens+quality split and are marked as
|
|
242
|
+
* version-skewed so the caller can tell the operator.
|
|
238
243
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
|
|
244
|
+
* @param {object} row
|
|
245
|
+
* @returns {{lane:string, version_skew:boolean}}
|
|
246
|
+
*/
|
|
247
|
+
function reviewLane(row) {
|
|
248
|
+
if (row && REVIEW_LANES.has(row.lane)) {
|
|
249
|
+
return { lane: row.lane, version_skew: false };
|
|
250
|
+
}
|
|
251
|
+
if (!row || !row.screens_passed) {
|
|
252
|
+
return { lane: LANE_NEEDS_EYES, version_skew: true };
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
lane: qualityClears(row, DEFAULT_QUALITY_THRESHOLD) ? LANE_READY : LANE_NEEDS_SCORE,
|
|
256
|
+
version_skew: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* THE approve-ready selection. The server's lane is authoritative:
|
|
262
|
+
*
|
|
263
|
+
* - threshold 14 (default): exactly rows in ready_to_publish
|
|
264
|
+
* - threshold >14: narrows within ready_to_publish
|
|
265
|
+
* - threshold <14: includes qualifying needs_score rows too
|
|
266
|
+
* - needs_your_eyes is excluded unless mode=all + includeFlagged
|
|
242
267
|
*
|
|
243
268
|
* Returns the selection plus what was excluded and why, so every confirmation
|
|
244
269
|
* surface can show the operator the exact consequences before anything runs.
|
|
245
270
|
*
|
|
246
271
|
* @param {Array<object>} rows
|
|
247
|
-
* @param {object} [opts] { mode: 'clean'|'all', minQuality, includeFlagged }
|
|
248
|
-
* @returns {{selected:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null}}
|
|
272
|
+
* @param {object} [opts] { mode: 'ready'|'clean'|'all', minQuality, includeFlagged }
|
|
273
|
+
* @returns {{selected:Array<object>, included_beyond_verdict:Array<object>, excluded_flagged:Array<object>, excluded_low_quality:Array<object>, excluded_unscored:Array<object>, min_quality:number|null, version_skew:boolean}}
|
|
249
274
|
*/
|
|
250
275
|
function selectForBulkApprove(rows, opts = {}) {
|
|
251
|
-
const mode = opts.mode === 'all' ? 'all' : '
|
|
252
|
-
const minQuality = mode === '
|
|
276
|
+
const mode = opts.mode === 'all' ? 'all' : 'ready';
|
|
277
|
+
const minQuality = mode === 'ready'
|
|
253
278
|
? (Number.isFinite(opts.minQuality) ? opts.minQuality : DEFAULT_QUALITY_THRESHOLD)
|
|
254
279
|
: null;
|
|
255
280
|
const includeFlagged = mode === 'all' && opts.includeFlagged === true;
|
|
256
281
|
|
|
257
282
|
const selected = [];
|
|
283
|
+
const included_beyond_verdict = [];
|
|
258
284
|
const excluded_flagged = [];
|
|
259
285
|
const excluded_low_quality = [];
|
|
260
286
|
const excluded_unscored = [];
|
|
287
|
+
let version_skew = false;
|
|
261
288
|
|
|
262
289
|
for (const row of rows || []) {
|
|
263
290
|
if (!row || !row.id) continue;
|
|
264
|
-
|
|
291
|
+
const resolved = reviewLane(row);
|
|
292
|
+
version_skew = version_skew || resolved.version_skew;
|
|
293
|
+
|
|
294
|
+
if (resolved.lane === LANE_NEEDS_EYES && !includeFlagged) {
|
|
265
295
|
excluded_flagged.push(row);
|
|
266
296
|
continue;
|
|
267
297
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
298
|
+
|
|
299
|
+
if (mode === 'ready') {
|
|
300
|
+
if (resolved.lane === LANE_READY) {
|
|
301
|
+
// At the server floor, consume the verdict verbatim. A stricter
|
|
302
|
+
// operator threshold may narrow it, but never broaden it.
|
|
303
|
+
if (minQuality > DEFAULT_QUALITY_THRESHOLD && !qualityClears(row, minQuality)) {
|
|
304
|
+
(row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
} else if (resolved.lane === LANE_NEEDS_SCORE) {
|
|
308
|
+
if (minQuality < DEFAULT_QUALITY_THRESHOLD && qualityClears(row, minQuality)) {
|
|
309
|
+
included_beyond_verdict.push(row);
|
|
310
|
+
} else {
|
|
311
|
+
(row.quality == null ? excluded_unscored : excluded_low_quality).push(row);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
271
315
|
}
|
|
316
|
+
|
|
272
317
|
selected.push(row);
|
|
273
318
|
}
|
|
274
319
|
|
|
275
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
selected,
|
|
322
|
+
included_beyond_verdict,
|
|
323
|
+
excluded_flagged,
|
|
324
|
+
excluded_low_quality,
|
|
325
|
+
excluded_unscored,
|
|
326
|
+
min_quality: minQuality,
|
|
327
|
+
version_skew,
|
|
328
|
+
};
|
|
276
329
|
}
|
|
277
330
|
|
|
278
331
|
module.exports = {
|
|
@@ -287,5 +340,6 @@ module.exports = {
|
|
|
287
340
|
chunkDecisions,
|
|
288
341
|
submitBulkChunked,
|
|
289
342
|
qualityClears,
|
|
343
|
+
reviewLane,
|
|
290
344
|
selectForBulkApprove,
|
|
291
345
|
};
|