claude-mem-lite 2.91.0 → 2.93.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/adopt-cli.mjs +19 -9
- package/bash-utils.mjs +45 -5
- package/cli/activity.mjs +12 -4
- package/cli/common.mjs +23 -0
- package/format-utils.mjs +12 -1
- package/hook-handoff.mjs +20 -2
- package/hook-llm.mjs +22 -41
- package/hook-optimize.mjs +23 -8
- package/hook-update.mjs +16 -5
- package/hook.mjs +8 -1
- package/lib/citation-tracker.mjs +15 -0
- package/lib/maintain-core.mjs +82 -22
- package/lib/observation-write.mjs +67 -0
- package/lib/save-observation.mjs +12 -26
- package/mem-cli.mjs +36 -26
- package/memdir.mjs +36 -11
- package/nlp.mjs +20 -3
- package/package.json +3 -2
- package/project-utils.mjs +6 -0
- package/registry-importer.mjs +8 -3
- package/registry-retriever.mjs +10 -6
- package/registry.mjs +0 -132
- package/schema.mjs +15 -8
- package/search-engine.mjs +14 -1
- package/secret-scrub.mjs +12 -3
- package/server.mjs +9 -1
- package/source-files.mjs +5 -0
- package/tier.mjs +5 -2
- package/utils.mjs +40 -3
package/registry.mjs
CHANGED
|
@@ -339,135 +339,3 @@ export function upsertResource(db, r) {
|
|
|
339
339
|
return row?.id || 0;
|
|
340
340
|
})();
|
|
341
341
|
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Get all active resources.
|
|
345
|
-
* @param {Database} db Registry database
|
|
346
|
-
* @returns {object[]} Array of resource objects
|
|
347
|
-
*/
|
|
348
|
-
export function getActiveResources(db) {
|
|
349
|
-
return db.prepare('SELECT * FROM resources WHERE status = ? ORDER BY name').all('active');
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Get a resource by type and name.
|
|
354
|
-
* @param {Database} db Registry database
|
|
355
|
-
* @param {string} type 'skill' or 'agent'
|
|
356
|
-
* @param {string} name Resource name
|
|
357
|
-
* @returns {object|undefined} Resource or undefined
|
|
358
|
-
*/
|
|
359
|
-
export function getResourceByName(db, type, name) {
|
|
360
|
-
return db.prepare('SELECT * FROM resources WHERE type = ? AND name = ?').get(type, name);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Get a resource by ID.
|
|
365
|
-
* @param {Database} db Registry database
|
|
366
|
-
* @param {number} id Resource ID
|
|
367
|
-
* @returns {object|undefined} Resource or undefined
|
|
368
|
-
*/
|
|
369
|
-
export function getResourceById(db, id) {
|
|
370
|
-
return db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Atomically increment a stats field on a resource.
|
|
375
|
-
* @param {Database} db Registry database
|
|
376
|
-
* @param {number} id Resource ID
|
|
377
|
-
* @param {'recommend_count'|'adopt_count'|'success_count'} field Stats field
|
|
378
|
-
*/
|
|
379
|
-
export function updateResourceStats(db, id, field) {
|
|
380
|
-
const allowed = new Set(['recommend_count', 'adopt_count', 'success_count']);
|
|
381
|
-
if (!allowed.has(field)) throw new Error(`Invalid stats field: ${field}`);
|
|
382
|
-
// String interpolation required: SQLite cannot parameterize column names.
|
|
383
|
-
// Safety: field is validated against allowlist above.
|
|
384
|
-
db.prepare(`UPDATE resources SET ${field} = ${field} + 1, updated_at = datetime('now') WHERE id = ?`).run(id);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Atomically increment weighted_adopt_sum by a continuous score value.
|
|
389
|
-
* @param {Database} db Registry database
|
|
390
|
-
* @param {number} id Resource ID
|
|
391
|
-
* @param {number} score Adoption confidence score (0.0-1.0)
|
|
392
|
-
*/
|
|
393
|
-
export function incrementWeightedAdopt(db, id, score) {
|
|
394
|
-
db.prepare(`UPDATE resources SET weighted_adopt_sum = COALESCE(weighted_adopt_sum, 0) + ?, updated_at = datetime('now') WHERE id = ?`).run(score, id);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Record a dispatch invocation.
|
|
399
|
-
* @param {Database} db Registry database
|
|
400
|
-
* @param {object} record Invocation record
|
|
401
|
-
* @returns {number} Invocation ID
|
|
402
|
-
*/
|
|
403
|
-
export function recordInvocation(db, record) {
|
|
404
|
-
const result = db.prepare(`
|
|
405
|
-
INSERT INTO invocations (resource_id, session_id, trigger, tier, recommended, adopted, outcome, score)
|
|
406
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
407
|
-
`).run(
|
|
408
|
-
record.resource_id, record.session_id || null,
|
|
409
|
-
record.trigger || null, record.tier || null,
|
|
410
|
-
record.recommended ?? 1, record.adopted ?? 0,
|
|
411
|
-
record.outcome || null, record.score ?? null
|
|
412
|
-
);
|
|
413
|
-
return Number(result.lastInsertRowid);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Get success rates for resources over a time period.
|
|
418
|
-
* @param {Database} db Registry database
|
|
419
|
-
* @param {number} [days=30] Lookback period
|
|
420
|
-
* @returns {object[]} Array of {resource_id, total, adopted, avg_score}
|
|
421
|
-
*/
|
|
422
|
-
export function getResourceSuccessRates(db, days = 30) {
|
|
423
|
-
const since = new Date(Date.now() - days * 86400000).toISOString();
|
|
424
|
-
return db.prepare(`
|
|
425
|
-
SELECT resource_id,
|
|
426
|
-
COUNT(*) as total,
|
|
427
|
-
SUM(adopted) as adopted,
|
|
428
|
-
AVG(CASE WHEN score IS NOT NULL THEN score END) as avg_score
|
|
429
|
-
FROM invocations
|
|
430
|
-
WHERE created_at > ?
|
|
431
|
-
GROUP BY resource_id
|
|
432
|
-
`).all(since);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Get invocations for a specific session (for feedback collection).
|
|
437
|
-
* @param {Database} db Registry database
|
|
438
|
-
* @param {string} sessionId Session identifier
|
|
439
|
-
* @returns {object[]} Array of invocation records
|
|
440
|
-
*/
|
|
441
|
-
export function getSessionInvocations(db, sessionId) {
|
|
442
|
-
return db.prepare(`
|
|
443
|
-
SELECT i.*, r.name as resource_name, r.type as resource_type,
|
|
444
|
-
r.invocation_name as invocation_name
|
|
445
|
-
FROM invocations i
|
|
446
|
-
JOIN resources r ON r.id = i.resource_id
|
|
447
|
-
WHERE i.session_id = ?
|
|
448
|
-
ORDER BY i.created_at
|
|
449
|
-
`).all(sessionId);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Update invocation outcome (for feedback).
|
|
454
|
-
* @param {Database} db Registry database
|
|
455
|
-
* @param {number} id Invocation ID
|
|
456
|
-
* @param {object} update Fields to update
|
|
457
|
-
*/
|
|
458
|
-
export function updateInvocation(db, id, update) {
|
|
459
|
-
const allowed = new Set(['adopted', 'outcome', 'score', 'rejection_reason']);
|
|
460
|
-
const sets = [];
|
|
461
|
-
const vals = [];
|
|
462
|
-
for (const [key, val] of Object.entries(update)) {
|
|
463
|
-
if (val === undefined) continue;
|
|
464
|
-
if (!allowed.has(key)) throw new Error(`Invalid invocation field: ${key}`);
|
|
465
|
-
sets.push(`${key} = ?`);
|
|
466
|
-
vals.push(val);
|
|
467
|
-
}
|
|
468
|
-
if (sets.length === 0) return;
|
|
469
|
-
vals.push(id);
|
|
470
|
-
// String interpolation required: SQLite cannot parameterize column names.
|
|
471
|
-
// Safety: column names are validated against allowlist above.
|
|
472
|
-
db.prepare(`UPDATE invocations SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
473
|
-
}
|
package/schema.mjs
CHANGED
|
@@ -382,6 +382,7 @@ export function initSchema(db) {
|
|
|
382
382
|
|
|
383
383
|
// FTS5 migration: recreate observations_fts when columns are missing (one-time)
|
|
384
384
|
// Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
|
|
385
|
+
let obsFtsRecreated = false;
|
|
385
386
|
try {
|
|
386
387
|
const ftsDdl = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`).get();
|
|
387
388
|
if (ftsDdl && (!ftsDdl.sql.includes('lesson_learned') || !ftsDdl.sql.includes('search_aliases'))) {
|
|
@@ -389,6 +390,7 @@ export function initSchema(db) {
|
|
|
389
390
|
db.exec(`DROP TRIGGER IF EXISTS observations_ad`);
|
|
390
391
|
db.exec(`DROP TRIGGER IF EXISTS observations_au`);
|
|
391
392
|
db.exec(`DROP TABLE IF EXISTS observations_fts`);
|
|
393
|
+
obsFtsRecreated = true;
|
|
392
394
|
}
|
|
393
395
|
} catch { /* non-critical — ensureFTS will create if missing */ }
|
|
394
396
|
|
|
@@ -416,14 +418,19 @@ export function initSchema(db) {
|
|
|
416
418
|
ensureFTS(db, 'session_summaries_fts', 'session_summaries', ['request', 'investigated', 'learned', 'completed', 'next_steps', 'notes', 'remaining_items']);
|
|
417
419
|
ensureFTS(db, 'user_prompts_fts', 'user_prompts', ['prompt_text']);
|
|
418
420
|
|
|
419
|
-
// Rebuild FTS5 if we just recreated it (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
421
|
+
// Rebuild FTS5 if we just recreated it above (the new index is empty and must be
|
|
422
|
+
// populated from the content table). The old emptiness probe — `SELECT COUNT(*) FROM
|
|
423
|
+
// observations_fts` — was DEAD: for an external-content FTS5 table, COUNT reads the
|
|
424
|
+
// CONTENT table (observations), not the index, so `ftsCount === 0` was only ever true
|
|
425
|
+
// on an empty DB (where needsRebuild>0 is false). The rebuild therefore never fired and
|
|
426
|
+
// full-text search silently returned 0 rows after the column-mismatch migration. Gate
|
|
427
|
+
// on the recreation flag instead, which is the only path that leaves the index empty.
|
|
428
|
+
if (obsFtsRecreated) {
|
|
429
|
+
try {
|
|
430
|
+
const cnt = db.prepare(`SELECT COUNT(*) as cnt FROM observations`).get();
|
|
431
|
+
if (cnt.cnt > 0) db.exec(`INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`);
|
|
432
|
+
} catch { /* non-critical */ }
|
|
433
|
+
}
|
|
427
434
|
|
|
428
435
|
// v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
|
|
429
436
|
// triggers were hand-written inline (below) rather than via ensureFTS, so
|
package/search-engine.mjs
CHANGED
|
@@ -179,7 +179,11 @@ export function countSearchTotal(db, {
|
|
|
179
179
|
export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
|
|
180
180
|
return {
|
|
181
181
|
source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle,
|
|
182
|
-
|
|
182
|
+
// `date` is the legacy key the MCP paired-search path reads; `created_at` aligns the
|
|
183
|
+
// obs row shape with the session/prompt rows the CLI interleaves in the same results
|
|
184
|
+
// array (cmdSearch reads r.created_at uniformly) and with recent/recall output. Both
|
|
185
|
+
// hold the same ISO string — keep both so neither consumer breaks.
|
|
186
|
+
project: r.project, date: r.created_at, created_at: r.created_at, created_at_epoch: r.created_at_epoch,
|
|
183
187
|
score: scoreMultiplier ? r.score * scoreMultiplier : r.score,
|
|
184
188
|
files_modified: r.files_modified, importance: r.importance, lesson_learned: r.lesson_learned,
|
|
185
189
|
snippet: snippet ? (r.match_snippet || '') : '',
|
|
@@ -366,6 +370,15 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
366
370
|
if (vecResults.length === 0) return results;
|
|
367
371
|
|
|
368
372
|
if (results.length > 0) {
|
|
373
|
+
// RRF fuses by RANK (array index), so the BM25 side must already be in
|
|
374
|
+
// composite-score order. `results` here is [full-FTS sorted, …concept ×0.7,
|
|
375
|
+
// …PRF ×0.6] with augmentation rows APPENDED, so its index order is only
|
|
376
|
+
// BM25-rank for the first block — a downweighted PRF row at the tail would be
|
|
377
|
+
// handed to RRF as a worse rank than its score warrants, and a strong one as
|
|
378
|
+
// better. Sort by the calibrated composite score (negative = more relevant)
|
|
379
|
+
// first so index == composite rank and the type-quality/decay/cite multipliers
|
|
380
|
+
// actually shape the fused ranking instead of being discarded by insertion order.
|
|
381
|
+
results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
369
382
|
const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
|
|
370
383
|
const resultMap = new Map(results.map(r => [r.id, r]));
|
|
371
384
|
for (const vr of vecResults) {
|
package/secret-scrub.mjs
CHANGED
|
@@ -18,8 +18,17 @@ export const SECRET_PATTERNS = [
|
|
|
18
18
|
// 2. Structured keys (api_key, auth_token, …) keep the original behavior —
|
|
19
19
|
// a separator/compound key is unambiguous config syntax even when
|
|
20
20
|
// preceded by prose ("see auth_token: shhhhhh").
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// `(?:\b|_)` before the keyword: a plain word-boundary misses the single most
|
|
22
|
+
// common credential shape — underscore-cased env vars (DB_PASSWORD, GH_TOKEN,
|
|
23
|
+
// MY_AUTH_TOKEN) — because `_` is a \w char, so there is NO \b between it and the
|
|
24
|
+
// keyword. Allowing a leading `_` catches those while the prose lookbehind still
|
|
25
|
+
// excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
|
|
26
|
+
// value is covered (the hex-only assignment pattern below misses non-hex values).
|
|
27
|
+
[/((?<![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***'],
|
|
28
|
+
// access_token / refresh_token are the canonical OAuth2 field names — they were
|
|
29
|
+
// missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
|
|
30
|
+
// underscore-prefix reason.
|
|
31
|
+
[/((?:\b|_)(?:api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
|
|
23
32
|
// AWS access keys (AKIA...)
|
|
24
33
|
[/\bAKIA[A-Z0-9]{16}\b/g, '***'],
|
|
25
34
|
// OpenAI / Anthropic keys (sk-...) — specific prefixes have lower length threshold
|
|
@@ -56,7 +65,7 @@ export const SECRET_PATTERNS = [
|
|
|
56
65
|
// as `{"api_key": "..."}`. The base key=value pattern stops at quotes, so
|
|
57
66
|
// these slip through. Match the value-quoted form explicitly. Length floor
|
|
58
67
|
// (6) avoids tripping on intentional placeholder shorts ("...", "secret").
|
|
59
|
-
[/("(?:password|passwd|token|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer|refresh[_-]?token|session[_-]?id|sessionid)"\s*:\s*")[^"]{6,}(")/gi, '$1***$2'],
|
|
68
|
+
[/("(?:password|passwd|token|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|access[_-]?token|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer|refresh[_-]?token|session[_-]?id|sessionid)"\s*:\s*")[^"]{6,}(")/gi, '$1***$2'],
|
|
60
69
|
// Session cookies in headers / urlencoded bodies (sessionid=, session_id=, JSESSIONID=, PHPSESSID=).
|
|
61
70
|
// 16+ chars filters out short test fixtures like sessionid=abc.
|
|
62
71
|
[/\b((?:session[_-]?id|sessionid|jsessionid|phpsessid)\s*[=:]\s*)[^\s,;'"}\]]{16,}/gi, '$1***'],
|
package/server.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
|
|
|
15
15
|
import {
|
|
16
16
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
17
17
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
18
|
+
recoverChildrenOf,
|
|
18
19
|
OP_CAP, STALE_AGE_MS,
|
|
19
20
|
} from './lib/maintain-core.mjs';
|
|
20
21
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
@@ -926,13 +927,20 @@ server.registerTool(
|
|
|
926
927
|
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
927
928
|
}
|
|
928
929
|
}
|
|
930
|
+
// Resurface rows merged/compressed INTO the doomed keepers before deleting, else
|
|
931
|
+
// they dangle behind a now-missing parent (compressed_into has no FK) — invisible
|
|
932
|
+
// to every COALESCE(compressed_into,0)=0 view and unrecoverable. Mirrors the CLI
|
|
933
|
+
// delete path + the maintain hard-delete guard (recoverChildrenOf).
|
|
934
|
+
const recovered = recoverChildrenOf(db, args.ids);
|
|
929
935
|
// Execute deletion (FTS5 cleanup handled by observations_ad trigger)
|
|
930
|
-
|
|
936
|
+
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
|
|
937
|
+
return { changes: deleted.changes, recovered };
|
|
931
938
|
});
|
|
932
939
|
const result = deleteTx();
|
|
933
940
|
|
|
934
941
|
const missing = args.ids.filter(id => !rows.some(r => r.id === id));
|
|
935
942
|
const msg = [`Deleted ${result.changes} observation(s).`];
|
|
943
|
+
if (result.recovered > 0) msg.push(`Recovered ${result.recovered} merged/compressed child observation(s) to live.`);
|
|
936
944
|
if (missing.length > 0) msg.push(`Note: ID(s) ${missing.join(', ')} not found.`);
|
|
937
945
|
return { content: [{ type: 'text', text: msg.join(' ') }] };
|
|
938
946
|
})
|
package/source-files.mjs
CHANGED
|
@@ -77,6 +77,11 @@ export const SOURCE_FILES = [
|
|
|
77
77
|
// mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
|
|
78
78
|
// entry points; missing it from the manifest broke MCP saves on auto-update.
|
|
79
79
|
'lib/save-observation.mjs',
|
|
80
|
+
// Single-source observations-table write primitives (insertObservationRow/Files/
|
|
81
|
+
// Vector). Statically imported by lib/save-observation.mjs and hook-llm.mjs (both
|
|
82
|
+
// entry-point-reachable); missing it from the manifest would break ALL saves on
|
|
83
|
+
// auto-update. Same single-source-of-truth pattern (see #8217).
|
|
84
|
+
'lib/observation-write.mjs',
|
|
80
85
|
// Shared "compress old low-value observations into weekly summaries" core.
|
|
81
86
|
// Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
|
|
82
87
|
// and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
|
package/tier.mjs
CHANGED
|
@@ -44,9 +44,12 @@ export function computeTier(obs, ctx) {
|
|
|
44
44
|
return 'working';
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// Rule 5: Active if within type-specific window
|
|
47
|
+
// Rule 5: Active if within type-specific window. Use `<=` so the exact-millisecond
|
|
48
|
+
// window edge matches TIER_CASE_SQL (`created_at_epoch >= now - window`, i.e. inclusive).
|
|
49
|
+
// The strict `<` here disagreed with the SQL classifier by one tier at the boundary,
|
|
50
|
+
// despite both being documented as the same classifier.
|
|
48
51
|
const activeWindow = ACTIVE_WINDOWS[obs.type] ?? DEFAULT_ACTIVE_WINDOW_MS;
|
|
49
|
-
if (now - obs.created_at_epoch
|
|
52
|
+
if (now - obs.created_at_epoch <= activeWindow) return 'active';
|
|
50
53
|
|
|
51
54
|
// Rule 6: Archive (fallback)
|
|
52
55
|
return 'archive';
|
package/utils.mjs
CHANGED
|
@@ -77,8 +77,11 @@ export function estimateTokens(text) {
|
|
|
77
77
|
* @returns {number} Clamped integer importance (1, 2, or 3)
|
|
78
78
|
*/
|
|
79
79
|
export function clampImportance(val) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// Coerce numeric strings: an LLM emitting "importance":"2" (quoted) would otherwise
|
|
81
|
+
// collapse to 1, silently dropping its signal. Non-numeric strings → NaN → 1.
|
|
82
|
+
const n = typeof val === 'number' ? val : (typeof val === 'string' ? Number(val) : NaN);
|
|
83
|
+
if (!Number.isFinite(n)) return 1;
|
|
84
|
+
return Math.max(1, Math.min(3, Math.round(n)));
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
@@ -267,9 +270,39 @@ export function debugCatch(e, context) {
|
|
|
267
270
|
|
|
268
271
|
// ─── JSON Parsing ────────────────────────────────────────────────────────────
|
|
269
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Extract the first brace-balanced JSON object substring from text, honoring strings
|
|
275
|
+
* and escapes so braces inside string values don't throw off the depth count. Returns
|
|
276
|
+
* null when there's no `{` or no balanced close. Used to recover a valid leading object
|
|
277
|
+
* when the LLM wrapped it in prose that ALSO contains braces — the greedy `{[\s\S]*}`
|
|
278
|
+
* fallback spans first-`{` to last-`}` and is defeated by an unrelated trailing `{…}`.
|
|
279
|
+
*/
|
|
280
|
+
function firstBalancedJsonObject(text) {
|
|
281
|
+
// Anchor on whichever structural opener comes first — `{` (object) or `[` (array) —
|
|
282
|
+
// so a prose-wrapped top-level array isn't truncated to its first inner object.
|
|
283
|
+
const braceAt = text.indexOf('{');
|
|
284
|
+
const brackAt = text.indexOf('[');
|
|
285
|
+
let start, open, close;
|
|
286
|
+
if (braceAt === -1 && brackAt === -1) return null;
|
|
287
|
+
if (brackAt !== -1 && (braceAt === -1 || brackAt < braceAt)) { start = brackAt; open = '['; close = ']'; }
|
|
288
|
+
else { start = braceAt; open = '{'; close = '}'; }
|
|
289
|
+
let depth = 0, inStr = false, esc = false;
|
|
290
|
+
for (let i = start; i < text.length; i++) {
|
|
291
|
+
const c = text[i];
|
|
292
|
+
if (inStr) {
|
|
293
|
+
if (esc) esc = false;
|
|
294
|
+
else if (c === '\\') esc = true;
|
|
295
|
+
else if (c === '"') inStr = false;
|
|
296
|
+
} else if (c === '"') inStr = true;
|
|
297
|
+
else if (c === open) depth++;
|
|
298
|
+
else if (c === close && --depth === 0) return text.slice(start, i + 1);
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
270
303
|
/**
|
|
271
304
|
* Parse JSON from LLM output, handling markdown fences and embedded objects.
|
|
272
|
-
* Tries: direct parse → fenced code block →
|
|
305
|
+
* Tries: direct parse → fenced code block → first balanced object → greedy regex.
|
|
273
306
|
* @param {string} text Raw LLM output text
|
|
274
307
|
* @returns {object|null} Parsed JSON object or null on failure
|
|
275
308
|
*/
|
|
@@ -278,6 +311,10 @@ export function parseJsonFromLLM(text) {
|
|
|
278
311
|
try { return JSON.parse(text); } catch {}
|
|
279
312
|
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
280
313
|
if (fenced) try { return JSON.parse(fenced[1]); } catch {}
|
|
314
|
+
// First balanced object — survives unfenced output wrapped in brace-containing prose.
|
|
315
|
+
const balanced = firstBalancedJsonObject(text);
|
|
316
|
+
if (balanced) try { return JSON.parse(balanced); } catch {}
|
|
317
|
+
// Last-resort greedy span (handles a payload that isn't the FIRST balanced object).
|
|
281
318
|
const obj = text.match(/\{[\s\S]*\}/);
|
|
282
319
|
if (obj) try { return JSON.parse(obj[0]); } catch {}
|
|
283
320
|
return null;
|