opencode-session-recall 0.7.1 → 0.8.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/README.md CHANGED
@@ -1,12 +1,10 @@
1
1
  # opencode-session-recall
2
2
 
3
- **Everything your agent ever did is already in the database. It's just not looking.**
3
+ **Every conversation your agent has ever had — across every session, every project — is already in the database. It's just not looking.**
4
4
 
5
- OpenCode stores the full conversation history your agent worked through — messages, tool calls, tool outputs, reasoning traces even after compaction removes them from the active context window. As conversations get long, OpenCode shrinks what the model can see. The old content is still stored, just no longer visible to the agent.
5
+ [OpenCode](https://github.com/opencode-ai/opencode) stores the full conversation history from every session your agent has ever run — messages, tool calls, tool outputs, reasoning traces. All of it. Not just the current session. Not just the current project. Every project on the machine. Even after compaction shrinks what the model can see, the original content stays in the database — just no longer visible to the agent.
6
6
 
7
- This plugin gives the agent five tools to search and retrieve all of it on demand — within the current session, across every session in the project, or across every project on the machine.
8
-
9
- [OpenCode](https://github.com/opencode-ai/opencode) is an open-source AI coding agent that runs in your terminal.
7
+ This plugin gives the agent five tools to search and retrieve all of it on demand.
10
8
 
11
9
  **No new database.**
12
10
  **No embeddings.**
@@ -14,12 +12,14 @@ This plugin gives the agent five tools to search and retrieve all of it on deman
14
12
  **No duplication.**
15
13
  **No overhead.**
16
14
 
17
- Just install the plugin. The agent can search its own history.
15
+ Just install the plugin. The agent gains access to its entire history.
18
16
 
19
17
  ## The problem is absurd when you think about it
20
18
 
21
19
  Your agent solves a tricky build error. Twenty minutes later, compaction runs. An hour later, the same error shows up. The agent starts from zero — debugging something it already figured out, while the answer sits in the database it's connected to.
22
20
 
21
+ You built rate-limiting middleware in your API project last week. Now you need it in another project. The agent has no idea it ever existed — while the original implementation, the requirements discussion, the edge cases you worked through, all of it is sitting in the same database, in a session from a different project.
22
+
23
23
  You're 200 tool calls and 3 compactions deep. The agent has drifted from your original request. Your exact words are gone from context. But they're not gone — they're in the database. The agent just can't see them.
24
24
 
25
25
  The data already exists. This plugin removes the blindfold.
@@ -0,0 +1,54 @@
1
+ import type { Part } from "@opencode-ai/sdk/v2";
2
+ export type SessionMeta = {
3
+ id: string;
4
+ title: string;
5
+ directory: string;
6
+ };
7
+ export type MsgInfo = {
8
+ id: string;
9
+ role: "user" | "assistant";
10
+ time: {
11
+ created: number;
12
+ };
13
+ };
14
+ export type Candidate = {
15
+ sessionID: string;
16
+ sessionTitle: string;
17
+ directory: string;
18
+ messageID: string;
19
+ role: "user" | "assistant";
20
+ time: number;
21
+ partID: string;
22
+ partType: string;
23
+ isPruned: boolean;
24
+ toolName?: string;
25
+ rawText: string;
26
+ tokens: string[];
27
+ primaryText?: string;
28
+ secondaryText?: string;
29
+ titleText?: string;
30
+ hintText?: string;
31
+ };
32
+ export type CandidateBudgets = {
33
+ maxMessagesPerSession: number;
34
+ maxPartsPerSession: number;
35
+ maxCharsPerCandidate: number;
36
+ maxCharsTotal: number;
37
+ maxCandidatesPerSession: number;
38
+ maxCandidatesTotal: number;
39
+ };
40
+ export declare const DEFAULT_BUDGETS: CandidateBudgets;
41
+ /** Build candidates from a single session's messages. Returns candidates and budget tracking info. */
42
+ export declare function buildCandidates(messages: Array<{
43
+ info: MsgInfo;
44
+ parts: Part[];
45
+ }>, session: SessionMeta, budgets: CandidateBudgets, type: string, role: string, before?: number, after?: number): {
46
+ candidates: Candidate[];
47
+ messagesProcessed: number;
48
+ partsProcessed: number;
49
+ charsUsed: number;
50
+ budgetHit: boolean;
51
+ };
52
+ /** Populate stage-2 normalized fields on a candidate (mutates in place). */
53
+ export declare function populateNormalized(candidate: Candidate): void;
54
+ //# sourceMappingURL=candidates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"candidates.d.ts","sourceRoot":"","sources":["../src/candidates.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAIhD,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IAEtB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAGhB,MAAM,EAAE,MAAM,EAAE,CAAC;IAGjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,oBAAoB,EAAE,MAAM,CAAC;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,uBAAuB,EAAE,MAAM,CAAC;IAChC,kBAAkB,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,eAAO,MAAM,eAAe,EAAE,gBAO7B,CAAC;AAEF,sGAAsG;AACtG,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,IAAI,EAAE,CAAA;CAAE,CAAC,EACjD,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,GACb;IACD,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB,CAkGA;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAU7D"}
package/dist/fuse.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { Candidate } from "./candidates.js";
2
+ import type { ParsedQuery } from "./query.js";
3
+ /** Match modes supported by Fuse.js (excludes "literal" which bypasses Fuse) */
4
+ type FuseMode = "smart" | "fuzzy";
5
+ export type FuseHit = {
6
+ candidate: Candidate;
7
+ /** 0 = perfect match, 1 = worst (raw from Fuse.js) */
8
+ fuseScore: number;
9
+ /** Inverted: 1 = perfect match, 0 = worst (for our ranking) */
10
+ normalizedScore: number;
11
+ };
12
+ /** Fuse.js threshold for smart mode (conservative) */
13
+ export declare const SMART_THRESHOLD = 0.3;
14
+ /** Fuse.js threshold for fuzzy mode (looser) */
15
+ export declare const FUZZY_THRESHOLD = 0.5;
16
+ /**
17
+ * Run Fuse.js search over pre-normalized candidates.
18
+ * Candidates MUST have stage-2 fields populated (primaryText etc.) before calling.
19
+ * Returns ALL matches above threshold so callers can compute accurate totals.
20
+ */
21
+ export declare function fuseSearch(candidates: Candidate[], query: ParsedQuery, mode: FuseMode): FuseHit[];
22
+ export {};
23
+ //# sourceMappingURL=fuse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fuse.d.ts","sourceRoot":"","sources":["../src/fuse.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,gFAAgF;AAChF,KAAK,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;AAElC,MAAM,MAAM,OAAO,GAAG;IACpB,SAAS,EAAE,SAAS,CAAC;IACrB,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,eAAe,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,eAAe,MAAM,CAAC;AAEnC,gDAAgD;AAChD,eAAO,MAAM,eAAe,MAAM,CAAC;AAanC;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,UAAU,EAAE,SAAS,EAAE,EACvB,KAAK,EAAE,WAAW,EAClB,IAAI,EAAE,QAAQ,GACb,OAAO,EAAE,CAwBX"}
@@ -0,0 +1,7 @@
1
+ /** Split camelCase/PascalCase at lowercase→uppercase boundaries */
2
+ export declare function splitCamelCase(text: string): string;
3
+ /** Stage 1: extract lightweight tokens from raw text for prefiltering */
4
+ export declare function tokenize(text: string): string[];
5
+ /** Stage 2: produce a fully normalized string for Fuse.js weighted fields */
6
+ export declare function normalize(text: string): string;
7
+ //# sourceMappingURL=normalize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../src/normalize.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAInD;AAED,yEAAyE;AACzE,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAM/C;AAED,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK9C"}
@@ -267,7 +267,481 @@ function formatMsg(msg) {
267
267
  };
268
268
  }
269
269
 
270
+ // src/normalize.ts
271
+ function splitCamelCase(text) {
272
+ return text.replace(/([a-z])([A-Z])/g, "$1 $2");
273
+ }
274
+ function tokenize(text) {
275
+ const separated = text.replace(/[_\-/.]/g, " ");
276
+ const camelSplit = splitCamelCase(separated);
277
+ const lowered = camelSplit.toLowerCase();
278
+ const tokens = lowered.split(/\s+/).filter((t) => t.length > 0);
279
+ return [...new Set(tokens)];
280
+ }
281
+ function normalize(text) {
282
+ const separated = text.replace(/[_\-/.]/g, " ");
283
+ const camelSplit = splitCamelCase(separated);
284
+ const lowered = camelSplit.toLowerCase();
285
+ return lowered.replace(/\s+/g, " ").trim();
286
+ }
287
+
288
+ // src/query.ts
289
+ var QUOTED_PHRASE_RE = /"([^"]*)"/g;
290
+ function parseQuery(query) {
291
+ const raw = query;
292
+ const lower = raw.toLowerCase();
293
+ const phrases = [];
294
+ let remaining = raw;
295
+ for (const match of raw.matchAll(QUOTED_PHRASE_RE)) {
296
+ const content = match[1]?.toLowerCase().trim();
297
+ if (content) {
298
+ phrases.push(content);
299
+ }
300
+ }
301
+ remaining = remaining.replace(QUOTED_PHRASE_RE, " ");
302
+ const phraseTokens = phrases.flatMap((p) => tokenize(p));
303
+ const remainingTokens = tokenize(remaining);
304
+ const tokens = [.../* @__PURE__ */ new Set([...phraseTokens, ...remainingTokens])];
305
+ return {
306
+ raw,
307
+ lower,
308
+ tokens,
309
+ phrases
310
+ };
311
+ }
312
+
313
+ // src/candidates.ts
314
+ var DEFAULT_BUDGETS = {
315
+ maxMessagesPerSession: 1e3,
316
+ maxPartsPerSession: 5e3,
317
+ maxCharsPerCandidate: 2e4,
318
+ maxCharsTotal: 2e6,
319
+ maxCandidatesPerSession: 500,
320
+ maxCandidatesTotal: 3e3
321
+ };
322
+ function buildCandidates(messages2, session, budgets, type, role, before, after) {
323
+ const candidates = [];
324
+ let messagesProcessed = 0;
325
+ let partsProcessed = 0;
326
+ let charsUsed = 0;
327
+ let budgetHit = false;
328
+ for (let mi = messages2.length - 1; mi >= 0; mi--) {
329
+ if (messagesProcessed >= budgets.maxMessagesPerSession) {
330
+ budgetHit = true;
331
+ break;
332
+ }
333
+ const msg = messages2[mi];
334
+ const info = msg.info;
335
+ if (role !== "all" && info.role !== role) continue;
336
+ if (before != null && info.time.created >= before) continue;
337
+ if (after != null && info.time.created <= after) continue;
338
+ messagesProcessed++;
339
+ for (const part of msg.parts) {
340
+ if (partsProcessed >= budgets.maxPartsPerSession) {
341
+ budgetHit = true;
342
+ break;
343
+ }
344
+ if (type !== "all" && part.type !== type) {
345
+ continue;
346
+ }
347
+ partsProcessed++;
348
+ const texts = searchable(part);
349
+ if (texts.length === 0) continue;
350
+ let rawText = texts.join("\n\n");
351
+ if (rawText.length > budgets.maxCharsPerCandidate) {
352
+ rawText = rawText.slice(0, budgets.maxCharsPerCandidate);
353
+ }
354
+ if (charsUsed + rawText.length > budgets.maxCharsTotal) {
355
+ budgetHit = true;
356
+ break;
357
+ }
358
+ charsUsed += rawText.length;
359
+ const candidate = {
360
+ sessionID: session.id,
361
+ sessionTitle: session.title,
362
+ directory: session.directory,
363
+ messageID: info.id,
364
+ role: info.role,
365
+ time: info.time.created,
366
+ partID: part.id,
367
+ partType: part.type,
368
+ isPruned: pruned(part),
369
+ rawText,
370
+ tokens: tokenize(rawText)
371
+ };
372
+ if (part.type === "tool") {
373
+ candidate.toolName = part.tool;
374
+ }
375
+ candidates.push(candidate);
376
+ if (candidates.length >= budgets.maxCandidatesPerSession) {
377
+ budgetHit = true;
378
+ break;
379
+ }
380
+ }
381
+ if (partsProcessed >= budgets.maxPartsPerSession || charsUsed >= budgets.maxCharsTotal || candidates.length >= budgets.maxCandidatesPerSession) {
382
+ break;
383
+ }
384
+ }
385
+ return {
386
+ candidates,
387
+ messagesProcessed,
388
+ partsProcessed,
389
+ charsUsed,
390
+ budgetHit
391
+ };
392
+ }
393
+ function populateNormalized(candidate) {
394
+ candidate.primaryText = normalize(candidate.rawText);
395
+ candidate.secondaryText = candidate.directory ? normalize(candidate.directory) : "";
396
+ candidate.titleText = candidate.sessionTitle ? normalize(candidate.sessionTitle) : "";
397
+ candidate.hintText = candidate.toolName ? normalize(candidate.toolName) : "";
398
+ }
399
+
400
+ // src/prefilter.ts
401
+ import { distance } from "fastest-levenshtein";
402
+ function tokenMatches(qt, candidateTokens) {
403
+ for (const ct of candidateTokens) {
404
+ if (ct.includes(qt)) return "exact";
405
+ }
406
+ if (qt.length >= 4) {
407
+ for (const ct of candidateTokens) {
408
+ if (Math.abs(ct.length - qt.length) > 1) continue;
409
+ if (distance(qt, ct) <= 1) return "typo";
410
+ }
411
+ }
412
+ return "none";
413
+ }
414
+ function prefilterScore(candidate, query) {
415
+ const rawLower = candidate.rawText.toLowerCase();
416
+ let score = 0;
417
+ if (rawLower.includes(query.lower)) {
418
+ score += 100;
419
+ }
420
+ for (const phrase of query.phrases) {
421
+ if (rawLower.includes(phrase)) {
422
+ score += 30;
423
+ }
424
+ }
425
+ let allMatched = true;
426
+ for (const qt of query.tokens) {
427
+ const result = tokenMatches(qt, candidate.tokens);
428
+ if (result === "exact") {
429
+ score += 10;
430
+ } else if (result === "typo") {
431
+ score += 3;
432
+ } else {
433
+ allMatched = false;
434
+ }
435
+ }
436
+ if (query.tokens.length > 0 && allMatched) {
437
+ score += 20;
438
+ }
439
+ return score;
440
+ }
441
+ function prefilter(candidates, query) {
442
+ const results = [];
443
+ for (const candidate of candidates) {
444
+ const score = prefilterScore(candidate, query);
445
+ if (score > 0) {
446
+ results.push({ candidate, prefilterScore: score });
447
+ }
448
+ }
449
+ return results;
450
+ }
451
+
452
+ // src/fuse.ts
453
+ import Fuse from "fuse.js";
454
+ var SMART_THRESHOLD = 0.3;
455
+ var FUZZY_THRESHOLD = 0.5;
456
+ var KEYS = [
457
+ { name: "primaryText", weight: 0.65 },
458
+ { name: "secondaryText", weight: 0.2 },
459
+ { name: "titleText", weight: 0.1 },
460
+ { name: "hintText", weight: 0.05 }
461
+ ];
462
+ function thresholdFor(mode) {
463
+ return mode === "smart" ? SMART_THRESHOLD : FUZZY_THRESHOLD;
464
+ }
465
+ function fuseSearch(candidates, query, mode) {
466
+ const fuse = new Fuse(candidates, {
467
+ includeScore: true,
468
+ ignoreLocation: true,
469
+ ignoreFieldNorm: true,
470
+ shouldSort: true,
471
+ includeMatches: false,
472
+ threshold: thresholdFor(mode),
473
+ keys: KEYS
474
+ });
475
+ const normalizedQuery = normalize(query.raw);
476
+ const results = fuse.search(normalizedQuery);
477
+ return results.map((result) => {
478
+ const fuseScore = result.score ?? 1;
479
+ return {
480
+ candidate: result.item,
481
+ fuseScore,
482
+ normalizedScore: 1 - fuseScore
483
+ };
484
+ });
485
+ }
486
+
487
+ // src/rank.ts
488
+ import { distance as distance2 } from "fastest-levenshtein";
489
+ var EXACT_PHRASE_BOOST = 0.15;
490
+ var ALL_TOKENS_BOOST = 0.1;
491
+ var REASONING_BOOST = 0.05;
492
+ var ERROR_TEXT_BOOST = 0.05;
493
+ var USER_ROLE_BOOST = 0.03;
494
+ var RECENCY_BOOST_MAX = 0.05;
495
+ var RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
496
+ var WEAK_FUZZY_PENALTY = -0.1;
497
+ var WEAK_FUZZY_THRESHOLD = 0.7;
498
+ var POOR_COVERAGE_PENALTY = -0.08;
499
+ var MAX_PREFILTER_SCORE = 150;
500
+ var ERROR_PATTERNS = ["error", "failed", "exception"];
501
+ function containsErrorPattern(text) {
502
+ const lower = text.toLowerCase();
503
+ return ERROR_PATTERNS.some((p) => lower.includes(p));
504
+ }
505
+ function clamp01(value) {
506
+ return Math.max(0, Math.min(1, value));
507
+ }
508
+ function recencyBoost(time) {
509
+ const ageMs = Date.now() - time;
510
+ const factor = Math.max(0, 1 - ageMs / RECENCY_WINDOW_MS);
511
+ return factor * RECENCY_BOOST_MAX;
512
+ }
513
+ function findMatchedTerms(queryTokens, candidateTokens) {
514
+ const matched = [];
515
+ for (const qt of queryTokens) {
516
+ const exactFound = candidateTokens.some((ct) => ct.includes(qt));
517
+ if (exactFound) {
518
+ matched.push(qt);
519
+ continue;
520
+ }
521
+ if (qt.length >= 4) {
522
+ const typoFound = candidateTokens.some(
523
+ (ct) => Math.abs(ct.length - qt.length) <= 1 && distance2(qt, ct) <= 1
524
+ );
525
+ if (typoFound) {
526
+ matched.push(qt);
527
+ }
528
+ }
529
+ }
530
+ return matched;
531
+ }
532
+ function sortResults(results) {
533
+ return results.sort((a, b) => {
534
+ const scoreDiff = b.score - a.score;
535
+ if (scoreDiff !== 0) return scoreDiff;
536
+ return b.candidate.time - a.candidate.time;
537
+ });
538
+ }
539
+ function rank(hits, query, explain) {
540
+ const results = [];
541
+ for (const hit of hits) {
542
+ const { candidate, normalizedScore } = hit;
543
+ let score = normalizedScore;
544
+ const reasons = [];
545
+ if (explain) {
546
+ reasons.push(`Fuse.js base score: ${normalizedScore.toFixed(2)}`);
547
+ }
548
+ const rawLower = candidate.rawText.toLowerCase();
549
+ let hasExactPhrase = false;
550
+ for (const phrase of query.phrases) {
551
+ if (rawLower.includes(phrase)) {
552
+ hasExactPhrase = true;
553
+ break;
554
+ }
555
+ }
556
+ if (hasExactPhrase) {
557
+ score += EXACT_PHRASE_BOOST;
558
+ if (explain) {
559
+ reasons.push(`Exact phrase match: +${EXACT_PHRASE_BOOST.toFixed(2)}`);
560
+ }
561
+ }
562
+ const matchedTerms = findMatchedTerms(query.tokens, candidate.tokens);
563
+ const allTokensMatched = query.tokens.length > 0 && matchedTerms.length === query.tokens.length;
564
+ if (allTokensMatched) {
565
+ score += ALL_TOKENS_BOOST;
566
+ if (explain) {
567
+ reasons.push(
568
+ `All query tokens matched: +${ALL_TOKENS_BOOST.toFixed(2)}`
569
+ );
570
+ }
571
+ }
572
+ if (candidate.partType === "reasoning") {
573
+ score += REASONING_BOOST;
574
+ if (explain) {
575
+ reasons.push(`Reasoning part boost: +${REASONING_BOOST.toFixed(2)}`);
576
+ }
577
+ }
578
+ if (candidate.partType === "tool" && containsErrorPattern(candidate.rawText)) {
579
+ score += ERROR_TEXT_BOOST;
580
+ if (explain) {
581
+ reasons.push(`Error text boost: +${ERROR_TEXT_BOOST.toFixed(2)}`);
582
+ }
583
+ }
584
+ if (candidate.role === "user") {
585
+ score += USER_ROLE_BOOST;
586
+ if (explain) {
587
+ reasons.push(`User text boost: +${USER_ROLE_BOOST.toFixed(2)}`);
588
+ }
589
+ }
590
+ const recency = recencyBoost(candidate.time);
591
+ if (recency > 0) {
592
+ score += recency;
593
+ if (explain) {
594
+ reasons.push(`Recency boost: +${recency.toFixed(2)}`);
595
+ }
596
+ }
597
+ if (matchedTerms.length === 1 && query.tokens.length === 1 && normalizedScore < WEAK_FUZZY_THRESHOLD) {
598
+ score += WEAK_FUZZY_PENALTY;
599
+ if (explain) {
600
+ reasons.push(
601
+ `Weak single-token fuzzy: ${WEAK_FUZZY_PENALTY.toFixed(2)}`
602
+ );
603
+ }
604
+ }
605
+ if (query.tokens.length > 1 && matchedTerms.length < query.tokens.length / 2) {
606
+ score += POOR_COVERAGE_PENALTY;
607
+ if (explain) {
608
+ reasons.push(
609
+ `Poor query coverage: ${POOR_COVERAGE_PENALTY.toFixed(2)}`
610
+ );
611
+ }
612
+ }
613
+ score = clamp01(score);
614
+ results.push({
615
+ candidate,
616
+ score,
617
+ matchedTerms,
618
+ matchReasons: explain ? reasons : []
619
+ });
620
+ }
621
+ return sortResults(results);
622
+ }
623
+ function rankDegraded(candidates, query, explain) {
624
+ const results = [];
625
+ for (const entry of candidates) {
626
+ const { candidate, prefilterScore: prefilterScore2 } = entry;
627
+ const reasons = [];
628
+ let score = Math.min(1, prefilterScore2 / MAX_PREFILTER_SCORE);
629
+ if (explain) {
630
+ reasons.push(
631
+ `Degraded mode: prefilter score ${prefilterScore2} \u2192 ${score.toFixed(2)}`
632
+ );
633
+ }
634
+ const recency = recencyBoost(candidate.time);
635
+ if (recency > 0) {
636
+ score += recency;
637
+ if (explain) {
638
+ reasons.push(`Recency boost: +${recency.toFixed(2)}`);
639
+ }
640
+ }
641
+ score = clamp01(score);
642
+ const matchedTerms = findMatchedTerms(query.tokens, candidate.tokens);
643
+ results.push({
644
+ candidate,
645
+ score,
646
+ matchedTerms,
647
+ matchReasons: explain ? reasons : []
648
+ });
649
+ }
650
+ return sortResults(results);
651
+ }
652
+
653
+ // src/snippet.ts
654
+ function frame(text, start, end, fullLength) {
655
+ let result = text;
656
+ if (start > 0) result = "..." + result;
657
+ if (end < fullLength) result = result + "...";
658
+ return result;
659
+ }
660
+ function headSlice(rawText, width) {
661
+ if (rawText.length <= width) return rawText;
662
+ return rawText.slice(0, width) + "...";
663
+ }
664
+ function extractWindow(rawText, idealStart, width) {
665
+ const start = Math.max(0, Math.min(idealStart, rawText.length - width));
666
+ const end = Math.min(rawText.length, start + width);
667
+ return frame(rawText.slice(start, end), start, end, rawText.length);
668
+ }
669
+ function findAllPositions(haystack, needle) {
670
+ const positions = [];
671
+ const lowerHaystack = haystack.toLowerCase();
672
+ const lowerNeedle = needle.toLowerCase();
673
+ if (lowerNeedle.length === 0) return positions;
674
+ let idx = 0;
675
+ while (idx <= lowerHaystack.length - lowerNeedle.length) {
676
+ const found = lowerHaystack.indexOf(lowerNeedle, idx);
677
+ if (found === -1) break;
678
+ positions.push(found);
679
+ idx = found + 1;
680
+ }
681
+ return positions;
682
+ }
683
+ function smartSnippet(rawText, query, width = 200) {
684
+ if (rawText.length === 0) return "";
685
+ if (rawText.length <= width) return rawText;
686
+ const allPositions = [];
687
+ for (const token of query.tokens) {
688
+ const positions = findAllPositions(rawText, token);
689
+ for (const position of positions) {
690
+ allPositions.push({ token, position });
691
+ }
692
+ }
693
+ for (const phrase of query.phrases) {
694
+ const positions = findAllPositions(rawText, phrase);
695
+ for (const position of positions) {
696
+ allPositions.push({ token: phrase, position });
697
+ }
698
+ }
699
+ if (allPositions.length === 0) {
700
+ return headSlice(rawText, width);
701
+ }
702
+ allPositions.sort((a, b) => a.position - b.position);
703
+ let bestStart = allPositions[0].position;
704
+ let bestDistinct = 0;
705
+ let bestSpread = Infinity;
706
+ for (const { position: windowStart } of allPositions) {
707
+ const seen = /* @__PURE__ */ new Set();
708
+ let minPos = Infinity;
709
+ let maxPos = -Infinity;
710
+ for (const { token, position } of allPositions) {
711
+ if (position >= windowStart && position <= windowStart + width) {
712
+ seen.add(token);
713
+ minPos = Math.min(minPos, position);
714
+ maxPos = Math.max(maxPos, position);
715
+ }
716
+ }
717
+ const distinct = seen.size;
718
+ const spread = maxPos - minPos;
719
+ if (distinct > bestDistinct || distinct === bestDistinct && spread < bestSpread) {
720
+ bestDistinct = distinct;
721
+ bestSpread = spread;
722
+ bestStart = windowStart;
723
+ }
724
+ }
725
+ const tokensInWindow = [];
726
+ for (const { position } of allPositions) {
727
+ if (position >= bestStart && position <= bestStart + width) {
728
+ tokensInWindow.push(position);
729
+ }
730
+ }
731
+ if (tokensInWindow.length > 0) {
732
+ const minPos = tokensInWindow[0];
733
+ const maxPos = tokensInWindow[tokensInWindow.length - 1];
734
+ const midpoint = Math.floor((minPos + maxPos) / 2);
735
+ const idealStart = midpoint - Math.floor(width / 2);
736
+ return extractWindow(rawText, idealStart, width);
737
+ }
738
+ return extractWindow(rawText, bestStart, width);
739
+ }
740
+
270
741
  // src/search.ts
742
+ var PROMOTED_SCOPES = /* @__PURE__ */ new Set(["session"]);
743
+ var TIME_BUDGET_MS = 2e3;
744
+ var PREFUSE_BUDGET_MS = 1500;
271
745
  function meta(s) {
272
746
  return { id: s.id, title: s.title, directory: s.directory };
273
747
  }
@@ -310,6 +784,120 @@ function scan(messages2, session, query, type, role, limit, before, after, width
310
784
  }
311
785
  return { results, total };
312
786
  }
787
+ function smartScan(allMessages, query, type, role, limit, explain, mode, before, after, width) {
788
+ const pq = parseQuery(query);
789
+ const startTime = performance.now();
790
+ const allCandidates = [];
791
+ let totalCharsUsed = 0;
792
+ let anyBudgetHit = false;
793
+ for (const { session, messages: messages2 } of allMessages) {
794
+ const { candidates, charsUsed, budgetHit } = buildCandidates(
795
+ messages2,
796
+ session,
797
+ {
798
+ ...DEFAULT_BUDGETS,
799
+ maxCharsTotal: DEFAULT_BUDGETS.maxCharsTotal - totalCharsUsed
800
+ },
801
+ type,
802
+ role,
803
+ before,
804
+ after
805
+ );
806
+ allCandidates.push(...candidates);
807
+ totalCharsUsed += charsUsed;
808
+ if (budgetHit) anyBudgetHit = true;
809
+ if (allCandidates.length >= DEFAULT_BUDGETS.maxCandidatesTotal) {
810
+ allCandidates.length = DEFAULT_BUDGETS.maxCandidatesTotal;
811
+ anyBudgetHit = true;
812
+ break;
813
+ }
814
+ }
815
+ let filtered = prefilter(allCandidates, pq);
816
+ if (filtered.length > DEFAULT_BUDGETS.maxCandidatesTotal) {
817
+ filtered.sort((a, b) => b.prefilterScore - a.prefilterScore);
818
+ filtered = filtered.slice(0, DEFAULT_BUDGETS.maxCandidatesTotal);
819
+ anyBudgetHit = true;
820
+ }
821
+ const prefuseTime = performance.now() - startTime;
822
+ if (prefuseTime > PREFUSE_BUDGET_MS) {
823
+ const ranked2 = rankDegraded(filtered, pq, explain);
824
+ const results2 = rankedToSearchResults(
825
+ ranked2.slice(0, limit),
826
+ mode,
827
+ explain,
828
+ pq,
829
+ width
830
+ );
831
+ return {
832
+ results: results2,
833
+ total: filtered.length,
834
+ degradeKind: "time",
835
+ matchMode: mode
836
+ };
837
+ }
838
+ for (const { candidate } of filtered) {
839
+ populateNormalized(candidate);
840
+ }
841
+ const fuseCandidates = filtered.map((f) => f.candidate);
842
+ const hits = fuseSearch(fuseCandidates, pq, mode);
843
+ const totalTime = performance.now() - startTime;
844
+ const ranked = rank(hits, pq, explain);
845
+ const fuseTotal = ranked.length;
846
+ if (totalTime > TIME_BUDGET_MS) {
847
+ const results2 = rankedToSearchResults(
848
+ ranked.slice(0, limit),
849
+ mode,
850
+ explain,
851
+ pq,
852
+ width
853
+ );
854
+ return {
855
+ results: results2,
856
+ total: fuseTotal,
857
+ degradeKind: "time",
858
+ matchMode: mode
859
+ };
860
+ }
861
+ const results = rankedToSearchResults(
862
+ ranked.slice(0, limit),
863
+ mode,
864
+ explain,
865
+ pq,
866
+ width
867
+ );
868
+ return {
869
+ results,
870
+ total: fuseTotal,
871
+ degradeKind: anyBudgetHit ? "budget" : "none",
872
+ matchMode: mode
873
+ };
874
+ }
875
+ function rankedToSearchResults(ranked, mode, explain, query, width) {
876
+ return ranked.map((r) => {
877
+ const c = r.candidate;
878
+ const snip = smartSnippet(c.rawText, query, width);
879
+ const result = {
880
+ sessionID: c.sessionID,
881
+ sessionTitle: c.sessionTitle,
882
+ directory: c.directory,
883
+ messageID: c.messageID,
884
+ role: c.role,
885
+ time: c.time,
886
+ partID: c.partID,
887
+ partType: c.partType,
888
+ pruned: c.isPruned,
889
+ snippet: snip,
890
+ toolName: c.toolName,
891
+ score: r.score,
892
+ matchMode: mode,
893
+ matchedTerms: r.matchedTerms
894
+ };
895
+ if (explain && r.matchReasons.length > 0) {
896
+ result.matchReasons = r.matchReasons;
897
+ }
898
+ return result;
899
+ });
900
+ }
313
901
  function search(client, unscoped, global, limits) {
314
902
  return tool2({
315
903
  description: `Search your conversation history in the opencode database. This is the primary discovery tool \u2014 use it before recall_sessions, which only searches titles. Before debugging an issue or implementing a feature, check whether prior sessions already tackled it \u2014 the history shows whether an approach succeeded or was abandoned. If you have access to a memory system, add useful findings to memory so they're available directly next time without searching history.
@@ -322,12 +910,20 @@ Scope costs: all scopes scan up to \`sessions\` sessions (default 10). "session"
322
910
 
323
911
  Returns { ok, results: [{ sessionID, messageID, role, time, partID, partType, pruned, snippet, toolName? }], scanned, total, truncated }. Each result includes a pruned flag \u2014 if true, the content was compacted from your context window and recall_get will return the original full output. Check truncated to know if more matches exist beyond your results limit.
324
912
 
325
- This tool's own outputs are excluded from search results to prevent recursive noise; use recall_get or recall_context to retrieve any message directly.`,
913
+ This tool's own outputs are excluded from search results to prevent recursive noise; use recall_get or recall_context to retrieve any message directly.
914
+
915
+ Use match:"smart" for fuzzy search when exact wording is uncertain \u2014 it handles typos, separator differences (rate-limit vs rateLimit), and ranks results by relevance. Currently available for scope:"session" only.`,
326
916
  args: {
327
917
  query: tool2.schema.string().min(1).describe("Text to search for (case-insensitive substring match)"),
328
918
  scope: tool2.schema.enum(["session", "project", "global"]).default("global").describe(
329
919
  "global = all projects (default), project = current project, session = current only. Searching broadly is fast."
330
920
  ),
921
+ match: tool2.schema.enum(["literal", "smart", "fuzzy"]).default("literal").describe(
922
+ 'Matching strategy: "literal" = exact substring (default), "smart" = fuzzy ranked search (session scope only), "fuzzy" = looser fuzzy search (session scope only)'
923
+ ),
924
+ explain: tool2.schema.boolean().default(false).describe(
925
+ "Return scoring metadata for debugging. Adds matchReasons to each result."
926
+ ),
331
927
  sessionID: tool2.schema.string().optional().describe("Search a specific session (overrides scope)"),
332
928
  type: tool2.schema.enum(["text", "tool", "reasoning", "all"]).default("all").describe("Filter by part type"),
333
929
  role: tool2.schema.enum(["user", "assistant", "all"]).default("all").describe("Filter by message role"),
@@ -347,7 +943,20 @@ This tool's own outputs are excluded from search results to prevent recursive no
347
943
  )
348
944
  },
349
945
  async execute(args, ctx) {
350
- ctx.metadata({ title: `Searching ${args.scope} for "${args.query}"` });
946
+ const matchMode = args.match;
947
+ ctx.metadata({
948
+ title: `Searching ${args.scope} for "${args.query}"${matchMode !== "literal" ? ` (${matchMode})` : ""}`
949
+ });
950
+ if (matchMode !== "literal") {
951
+ const effectiveScope = args.sessionID ? "session" : args.scope;
952
+ if (!PROMOTED_SCOPES.has(effectiveScope)) {
953
+ const err = {
954
+ ok: false,
955
+ error: `match:"${matchMode}" is not yet available for scope:"${effectiveScope}". Try scope:"session" or use match:"literal" for broader searches.`
956
+ };
957
+ return JSON.stringify(err);
958
+ }
959
+ }
351
960
  if (args.scope === "global" && !args.sessionID && !global) {
352
961
  const err = {
353
962
  ok: false,
@@ -410,36 +1019,43 @@ This tool's own outputs are excluded from search results to prevent recursive no
410
1019
  }
411
1020
  if (resp.data) targets = resp.data.map(meta);
412
1021
  }
413
- const collected = [];
1022
+ const allLoaded = [];
414
1023
  let scanned = 0;
415
- let total = 0;
416
- let early = false;
417
1024
  for (let i = 0; i < targets.length; i += limits.concurrency) {
418
- if (ctx.abort.aborted) {
419
- early = true;
420
- break;
421
- }
422
- if (collected.length >= args.results) {
423
- early = true;
424
- break;
425
- }
426
- const remaining = args.results - collected.length;
1025
+ if (ctx.abort.aborted) break;
427
1026
  const batch = targets.slice(i, i + limits.concurrency);
428
1027
  const loaded = await Promise.all(
429
1028
  batch.map(async (t) => {
430
1029
  try {
431
- const resp = await client.session.messages({ sessionID: t.id });
432
- return { session: t, messages: resp.data ?? [] };
1030
+ const resp = await client.session.messages({
1031
+ sessionID: t.id
1032
+ });
1033
+ return {
1034
+ session: t,
1035
+ messages: resp.data ?? []
1036
+ };
433
1037
  } catch {
434
1038
  return { session: t, messages: [] };
435
1039
  }
436
1040
  })
437
1041
  );
438
- for (const { session: sess, messages: msgs } of loaded) {
1042
+ allLoaded.push(...loaded);
1043
+ scanned += batch.length;
1044
+ }
1045
+ if (ctx.abort.aborted) {
1046
+ const err = { ok: false, error: "aborted" };
1047
+ return JSON.stringify(err);
1048
+ }
1049
+ if (matchMode === "literal") {
1050
+ const collected = [];
1051
+ let total = 0;
1052
+ let early = false;
1053
+ for (const { session: sess, messages: msgs } of allLoaded) {
439
1054
  if (collected.length >= args.results) {
440
1055
  early = true;
441
1056
  break;
442
1057
  }
1058
+ const remaining = args.results - collected.length;
443
1059
  const result = scan(
444
1060
  msgs,
445
1061
  sess,
@@ -454,18 +1070,83 @@ This tool's own outputs are excluded from search results to prevent recursive no
454
1070
  collected.push(...result.results);
455
1071
  total += result.total;
456
1072
  }
457
- scanned += batch.length;
1073
+ const final = collected.slice(0, args.results);
1074
+ ctx.metadata({
1075
+ title: `Found ${final.length} result${final.length !== 1 ? "s" : ""} for "${args.query}" (${scanned} session${scanned !== 1 ? "s" : ""} searched)`
1076
+ });
1077
+ const out2 = {
1078
+ ok: true,
1079
+ results: final,
1080
+ scanned,
1081
+ total,
1082
+ truncated: early || total > final.length
1083
+ };
1084
+ return JSON.stringify(out2);
1085
+ }
1086
+ const smartResult = smartScan(
1087
+ allLoaded,
1088
+ args.query,
1089
+ args.type,
1090
+ args.role,
1091
+ args.results,
1092
+ args.explain,
1093
+ matchMode,
1094
+ args.before,
1095
+ args.after,
1096
+ args.width
1097
+ );
1098
+ if (smartResult.results.length === 0) {
1099
+ const collected = [];
1100
+ let total = 0;
1101
+ let early = false;
1102
+ for (const { session: sess, messages: msgs } of allLoaded) {
1103
+ if (collected.length >= args.results) {
1104
+ early = true;
1105
+ break;
1106
+ }
1107
+ const remaining = args.results - collected.length;
1108
+ const result = scan(
1109
+ msgs,
1110
+ sess,
1111
+ args.query,
1112
+ args.type,
1113
+ args.role,
1114
+ remaining,
1115
+ args.before,
1116
+ args.after,
1117
+ args.width
1118
+ );
1119
+ collected.push(...result.results);
1120
+ total += result.total;
1121
+ }
1122
+ const final = collected.slice(0, args.results);
1123
+ if (final.length > 0) {
1124
+ ctx.metadata({
1125
+ title: `Found ${final.length} result${final.length !== 1 ? "s" : ""} for "${args.query}" (literal fallback, ${scanned} session${scanned !== 1 ? "s" : ""})`
1126
+ });
1127
+ const out2 = {
1128
+ ok: true,
1129
+ results: final,
1130
+ scanned,
1131
+ total,
1132
+ truncated: early || total > final.length,
1133
+ matchMode: "literal",
1134
+ degradeKind: "fallback"
1135
+ };
1136
+ return JSON.stringify(out2);
1137
+ }
458
1138
  }
459
- const final = collected.slice(0, args.results);
460
1139
  ctx.metadata({
461
- title: `Found ${final.length} result${final.length !== 1 ? "s" : ""} for "${args.query}" (${scanned} session${scanned !== 1 ? "s" : ""} searched)`
1140
+ title: `Found ${smartResult.results.length} result${smartResult.results.length !== 1 ? "s" : ""} for "${args.query}" (${matchMode}, ${scanned} session${scanned !== 1 ? "s" : ""})`
462
1141
  });
463
1142
  const out = {
464
1143
  ok: true,
465
- results: final,
1144
+ results: smartResult.results,
466
1145
  scanned,
467
- total,
468
- truncated: early || total > final.length
1146
+ total: smartResult.total,
1147
+ truncated: smartResult.total > smartResult.results.length,
1148
+ matchMode: smartResult.matchMode,
1149
+ degradeKind: smartResult.degradeKind
469
1150
  };
470
1151
  return JSON.stringify(out);
471
1152
  } catch (e) {
@@ -0,0 +1,23 @@
1
+ import type { Candidate } from "./candidates.js";
2
+ import type { ParsedQuery } from "./query.js";
3
+ export type PrefilterResult = {
4
+ candidate: Candidate;
5
+ prefilterScore: number;
6
+ };
7
+ /**
8
+ * Score a candidate for degraded-mode ranking.
9
+ * Deliberately crude — just good enough for degraded output.
10
+ */
11
+ export declare function prefilterScore(candidate: Candidate, query: ParsedQuery): number;
12
+ /**
13
+ * Filter candidates that have at least some lexical relevance to the query.
14
+ * Returns candidates that pass, with prefilter scores attached.
15
+ *
16
+ * A candidate survives if ANY of these are true:
17
+ * 1. Exact raw substring match of the full query in rawText
18
+ * 2. Any quoted phrase found as substring in rawText (lowercased)
19
+ * 3. At least one query token found as exact substring in candidate tokens
20
+ * 4. At least one query token of length >= 4 has edit-distance <= 1 to any candidate token
21
+ */
22
+ export declare function prefilter(candidates: Candidate[], query: ParsedQuery): PrefilterResult[];
23
+ //# sourceMappingURL=prefilter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prefilter.d.ts","sourceRoot":"","sources":["../src/prefilter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,SAAS,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAmBF;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,WAAW,GACjB,MAAM,CAmCR;AAED;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACvB,UAAU,EAAE,SAAS,EAAE,EACvB,KAAK,EAAE,WAAW,GACjB,eAAe,EAAE,CAWnB"}
@@ -0,0 +1,12 @@
1
+ export type ParsedQuery = {
2
+ /** Original query string */
3
+ raw: string;
4
+ /** Lowercased version of the raw query */
5
+ lower: string;
6
+ /** Individual normalized tokens (from tokenize()) */
7
+ tokens: string[];
8
+ /** Quoted phrases extracted from the query (lowercased, without quotes) */
9
+ phrases: string[];
10
+ };
11
+ export declare function parseQuery(query: string): ParsedQuery;
12
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG;IACxB,4BAA4B;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,2EAA2E;IAC3E,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AAIF,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CA4BrD"}
package/dist/rank.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { FuseHit } from "./fuse.js";
2
+ import type { Candidate } from "./candidates.js";
3
+ import type { ParsedQuery } from "./query.js";
4
+ export type RankedResult = {
5
+ candidate: Candidate;
6
+ score: number;
7
+ matchedTerms: string[];
8
+ matchReasons: string[];
9
+ };
10
+ /**
11
+ * Apply structural boosts/penalties and produce final ranked results.
12
+ * Results are sorted by score descending, then by time descending for ties.
13
+ */
14
+ export declare function rank(hits: FuseHit[], query: ParsedQuery, explain: boolean): RankedResult[];
15
+ /**
16
+ * Rank prefilter-scored candidates for degraded mode (no Fuse.js).
17
+ * Used when time budget is exceeded.
18
+ */
19
+ export declare function rankDegraded(candidates: Array<{
20
+ candidate: Candidate;
21
+ prefilterScore: number;
22
+ }>, query: ParsedQuery, explain: boolean): RankedResult[];
23
+ //# sourceMappingURL=rank.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rank.d.ts","sourceRoot":"","sources":["../src/rank.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAgD9C,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AA+CF;;;GAGG;AACH,wBAAgB,IAAI,CAClB,IAAI,EAAE,OAAO,EAAE,EACf,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,OAAO,GACf,YAAY,EAAE,CAoHhB;AAID;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,KAAK,CAAC;IAAE,SAAS,EAAE,SAAS,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAAC,EACnE,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,OAAO,GACf,YAAY,EAAE,CAsChB"}
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EACV,cAAc,EAIf,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,YAAY,CAAC;AAqEpB,wBAAgB,MAAM,CACpB,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,GACb,cAAc,CAwNhB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAEpB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EACV,cAAc,EAIf,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAKL,KAAK,MAAM,EAGZ,MAAM,YAAY,CAAC;AA0RpB,wBAAgB,MAAM,CACpB,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,GACb,cAAc,CAmVhB"}
@@ -0,0 +1,8 @@
1
+ import type { ParsedQuery } from "./query.js";
2
+ /**
3
+ * Select the best snippet window from raw text based on query token density.
4
+ * For smart/fuzzy mode: finds the window with the most query token matches.
5
+ * Falls back to centering on the first token match if no dense window found.
6
+ */
7
+ export declare function smartSnippet(rawText: string, query: ParsedQuery, width?: number): string;
8
+ //# sourceMappingURL=snippet.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snippet.d.ts","sourceRoot":"","sources":["../src/snippet.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AA8D9C;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,WAAW,EAClB,KAAK,GAAE,MAAY,GAClB,MAAM,CA8ER"}
package/dist/types.d.ts CHANGED
@@ -9,6 +9,8 @@ export type Limits = {
9
9
  defaultWidth: number;
10
10
  };
11
11
  export declare const DEFAULTS: Limits;
12
+ export type MatchMode = "literal" | "smart" | "fuzzy";
13
+ export type DegradeKind = "none" | "time" | "budget" | "fallback";
12
14
  export type SearchResult = {
13
15
  sessionID: string;
14
16
  sessionTitle: string;
@@ -21,6 +23,14 @@ export type SearchResult = {
21
23
  pruned: boolean;
22
24
  snippet: string;
23
25
  toolName?: string;
26
+ /** Present for smart/fuzzy results */
27
+ score?: number;
28
+ /** Present for smart/fuzzy results */
29
+ matchMode?: MatchMode;
30
+ /** Present for smart/fuzzy results */
31
+ matchedTerms?: string[];
32
+ /** Present when explain=true */
33
+ matchReasons?: string[];
24
34
  };
25
35
  export type SearchOutput = {
26
36
  ok: true;
@@ -28,6 +38,10 @@ export type SearchOutput = {
28
38
  scanned: number;
29
39
  total: number;
30
40
  truncated: boolean;
41
+ /** Which strategy produced the returned results */
42
+ matchMode?: MatchMode;
43
+ /** What happened during ranking */
44
+ degradeKind?: DegradeKind;
31
45
  };
32
46
  export type MessageOutput = {
33
47
  ok: true;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-session-recall",
4
- "version": "0.7.1",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "description": "Everything your agent ever did is already in the database — this plugin lets it look",
7
7
  "main": "./dist/opencode-session-recall.js",
@@ -54,6 +54,8 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@opencode-ai/sdk": "^1.3.2",
57
+ "fastest-levenshtein": "^1.0.16",
58
+ "fuse.js": "^7.3.0",
57
59
  "zod": "^4.3.6"
58
60
  },
59
61
  "devDependencies": {