opencode-session-recall 0.7.0 → 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,26 +1,77 @@
1
1
  # opencode-session-recall
2
2
 
3
- A plugin for [opencode](https://github.com/opencode-ai/opencode) that gives your agent a memory that survives compactionwithout building another memory system.
3
+ **Every conversation your agent has ever had across every session, every projectis already in the database. It's just not looking.**
4
4
 
5
- opencode is an open-source AI coding agent that runs in your terminal. It manages long conversations through compaction: summarizing older context to keep the active window focused. But compaction means the agent forgets original tool outputs, earlier reasoning, the user's exact words.
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 adds five tools to the agent's toolkit that let it search and retrieve that lost context on demand, within the current session, across all sessions in the project, or across every project on the machine.
7
+ This plugin gives the agent five tools to search and retrieve all of it on demand.
8
8
 
9
- **It doesn't create a separate memory store.** Most agent "memory" solutions add vector databases, embedding pipelines, or knowledge graphs — duplicating your data into yet another system. `opencode-session-recall` does none of that. opencode already stores every message, every tool output, every reasoning trace in its database, even after compaction prunes them from context. This plugin simply gives the agent access to what's already there.
9
+ **No new database.**
10
+ **No embeddings.**
11
+ **No summarization.**
12
+ **No duplication.**
13
+ **No overhead.**
10
14
 
11
- No embeddings. No vector store. No data duplication. No setup. Just install the plugin and the agent can remember.
15
+ Just install the plugin. The agent gains access to its entire history.
12
16
 
13
- ## What this enables
17
+ ## The problem is absurd when you think about it
14
18
 
15
- **"We already solved this."** The agent searches its own history and finds the solution from 2 hours ago that got compacted away, instead of solving the same problem again.
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.
16
20
 
17
- **"How did we do it in that other project?"** Cross-project search finds the JWT middleware implementation from the auth project, the Docker config from the deployment project, the test patterns from the API project.
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.
18
22
 
19
- **"What did you originally ask for?"** After 50+ tool calls and 3 compactions, the agent can pull up the user's exact original requirements to make sure it's still on track.
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.
20
24
 
21
- **"What was that error?"** The full stack trace from the tool output that got pruned is still there. The agent retrieves it instead of reproducing the error.
25
+ The data already exists. This plugin removes the blindfold.
22
26
 
23
- **"Show me what happened."** Browse any session chronologically, play back the conversation, understand the narrative of how a problem was investigated and solved.
27
+ ## What it looks like
28
+
29
+ **"We already fixed this."**
30
+
31
+ ```
32
+ recall({ query: "ECONNREFUSED retry", scope: "session" })
33
+ ```
34
+
35
+ Agent finds its own solution from 2 hours ago. Doesn't re-derive it.
36
+
37
+ **"It was in that other project."**
38
+
39
+ ```
40
+ recall_sessions({ scope: "global", search: "rate limit" })
41
+ recall_get({ sessionID: "...", messageID: "..." })
42
+ ```
43
+
44
+ Finds the implementation from your API project. Reuses it instead of reinventing it.
45
+
46
+ **"What did I originally ask for?"**
47
+
48
+ ```
49
+ recall_messages({ limit: 5, role: "user" })
50
+ ```
51
+
52
+ Pulls up exact original requirements after 3 compactions. Checks its own work against what you actually said.
53
+
54
+ **"What was that error?"**
55
+
56
+ ```
57
+ recall({ query: "TypeError", type: "tool", scope: "session" })
58
+ ```
59
+
60
+ Gets the full stack trace from a tool output that got pruned. Doesn't re-run the failing command.
61
+
62
+ **"Why did we decide on that approach?"**
63
+
64
+ ```
65
+ recall({ query: "chose postgres over", scope: "project", type: "reasoning" })
66
+ ```
67
+
68
+ Recovers the reasoning behind an architectural decision from three sessions ago. Context that no summary captures.
69
+
70
+ ## Recall is not memory
71
+
72
+ This is not a memory system. Memory is selective and curated. Recall is raw history retrieval — verbatim, exhaustive, on demand.
73
+
74
+ If you use a persistent memory system alongside this plugin, recall gives it source material. The agent searches history, finds something useful, and stores it deliberately. Discovery first, then permanent memory.
24
75
 
25
76
  ## Install
26
77
 
@@ -28,16 +79,19 @@ No embeddings. No vector store. No data duplication. No setup. Just install the
28
79
  opencode plugin opencode-session-recall
29
80
  ```
30
81
 
31
- Or add it to your `opencode.json` manually:
82
+ Or add it to your `opencode.json`:
32
83
 
33
84
  ```jsonc
34
85
  {
35
- "plugin": [
36
- "opencode-session-recall",
86
+ "plugin": ["opencode-session-recall"],
87
+ }
88
+ ```
89
+
90
+ To disable cross-project search:
37
91
 
38
- // Disable cross-project search if needed
39
- ["opencode-session-recall", { "global": false }],
40
- ],
92
+ ```jsonc
93
+ {
94
+ "plugin": [["opencode-session-recall", { "global": false }]],
41
95
  }
42
96
  ```
43
97
 
@@ -47,25 +101,25 @@ Five tools, designed around how agents actually navigate conversation history:
47
101
 
48
102
  ### `recall` — Search
49
103
 
50
- The primary tool. Full-text search across text, tool outputs, tool inputs, reasoning, and subtask descriptions. Searches the current session by default, or widen to all project sessions or all sessions globally.
104
+ The primary tool. Full-text search across messages, tool outputs, tool inputs, reasoning, and subtask descriptions. Searches globally by default, or narrow to the current project or session.
51
105
 
52
106
  ```
53
107
  recall({ query: "authentication", scope: "project" })
54
- recall({ query: "error", after: <2 days ago>, type: "tool" })
108
+ recall({ query: "error", type: "tool", scope: "session" })
55
109
  recall({ query: "JWT", sessionID: "ses_from_another_project" })
56
110
  ```
57
111
 
58
- | Param | Default | Description |
59
- | ---------------- | ----------- | --------------------------------------------- |
60
- | `query` | required | Text to search for (case-insensitive) |
61
- | `scope` | `"session"` | `"session"`, `"project"`, or `"global"` |
62
- | `sessionID` | — | Target a specific session (overrides scope) |
63
- | `type` | `"all"` | `"text"`, `"tool"`, `"reasoning"`, or `"all"` |
64
- | `role` | `"all"` | `"user"`, `"assistant"`, or `"all"` |
65
- | `before`/`after` | — | Timestamp filters (ms epoch) |
66
- | `width` | `200` | Snippet size (50-1000 chars) |
67
- | `sessions` | `10` | Max sessions to scan |
68
- | `results` | `10` | Max results to return |
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 (501000 chars) |
121
+ | `sessions` | `10` | Max sessions to scan |
122
+ | `results` | `10` | Max results to return |
69
123
 
70
124
  ### `recall_get` — Retrieve
71
125
 
@@ -107,29 +161,6 @@ recall_sessions({ scope: "project", search: "auth" })
107
161
  recall_sessions({ scope: "global", search: "deployment" })
108
162
  ```
109
163
 
110
- ## Real-world workflow
111
-
112
- This is what it actually looks like when an agent uses these tools to answer "what have we been doing with our UniFi network?" across a 3-week, 600+ message session in a different project:
113
-
114
- ```
115
- 1. recall_sessions({ scope: "global", search: "unifi" })
116
- → discovers the ubiopti project session
117
-
118
- 2. recall_messages({ sessionID: "...", limit: 5, role: "user", reverse: true })
119
- → reads the most recent user messages to understand current state
120
-
121
- 3. recall({ query: "kickout threshold", sessionID: "...", width: 500 })
122
- → finds the technical root cause analysis in tool outputs
123
-
124
- 4. recall_context({ sessionID: "...", messageID: "...", window: 3 })
125
- → expands around the Ubiquiti support chat to see the full interaction
126
-
127
- 5. recall({ query: "iwpriv", sessionID: "...", after: <recent timestamp> })
128
- → finds only recent mentions, not the whole session history
129
- ```
130
-
131
- Five tool calls, complete narrative reconstructed across projects.
132
-
133
164
  ## Options
134
165
 
135
166
  | Option | Type | Default | Description |
@@ -139,13 +170,13 @@ Five tool calls, complete narrative reconstructed across projects.
139
170
 
140
171
  ## How it works
141
172
 
142
- This plugin doesn't create a separate memory store. It reads what opencode already has.
173
+ 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.
143
174
 
144
- 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. The plugin accesses all of this through the opencode SDK.
175
+ This plugin reads all of it through the OpenCode SDK:
145
176
 
146
- - Uses the opencode SDK client (no direct database queries, no separate storage)
177
+ - No direct database queries, no separate storage
147
178
  - Zero setup — no embeddings to generate, no indexes to build, no data to sync
148
- - Sessions are scanned newest-first with bounded concurrency
179
+ - Sessions scanned newest-first with bounded concurrency
149
180
  - Respects abort signals for long-running searches
150
181
  - Cross-project search enabled by default (disable with `global: false`)
151
182
 
@@ -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,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-session-recall",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
- "description": "Agent memory without a memory system search and retrieve opencode conversation history that was lost to compaction, across sessions and projects",
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",
8
8
  "exports": {
9
9
  ".": {
@@ -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": {