claude-mem-lite 3.63.0 → 3.65.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/deep-search.mjs +2 -1
- package/hook-context.mjs +23 -18
- package/hook-handoff.mjs +3 -4
- package/hook-memory.mjs +6 -9
- package/hook-optimize.mjs +4 -6
- package/hook-precompact.mjs +15 -2
- package/hook-shared.mjs +6 -0
- package/hook.mjs +59 -31
- package/lib/browse-core.mjs +67 -0
- package/lib/delete-core.mjs +18 -1
- package/lib/get-core.mjs +33 -0
- package/lib/inject-search-core.mjs +87 -0
- package/lib/injected-ids.mjs +49 -0
- package/lib/maintain-core.mjs +3 -3
- package/lib/observation-write.mjs +38 -2
- package/lib/recall-core.mjs +2 -2
- package/lib/recent-core.mjs +3 -1
- package/lib/search-core.mjs +14 -4
- package/lib/stats-core.mjs +2 -1
- package/lib/timeline-core.mjs +7 -6
- package/mem-cli.mjs +75 -141
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry.mjs +53 -1
- package/scoring-sql.mjs +5 -4
- package/scripts/pre-skill-bridge.js +3 -1
- package/scripts/pre-tool-recall.js +13 -9
- package/scripts/prompt-search-utils.mjs +3 -0
- package/scripts/user-prompt-search.js +26 -25
- package/search-engine.mjs +21 -15
- package/search-scoring.mjs +2 -2
- package/server.mjs +43 -112
- package/source-files.mjs +14 -0
- package/tfidf.mjs +3 -3
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// lib/inject-search-core.mjs — the retrieval-side shared core (P2-11, audit
|
|
2
|
+
// 2026-08-14; second cut D#123, 2026-08-16). Shared home for the three SQL
|
|
3
|
+
// atoms that kept drifting across hand-copied twins — first on the five
|
|
4
|
+
// injection-side surfaces, then extended across the remaining read surfaces
|
|
5
|
+
// (hook-context / hook-handoff / hook-optimize / mem-cli / search-scoring /
|
|
6
|
+
// tfidf / deep-search / recall-core / recent-core / timeline-core / stats-core /
|
|
7
|
+
// search-core incl. its sessions+events decay arms / maintain-core; the ledger
|
|
8
|
+
// test enforces the full list). Deliberate compressed-only singles (maintain
|
|
9
|
+
// UPDATE guards, stats noise-gauge counts, export tombstone toggles, session-own
|
|
10
|
+
// history) stay inline — see the ledger test's non-member notes:
|
|
11
|
+
//
|
|
12
|
+
// * live-row filter — the compressed+superseded pair whose omission was
|
|
13
|
+
// the superseded-invariant's recurring reopening
|
|
14
|
+
// (7th occurrence fixed in v3.63 H-1, write-side)
|
|
15
|
+
// * clamped recency decay — the MAX(0,…) age clamp (audit M-1: a far-future
|
|
16
|
+
// created_at from restore/import-jsonl EXP-overflowed
|
|
17
|
+
// and pinned that row #1; the fix reached
|
|
18
|
+
// search-engine but not the UPS/error-recall twins)
|
|
19
|
+
// * injection relevance — the full multiplicative chain incl. cite/noise
|
|
20
|
+
// behavior factors (audit M-3: wired on every auto
|
|
21
|
+
// surface, missing from the explicit-surface score)
|
|
22
|
+
//
|
|
23
|
+
// Each surface keeps its own deliberate pipeline composition (BM25-sort + JS
|
|
24
|
+
// scoring vs SQL full chain vs file-keyed sort — see #8786: per-surface
|
|
25
|
+
// asymmetries stay explicit); only the ATOMS are shared.
|
|
26
|
+
// tests/inject-search-core.test.mjs holds the ledger: consumer files must
|
|
27
|
+
// compose these builders, never re-inline copies (and the decay shape may not
|
|
28
|
+
// be hand-rolled anywhere, benchmark included).
|
|
29
|
+
//
|
|
30
|
+
// Lives under lib/ (not scripts/) so hook.mjs can statically import it without
|
|
31
|
+
// colliding with the installExtractedRelease scripts-dir rename (same constraint
|
|
32
|
+
// as lib/mem-override.mjs). Dependency-light by design: pre-tool-recall's
|
|
33
|
+
// standalone fast-path (#8447) already imports scoring-sql.mjs.
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
|
|
37
|
+
noisePenaltyClause, citeFactorClause,
|
|
38
|
+
} from '../scoring-sql.mjs';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Live-row filter: rows a model-facing retrieval surface may return. Excludes
|
|
42
|
+
* compression tombstones (positive keeper ids AND -2 pending-purge) and
|
|
43
|
+
* superseded rows (a retracted lesson must never outrank its correction).
|
|
44
|
+
* @param {string} [alias='o'] table alias; '' for unqualified single-table queries
|
|
45
|
+
* @returns {string} SQL boolean expression (no leading AND)
|
|
46
|
+
*/
|
|
47
|
+
export function liveObsFilterSql(alias = 'o') {
|
|
48
|
+
const a = alias ? `${alias}.` : '';
|
|
49
|
+
return `COALESCE(${a}compressed_into, 0) = 0 AND ${a}superseded_at IS NULL`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Clamped recency-decay factor: (1 + EXP(-ln2 · age / halfLife)), age >= 0.
|
|
54
|
+
* The MAX(0,…) clamp is the M-1 fix — restore/import-jsonl accept arbitrary
|
|
55
|
+
* epochs, and an unclamped future timestamp makes the exponent large-positive →
|
|
56
|
+
* EXP overflows to +Infinity → that row sorts #1 for every query (and its score
|
|
57
|
+
* serializes as null). A future row reads as age 0 = max finite recency instead.
|
|
58
|
+
* Binds one `?` placeholder: the caller passes `now` (epoch ms) at that position.
|
|
59
|
+
* @param {object} opts
|
|
60
|
+
* @param {string} opts.tsExpr - SQL expression for the row's reference timestamp
|
|
61
|
+
* (e.g. 'o.created_at_epoch', or the created/last-accessed MAX search-engine uses)
|
|
62
|
+
* @param {string} [opts.halfLifeSql=TYPE_DECAY_CASE] - SQL expression for the
|
|
63
|
+
* half-life in ms (constant or per-type CASE; TYPE_DECAY_CASE assumes alias 'o')
|
|
64
|
+
* @returns {string} SQL numeric expression (parenthesized)
|
|
65
|
+
*/
|
|
66
|
+
export function recencyDecaySql({ tsExpr, halfLifeSql = TYPE_DECAY_CASE }) {
|
|
67
|
+
return `(1.0 + EXP(-0.693 * MAX(0, ? - ${tsExpr}) / ${halfLifeSql}))`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Injection relevance: the full multiplicative chain the prompt-time injection
|
|
72
|
+
* surface (UPS searchByFts) ranks by — BM25 × clamped type-decay × type-quality
|
|
73
|
+
* × importance × noise penalty × cite factor. Alias is fixed to 'o' because
|
|
74
|
+
* TYPE_DECAY_CASE / TYPE_QUALITY_CASE bake that alias in.
|
|
75
|
+
* Binds one `?` placeholder (the decay `now`), first in parameter order.
|
|
76
|
+
* @param {string} [alias='o'] must be 'o'
|
|
77
|
+
* @returns {string} SQL numeric expression
|
|
78
|
+
*/
|
|
79
|
+
export function injectionRelevanceSql(alias = 'o') {
|
|
80
|
+
if (alias !== 'o') throw new Error('injectionRelevanceSql: alias must be "o" (TYPE_*_CASE bake it in)');
|
|
81
|
+
return `${OBS_BM25}
|
|
82
|
+
* ${recencyDecaySql({ tsExpr: 'o.created_at_epoch' })}
|
|
83
|
+
* ${TYPE_QUALITY_CASE}
|
|
84
|
+
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
85
|
+
* ${noisePenaltyClause('o')}
|
|
86
|
+
* ${citeFactorClause('o')}`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// lib/injected-ids.mjs — file-name derivation for the cross-hook injected-ids
|
|
2
|
+
// dedup marker. Single source of truth for user-prompt-search.js (writer),
|
|
3
|
+
// pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
|
|
4
|
+
// derive the same name or cross-hook dedup silently goes blind.
|
|
5
|
+
//
|
|
6
|
+
// D#120: M-6 session-keyed the marker's PAYLOAD but kept ONE file per project,
|
|
7
|
+
// so two concurrent CC windows full-replaced each other's marker — no dedup
|
|
8
|
+
// between them and `count` reset on every alternation (MAX_SESSION_INJECTIONS
|
|
9
|
+
// unreachable). One file per SESSION instead, mirroring
|
|
10
|
+
// pre-recall-cooldown-<session>.json in the same runtime dir. GC: session-start
|
|
11
|
+
// sweep in hook.mjs (24h mtime, same policy as the cooldown files).
|
|
12
|
+
//
|
|
13
|
+
// Lives under lib/ (not scripts/) so hook.mjs can statically import it without
|
|
14
|
+
// colliding with the scripts/ directory rename in installExtractedRelease —
|
|
15
|
+
// same constraint as lib/mem-override.mjs.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runtime-dir FILE NAME for the injected-ids marker (no directory component).
|
|
19
|
+
* No sessionId → legacy project-keyed name (env-less harnesses, old callers).
|
|
20
|
+
* @param {string} project - inferProject() value (already filename-safe)
|
|
21
|
+
* @param {string} [sessionId] - CC session id
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function injectedIdsFileName(project, sessionId) {
|
|
25
|
+
const base = `.claude-mem-injected-${project}`;
|
|
26
|
+
if (!sessionId) return base;
|
|
27
|
+
const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
28
|
+
return `${base}-${safe}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
|
|
33
|
+
* ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
|
|
34
|
+
* sections (empty under quiet/adopted). handleUserPrompt reads it as its
|
|
35
|
+
* exclude-set — D#123 review C-1: excluding the injector's QUERY result instead
|
|
36
|
+
* of what was really shown suppressed <memory-context> injection outright on
|
|
37
|
+
* quiet/adopted projects, where Key Context never renders at all.
|
|
38
|
+
* Session-lifetime validity (no time window): the SessionStart block stays in
|
|
39
|
+
* context for the whole session. Swept with the same 24h GC as the marker above.
|
|
40
|
+
* @param {string} project - inferProject() value (already filename-safe)
|
|
41
|
+
* @param {string} [sessionId] - CC session id
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function keyContextIdsFileName(project, sessionId) {
|
|
45
|
+
const base = `.claude-mem-keyctx-${project}`;
|
|
46
|
+
if (!sessionId) return base;
|
|
47
|
+
const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
48
|
+
return `${base}-${safe}`;
|
|
49
|
+
}
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { COMPRESSED_PENDING_PURGE, computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '../utils.mjs';
|
|
16
16
|
import { rebuildVocabulary, computeVector, _resetVocabCache, vectorsEnabled, vecTextForRow } from '../tfidf.mjs';
|
|
17
17
|
import { DEDUP_JACCARD_THRESHOLD, MINHASH_PRE_THRESHOLD as MINHASH_PRE_THRESHOLD_SRC, FUZZY_DEDUP_THRESHOLD, FUZZY_BODY_THRESHOLD, MINHASH_PREFILTER } from './dedup-constants.mjs';
|
|
18
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
18
19
|
|
|
19
20
|
export const STALE_AGE_MS = 30 * 86400000;
|
|
20
21
|
export const OP_CAP = 1000;
|
|
@@ -132,9 +133,8 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
|
|
|
132
133
|
export function recoverBuriedLessons(db, { projectFilter = '', baseParams = [] } = {}) {
|
|
133
134
|
return db.prepare(`
|
|
134
135
|
UPDATE observations SET importance = 1
|
|
135
|
-
WHERE
|
|
136
|
+
WHERE ${liveObsFilterSql('')}
|
|
136
137
|
AND COALESCE(importance, 1) = 0
|
|
137
|
-
AND superseded_at IS NULL
|
|
138
138
|
AND lesson_learned IS NOT NULL AND lesson_learned <> '' AND lower(lesson_learned) <> 'none'
|
|
139
139
|
${projectFilter}
|
|
140
140
|
`).run(...baseParams).changes;
|
|
@@ -453,7 +453,7 @@ export function rebuildVectors(db) {
|
|
|
453
453
|
if (!vocab) return { ok: false, reason: 'no observations to build vocabulary from' };
|
|
454
454
|
const allObs = db.prepare(`
|
|
455
455
|
SELECT id, title, narrative, concepts, lesson_learned, search_aliases FROM observations
|
|
456
|
-
WHERE
|
|
456
|
+
WHERE ${liveObsFilterSql('')}
|
|
457
457
|
`).all();
|
|
458
458
|
let updated = 0;
|
|
459
459
|
const insertStmt = db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)');
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// + vector writes in one db.transaction so a failure can't leave a partial row).
|
|
10
10
|
|
|
11
11
|
import { getVocabulary, computeVector, vectorsEnabled } from '../tfidf.mjs';
|
|
12
|
-
import { debugCatch, cjkBigrams } from '../utils.mjs';
|
|
12
|
+
import { debugCatch, cjkBigrams, scrubSecrets } from '../utils.mjs';
|
|
13
13
|
|
|
14
14
|
// Canonical column order — must mirror the observations schema (schema.mjs).
|
|
15
15
|
const OBS_COLUMNS = [
|
|
@@ -84,7 +84,9 @@ export function insertObservationVector(db, obsId, vecText) {
|
|
|
84
84
|
* block — the same drift class #8614/#8639 closed for compress/maintain. Caller
|
|
85
85
|
* owns the transaction (vector write is internally non-critical).
|
|
86
86
|
*/
|
|
87
|
-
|
|
87
|
+
// P2-12: internal-only since applyObsUpdate became the single update choke point
|
|
88
|
+
// (both faces previously imported this directly; un-exported per knip discipline).
|
|
89
|
+
function rebuildObservationDerived(db, obsId) {
|
|
88
90
|
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
|
|
89
91
|
if (!row) return;
|
|
90
92
|
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
@@ -93,3 +95,37 @@ export function rebuildObservationDerived(db, obsId) {
|
|
|
93
95
|
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
|
|
94
96
|
insertObservationVector(db, obsId, textField);
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
// P2-12 (audit 2026-08-14): shared update mutation for the CLI `update` / MCP
|
|
100
|
+
// `mem_update` twin. Both faces previously built the SET list + ran the
|
|
101
|
+
// transaction + rebuild inline (byte-equivalent copies); each keeps its own
|
|
102
|
+
// validation front-end (CLI flag guards / MCP zod) and passes only the fields
|
|
103
|
+
// it accepted. String values are secret-scrubbed here — the single choke point,
|
|
104
|
+
// so a new face can't forget it (concepts had already slipped through once).
|
|
105
|
+
const UPDATABLE_OBS_COLS = ['title', 'narrative', 'type', 'importance', 'lesson_learned', 'concepts'];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Apply a validated field patch to one observation: UPDATE + derived-column
|
|
109
|
+
* rebuild (FTS text + vector) in one transaction.
|
|
110
|
+
* @param {import('better-sqlite3').Database} db
|
|
111
|
+
* @param {number} id - observation id (caller has verified existence)
|
|
112
|
+
* @param {object} fields - subset of {title, narrative, type, importance, lesson_learned, concepts}
|
|
113
|
+
* @returns {string[]} column names actually updated ([] = nothing to do, no write)
|
|
114
|
+
*/
|
|
115
|
+
export function applyObsUpdate(db, id, fields) {
|
|
116
|
+
const updates = [];
|
|
117
|
+
const params = [];
|
|
118
|
+
for (const col of UPDATABLE_OBS_COLS) {
|
|
119
|
+
if (fields[col] !== undefined) {
|
|
120
|
+
updates.push(`${col} = ?`);
|
|
121
|
+
params.push(typeof fields[col] === 'string' ? scrubSecrets(fields[col]) : fields[col]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (updates.length === 0) return [];
|
|
125
|
+
params.push(id);
|
|
126
|
+
db.transaction(() => {
|
|
127
|
+
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
128
|
+
rebuildObservationDerived(db, id);
|
|
129
|
+
})();
|
|
130
|
+
return updates.map((u) => u.split(' =')[0]);
|
|
131
|
+
}
|
package/lib/recall-core.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { basename } from 'path';
|
|
9
9
|
import { notLowSignalTitleClause } from '../utils.mjs';
|
|
10
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Recall observations linked to a file (basename or full path). Returns
|
|
@@ -31,8 +32,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
|
|
|
31
32
|
o.created_at, o.created_at_epoch, o.project
|
|
32
33
|
FROM observations o
|
|
33
34
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
34
|
-
WHERE
|
|
35
|
-
AND o.superseded_at IS NULL
|
|
35
|
+
WHERE ${liveObsFilterSql('o')}
|
|
36
36
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
37
37
|
${noiseClause}
|
|
38
38
|
ORDER BY o.created_at_epoch DESC
|
package/lib/recent-core.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// MCP wants `project`) — same convention as recall-core, so neither surface needs
|
|
15
15
|
// its own SELECT list.
|
|
16
16
|
|
|
17
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
18
|
+
|
|
17
19
|
const RECENT_COLS = 'id, type, title, subtitle, importance, project, created_at, created_at_epoch';
|
|
18
20
|
|
|
19
21
|
// Upper bound on rows a single `recent` call may pull, shared so the cap can't
|
|
@@ -38,7 +40,7 @@ export const RECENT_MAX = 1000;
|
|
|
38
40
|
*/
|
|
39
41
|
export function fetchRecent(db, { project = null, type = null, since = null, limit = 10 } = {}) {
|
|
40
42
|
const params = [];
|
|
41
|
-
const wheres = [
|
|
43
|
+
const wheres = [liveObsFilterSql('')];
|
|
42
44
|
if (project) { wheres.push('project = ?'); params.push(project); }
|
|
43
45
|
if (type) { wheres.push('type = ?'); params.push(type); }
|
|
44
46
|
if (Number.isFinite(since)) { wheres.push('created_at_epoch >= ?'); params.push(since); }
|
package/lib/search-core.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
|
|
|
29
29
|
import { computeTier } from '../tier.mjs';
|
|
30
30
|
import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
|
|
31
31
|
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
32
|
+
import { liveObsFilterSql, recencyDecaySql } from './inject-search-core.mjs';
|
|
32
33
|
|
|
33
34
|
/** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
|
|
34
35
|
export function buildSearchFtsQuery(query, { or = false } = {}) {
|
|
@@ -146,7 +147,7 @@ export function searchSessionsFts(db, { ftsQuery, project = null, projectBoost =
|
|
|
146
147
|
return db.prepare(`
|
|
147
148
|
SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
|
|
148
149
|
${SESS_BM25}
|
|
149
|
-
* (
|
|
150
|
+
* ${recencyDecaySql({ tsExpr: 's.created_at_epoch', halfLifeSql: `${DEFAULT_DECAY_HALF_LIFE_MS}.0` })}
|
|
150
151
|
* (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
151
152
|
FROM session_summaries_fts
|
|
152
153
|
JOIN session_summaries s ON session_summaries_fts.rowid = s.id
|
|
@@ -235,7 +236,7 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
|
|
|
235
236
|
return db.prepare(`
|
|
236
237
|
SELECT e.id, e.event_type, e.title, e.body, e.project, e.importance, e.file_paths, e.created_at_epoch,
|
|
237
238
|
${EVT_BM25}
|
|
238
|
-
* (
|
|
239
|
+
* ${recencyDecaySql({ tsExpr: 'e.created_at_epoch', halfLifeSql: `${DEFAULT_DECAY_HALF_LIFE_MS}.0` })}
|
|
239
240
|
* (CASE WHEN ? IS NOT NULL AND e.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
240
241
|
FROM events_fts
|
|
241
242
|
JOIN events e ON events_fts.rowid = e.id
|
|
@@ -258,7 +259,16 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
|
|
|
258
259
|
// obs/event/session BM25 live on comparable scales (weighted bm25 × decay ×
|
|
259
260
|
// project-boost), so the ratio is meaningful there; small-scale sources (prompts,
|
|
260
261
|
// bm25 ≈ -1) can only be penalized by this comparison, never inflated — the safe
|
|
261
|
-
// direction.
|
|
262
|
+
// direction.
|
|
263
|
+
// D#121 (2026-08-16): obs FULL_SCORE additionally carries citeFactor (0.4–3.0) ×
|
|
264
|
+
// noisePenalty (0.2–1.0) — behavior state events/sessions never have. A heavily-
|
|
265
|
+
// cited obs can therefore widen globalMaxAbs and demote a lone event's band: this
|
|
266
|
+
// is ACCEPTED direction (cite is genuine relevance evidence), and it is BOUNDED —
|
|
267
|
+
// citeFactor caps at 3.0, so a lone hit that was the raw max keeps ratio >= 1/3
|
|
268
|
+
// and never sinks below the -0.5 neutral mid; noise only shrinks obs (can only
|
|
269
|
+
// IMPROVE the lone hit's band). Real-SQL pins: benchmark/events-pipeline-probes.mjs
|
|
270
|
+
// (cite-widening-bounded-3x / decisive-lone-event-survives-max-cite / noise-shrink).
|
|
271
|
+
// Bands (ratio = |lone| / globalMaxAbs, first match wins):
|
|
262
272
|
// ratio ≥ 1 → -1.05 the lone hit IS the strongest raw match anywhere: rank it
|
|
263
273
|
// strictly ahead of every normalized -1 (ties in raw
|
|
264
274
|
// strength resolve toward the lone hit — the [-1, 0] band
|
|
@@ -598,7 +608,7 @@ export async function coreRunSearchPipeline(ctx, opts) {
|
|
|
598
608
|
|
|
599
609
|
// ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
|
|
600
610
|
if (obsTypeFallback && results.length === 0 && obsType) {
|
|
601
|
-
const typeWheres = [
|
|
611
|
+
const typeWheres = [liveObsFilterSql(''), 'type = ?'];
|
|
602
612
|
// Mirror the FTS path's low-signal filter (buildObsFtsQuery): this fallback is still a
|
|
603
613
|
// SEARCH surface, so degraded titles ("Modified X", "Error: …") must not lead it —
|
|
604
614
|
// acute for obs_type='change', the noise band. (The no-query recent-listing in
|
package/lib/stats-core.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { inferProject } from '../utils.mjs';
|
|
|
10
10
|
import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
|
|
11
11
|
import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
|
|
12
12
|
import { computeNoiseGauge } from './stats-quality.mjs';
|
|
13
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Compute the primary stats feed. Row shapes are returned exactly as the twin
|
|
@@ -98,7 +99,7 @@ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now
|
|
|
98
99
|
const tierDist = db.prepare(`
|
|
99
100
|
SELECT tier, COUNT(*) as c FROM (
|
|
100
101
|
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
101
|
-
WHERE
|
|
102
|
+
WHERE ${liveObsFilterSql('')} ${projectFilter}
|
|
102
103
|
) GROUP BY tier ORDER BY tier
|
|
103
104
|
`).all(...tdParams, ...baseParams);
|
|
104
105
|
const tierMap = Object.fromEntries(tierDist.map((r) => [r.tier, r.c]));
|
package/lib/timeline-core.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { parseIdToken } from './id-routing.mjs';
|
|
15
15
|
import { findFtsAnchor } from '../search-engine.mjs';
|
|
16
16
|
import { sanitizeFtsQuery } from '../utils.mjs';
|
|
17
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
17
18
|
|
|
18
19
|
const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
|
|
19
20
|
|
|
@@ -21,7 +22,7 @@ const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_a
|
|
|
21
22
|
function nearestObservation(db, epoch, project) {
|
|
22
23
|
return db.prepare(`
|
|
23
24
|
SELECT id FROM observations
|
|
24
|
-
WHERE
|
|
25
|
+
WHERE ${liveObsFilterSql('')} ${project ? 'AND project = ?' : ''}
|
|
25
26
|
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
26
27
|
`).get(...(project ? [project, epoch] : [epoch]));
|
|
27
28
|
}
|
|
@@ -162,9 +163,9 @@ export function resolveQueryAnchor(db, queryStr, { project = null } = {}) {
|
|
|
162
163
|
|
|
163
164
|
/** No-anchor fallback: most recent live (non-compressed, non-superseded) obs, newest first. */
|
|
164
165
|
export function fetchRecentTimeline(db, { project = null, limit }) {
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
const liveFilter = '
|
|
166
|
+
// A superseded row must not lead the "most recent" timeline — same live-row
|
|
167
|
+
// invariant as the before/after window legs (fetchTimelineWindow).
|
|
168
|
+
const liveFilter = liveObsFilterSql('');
|
|
168
169
|
const where = project ? `WHERE ${liveFilter} AND project = ?` : `WHERE ${liveFilter}`;
|
|
169
170
|
const params = project ? [project, limit] : [limit];
|
|
170
171
|
return db.prepare(`
|
|
@@ -200,7 +201,7 @@ export function fetchTimelineWindow(db, anchorId, { before, after, project = nul
|
|
|
200
201
|
const beforeRows = db.prepare(`
|
|
201
202
|
SELECT ${TIMELINE_COLS}
|
|
202
203
|
FROM observations
|
|
203
|
-
WHERE created_at_epoch < ? AND
|
|
204
|
+
WHERE created_at_epoch < ? AND ${liveObsFilterSql('')} ${projectFilter}
|
|
204
205
|
ORDER BY created_at_epoch DESC
|
|
205
206
|
LIMIT ?
|
|
206
207
|
`).all(anchorRow.created_at_epoch, ...baseParams, before).reverse();
|
|
@@ -208,7 +209,7 @@ export function fetchTimelineWindow(db, anchorId, { before, after, project = nul
|
|
|
208
209
|
const afterRows = db.prepare(`
|
|
209
210
|
SELECT ${TIMELINE_COLS}
|
|
210
211
|
FROM observations
|
|
211
|
-
WHERE created_at_epoch > ? AND
|
|
212
|
+
WHERE created_at_epoch > ? AND ${liveObsFilterSql('')} ${projectFilter}
|
|
212
213
|
ORDER BY created_at_epoch ASC
|
|
213
214
|
LIMIT ?
|
|
214
215
|
`).all(anchorRow.created_at_epoch, ...baseParams, after);
|