opencode-session-recall 0.7.1 → 0.9.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,14 @@
1
1
  # opencode-session-recall
2
2
 
3
- **Everything your agent ever did is already in the database. It's just not looking.**
3
+ [![npm version](https://img.shields.io/npm/v/opencode-session-recall)](https://www.npmjs.com/package/opencode-session-recall)
4
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-session-recall)](https://www.npmjs.com/package/opencode-session-recall)
5
+ [![license](https://img.shields.io/npm/l/opencode-session-recall)](https://github.com/rmk40/opencode-session-recall/blob/main/LICENSE)
4
6
 
5
- OpenCode stores the full conversation history your agent worked throughmessages, 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.
7
+ **Every conversation your agent has ever had across every session, every projectis already in the database. It's just not looking.**
6
8
 
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.
9
+ [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.
8
10
 
9
- [OpenCode](https://github.com/opencode-ai/opencode) is an open-source AI coding agent that runs in your terminal.
11
+ This plugin gives the agent five tools to search and retrieve all of it on demand.
10
12
 
11
13
  **No new database.**
12
14
  **No embeddings.**
@@ -14,12 +16,14 @@ This plugin gives the agent five tools to search and retrieve all of it on deman
14
16
  **No duplication.**
15
17
  **No overhead.**
16
18
 
17
- Just install the plugin. The agent can search its own history.
19
+ Just install the plugin. The agent gains access to its entire history.
18
20
 
19
21
  ## The problem is absurd when you think about it
20
22
 
21
23
  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
24
 
25
+ 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.
26
+
23
27
  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
28
 
25
29
  The data already exists. This plugin removes the blindfold.
@@ -67,6 +71,47 @@ recall({ query: "chose postgres over", scope: "project", type: "reasoning" })
67
71
 
68
72
  Recovers the reasoning behind an architectural decision from three sessions ago. Context that no summary captures.
69
73
 
74
+ **"Find it even with a typo."**
75
+
76
+ ```
77
+ recall({ query: "prefiltr", match: "fuzzy", scope: "session" })
78
+ ```
79
+
80
+ Fuzzy search finds `prefilter` even when the agent misremembers the exact spelling. Results ranked by relevance, not just recency.
81
+
82
+ **"Which sessions touched this topic?"**
83
+
84
+ ```
85
+ recall({ query: "rate limiting", scope: "global", match: "smart", group: "session" })
86
+ ```
87
+
88
+ 4 sessions across 3 projects, each with `hitCount` and best representative snippet. One call to discover everywhere a topic came up.
89
+
90
+ ## Smart and fuzzy search
91
+
92
+ Ranked fuzzy retrieval powered by [Fuse.js](https://www.fusejs.io/). Three matching strategies:
93
+
94
+ | Mode | Behavior | Best for |
95
+ | ------------------- | ----------------------------------- | ----------------------------------------------- |
96
+ | `literal` (default) | Case-insensitive substring match | Exact terms, all scopes |
97
+ | `smart` | Fuzzy ranked search (threshold 0.3) | Uncertain wording, typos, separator differences |
98
+ | `fuzzy` | Looser fuzzy search (threshold 0.5) | Very approximate queries, exploratory search |
99
+
100
+ ```
101
+ recall({ query: "rate limit middleware", match: "smart", scope: "project" })
102
+ ```
103
+
104
+ Smart and fuzzy modes:
105
+
106
+ - **Handle typos** — `prefiltr` finds `prefilter`, `ECONNREFUSD` finds `ECONNREFUSED`
107
+ - **Normalize separators** — `rate-limit` matches `rateLimit` matches `rate_limit`
108
+ - **Rank by relevance** — results scored 0–1 with structural boosts for exact phrases, full token coverage, reasoning traces, and recency
109
+ - **Fall back gracefully** — if smart/fuzzy finds nothing, literal search runs automatically
110
+ - **Time-budget degradation** — if ranking takes too long, returns prefilter-ranked results instead of timing out
111
+ - **Explain mode** — add `explain: true` to see scoring breakdowns via `matchReasons`
112
+
113
+ Available across all scopes — `"session"`, `"project"`, and `"global"`.
114
+
70
115
  ## Recall is not memory
71
116
 
72
117
  This is not a memory system. Memory is selective and curated. Recall is raw history retrieval — verbatim, exhaustive, on demand.
@@ -107,19 +152,43 @@ The primary tool. Full-text search across messages, tool outputs, tool inputs, r
107
152
  recall({ query: "authentication", scope: "project" })
108
153
  recall({ query: "error", type: "tool", scope: "session" })
109
154
  recall({ query: "JWT", sessionID: "ses_from_another_project" })
155
+ recall({ query: "rate limit", match: "smart", scope: "global", group: "session" })
156
+ recall({ query: "prefiltr", match: "fuzzy", scope: "session", explain: true })
110
157
  ```
111
158
 
112
- | Param | Default | Description |
113
- | ---------------- | ---------- | --------------------------------------------- |
114
- | `query` | required | Text to search for (case-insensitive) |
115
- | `scope` | `"global"` | `"session"`, `"project"`, or `"global"` |
116
- | `sessionID` | — | Target a specific session (overrides scope) |
117
- | `type` | `"all"` | `"text"`, `"tool"`, `"reasoning"`, or `"all"` |
118
- | `role` | `"all"` | `"user"`, `"assistant"`, or `"all"` |
119
- | `before`/`after` | | Timestamp filters (ms epoch) |
120
- | `width` | `200` | Snippet size (50–1000 chars) |
121
- | `sessions` | `10` | Max sessions to scan |
122
- | `results` | `10` | Max results to return |
159
+ | Param | Default | Description |
160
+ | ---------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
161
+ | `query` | required | Text to search for |
162
+ | `scope` | `"global"` | `"session"`, `"project"`, or `"global"` |
163
+ | `match` | `"literal"` | `"literal"`, `"smart"`, or `"fuzzy"` |
164
+ | `explain` | `false` | Include scoring metadata in results |
165
+ | `sessionID` | | Target a specific session (overrides scope) |
166
+ | `type` | `"all"` | `"text"`, `"tool"`, `"reasoning"`, or `"all"` |
167
+ | `role` | `"all"` | `"user"`, `"assistant"`, or `"all"` |
168
+ | `before`/`after` | | Timestamp filters (ms epoch) |
169
+ | `width` | `200` | Snippet size (50–1000 chars) |
170
+ | `sessions` | `10` | Max sessions to scan |
171
+ | `title` | — | Filter by session title substring (rarely needed) |
172
+ | `group` | `"part"` | `"part"` or `"session"` — when `"session"`, collapses results by session (one entry per session with the best-scoring or most-recent hit as representative, plus `hitCount`) |
173
+ | `results` | `10` | Max results to return |
174
+
175
+ Smart/fuzzy results include additional fields:
176
+
177
+ | Field | Description |
178
+ | -------------- | ------------------------------------------------------------------------ |
179
+ | `score` | Relevance score (0–1, higher is better) |
180
+ | `matchMode` | Which strategy produced this result |
181
+ | `matchedTerms` | Query tokens found in the candidate |
182
+ | `matchReasons` | Scoring breakdown (only when `explain: true`) |
183
+ | `hitCount` | Number of part-level hits in this session (only when `group: "session"`) |
184
+
185
+ Response-level metadata for smart/fuzzy:
186
+
187
+ | Field | Description |
188
+ | ------------- | ---------------------------------------------------------- |
189
+ | `matchMode` | `"smart"`, `"fuzzy"`, or `"literal"` (if fell back) |
190
+ | `degradeKind` | `"none"`, `"time"`, `"budget"`, or `"fallback"` |
191
+ | `group` | `"part"` or `"session"` — echoes back the grouping applied |
123
192
 
124
193
  ### `recall_get` — Retrieve
125
194
 
@@ -168,6 +237,18 @@ recall_sessions({ scope: "global", search: "deployment" })
168
237
  | `primary` | `boolean` | `true` | Register tools as primary (available to all agents) |
169
238
  | `global` | `boolean` | `true` | Allow cross-project search via `scope: "global"` |
170
239
 
240
+ Advanced limits (all have sensible defaults):
241
+
242
+ | Option | Default | Description |
243
+ | ---------------- | ------- | ----------------------- |
244
+ | `concurrency` | `3` | Parallel session loads |
245
+ | `maxSessions` | `50` | Max sessions per search |
246
+ | `maxResults` | `50` | Max results per search |
247
+ | `maxSessionList` | `100` | Max sessions in listing |
248
+ | `maxMessages` | `50` | Max messages per browse |
249
+ | `maxWindow` | `10` | Max context window size |
250
+ | `defaultWidth` | `200` | Default snippet width |
251
+
171
252
  ## How it works
172
253
 
173
254
  When OpenCode compacts a session, it doesn't delete anything. Tool outputs get a `compacted` timestamp and are replaced with placeholder text in the LLM's context — but the original data stays in the database. Messages before a compaction boundary are skipped when building the LLM context — but they're still there.
@@ -179,6 +260,24 @@ This plugin reads all of it through the OpenCode SDK:
179
260
  - Sessions scanned newest-first with bounded concurrency
180
261
  - Respects abort signals for long-running searches
181
262
  - Cross-project search enabled by default (disable with `global: false`)
263
+ - Smart and fuzzy ranking works across all scopes — session, project, and global
264
+
265
+ ### Smart/fuzzy pipeline
266
+
267
+ When `match` is `"smart"` or `"fuzzy"`, the search goes through a multi-stage ranking pipeline:
268
+
269
+ 1. **Candidate construction** — Messages are scanned newest-first. Each part's searchable text is extracted and tokenized. Per-session and global budgets cap the candidate pool.
270
+ 2. **Prefiltering** — Cheap lexical gate using exact substring, quoted phrase, token overlap, and bounded edit-distance (Levenshtein ≤ 1 for tokens ≥ 4 chars). Only candidates with at least one match survive.
271
+ 3. **Normalization** — Surviving candidates get full stage-2 normalization (camelCase splitting, separator normalization, whitespace collapse) for Fuse.js field matching.
272
+ 4. **Fuse.js ranking** — Weighted search across primary text (0.65), project directory (0.20), session title (0.10), and tool name (0.05). Returns all matches above the mode threshold.
273
+ 5. **Structural re-ranking** — Fuse scores are adjusted with deterministic boosts (exact phrase, full token coverage, reasoning traces, error text, user role, recency) and penalties (weak single-token fuzzy, poor coverage).
274
+ 6. **Snippet selection** — Token-density sliding window finds the most relevant excerpt from the raw text.
275
+
276
+ The entire pipeline runs within a 2-second post-fetch time budget. If the pre-Fuse stage alone exceeds 1.5 seconds, Fuse.js is skipped and prefilter-ranked results are returned with `degradeKind: "time"`. If the full pipeline completes but exceeds the total budget, Fuse-ranked results are still returned but marked as time-degraded.
277
+
278
+ ## Contributing
279
+
280
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, module guide, and development setup.
182
281
 
183
282
  ## License
184
283
 
@@ -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 TIME_BUDGET_MS = 2e3;
743
+ var PREFUSE_BUDGET_MS = 1500;
744
+ var MAX_GROUPED_LITERAL_RESULTS = 1e3;
271
745
  function meta(s) {
272
746
  return { id: s.id, title: s.title, directory: s.directory };
273
747
  }
@@ -310,13 +784,136 @@ 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, 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 results = rankedToSearchResults(ranked2, mode, explain, pq, width);
825
+ return {
826
+ results,
827
+ total: results.length,
828
+ degradeKind: "time",
829
+ matchMode: mode
830
+ };
831
+ }
832
+ for (const { candidate } of filtered) {
833
+ populateNormalized(candidate);
834
+ }
835
+ const fuseCandidates = filtered.map((f) => f.candidate);
836
+ const hits = fuseSearch(fuseCandidates, pq, mode);
837
+ const totalTime = performance.now() - startTime;
838
+ const ranked = rank(hits, pq, explain);
839
+ const allResults = rankedToSearchResults(ranked, mode, explain, pq, width);
840
+ if (totalTime > TIME_BUDGET_MS) {
841
+ return {
842
+ results: allResults,
843
+ total: allResults.length,
844
+ degradeKind: "time",
845
+ matchMode: mode
846
+ };
847
+ }
848
+ return {
849
+ results: allResults,
850
+ total: allResults.length,
851
+ degradeKind: anyBudgetHit ? "budget" : "none",
852
+ matchMode: mode
853
+ };
854
+ }
855
+ function rankedToSearchResults(ranked, mode, explain, query, width) {
856
+ return ranked.map((r) => {
857
+ const c = r.candidate;
858
+ const snip = smartSnippet(c.rawText, query, width);
859
+ const result = {
860
+ sessionID: c.sessionID,
861
+ sessionTitle: c.sessionTitle,
862
+ directory: c.directory,
863
+ messageID: c.messageID,
864
+ role: c.role,
865
+ time: c.time,
866
+ partID: c.partID,
867
+ partType: c.partType,
868
+ pruned: c.isPruned,
869
+ snippet: snip,
870
+ toolName: c.toolName,
871
+ score: r.score,
872
+ matchMode: mode,
873
+ matchedTerms: r.matchedTerms
874
+ };
875
+ if (explain && r.matchReasons.length > 0) {
876
+ result.matchReasons = r.matchReasons;
877
+ }
878
+ return result;
879
+ });
880
+ }
881
+ function groupBySession(results) {
882
+ const groups = /* @__PURE__ */ new Map();
883
+ for (const r of results) {
884
+ const existing = groups.get(r.sessionID);
885
+ if (!existing) {
886
+ groups.set(r.sessionID, { best: r, count: 1 });
887
+ } else {
888
+ existing.count++;
889
+ if (r.score != null && existing.best.score != null) {
890
+ if (r.score > existing.best.score) {
891
+ existing.best = r;
892
+ }
893
+ } else if (r.time > existing.best.time) {
894
+ existing.best = r;
895
+ }
896
+ }
897
+ }
898
+ return [...groups.values()].map(({ best, count }) => ({
899
+ ...best,
900
+ hitCount: count
901
+ }));
902
+ }
313
903
  function search(client, unscoped, global, limits) {
314
904
  return tool2({
315
905
  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.
316
906
 
317
- Searches text content, tool inputs/outputs, and reasoning via case-insensitive substring matching. Returns matching snippets with session/message IDs you can pass to recall_get for full content, or recall_context if you need surrounding messages.
907
+ Supports three matching strategies via the \`match\` parameter:
908
+ - "literal" (default): case-insensitive substring matching. Works across all scopes. Fast and predictable.
909
+ - "smart": fuzzy ranked search using Fuse.js. Handles typos, separator differences (rate-limit vs rateLimit), and ranks results by relevance. Works across all scopes.
910
+ - "fuzzy": looser fuzzy search with a higher match threshold. Works across all scopes.
911
+
912
+ When using smart or fuzzy, results include a relevance \`score\` (0-1, higher is better) and \`matchedTerms\`. Add \`explain: true\` for detailed scoring breakdowns via \`matchReasons\`. If smart/fuzzy finds no matches, it automatically falls back to literal search.
913
+
914
+ Use \`group: "session"\` to collapse results by session \u2014 returns one entry per session with the best-scoring hit as representative (or most recent for literal), plus a \`hitCount\` showing how many part-level hits that session had. Useful for cross-project discovery: "which sessions are about this topic?"
318
915
 
319
- Searches globally by default \u2014 this is fast and finds results across all projects. Results are ordered by session recency (newest first). Try multiple query terms before concluding no prior work exists. Use role "user" to find original requirements.
916
+ Searches globally by default \u2014 this is fast and finds results across all projects. Results are ordered by session recency (newest first) for literal, or by relevance score for smart/fuzzy. Try multiple query terms before concluding no prior work exists. Use role "user" to find original requirements.
320
917
 
321
918
  Scope costs: all scopes scan up to \`sessions\` sessions (default 10). "session" scans 1. "project" and "global" scan up to 10 newest. Increase \`sessions\` if nothing found.
322
919
 
@@ -328,6 +925,15 @@ This tool's own outputs are excluded from search results to prevent recursive no
328
925
  scope: tool2.schema.enum(["session", "project", "global"]).default("global").describe(
329
926
  "global = all projects (default), project = current project, session = current only. Searching broadly is fast."
330
927
  ),
928
+ match: tool2.schema.enum(["literal", "smart", "fuzzy"]).default("literal").describe(
929
+ 'Matching strategy: "literal" = exact substring (default), "smart" = fuzzy ranked search, "fuzzy" = looser fuzzy search. All work across all scopes.'
930
+ ),
931
+ explain: tool2.schema.boolean().default(false).describe(
932
+ "Return scoring metadata for debugging. Adds matchReasons to each result."
933
+ ),
934
+ group: tool2.schema.enum(["part", "session"]).default("part").describe(
935
+ '"part" (default) = one result per matching part. "session" = collapse by session, returning one entry per session with best-scoring (smart/fuzzy) or most-recent (literal) hit and a hitCount.'
936
+ ),
331
937
  sessionID: tool2.schema.string().optional().describe("Search a specific session (overrides scope)"),
332
938
  type: tool2.schema.enum(["text", "tool", "reasoning", "all"]).default("all").describe("Filter by part type"),
333
939
  role: tool2.schema.enum(["user", "assistant", "all"]).default("all").describe("Filter by message role"),
@@ -347,7 +953,10 @@ This tool's own outputs are excluded from search results to prevent recursive no
347
953
  )
348
954
  },
349
955
  async execute(args, ctx) {
350
- ctx.metadata({ title: `Searching ${args.scope} for "${args.query}"` });
956
+ const matchMode = args.match;
957
+ ctx.metadata({
958
+ title: `Searching ${args.scope} for "${args.query}"${matchMode !== "literal" ? ` (${matchMode})` : ""}`
959
+ });
351
960
  if (args.scope === "global" && !args.sessionID && !global) {
352
961
  const err = {
353
962
  ok: false,
@@ -410,36 +1019,45 @@ 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) {
439
- if (collected.length >= args.results) {
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
+ const groupMode = args.group;
1050
+ const isGrouped = groupMode === "session";
1051
+ const literalScan = (scanLimit) => {
1052
+ const collected = [];
1053
+ let total = 0;
1054
+ let early = false;
1055
+ for (const { session: sess, messages: msgs } of allLoaded) {
1056
+ if (collected.length >= scanLimit) {
440
1057
  early = true;
441
1058
  break;
442
1059
  }
1060
+ const remaining = scanLimit - collected.length;
443
1061
  const result = scan(
444
1062
  msgs,
445
1063
  sess,
@@ -454,18 +1072,102 @@ This tool's own outputs are excluded from search results to prevent recursive no
454
1072
  collected.push(...result.results);
455
1073
  total += result.total;
456
1074
  }
457
- scanned += batch.length;
1075
+ return { collected, total, early };
1076
+ };
1077
+ const applyGroupAndSlice = (results, partTotal, earlyExit) => {
1078
+ if (isGrouped) {
1079
+ const grouped = groupBySession(results);
1080
+ const final3 = grouped.slice(0, args.results);
1081
+ return {
1082
+ final: final3,
1083
+ total: grouped.length,
1084
+ truncated: earlyExit || grouped.length > final3.length
1085
+ };
1086
+ }
1087
+ const final2 = results.slice(0, args.results);
1088
+ return {
1089
+ final: final2,
1090
+ total: partTotal,
1091
+ truncated: earlyExit || partTotal > final2.length
1092
+ };
1093
+ };
1094
+ if (matchMode === "literal") {
1095
+ const limit = isGrouped ? MAX_GROUPED_LITERAL_RESULTS : args.results;
1096
+ const { collected, total, early } = literalScan(limit);
1097
+ const {
1098
+ final: final2,
1099
+ total: outTotal2,
1100
+ truncated: truncated2
1101
+ } = applyGroupAndSlice(collected, total, early);
1102
+ const unit2 = isGrouped ? "session" : "result";
1103
+ ctx.metadata({
1104
+ title: `Found ${final2.length} ${unit2}${final2.length !== 1 ? "s" : ""} for "${args.query}" (${scanned} session${scanned !== 1 ? "s" : ""} searched)`
1105
+ });
1106
+ const out2 = {
1107
+ ok: true,
1108
+ results: final2,
1109
+ scanned,
1110
+ total: outTotal2,
1111
+ truncated: truncated2,
1112
+ group: groupMode
1113
+ };
1114
+ return JSON.stringify(out2);
1115
+ }
1116
+ const smartResult = smartScan(
1117
+ allLoaded,
1118
+ args.query,
1119
+ args.type,
1120
+ args.role,
1121
+ args.explain,
1122
+ matchMode,
1123
+ args.before,
1124
+ args.after,
1125
+ args.width
1126
+ );
1127
+ if (smartResult.results.length === 0) {
1128
+ const limit = isGrouped ? MAX_GROUPED_LITERAL_RESULTS : args.results;
1129
+ const { collected, total, early } = literalScan(limit);
1130
+ const {
1131
+ final: final2,
1132
+ total: outTotal2,
1133
+ truncated: truncated2
1134
+ } = applyGroupAndSlice(collected, total, early);
1135
+ if (final2.length > 0) {
1136
+ const unit2 = isGrouped ? "session" : "result";
1137
+ ctx.metadata({
1138
+ title: `Found ${final2.length} ${unit2}${final2.length !== 1 ? "s" : ""} for "${args.query}" (literal fallback, ${scanned} session${scanned !== 1 ? "s" : ""})`
1139
+ });
1140
+ const out2 = {
1141
+ ok: true,
1142
+ results: final2,
1143
+ scanned,
1144
+ total: outTotal2,
1145
+ truncated: truncated2,
1146
+ matchMode: "literal",
1147
+ degradeKind: "fallback",
1148
+ group: groupMode
1149
+ };
1150
+ return JSON.stringify(out2);
1151
+ }
458
1152
  }
459
- const final = collected.slice(0, args.results);
1153
+ const {
1154
+ final,
1155
+ total: outTotal,
1156
+ truncated
1157
+ } = applyGroupAndSlice(smartResult.results, smartResult.total, false);
1158
+ const unit = isGrouped ? "session" : "result";
460
1159
  ctx.metadata({
461
- title: `Found ${final.length} result${final.length !== 1 ? "s" : ""} for "${args.query}" (${scanned} session${scanned !== 1 ? "s" : ""} searched)`
1160
+ title: `Found ${final.length} ${unit}${final.length !== 1 ? "s" : ""} for "${args.query}" (${matchMode}, ${scanned} session${scanned !== 1 ? "s" : ""})`
462
1161
  });
463
1162
  const out = {
464
1163
  ok: true,
465
1164
  results: final,
466
1165
  scanned,
467
- total,
468
- truncated: early || total > final.length
1166
+ total: outTotal,
1167
+ truncated,
1168
+ matchMode: smartResult.matchMode,
1169
+ degradeKind: smartResult.degradeKind,
1170
+ group: groupMode
469
1171
  };
470
1172
  return JSON.stringify(out);
471
1173
  } 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,EAIZ,MAAM,YAAY,CAAC;AA+RpB,wBAAgB,MAAM,CACpB,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,MAAM,GACb,cAAc,CAgXhB"}
@@ -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,9 @@ 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";
14
+ export type GroupMode = "part" | "session";
12
15
  export type SearchResult = {
13
16
  sessionID: string;
14
17
  sessionTitle: string;
@@ -21,6 +24,16 @@ export type SearchResult = {
21
24
  pruned: boolean;
22
25
  snippet: string;
23
26
  toolName?: string;
27
+ /** Present for smart/fuzzy results */
28
+ score?: number;
29
+ /** Present for smart/fuzzy results */
30
+ matchMode?: MatchMode;
31
+ /** Present for smart/fuzzy results */
32
+ matchedTerms?: string[];
33
+ /** Present when explain=true */
34
+ matchReasons?: string[];
35
+ /** Present when group:"session" — number of part-level hits in this session */
36
+ hitCount?: number;
24
37
  };
25
38
  export type SearchOutput = {
26
39
  ok: true;
@@ -28,6 +41,12 @@ export type SearchOutput = {
28
41
  scanned: number;
29
42
  total: number;
30
43
  truncated: boolean;
44
+ /** Which strategy produced the returned results */
45
+ matchMode?: MatchMode;
46
+ /** What happened during ranking */
47
+ degradeKind?: DegradeKind;
48
+ /** Which grouping was applied */
49
+ group?: GroupMode;
31
50
  };
32
51
  export type MessageOutput = {
33
52
  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.9.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": {