auxilo-mcp 0.9.4 → 0.9.6
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/config/near-duplicate.json +9 -0
- package/lib/extraction-index.js +462 -0
- package/lib/installer.js +3 -0
- package/lib/similarity.js +275 -0
- package/package.json +4 -1
- package/scripts/extract-local.js +442 -25
- package/scripts/runner.js +49 -3
|
@@ -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
|
+
};
|
package/lib/installer.js
CHANGED
|
@@ -73,6 +73,9 @@ const RUNNER_STACK = Object.freeze([
|
|
|
73
73
|
['scripts/review-notice.js', 'scripts/review-notice.js', 0o755],
|
|
74
74
|
...sourceAdapterRows(),
|
|
75
75
|
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
76
|
+
['lib/extraction-index.js', 'lib/extraction-index.js', 0o644],
|
|
77
|
+
['lib/similarity.js', 'lib/similarity.js', 0o644],
|
|
78
|
+
['config/near-duplicate.json', 'config/near-duplicate.json', 0o644],
|
|
76
79
|
]);
|
|
77
80
|
|
|
78
81
|
// ─── Client registry + detection (spec §LW-12 step 1; UC-0 expansion) ───────
|