Skip to content

Commit 42e1f8d

Browse files
committed
fix(sessions): Search Sessions searches every session and whole conversations, title matches first
Three review findings in History search: - Each conversation was cut to its first 400,000 characters before matching, so a phrase later in a long or often-resumed session was never found. The whole conversation is matched now; the cache is bounded by the characters it holds in all (20M, least recently used out first) rather than by cutting each session. - Only the newest 150 sessions were searched, title matches included, and the quick pick had no way to reach the rest. searchStep(query, {from, readMax}) now reads conversations a batch at a time (50) and returns where to go on. Its first step reports every title or preview match in the index with no read. search() runs every step; the quick pick runs them in the background, re-ranks as each lands, stops when the query changes or the pick closes, and says "no session mentions" only after every session was searched. - The limit was applied while scanning, before title matches were put first, so 40 newer matches in the conversation pushed out an older exact title match. rankSearch ranks first, then cuts. Tests over a store of 163 sessions: a match in the second-oldest conversation, a phrase past the first 400,000 characters, an older title match ahead of 40 newer ones under limit 5, steps that add up to search(); and the quick pick reaching past the first batch and dropping a superseded query's scan. The test that pinned scanMax is replaced.
1 parent 4732443 commit 42e1f8d

4 files changed

Lines changed: 176 additions & 44 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,20 +1649,36 @@ function searchSessionsQuickPick() {
16491649
qp.placeholder = 'Search this project\'s sessions — titles and what was said';
16501650
qp.matchOnDescription = false; qp.matchOnDetail = false;
16511651
let timer = null;
1652+
let gen = 0; // bumped by every keystroke and by closing: a scan still going for an older query stops
16521653
const when = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''; };
1654+
// Every session is searched, a step at a time (searchStep reads a batch of conversations), so a project
1655+
// with years of history never blocks the editor. The list is re-ranked as each step lands, and says
1656+
// "no session mentions" only once every session has been searched.
16531657
const run = (q) => {
1654-
let results = [];
1655-
try { results = m.search(q, { limit: 40 }); } catch (e) { results = []; }
1656-
dbg('sessions.search', { chars: q.length, results: results.length });
1657-
// alwaysShow: these items are already the matches; the quick pick's own label filter would drop the
1658-
// ones that matched inside the conversation rather than in the title.
1659-
qp.items = results.length
1660-
? results.map((r) => ({ label: r.title || 'Untitled', description: [when(r.updatedAt), r.lifecycle === 'archived' ? 'done' : '', r.pinned ? 'pinned' : ''].filter(Boolean).join(' · '), detail: r.snippet || '', alwaysShow: true, id: r.id }))
1661-
: [{ label: 'No session mentions “' + q + '”', alwaysShow: true, id: '' }];
1662-
qp.busy = false;
1658+
const mine = gen;
1659+
const found = [];
1660+
let from = 0;
1661+
const step = () => {
1662+
if (mine !== gen) { return; }
1663+
let r;
1664+
try { r = m.searchStep(q, { from }); } catch (e) { r = { matches: [], next: null }; }
1665+
found.push(...r.matches);
1666+
const done = r.next == null;
1667+
const results = m.rankSearch(found, 40);
1668+
if (done) { dbg('sessions.search', { chars: q.length, results: results.length }); }
1669+
// alwaysShow: these items are already the matches; the quick pick's own label filter would drop the
1670+
// ones that matched inside the conversation rather than in the title.
1671+
qp.items = results.length
1672+
? results.map((x) => ({ label: x.title || 'Untitled', description: [when(x.updatedAt), x.lifecycle === 'archived' ? 'done' : '', x.pinned ? 'pinned' : ''].filter(Boolean).join(' · '), detail: x.snippet || '', alwaysShow: true, id: x.id }))
1673+
: (done ? [{ label: 'No session mentions “' + q + '”', alwaysShow: true, id: '' }] : []);
1674+
qp.busy = !done;
1675+
if (!done) { from = r.next; setTimeout(step, 0); }
1676+
};
1677+
step();
16631678
};
16641679
qp.onDidChangeValue((v) => {
16651680
clearTimeout(timer);
1681+
gen++;
16661682
const q = String(v || '').trim();
16671683
if (q.length < 2) { qp.items = []; qp.busy = false; return; }
16681684
qp.busy = true;
@@ -1674,7 +1690,7 @@ function searchSessionsQuickPick() {
16741690
qp.hide();
16751691
await handleSessionAction('resume', pick.id);
16761692
});
1677-
qp.onDidHide(() => { clearTimeout(timer); qp.dispose(); });
1693+
qp.onDidHide(() => { clearTimeout(timer); gen++; qp.dispose(); });
16781694
qp.show();
16791695
}
16801696

‎extensions/levelcode-ai/sessions.js‎

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,26 @@ const planner = require('./sessionResume');
2121
const memory = require('./sessionMemory');
2222
const fs = require('fs');
2323

24-
/** History search bounds: characters of one session kept for matching, and sessions kept in the cache. */
25-
const SEARCH_MAX_CHARS = 400000;
26-
const SEARCH_CACHE_MAX = 200;
24+
/** History search bounds: characters of conversation the cache holds in all (one larger session is still
25+
* searched whole, and is then the only one kept), and conversations read from disk per searchStep. */
26+
const SEARCH_CACHE_CHARS = 20000000;
27+
const SEARCH_READ_BATCH = 50;
28+
29+
/**
30+
* Search results in the order they are shown, at most `limit`: a session whose title has every word first,
31+
* then the rest, each newest first. One result per session; a later one with a snippet replaces one without.
32+
* Ranked BEFORE the cut, so an older title match is never pushed out by newer matches in the conversation.
33+
*/
34+
function rankSearch(results, limit) {
35+
const byId = new Map();
36+
for (const r of (Array.isArray(results) ? results : [])) {
37+
if (!r || !r.id) { continue; }
38+
const had = byId.get(r.id);
39+
if (!had || (!had.snippet && r.snippet)) { byId.set(r.id, r); }
40+
}
41+
const out = [...byId.values()].sort((a, b) => (Number(b.inTitle) - Number(a.inTitle)) || String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
42+
return Number.isFinite(limit) && limit > 0 ? out.slice(0, limit) : out;
43+
}
2744
const imageStore = require('./imageStore');
2845

2946
/**
@@ -388,59 +405,92 @@ function createSessions(opts) {
388405
function list() { try { return store.loadIndex(root, slug).entries; } catch (e) { return []; } }
389406

390407
// What each session SAYS, as one lowercased string plus its readable turns, cached by file size + mtime:
391-
// History search runs on every pause in typing, and a session file only grows by appends.
408+
// History search runs on every pause in typing, and a session file only grows by appends. The whole
409+
// conversation is kept: a phrase in its last turn must be found like one in its first. The cache is
410+
// bounded by the characters it holds in all, least recently used out first.
392411
const searchCache = new Map();
412+
let searchCacheChars = 0;
393413
function searchable(id) {
394414
const file = store.sessionFile(root, slug, id);
395415
let st;
396416
try { st = fs.statSync(file); } catch (e) { return null; }
397417
const hit = searchCache.get(file);
398-
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) { return hit; }
418+
if (hit) { searchCache.delete(file); }
419+
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) { searchCache.set(file, hit); return hit; }
420+
if (hit) { searchCacheChars -= hit.hay.length; }
399421
let turns = [];
400422
try { turns = events.toDisplayTurns(events.eventsToMessages(store.readSession(file).events)); } catch (e) { turns = []; }
401-
const hay = turns.map((t) => String((t && t.text) || '')).join('\n').toLowerCase().slice(0, SEARCH_MAX_CHARS);
423+
const hay = turns.map((t) => String((t && t.text) || '')).join('\n').toLowerCase();
402424
const v = { size: st.size, mtimeMs: st.mtimeMs, turns, hay };
403425
searchCache.set(file, v);
404-
if (searchCache.size > SEARCH_CACHE_MAX) { searchCache.delete(searchCache.keys().next().value); }
426+
searchCacheChars += hay.length;
427+
while (searchCacheChars > SEARCH_CACHE_CHARS && searchCache.size > 1) {
428+
const [k, old] = searchCache.entries().next().value;
429+
searchCache.delete(k);
430+
searchCacheChars -= old.hay.length;
431+
}
405432
return v;
406433
}
407434
/**
408-
* History search: the sessions whose title or conversation contains EVERY word of the query, newest first,
409-
* each with a cited snippet. Archived sessions are included (finding old work is the point); trashed ones
410-
* are not. Reads at most `scanMax` sessions' transcripts. Empty for an empty query or on error.
411-
* @returns {Array<{id:string, title:string|null, updatedAt:string, lifecycle:string, pinned:boolean, snippet:string, inTitle:boolean}>}
435+
* One step of History search, newest first. Every session's title and preview are in the index, so the
436+
* first step reports every session whose title or preview has every word, with no read. Each step then
437+
* reads the conversations of the next `readMax` sessions from `from` (cached ones cost nothing) and
438+
* reports those that have every word, in the title or what was said, each with a snippet. Pass `next`
439+
* back as `from` until it is null; rankSearch puts the steps together. Archived sessions are included
440+
* (finding old work is the point); trashed ones are not.
441+
* @returns {{ matches: Array<{id:string, title:string|null, updatedAt:string, lifecycle:string, pinned:boolean, snippet:string, inTitle:boolean}>, next: number|null }}
412442
*/
413-
function search(query, opts) {
443+
function searchStep(query, opts) {
414444
const o = opts || {};
415-
const limit = Number.isFinite(o.limit) && o.limit > 0 ? o.limit : 30;
416-
const scanMax = Number.isFinite(o.scanMax) && o.scanMax > 0 ? o.scanMax : 150;
445+
const from = Number.isFinite(o.from) && o.from > 0 ? Math.floor(o.from) : 0;
446+
const readMax = Number.isFinite(o.readMax) && o.readMax > 0 ? Math.floor(o.readMax) : SEARCH_READ_BATCH;
417447
const terms = memory.queryTerms(query);
418-
if (!terms.length) { return []; }
448+
if (!terms.length) { return { matches: [], next: null }; }
419449
const phrase = String(query || '').toLowerCase().replace(/\s+/g, ' ').trim();
420-
const out = [];
450+
const matches = [];
451+
const head = (e) => ((e.title || '') + ' ' + (e.preview || '')).toLowerCase();
452+
const result = (e, body) => ({ id: e.id, title: e.title || null, updatedAt: e.updatedAt || '', lifecycle: e.lifecycle || 'active', pinned: !!e.pinned,
453+
// The snippet shows the whole phrase when the conversation has it, else the first word that is there.
454+
snippet: body ? memory.snippetFor(body.turns, [phrase].concat(terms)) : '',
455+
inTitle: terms.every((t) => (e.title || '').toLowerCase().indexOf(t) >= 0) });
421456
try {
422457
const entries = list().filter((e) => e && e.lifecycle !== 'trashed')
423-
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
424-
.slice(0, scanMax);
425-
for (const e of entries) {
426-
const head = ((e.title || '') + ' ' + (e.preview || '')).toLowerCase();
458+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
459+
const end = Math.min(entries.length, from + readMax);
460+
if (from === 0) {
461+
for (let i = end; i < entries.length; i++) { const h = head(entries[i]); if (terms.every((t) => h.indexOf(t) >= 0)) { matches.push(result(entries[i], null)); } }
462+
}
463+
for (let i = from; i < end; i++) {
464+
const e = entries[i];
427465
const body = searchable(e.id);
428-
const hay = head + '\n' + (body ? body.hay : '');
429-
if (!terms.every((t) => hay.indexOf(t) >= 0)) { continue; }
430-
// The snippet shows the whole phrase when the conversation has it, else the first word that is there.
431-
const snippet = body ? (memory.snippetFor(body.turns, [phrase].concat(terms))) : '';
432-
out.push({ id: e.id, title: e.title || null, updatedAt: e.updatedAt || '', lifecycle: e.lifecycle || 'active',
433-
pinned: !!e.pinned, snippet, inTitle: terms.every((t) => (e.title || '').toLowerCase().indexOf(t) >= 0) });
434-
if (out.length >= limit) { break; }
466+
const hay = head(e) + '\n' + (body ? body.hay : '');
467+
if (terms.every((t) => hay.indexOf(t) >= 0)) { matches.push(result(e, body)); }
435468
}
436-
} catch (e) { return out; }
437-
// Title matches first, then the rest, each newest first (the scan order).
438-
return out.filter((r) => r.inTitle).concat(out.filter((r) => !r.inTitle));
469+
return { matches, next: end < entries.length ? end : null };
470+
} catch (e) { return { matches, next: null }; }
471+
}
472+
/**
473+
* History search, whole: every session not in the trash whose title or conversation has EVERY word of the
474+
* query, ranked (rankSearch), at most `limit`. Reads every conversation it has not cached; the quick pick
475+
* uses searchStep instead, so a large project never blocks it. Empty for an empty query or on error.
476+
*/
477+
function search(query, opts) {
478+
const o = opts || {};
479+
const limit = Number.isFinite(o.limit) && o.limit > 0 ? o.limit : 30;
480+
const all = [];
481+
let from = 0;
482+
for (;;) {
483+
const r = searchStep(query, { from, readMax: o.readMax });
484+
all.push(...r.matches);
485+
if (r.next == null) { break; }
486+
from = r.next;
487+
}
488+
return rankSearch(all, limit);
439489
}
440490

441491
function liveId() { return live ? live.id : null; }
442492

443-
return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, diagrams, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, rememberNote, factsList, factAction, supersedeFact, memoryPaths, mediaRoot, sweepMedia, list, search, liveId };
493+
return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, diagrams, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, rememberNote, factsList, factAction, supersedeFact, memoryPaths, mediaRoot, sweepMedia, list, search, searchStep, rankSearch, liveId };
444494
}
445495

446-
module.exports = { createSessions };
496+
module.exports = { createSessions, rankSearch };

‎extensions/levelcode-ai/test/memoryNotes.test.js‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,51 @@ test('SEARCH: a session that grows is searched as it is now', () => {
233233
assert.deepStrictEqual(m.search('pool pagination').map((x) => x.id), [idPool]);
234234
m.seal('done');
235235
});
236-
test('SEARCH: an empty or one-letter query finds nothing; scanMax bounds the reads', () => {
236+
test('SEARCH: an empty or one-letter query finds nothing; a step reads at most readMax conversations and says where to go on', () => {
237237
assert.deepStrictEqual(m.search(''), []);
238238
assert.deepStrictEqual(m.search('a'), []);
239-
assert.strictEqual(m.search('redis', { scanMax: 1 }).length, 1);
239+
assert.deepStrictEqual(m.searchStep('', {}), { matches: [], next: null });
240+
const steps = []; let from = 0;
241+
for (;;) { const r = m.searchStep('redis', { from, readMax: 1 }); steps.push(r); if (r.next == null) { break; } assert.strictEqual(r.next, from + 1); from = r.next; }
242+
assert.strictEqual(steps.length, 3, 'three sessions not in the trash, one read a step');
243+
assert.deepStrictEqual(m.rankSearch(steps.flatMap((r) => r.matches)).map((x) => x.id), m.search('redis').map((x) => x.id));
244+
});
245+
246+
// A project with more history than one step reads: 160 sessions, the oldest ones holding the matches.
247+
const tmpBig = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-search-'));
248+
process.on('exit', () => { try { fs.rmSync(tmpBig, { recursive: true, force: true }); } catch { /* best effort */ } });
249+
let tb = NOW;
250+
const big = createSessions({ root: tmpBig, slug: 'big', projectPath: '/big', memory: true, now: () => new Date(tb += 60000) });
251+
big.recordTurn(turn('Quokka migration plan', 'Planned in three steps.'), 'm'); const idQuokkaTitle = big.liveId(); big.seal('done');
252+
big.recordTurn(turn('Look at the old importer', 'Its retry loop is fine. The wombat flag is never read.'), 'm'); const idWombat = big.liveId(); big.seal('done');
253+
// one long conversation: the phrase is in its last turn, past the first 400,000 characters
254+
const long = []; for (let i = 0; i < 9; i++) { long.push(...turn('Step ' + i + ' of the long refactor', 'x'.repeat(50000))); }
255+
long.push(...turn('One more thing', 'The numbat cache is warmed at boot.'));
256+
big.recordTurn(long, 'm'); const idNumbat = big.liveId(); big.seal('done');
257+
for (let i = 0; i < 160; i++) { big.recordTurn(turn('Routine task ' + i, i < 40 ? 'Touched the quokka adapter again.' : 'Nothing notable.'), 'm'); big.seal('done'); }
258+
test('SEARCH: every session is searched, not only the newest 150', () => {
259+
assert.deepStrictEqual(big.search('wombat').map((x) => x.id), [idWombat], 'a match in the conversation of the second-oldest of 163');
260+
assert.match(big.search('wombat')[0].snippet, /wombat flag is never read/);
261+
});
262+
test('SEARCH: the whole conversation is searched, not only its first 400,000 characters', () => {
263+
assert.deepStrictEqual(big.search('numbat').map((x) => x.id), [idNumbat]);
264+
});
265+
test('SEARCH: an older title match outranks newer matches in the conversation, before the limit', () => {
266+
// 40 newer sessions mention quokka; the one titled with it is the oldest of all.
267+
const r = big.search('quokka', { limit: 5 });
268+
assert.strictEqual(r.length, 5);
269+
assert.deepStrictEqual([r[0].id, r[0].inTitle], [idQuokkaTitle, true]);
270+
assert.strictEqual(big.search('quokka', { limit: 100 }).length, 41);
271+
});
272+
test('SEARCH: the first step reports a title match anywhere, with no read; a later step adds its snippet', () => {
273+
const first = big.searchStep('quokka migration', { readMax: 10 });
274+
assert.deepStrictEqual(first.matches.map((x) => [x.id, x.snippet]), [[idQuokkaTitle, '']]);
275+
assert.strictEqual(first.next, 10);
276+
let r = first, all = first.matches.slice();
277+
while (r.next != null) { r = big.searchStep('quokka migration', { from: r.next, readMax: 10 }); all = all.concat(r.matches); }
278+
const [top] = big.rankSearch(all);
279+
assert.strictEqual(top.id, idQuokkaTitle);
280+
assert.match(top.snippet, /Quokka migration plan/i);
240281
});
241282

242283
// ---- rememberNote, through the manager --------------------------------------------------------------

‎extensions/levelcode-ai/test/memoryNotesHost.test.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,31 @@ const host = new Function('env', [
251251
assert.deepStrictEqual(ui.resumed, [refundId]);
252252
assert.strictEqual(qp.disposed, true);
253253
});
254+
await testAsync('SEARCH: the quick pick searches every session, a batch at a time; a new keystroke stops the older scan', async () => {
255+
sessions.recordTurn([{ role: 'user', content: 'Check the platypus exporter' }, { role: 'assistant', content: 'The platypus exporter writes CSV.' }], 'm');
256+
const oldId = sessions.liveId(); sessions.seal('done');
257+
for (let i = 0; i < 60; i++) { sessions.recordTurn([{ role: 'user', content: 'Chore ' + i }, { role: 'assistant', content: 'Done.' }], 'm'); sessions.seal('done'); }
258+
/** @type {string[]} */ const asked = [];
259+
ui.manager = Object.assign({}, sessions, { searchStep: (q, o) => { asked.push(q); return sessions.searchStep(q, o); } });
260+
host.searchSessionsQuickPick();
261+
const qp = ui.quickPick;
262+
const settled = async () => { const end = Date.now() + 3000; while (Date.now() < end) { await new Promise((r) => setTimeout(r, 20)); if (!qp.busy) { return; } } throw new Error('the search never finished'); };
263+
qp._h.change('platypus exporter');
264+
await settled();
265+
assert.deepStrictEqual(qp.items.map((x) => x.id), [oldId], 'the oldest of 60+ sessions, past the first batch');
266+
assert.match(qp.items[0].detail, /you: Check the platypus exporter/);
267+
assert.ok(asked.length >= 2, 'one step read everything: ' + asked.length);
268+
// type again while a scan is running: the old query's scan stops after the step it is in
269+
asked.length = 0;
270+
qp._h.change('chore');
271+
const end = Date.now() + 3000; while (!asked.length && Date.now() < end) { await new Promise((r) => setTimeout(r, 5)); }
272+
qp._h.change('platypus');
273+
await settled();
274+
assert.strictEqual(asked.filter((q) => q === 'chore').length, 1, 'the scan for "chore" went on after the next keystroke');
275+
assert.deepStrictEqual(qp.items.map((x) => x.id), [oldId]);
276+
qp.hide();
277+
ui.manager = undefined;
278+
});
254279
await testAsync('SEARCH: no match says so, and picking that line does nothing', async () => {
255280
host.searchSessionsQuickPick();
256281
const qp = ui.quickPick;

0 commit comments

Comments
 (0)