claude-mem-lite 3.35.2 → 3.37.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bash-utils.mjs +7 -1
- package/deep-search.mjs +13 -6
- package/format-utils.mjs +6 -2
- package/haiku-client.mjs +16 -10
- package/hook-context.mjs +12 -1
- package/hook-handoff.mjs +9 -3
- package/hook-llm.mjs +116 -23
- package/hook-memory.mjs +8 -2
- package/hook-optimize.mjs +45 -8
- package/hook-shared.mjs +22 -9
- package/hook-update.mjs +5 -2
- package/hook.mjs +31 -16
- package/install.mjs +33 -11
- package/lib/atomic-write.mjs +19 -8
- package/lib/citation-tracker.mjs +5 -2
- package/lib/deferred-work.mjs +3 -1
- package/lib/doctor-benchmark.mjs +6 -1
- package/lib/maintain-core.mjs +12 -0
- package/lib/task-imperative.mjs +4 -2
- package/mem-cli.mjs +57 -28
- package/nlp.mjs +36 -4
- package/package.json +1 -2
- package/registry-recommend.mjs +55 -14
- package/registry.mjs +19 -7
- package/schema.mjs +55 -4
- package/scripts/post-tool-use.sh +10 -1
- package/search-engine.mjs +5 -1
- package/search-scoring.mjs +10 -0
- package/secret-scrub.mjs +17 -4
- package/source-files.mjs +1 -1
- package/tool-schemas.mjs +2 -2
- package/utils.mjs +5 -1
- package/registry-indexer.mjs +0 -175
package/registry-recommend.mjs
CHANGED
|
@@ -18,8 +18,12 @@ export function getRecommendMode() {
|
|
|
18
18
|
|
|
19
19
|
// Provisional gate thresholds — calibrated from shadow data before Phase 2 (spec §8).
|
|
20
20
|
// relevance is raw bm25 (negative; more negative = better). Candidate must clear |floor|.
|
|
21
|
-
export const RECO_BM25_FLOOR = -1.5; //
|
|
22
|
-
|
|
21
|
+
export const RECO_BM25_FLOOR = -1.5; // floor stays on raw bm25 (absolute topical relevance)
|
|
22
|
+
// Margin is measured in COMPOSITE space (the metric that orders + selects the winner), NOT raw
|
|
23
|
+
// bm25 — see applyGate. composite = bm25*0.4 - priors, so the scale is ~0.4x raw; 0.5 raw ≈ 0.2
|
|
24
|
+
// composite is the principled starting point. Final value comes from `recommend-stats --sweep`
|
|
25
|
+
// after dogfood (D#47); DEFAULT_SWEEP_MARGINS is likewise on the composite scale.
|
|
26
|
+
export const RECO_MARGIN = 0.2; // require candidates[1].composite_score - candidates[0].composite_score >= this
|
|
23
27
|
const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS (internal)
|
|
24
28
|
|
|
25
29
|
// Lazy runtime-path resolution: read CLAUDE_MEM_DIR at call time (mirrors schema.mjs:13
|
|
@@ -35,9 +39,14 @@ const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
|
|
|
35
39
|
export function fetchInstalledSkillCandidates(rdb, promptText, limit = 10) {
|
|
36
40
|
if (!rdb || !promptText) return [];
|
|
37
41
|
let rows;
|
|
38
|
-
|
|
42
|
+
// Over-fetch, THEN filter to installed. quality_tier='installed' is a post-SQL JS filter, so a
|
|
43
|
+
// plain LIMIT starves installed skills that rank below other-tier matches: a weak installed
|
|
44
|
+
// match sitting past the top-N is sliced off → the gate sees no candidate → spurious
|
|
45
|
+
// no_candidate BLOCK (the −0.15 installed composite bonus is negligible vs the bm25 spread).
|
|
46
|
+
// A wide headroom lets the composite-best installed skill actually survive to the gate.
|
|
47
|
+
try { rows = searchResources(rdb, promptText, { type: 'skill', limit: Math.max(limit * 5, 50) }); }
|
|
39
48
|
catch { return []; }
|
|
40
|
-
return rows.filter(r => r.quality_tier === 'installed');
|
|
49
|
+
return rows.filter(r => r.quality_tier === 'installed').slice(0, limit);
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -65,7 +74,13 @@ export function applyGate(candidates, promptText, cooldownSet) {
|
|
|
65
74
|
const top = candidates[0];
|
|
66
75
|
if (!(top.relevance <= RECO_BM25_FLOOR)) return { verdict: 'BLOCK', reason: 'below_floor', candidate: top };
|
|
67
76
|
if (candidates.length >= 2) {
|
|
68
|
-
|
|
77
|
+
// Separation measured in the SAME metric that ordered the candidates (composite_score),
|
|
78
|
+
// NOT relevance. candidates arrive composite ASC so `top` IS the composite winner; a raw
|
|
79
|
+
// relevance margin let a composite-promoted top sit BELOW candidates[1] in relevance →
|
|
80
|
+
// negative margin → spurious low_margin BLOCK (and a permanent dead-zone in the ROC sweep).
|
|
81
|
+
// composite2 - composite1 is >= 0 by sort order. The floor above stays on raw bm25 — the
|
|
82
|
+
// absolute-relevance gate that stops a popular-but-irrelevant composite promotion.
|
|
83
|
+
const margin = candidates[1].composite_score - top.composite_score;
|
|
69
84
|
if (!(margin >= RECO_MARGIN)) return { verdict: 'BLOCK', reason: 'low_margin', candidate: top };
|
|
70
85
|
}
|
|
71
86
|
if (!intentMatch(promptText, top)) return { verdict: 'BLOCK', reason: 'intent_mismatch', candidate: top };
|
|
@@ -192,11 +207,13 @@ export function computeFunnel(days = 7) {
|
|
|
192
207
|
return stats;
|
|
193
208
|
}
|
|
194
209
|
|
|
195
|
-
// Default sweep grid (B3).
|
|
196
|
-
//
|
|
197
|
-
//
|
|
210
|
+
// Default sweep grid (B3). FLOORS are raw bm25 (real matches run ≈ -10..-15, so the shipped
|
|
211
|
+
// -1.5 floor is far more permissive than it looks; the grid spans that real range). MARGINS are
|
|
212
|
+
// on the COMPOSITE scale (composite = bm25*0.4 - priors), matching replayGate's margin metric —
|
|
213
|
+
// the old raw-bm25 grid [0,0.5,1,2,4] was ~2.5x too wide for composite separations.
|
|
214
|
+
// Override via `recommend-stats --sweep --floors a,b --margins x,y`.
|
|
198
215
|
export const DEFAULT_SWEEP_FLOORS = [-1.5, -5, -8, -10, -12];
|
|
199
|
-
export const DEFAULT_SWEEP_MARGINS = [0, 0.
|
|
216
|
+
export const DEFAULT_SWEEP_MARGINS = [0, 0.05, 0.1, 0.2, 0.4];
|
|
200
217
|
|
|
201
218
|
/**
|
|
202
219
|
* Recompute the gate verdict for a logged reco row at a hypothetical (floor, margin),
|
|
@@ -207,7 +224,15 @@ export function replayGate(row, floor, margin) {
|
|
|
207
224
|
const r1 = row.relevance;
|
|
208
225
|
// null/undefined relevance coerces to 0/NaN, so `r1 <= floor` is false → BLOCK (no null check).
|
|
209
226
|
if (!(r1 <= floor)) return 'BLOCK';
|
|
210
|
-
|
|
227
|
+
// Margin on composite when the row logged it (post-switch rows), matching the live applyGate
|
|
228
|
+
// metric; fall back to the legacy relevance margin for rows written before composite1/composite2
|
|
229
|
+
// existed (they age out of the 7d sweep window). Single-candidate rows (no rel2/composite2) skip
|
|
230
|
+
// the margin gate either way. NOTE: sweep `margin` is on the COMPOSITE scale for new rows.
|
|
231
|
+
if (Number.isFinite(row.composite1) && Number.isFinite(row.composite2)) {
|
|
232
|
+
if (!((row.composite2 - row.composite1) >= margin)) return 'BLOCK';
|
|
233
|
+
} else if (Number.isFinite(row.rel2)) {
|
|
234
|
+
if (!((row.rel2 - r1) >= margin)) return 'BLOCK';
|
|
235
|
+
}
|
|
211
236
|
if (!row.intentTop) return 'BLOCK';
|
|
212
237
|
if (row.cooldownTop) return 'BLOCK';
|
|
213
238
|
return 'PASS';
|
|
@@ -230,11 +255,25 @@ export function computeSweep(days = 7, floors = DEFAULT_SWEEP_FLOORS, margins =
|
|
|
230
255
|
}
|
|
231
256
|
const grid = [];
|
|
232
257
|
for (const floor of floors) for (const margin of margins) {
|
|
233
|
-
let pass = 0
|
|
258
|
+
let pass = 0;
|
|
259
|
+
// Matched precision MUST dedup (session, skill) exactly like computeFunnel (mPass/mAdopt
|
|
260
|
+
// via per-session Sets). Counting raw reco rows here inflated matchPass by every repeat
|
|
261
|
+
// recommendation of the same skill in one session, so the sweep's precision silently
|
|
262
|
+
// disagreed with the funnel headline and was biased toward frequently-recommended skills —
|
|
263
|
+
// corrupting the exact (floor,margin) signal the sweep exists to inform. `pass` stays a raw
|
|
264
|
+
// volume count (it is not a precision denominator).
|
|
265
|
+
const passBySession = new Map(); // session -> Set<skillLower> that PASSED at this cell
|
|
234
266
|
for (const r of recos) {
|
|
235
267
|
if (replayGate(r, floor, margin) !== 'PASS') continue;
|
|
236
268
|
pass++;
|
|
237
|
-
if (r.session)
|
|
269
|
+
if (!r.session) continue;
|
|
270
|
+
if (!passBySession.has(r.session)) passBySession.set(r.session, new Set());
|
|
271
|
+
passBySession.get(r.session).add(lc(r.skill));
|
|
272
|
+
}
|
|
273
|
+
let matchPass = 0, matchAdopt = 0;
|
|
274
|
+
for (const [session, skills] of passBySession) {
|
|
275
|
+
const adopted = adoptBySession.get(session);
|
|
276
|
+
for (const sk of skills) { matchPass++; if (adopted?.has(sk)) matchAdopt++; }
|
|
238
277
|
}
|
|
239
278
|
grid.push({ floor, margin, pass, matchPass, matchAdopt, precision: matchPass ? matchAdopt / matchPass : null });
|
|
240
279
|
}
|
|
@@ -265,8 +304,10 @@ export function recommendSkill(rdb, promptText, project, opts = {}) {
|
|
|
265
304
|
// NOT the registry short name ('superpowers-tdd') — adoption rows log toolInput.skill (the slug),
|
|
266
305
|
// so logging top.name made in-session matched precision a guaranteed 0 for every namespaced skill.
|
|
267
306
|
skill: top ? (top.invocation_name || top.name) : null,
|
|
268
|
-
relevance: top ? top.relevance : null,
|
|
269
|
-
rel2: candidates[1] ? candidates[1].relevance : null,
|
|
307
|
+
relevance: top ? top.relevance : null, // floor replay (absolute bm25)
|
|
308
|
+
rel2: candidates[1] ? candidates[1].relevance : null, // legacy margin replay (pre-composite rows)
|
|
309
|
+
composite1: top ? top.composite_score : null, // margin replay (composite — the live gate metric)
|
|
310
|
+
composite2: candidates[1] ? candidates[1].composite_score : null,
|
|
270
311
|
intentTop: top ? intentMatch(promptText, top) : false,
|
|
271
312
|
cooldownTop: top ? cooldownSet.has(String(top.name).toLowerCase()) : false,
|
|
272
313
|
ncand: candidates.length,
|
package/registry.mjs
CHANGED
|
@@ -62,8 +62,9 @@ const RESOURCES_SCHEMA = `
|
|
|
62
62
|
`;
|
|
63
63
|
|
|
64
64
|
// Canonical FTS5 column order — all consumers must use this order.
|
|
65
|
-
// BM25 weights (positional): trigger_patterns(
|
|
65
|
+
// BM25 weights (positional): trigger_patterns(3), keywords(3), capability_summary(3),
|
|
66
66
|
// intent_tags(2), use_cases(2), domain_tags(1), tech_stack(1), name(1)
|
|
67
|
+
// — must match the bm25(resources_fts, 3,3,3,2,2,1,1,1) call in COMPOSITE_EXPR.
|
|
67
68
|
const FTS5_SCHEMA = `
|
|
68
69
|
CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
|
|
69
70
|
trigger_patterns,
|
|
@@ -371,12 +372,23 @@ const UPSERT_SQL = `
|
|
|
371
372
|
repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
|
|
372
373
|
local_path=excluded.local_path, file_hash=excluded.file_hash,
|
|
373
374
|
invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
375
|
+
-- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
|
|
376
|
+
-- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
|
|
377
|
+
-- other flag to '' -- must NOT blank the FTS text columns and silently drop the resource
|
|
378
|
+
-- out of search (import is the ONLY registry edit path; there is no update subcommand).
|
|
379
|
+
-- Full upserts are unaffected: every field is non-empty, so the CASE picks excluded.
|
|
380
|
+
intent_tags=CASE WHEN excluded.intent_tags != '' THEN excluded.intent_tags ELSE intent_tags END,
|
|
381
|
+
domain_tags=CASE WHEN excluded.domain_tags != '' THEN excluded.domain_tags ELSE domain_tags END,
|
|
382
|
+
action_type=CASE WHEN excluded.action_type != '' THEN excluded.action_type ELSE action_type END,
|
|
383
|
+
trigger_patterns=CASE WHEN excluded.trigger_patterns != '' THEN excluded.trigger_patterns ELSE trigger_patterns END,
|
|
384
|
+
capability_summary=CASE WHEN excluded.capability_summary != '' THEN excluded.capability_summary ELSE capability_summary END,
|
|
385
|
+
input_type=CASE WHEN excluded.input_type != '' THEN excluded.input_type ELSE input_type END,
|
|
386
|
+
output_type=CASE WHEN excluded.output_type != '' THEN excluded.output_type ELSE output_type END,
|
|
387
|
+
prerequisites=excluded.prerequisites,
|
|
388
|
+
keywords=CASE WHEN excluded.keywords != '' THEN excluded.keywords ELSE keywords END,
|
|
389
|
+
tech_stack=CASE WHEN excluded.tech_stack != '' THEN excluded.tech_stack ELSE tech_stack END,
|
|
390
|
+
use_cases=CASE WHEN excluded.use_cases != '' THEN excluded.use_cases ELSE use_cases END,
|
|
391
|
+
complexity=excluded.complexity,
|
|
380
392
|
indexed_at=excluded.indexed_at, updated_at=datetime('now')
|
|
381
393
|
`;
|
|
382
394
|
|
package/schema.mjs
CHANGED
|
@@ -89,7 +89,16 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
89
89
|
// failure leaves the marker unset and retries on the next open. New TABLE via
|
|
90
90
|
// CORE_SCHEMA on the forced pass; LATEST_MIGRATION_COLUMN unchanged (no new
|
|
91
91
|
// column) — same pattern as v35/v36/v38.
|
|
92
|
-
|
|
92
|
+
// v40 (round-5 audit HIGH): forces one migration pass so the now column-aware ensureFTS
|
|
93
|
+
// widens any STALE FTS table on existing DBs. Early-adopter stores created before a column
|
|
94
|
+
// was added to an FTS list (session_summaries_fts predates `remaining_items`, v2.2.0) carried
|
|
95
|
+
// a narrow FTS table forever — the old ensureFTS only created a table when absent, never
|
|
96
|
+
// widened it — while its triggers were rebuilt with the current wider column list, so every
|
|
97
|
+
// session_summaries UPDATE threw "no column named remaining_items" and was silently swallowed
|
|
98
|
+
// (Haiku summary enrichment lost every session). Pure index reheal (no data migration, no
|
|
99
|
+
// column drop); idempotent. New behavior via the forced pass; LATEST_MIGRATION_COLUMN
|
|
100
|
+
// unchanged (no new column) — same pattern as v35/v36/v38/v39.
|
|
101
|
+
export const CURRENT_SCHEMA_VERSION = 40;
|
|
93
102
|
|
|
94
103
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
95
104
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
@@ -816,7 +825,14 @@ const DEFERRED_CLEANUPS = [
|
|
|
816
825
|
}
|
|
817
826
|
}
|
|
818
827
|
if (canonical) {
|
|
819
|
-
|
|
828
|
+
// Rename the short project to canonical on EVERY project-scoped table.
|
|
829
|
+
// Originally only the first three were rewritten, so a short-named
|
|
830
|
+
// project's deferred TODOs (deferred_work), activity (events), citation
|
|
831
|
+
// history (citation_log), and /clear-/exit handoffs (session_handoffs)
|
|
832
|
+
// were stranded on the old name — invisible to every project-scoped query
|
|
833
|
+
// after normalization. All seven carry a `project` column (verified).
|
|
834
|
+
for (const table of ['observations', 'sdk_sessions', 'session_summaries',
|
|
835
|
+
'session_handoffs', 'citation_log', 'events', 'deferred_work']) {
|
|
820
836
|
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
|
|
821
837
|
}
|
|
822
838
|
}
|
|
@@ -974,8 +990,32 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
974
990
|
const newVals = columns.map(c => `new.${c}`).join(', ');
|
|
975
991
|
const oldVals = columns.map(c => `old.${c}`).join(', ');
|
|
976
992
|
|
|
977
|
-
|
|
978
|
-
|
|
993
|
+
// Column-aware (re)creation. An existing FTS table is never silently reused when its
|
|
994
|
+
// indexed-column set has drifted from `columns`. Root cause of a silent-write bug class:
|
|
995
|
+
// a DB created before a column was added to an FTS list (session_summaries_fts predates
|
|
996
|
+
// `remaining_items`, added v2.2.0) kept the OLD narrow table forever, because ensureFTS
|
|
997
|
+
// only created the table when it was absent. The triggers below, however, are rebuilt
|
|
998
|
+
// from the CURRENT (wider) column list, so every UPDATE fired a trigger that INSERTed
|
|
999
|
+
// into a column the stale FTS table lacked and threw "no column named <X>", silently
|
|
1000
|
+
// failing the write (session-summary Haiku enrichment was discarded every session for the
|
|
1001
|
+
// early-adopter cohort, and the new column stayed unindexed). On drift, drop the triggers +
|
|
1002
|
+
// table and fall through to CREATE + repopulate. Generalizes the one-off observations_fts
|
|
1003
|
+
// guard in ensureDb so ALL three ensureFTS-managed tables self-heal on any column addition.
|
|
1004
|
+
const ftsRow = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(ftsName);
|
|
1005
|
+
let recreated = false;
|
|
1006
|
+
if (ftsRow) {
|
|
1007
|
+
let existingCols = [];
|
|
1008
|
+
try { existingCols = db.prepare(`PRAGMA table_info(${ftsName})`).all().map(c => c.name); } catch { /* unreadable → treat as drifted, recreate */ }
|
|
1009
|
+
const drifted = existingCols.length !== columns.length || columns.some(c => !existingCols.includes(c));
|
|
1010
|
+
if (drifted) {
|
|
1011
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ai`);
|
|
1012
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ad`);
|
|
1013
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_au`);
|
|
1014
|
+
db.exec(`DROP TABLE IF EXISTS ${ftsName}`);
|
|
1015
|
+
recreated = true;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (!ftsRow || recreated) {
|
|
979
1019
|
db.exec(`CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`);
|
|
980
1020
|
}
|
|
981
1021
|
|
|
@@ -1001,4 +1041,15 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
1001
1041
|
INSERT INTO ${ftsName}(rowid, ${colList}) VALUES (new.id, ${newVals});
|
|
1002
1042
|
END;
|
|
1003
1043
|
`);
|
|
1044
|
+
|
|
1045
|
+
// Repopulate a freshly (re)created external-content FTS index from its content table.
|
|
1046
|
+
// An empty index otherwise returns 0 rows until each row is next written — and unlike
|
|
1047
|
+
// observations_fts (rebuilt via the obsFtsRecreated flag in ensureDb), session_summaries_fts
|
|
1048
|
+
// and user_prompts_fts have no other rebuild path, so a widened table must repopulate here.
|
|
1049
|
+
if (recreated) {
|
|
1050
|
+
try {
|
|
1051
|
+
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM ${tableName}`).get();
|
|
1052
|
+
if (cnt.c > 0) db.exec(`INSERT INTO ${ftsName}(${ftsName}) VALUES('rebuild')`);
|
|
1053
|
+
} catch { /* non-critical — index repopulates lazily on next write */ }
|
|
1054
|
+
}
|
|
1004
1055
|
}
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -22,6 +22,12 @@ if [[ "$tool" == "Read" ]]; then
|
|
|
22
22
|
if [[ "$input" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
|
23
23
|
file_path="${BASH_REMATCH[1]}"
|
|
24
24
|
_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
25
|
+
# Strip trailing slashes so ${_dir##*/} / ${_dir%/*} match Node's path.basename /
|
|
26
|
+
# path.dirname in inferProject(). Without this, CLAUDE_PROJECT_DIR="/org/proj/"
|
|
27
|
+
# gave bash "proj--" (empty base) while JS gave "org--proj" — a name mismatch that
|
|
28
|
+
# made flushEpisode read a DIFFERENT reads-<project>.txt, silently dropping this
|
|
29
|
+
# session's Read context AND orphaning the bash-named file (nothing ever collects it).
|
|
30
|
+
while [[ "$_dir" == */ && ${#_dir} -gt 1 ]]; do _dir="${_dir%/}"; done
|
|
25
31
|
_base="${_dir##*/}"
|
|
26
32
|
_parent="${_dir%/*}"; _parent="${_parent##*/}"
|
|
27
33
|
if [[ -n "$_parent" && "$_parent" != "." && "$_parent" != "/" ]]; then
|
|
@@ -29,8 +35,11 @@ if [[ "$tool" == "Read" ]]; then
|
|
|
29
35
|
else
|
|
30
36
|
project="${_base}"
|
|
31
37
|
fi
|
|
32
|
-
# Sanitize
|
|
38
|
+
# Sanitize + truncate to 100 to match utils.mjs inferProject() EXACTLY
|
|
39
|
+
# (raw.replace(/[^a-zA-Z0-9_.-]/g,'-').slice(0,100)). A >100-char parent--base
|
|
40
|
+
# otherwise diverges from the JS side (same reads-file mismatch as above).
|
|
33
41
|
project="${project//[^a-zA-Z0-9_.-]/-}"
|
|
42
|
+
project="${project:0:100}"
|
|
34
43
|
project="${project:-unknown}"
|
|
35
44
|
# Honor CLAUDE_MEM_DIR relocation (mirrors schema.mjs DB_DIR → hook-shared RUNTIME_DIR).
|
|
36
45
|
# hook.mjs flushEpisode reads reads-<project>.txt from CLAUDE_MEM_DIR/runtime; if this
|
package/search-engine.mjs
CHANGED
|
@@ -259,6 +259,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
259
259
|
SELECT o.title, o.narrative FROM observations_fts
|
|
260
260
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
261
261
|
WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
|
|
262
|
+
AND o.superseded_at IS NULL
|
|
262
263
|
AND (? IS NULL OR o.project = ?)
|
|
263
264
|
ORDER BY ${OBS_BM25}
|
|
264
265
|
LIMIT 8
|
|
@@ -304,7 +305,9 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
304
305
|
* 1. FTS5 MATCH with the sanitized query (AND-by-default), recency-weighted
|
|
305
306
|
* 2. If AND returns 0 → relaxFtsQueryToOr fallback (mirrors searchObservationsHybrid)
|
|
306
307
|
*
|
|
307
|
-
* Always skips compressed rows
|
|
308
|
+
* Always skips compressed AND superseded rows — paired with searchObservationsHybrid /
|
|
309
|
+
* buildObsFtsQuery so `timeline --query` / mem_timeline never anchor on a memory that
|
|
310
|
+
* search itself hides (an anchor on a replaced row strands navigation on stale content).
|
|
308
311
|
*
|
|
309
312
|
* @param {Database} db
|
|
310
313
|
* @param {object} opts
|
|
@@ -324,6 +327,7 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
|
|
|
324
327
|
WHERE observations_fts MATCH ?
|
|
325
328
|
AND (? IS NULL OR o.project = ?)
|
|
326
329
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
330
|
+
AND o.superseded_at IS NULL
|
|
327
331
|
ORDER BY ${OBS_BM25}
|
|
328
332
|
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
|
|
329
333
|
LIMIT 1
|
package/search-scoring.mjs
CHANGED
|
@@ -199,6 +199,7 @@ export function expandQueryByConcepts(db, ftsQuery, project) {
|
|
|
199
199
|
SELECT o.concepts FROM observations_fts
|
|
200
200
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
201
201
|
WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
|
|
202
|
+
AND o.superseded_at IS NULL
|
|
202
203
|
AND (? IS NULL OR o.project = ?)
|
|
203
204
|
ORDER BY ${OBS_BM25}
|
|
204
205
|
LIMIT 20
|
|
@@ -281,6 +282,11 @@ export function runIdleCleanup(db) {
|
|
|
281
282
|
WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
|
|
282
283
|
AND type IN (${types})
|
|
283
284
|
AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
|
|
285
|
+
-- Never auto-mark a lesson-bearing row for purge. This idle path is the
|
|
286
|
+
-- MCP-server sibling of maintain-core.decayAndMarkIdle and must carry the
|
|
287
|
+
-- SAME "lessons never auto-GC" guard; without it a lesson demoted to imp≤1
|
|
288
|
+
-- by citation-decay gets pending-purge'd here and hard-deleted by purgeStale.
|
|
289
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
284
290
|
`).run(cutoff);
|
|
285
291
|
totalMarked += marked.changes;
|
|
286
292
|
|
|
@@ -289,6 +295,10 @@ export function runIdleCleanup(db) {
|
|
|
289
295
|
WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
|
|
290
296
|
AND type IN (${types})
|
|
291
297
|
AND created_at_epoch < ?
|
|
298
|
+
-- Same lesson guard: auto-compress (-1) hides the row from all retrieval and
|
|
299
|
+
-- recoverBuriedLessons only re-floors live (compressed_into=0) rows, so a
|
|
300
|
+
-- compressed lesson is unrecoverable. Parity with selectCompressionCandidates.
|
|
301
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
292
302
|
`).run(cutoff);
|
|
293
303
|
totalCompressed += compressed.changes;
|
|
294
304
|
}
|
package/secret-scrub.mjs
CHANGED
|
@@ -29,9 +29,9 @@ export const SECRET_PATTERNS = [
|
|
|
29
29
|
// excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
|
|
30
30
|
// value is covered (the hex-only assignment pattern below misses non-hex values).
|
|
31
31
|
// 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
|
|
32
|
-
[/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
32
|
+
[/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
33
33
|
// 1b. `:` separator → keep the prose lookbehind ("the token: alice" is prose):
|
|
34
|
-
[/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
34
|
+
[/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
35
35
|
// access_token / refresh_token are the canonical OAuth2 field names — they were
|
|
36
36
|
// missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
|
|
37
37
|
// underscore-prefix reason.
|
|
@@ -55,8 +55,8 @@ export const SECRET_PATTERNS = [
|
|
|
55
55
|
// (a) bare credential nouns: `=` always scrubs; `:` keeps the prose lookbehind
|
|
56
56
|
// (mirrors the unquoted 1a/1b split — a quoted value doesn't turn `:` prose
|
|
57
57
|
// into config, but `<word> password="x"` is still a leak):
|
|
58
|
-
[/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
59
|
-
[/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
58
|
+
[/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
59
|
+
[/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
60
60
|
// (b) structured keys + named env vars are unambiguous config even after a word
|
|
61
61
|
// (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
|
|
62
62
|
[/((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
@@ -121,6 +121,19 @@ export const SECRET_PATTERNS = [
|
|
|
121
121
|
// `"token_count"` value (numeric, <6 non-quote chars after scrub) and prose
|
|
122
122
|
// keys stay low-FP; over-scrub is the safe direction for at-rest memory.
|
|
123
123
|
[/("\w*(?:password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w*"\s*:\s*")[^"]{6,}(")/gi, '$1***$2'],
|
|
124
|
+
// Quoted-KEY credential values — Python dict reprs `{'api_key': '...'}`, single-quoted
|
|
125
|
+
// JS/JSON, and any mixed quoting. The quoted-VALUE patterns above match an UNQUOTED key
|
|
126
|
+
// (the key's closing quote sits between the key name and the `[=:]`, so `keyword\s*[=:]`
|
|
127
|
+
// never fires); the JSON patterns require BOTH key and value DOUBLE-quoted. So a single-
|
|
128
|
+
// quoted or mixed-quoted pair — the most common at-rest shape for opaque app secrets in
|
|
129
|
+
// stored LLM output / error payloads / code snippets — slipped through unredacted (#8805
|
|
130
|
+
// sibling). A quoted key is unambiguous config/data, so — like the JSON patterns — no prose
|
|
131
|
+
// guard is needed. Key quote and value quote are matched independently (`['"]` each); the
|
|
132
|
+
// value's close is a backref (\2) to its own opening quote. Same credential-noun set as the
|
|
133
|
+
// vendor-prefix JSON pattern above (bare `token`/`bearer` deliberately excluded to avoid
|
|
134
|
+
// `'token_count': 123456`); `passphrase` added here too (double-quoted JSON passphrase is
|
|
135
|
+
// subsumed by this pattern since `['"]` matches `"`). Over-scrub is the safe direction.
|
|
136
|
+
[/(['"]\w*(?:password|passwd|passphrase|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w*['"]\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
|
|
124
137
|
// Session cookies in headers / urlencoded bodies (sessionid=, session_id=, JSESSIONID=, PHPSESSID=).
|
|
125
138
|
// 16+ chars filters out short test fixtures like sessionid=abc.
|
|
126
139
|
[/\b((?:session[_-]?id|sessionid|jsessionid|phpsessid)\s*[=:]\s*)[^\s,;'"}\]]{16,}/gi, '$1***'],
|
package/source-files.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export const SOURCE_FILES = [
|
|
|
13
13
|
'plugin-cache-guard.mjs',
|
|
14
14
|
'haiku-client.mjs', 'utils.mjs', 'schema.mjs',
|
|
15
15
|
'package.json', 'package-lock.json', 'skill.md',
|
|
16
|
-
'registry.mjs', 'registry-scanner.mjs',
|
|
16
|
+
'registry.mjs', 'registry-scanner.mjs',
|
|
17
17
|
'registry-retriever.mjs', 'resource-discovery.mjs',
|
|
18
18
|
// registry-recommend.mjs: statically imported by hook.mjs (PostToolUse adoption probe)
|
|
19
19
|
// and scripts/user-prompt-search.js (UserPromptSubmit shadow recommendation).
|
package/tool-schemas.mjs
CHANGED
|
@@ -92,7 +92,7 @@ export const memSearchSchema = {
|
|
|
92
92
|
limit: coerceInt.pipe(z.number().int().min(1).max(100)).optional().describe('Max results (default 20)'),
|
|
93
93
|
offset: coerceInt.pipe(z.number().int().min(0)).optional().describe('Offset for pagination'),
|
|
94
94
|
sort: z.enum(['relevance', 'time', 'importance']).optional().describe('Sort order: relevance (default, BM25), time (newest first), importance (highest first)'),
|
|
95
|
-
include_noise:
|
|
95
|
+
include_noise: coerceBool.optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default as they have ~3% access rate'),
|
|
96
96
|
or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
|
|
97
97
|
deep: coerceBool.optional().describe('Tri-state LLM multi-query/HyDE deep search (observations-only). true=force; false=never; omit=AUTO (default ON for mem_search): a normal search that returns weak/few results auto-escalates with ONE Haiku call (query rewritten to keyword/concept/HyDE variants, RRF-fused). Set CLAUDE_MEM_AUTO_DEEP=0 to disable AUTO. Passive recall stays single-query.'),
|
|
98
98
|
rerank: coerceBool.optional().describe('Opt-in: LLM-rerank the deep-search candidates for ranking precision (one extra Haiku call, ~1.4s). Requires deep=true (no effect on AUTO/normal). Reserve for hard, ranking-sensitive queries where the right memory is likely retrieved but mis-ranked — skip for routine search. Default off.'),
|
|
@@ -249,7 +249,7 @@ export const memExportSchema = {
|
|
|
249
249
|
export const memRecallSchema = {
|
|
250
250
|
file: z.string().min(1).describe('File path or filename to recall observations for'),
|
|
251
251
|
limit: coerceInt.pipe(z.number().int().min(1).max(50)).optional().describe('Max results (default 10)'),
|
|
252
|
-
include_noise:
|
|
252
|
+
include_noise: coerceBool.optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default for parity with mem_search'),
|
|
253
253
|
};
|
|
254
254
|
|
|
255
255
|
export const memFtsCheckSchema = {
|
package/utils.mjs
CHANGED
|
@@ -309,7 +309,11 @@ function firstBalancedJsonObject(text) {
|
|
|
309
309
|
export function parseJsonFromLLM(text) {
|
|
310
310
|
if (!text) return null;
|
|
311
311
|
try { return JSON.parse(text); } catch {}
|
|
312
|
-
|
|
312
|
+
// No `\s*` around the lazy capture: `\s*([\s\S]*?)\s*` catastrophically backtracks
|
|
313
|
+
// (O(n²)) on a fence + long whitespace + no closing fence — a ~5KB partial buffer hung
|
|
314
|
+
// the CLI-timeout salvage path for 10+s (round-5 review). `([\s\S]*?)```` is a single
|
|
315
|
+
// O(n) lazy scan; JSON.parse tolerates the surrounding whitespace the trim used to strip.
|
|
316
|
+
const fenced = text.match(/```(?:json)?([\s\S]*?)```/);
|
|
313
317
|
if (fenced) try { return JSON.parse(fenced[1]); } catch {}
|
|
314
318
|
// First balanced object — survives unfenced output wrapped in brace-containing prose.
|
|
315
319
|
const balanced = firstBalancedJsonObject(text);
|
package/registry-indexer.mjs
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
// claude-mem-lite: Resource indexer — extracts metadata via Haiku LLM
|
|
2
|
-
// Called during installation and when resource content changes
|
|
3
|
-
|
|
4
|
-
import { upsertResource } from './registry.mjs';
|
|
5
|
-
import { callHaikuJSON } from './haiku-client.mjs';
|
|
6
|
-
import { debugLog, debugCatch, truncate } from './utils.mjs';
|
|
7
|
-
|
|
8
|
-
// ─── Index Prompt ────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Build the LLM prompt for extracting structured metadata from a resource.
|
|
12
|
-
* @param {object} resource Scanned resource with name, type, and content
|
|
13
|
-
* @returns {string} Prompt string for Haiku JSON extraction
|
|
14
|
-
*/
|
|
15
|
-
function buildIndexPrompt(resource) {
|
|
16
|
-
const content = truncate(resource.content, 3000);
|
|
17
|
-
return `Analyze this Claude Code ${resource.type} definition and extract structured metadata.
|
|
18
|
-
Return ONLY valid JSON, no markdown fences.
|
|
19
|
-
|
|
20
|
-
<resource-name>${resource.name}</resource-name>
|
|
21
|
-
<resource-type>${resource.type}</resource-type>
|
|
22
|
-
<content>
|
|
23
|
-
${content}
|
|
24
|
-
</content>
|
|
25
|
-
|
|
26
|
-
JSON format:
|
|
27
|
-
{"intent_tags":"comma-separated intent keywords (e.g. test,debug,deploy,review)","domain_tags":"comma-separated tech/language tags (e.g. javascript,react,python)","action_type":"analyze|generate|transform|validate|deploy|configure|review","trigger_patterns":"natural language describing when to use this (e.g. when user needs to write tests; when debugging errors)","capability_summary":"50-100 char description of what it does","keywords":"specific technical terms, framework names, method names not duplicating intent_tags (e.g. jest,vitest,red-green-refactor,pytest)","tech_stack":"specific frameworks and tools this works with (e.g. jest,vitest,mocha,pytest)","use_cases":"3-5 specific usage scenarios separated by semicolons","input_type":"code|file|directory|url|text","output_type":"report|file|diff|terminal_output|analysis"}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ─── Fallback Extraction ─────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Extract basic metadata from resource content using regex when Haiku fails.
|
|
34
|
-
* Better than nothing — provides minimal FTS5 searchability.
|
|
35
|
-
*/
|
|
36
|
-
function fallbackExtract(resource) {
|
|
37
|
-
const content = (resource.content || '').toLowerCase();
|
|
38
|
-
const name = resource.name.toLowerCase();
|
|
39
|
-
|
|
40
|
-
// Infer intent tags from name and common patterns
|
|
41
|
-
const intentMap = {
|
|
42
|
-
commit: 'commit,git,version-control',
|
|
43
|
-
test: 'test,testing,tdd,qa',
|
|
44
|
-
debug: 'debug,troubleshoot,fix,error',
|
|
45
|
-
review: 'review,code-review,quality',
|
|
46
|
-
lint: 'lint,format,style,quality',
|
|
47
|
-
deploy: 'deploy,release,ci,cd',
|
|
48
|
-
build: 'build,compile,bundle',
|
|
49
|
-
refactor: 'refactor,clean,simplify',
|
|
50
|
-
security: 'security,audit,vulnerability',
|
|
51
|
-
perf: 'performance,optimize,benchmark',
|
|
52
|
-
doc: 'documentation,readme,docs',
|
|
53
|
-
design: 'design,ui,ux,frontend',
|
|
54
|
-
infra: 'infrastructure,devops,cloud',
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const intentTagSet = new Set();
|
|
58
|
-
for (const [key, tags] of Object.entries(intentMap)) {
|
|
59
|
-
if (name.includes(key) || content.includes(key)) {
|
|
60
|
-
for (const t of tags.split(',')) intentTagSet.add(t);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
const intentTags = [...intentTagSet].join(',');
|
|
64
|
-
|
|
65
|
-
// Infer domain tags from content
|
|
66
|
-
const domainPatterns = [
|
|
67
|
-
[/\b(javascript|typescript|node|npm|yarn|pnpm)\b/, 'javascript,typescript,node'],
|
|
68
|
-
[/\b(python|pip|poetry|django|flask)\b/, 'python'],
|
|
69
|
-
[/\b(react|vue|angular|svelte|next)\b/, 'frontend,javascript'],
|
|
70
|
-
[/\b(rust|cargo)\b/, 'rust'],
|
|
71
|
-
[/\b(go|golang)\b/, 'go'],
|
|
72
|
-
[/\b(docker|kubernetes|k8s|terraform)\b/, 'infrastructure,devops'],
|
|
73
|
-
[/\b(postgres|mysql|sqlite|mongodb)\b/, 'database'],
|
|
74
|
-
[/\b(api|rest|graphql|grpc)\b/, 'api,backend'],
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
let domainTags = '';
|
|
78
|
-
for (const [pattern, tags] of domainPatterns) {
|
|
79
|
-
if (pattern.test(content)) {
|
|
80
|
-
domainTags = domainTags ? `${domainTags},${tags}` : tags;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
intent_tags: intentTags || resource.type,
|
|
86
|
-
domain_tags: domainTags || '',
|
|
87
|
-
action_type: resource.type === 'agent' ? 'analyze' : 'generate',
|
|
88
|
-
trigger_patterns: `when user needs ${name.replace(/-/g, ' ')} functionality`,
|
|
89
|
-
capability_summary: truncate(`${resource.type}: ${name.replace(/-/g, ' ')}`, 100),
|
|
90
|
-
input_type: 'code',
|
|
91
|
-
output_type: resource.type === 'agent' ? 'analysis' : 'file',
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── Single Resource Indexing ────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Index a single resource: call Haiku for metadata, then upsert to DB.
|
|
99
|
-
* Falls back to regex extraction if Haiku fails.
|
|
100
|
-
* @param {Database} db Registry database
|
|
101
|
-
* @param {object} resource Scanned resource object
|
|
102
|
-
* @returns {Promise<number>} Resource ID
|
|
103
|
-
*/
|
|
104
|
-
export async function indexResource(db, resource) {
|
|
105
|
-
const prompt = buildIndexPrompt(resource);
|
|
106
|
-
let metadata = await callHaikuJSON(prompt, { timeout: 15000, maxTokens: 400 });
|
|
107
|
-
|
|
108
|
-
if (!metadata || !metadata.capability_summary) {
|
|
109
|
-
debugLog('WARN', 'indexer', `Haiku failed for ${resource.name}, using fallback`);
|
|
110
|
-
metadata = fallbackExtract(resource);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const now = new Date().toISOString();
|
|
114
|
-
const id = upsertResource(db, {
|
|
115
|
-
name: resource.name,
|
|
116
|
-
type: resource.type,
|
|
117
|
-
status: 'active',
|
|
118
|
-
source: resource.source,
|
|
119
|
-
repo_url: resource.repoUrl || null,
|
|
120
|
-
repo_stars: resource.repoStars || 0,
|
|
121
|
-
local_path: resource.localPath,
|
|
122
|
-
file_hash: resource.fileHash,
|
|
123
|
-
intent_tags: metadata.intent_tags || '',
|
|
124
|
-
domain_tags: metadata.domain_tags || '',
|
|
125
|
-
action_type: metadata.action_type || '',
|
|
126
|
-
trigger_patterns: metadata.trigger_patterns || '',
|
|
127
|
-
capability_summary: metadata.capability_summary || '',
|
|
128
|
-
input_type: metadata.input_type || '',
|
|
129
|
-
output_type: metadata.output_type || '',
|
|
130
|
-
keywords: metadata.keywords || '',
|
|
131
|
-
tech_stack: metadata.tech_stack || '',
|
|
132
|
-
use_cases: metadata.use_cases || '',
|
|
133
|
-
prerequisites: typeof metadata.prerequisites === 'object'
|
|
134
|
-
? JSON.stringify(metadata.prerequisites) : '{}',
|
|
135
|
-
indexed_at: now,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
return id;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Index multiple resources sequentially.
|
|
143
|
-
* Logs progress and continues on individual failures.
|
|
144
|
-
* @param {Database} db Registry database
|
|
145
|
-
* @param {object[]} resources Array of scanned resources
|
|
146
|
-
* @returns {Promise<{indexed: number, failed: number}>} Results summary
|
|
147
|
-
*/
|
|
148
|
-
export async function indexAll(db, resources) {
|
|
149
|
-
let indexed = 0;
|
|
150
|
-
let failed = 0;
|
|
151
|
-
|
|
152
|
-
for (const resource of resources) {
|
|
153
|
-
try {
|
|
154
|
-
await indexResource(db, resource);
|
|
155
|
-
indexed++;
|
|
156
|
-
debugLog('DEBUG', 'indexer', `indexed: ${resource.type}/${resource.name}`);
|
|
157
|
-
} catch (e) {
|
|
158
|
-
failed++;
|
|
159
|
-
debugCatch(e, `indexResource(${resource.name})`);
|
|
160
|
-
// Mark as error in DB so it can be retried later
|
|
161
|
-
try {
|
|
162
|
-
upsertResource(db, {
|
|
163
|
-
name: resource.name,
|
|
164
|
-
type: resource.type,
|
|
165
|
-
status: 'error',
|
|
166
|
-
source: resource.source,
|
|
167
|
-
local_path: resource.localPath,
|
|
168
|
-
file_hash: resource.fileHash,
|
|
169
|
-
});
|
|
170
|
-
} catch {}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return { indexed, failed };
|
|
175
|
-
}
|