opencode-session-recall 0.8.0 → 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,5 +1,9 @@
1
1
  # opencode-session-recall
2
2
 
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)
6
+
3
7
  **Every conversation your agent has ever had — across every session, every project — is already in the database. It's just not looking.**
4
8
 
5
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.
@@ -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
 
@@ -739,9 +739,9 @@ function smartSnippet(rawText, query, width = 200) {
739
739
  }
740
740
 
741
741
  // src/search.ts
742
- var PROMOTED_SCOPES = /* @__PURE__ */ new Set(["session"]);
743
742
  var TIME_BUDGET_MS = 2e3;
744
743
  var PREFUSE_BUDGET_MS = 1500;
744
+ var MAX_GROUPED_LITERAL_RESULTS = 1e3;
745
745
  function meta(s) {
746
746
  return { id: s.id, title: s.title, directory: s.directory };
747
747
  }
@@ -784,7 +784,7 @@ function scan(messages2, session, query, type, role, limit, before, after, width
784
784
  }
785
785
  return { results, total };
786
786
  }
787
- function smartScan(allMessages, query, type, role, limit, explain, mode, before, after, width) {
787
+ function smartScan(allMessages, query, type, role, explain, mode, before, after, width) {
788
788
  const pq = parseQuery(query);
789
789
  const startTime = performance.now();
790
790
  const allCandidates = [];
@@ -821,16 +821,10 @@ function smartScan(allMessages, query, type, role, limit, explain, mode, before,
821
821
  const prefuseTime = performance.now() - startTime;
822
822
  if (prefuseTime > PREFUSE_BUDGET_MS) {
823
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
- );
824
+ const results = rankedToSearchResults(ranked2, mode, explain, pq, width);
831
825
  return {
832
- results: results2,
833
- total: filtered.length,
826
+ results,
827
+ total: results.length,
834
828
  degradeKind: "time",
835
829
  matchMode: mode
836
830
  };
@@ -842,32 +836,18 @@ function smartScan(allMessages, query, type, role, limit, explain, mode, before,
842
836
  const hits = fuseSearch(fuseCandidates, pq, mode);
843
837
  const totalTime = performance.now() - startTime;
844
838
  const ranked = rank(hits, pq, explain);
845
- const fuseTotal = ranked.length;
839
+ const allResults = rankedToSearchResults(ranked, mode, explain, pq, width);
846
840
  if (totalTime > TIME_BUDGET_MS) {
847
- const results2 = rankedToSearchResults(
848
- ranked.slice(0, limit),
849
- mode,
850
- explain,
851
- pq,
852
- width
853
- );
854
841
  return {
855
- results: results2,
856
- total: fuseTotal,
842
+ results: allResults,
843
+ total: allResults.length,
857
844
  degradeKind: "time",
858
845
  matchMode: mode
859
846
  };
860
847
  }
861
- const results = rankedToSearchResults(
862
- ranked.slice(0, limit),
863
- mode,
864
- explain,
865
- pq,
866
- width
867
- );
868
848
  return {
869
- results,
870
- total: fuseTotal,
849
+ results: allResults,
850
+ total: allResults.length,
871
851
  degradeKind: anyBudgetHit ? "budget" : "none",
872
852
  matchMode: mode
873
853
  };
@@ -898,32 +878,62 @@ function rankedToSearchResults(ranked, mode, explain, query, width) {
898
878
  return result;
899
879
  });
900
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
+ }
901
903
  function search(client, unscoped, global, limits) {
902
904
  return tool2({
903
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.
904
906
 
905
- 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.
906
913
 
907
- 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.
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?"
915
+
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.
908
917
 
909
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.
910
919
 
911
920
  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.
912
921
 
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.`,
922
+ 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.`,
916
923
  args: {
917
924
  query: tool2.schema.string().min(1).describe("Text to search for (case-insensitive substring match)"),
918
925
  scope: tool2.schema.enum(["session", "project", "global"]).default("global").describe(
919
926
  "global = all projects (default), project = current project, session = current only. Searching broadly is fast."
920
927
  ),
921
928
  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)'
929
+ 'Matching strategy: "literal" = exact substring (default), "smart" = fuzzy ranked search, "fuzzy" = looser fuzzy search. All work across all scopes.'
923
930
  ),
924
931
  explain: tool2.schema.boolean().default(false).describe(
925
932
  "Return scoring metadata for debugging. Adds matchReasons to each result."
926
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
+ ),
927
937
  sessionID: tool2.schema.string().optional().describe("Search a specific session (overrides scope)"),
928
938
  type: tool2.schema.enum(["text", "tool", "reasoning", "all"]).default("all").describe("Filter by part type"),
929
939
  role: tool2.schema.enum(["user", "assistant", "all"]).default("all").describe("Filter by message role"),
@@ -947,16 +957,6 @@ Use match:"smart" for fuzzy search when exact wording is uncertain \u2014 it han
947
957
  ctx.metadata({
948
958
  title: `Searching ${args.scope} for "${args.query}"${matchMode !== "literal" ? ` (${matchMode})` : ""}`
949
959
  });
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
- }
960
960
  if (args.scope === "global" && !args.sessionID && !global) {
961
961
  const err = {
962
962
  ok: false,
@@ -1046,16 +1046,18 @@ Use match:"smart" for fuzzy search when exact wording is uncertain \u2014 it han
1046
1046
  const err = { ok: false, error: "aborted" };
1047
1047
  return JSON.stringify(err);
1048
1048
  }
1049
- if (matchMode === "literal") {
1049
+ const groupMode = args.group;
1050
+ const isGrouped = groupMode === "session";
1051
+ const literalScan = (scanLimit) => {
1050
1052
  const collected = [];
1051
1053
  let total = 0;
1052
1054
  let early = false;
1053
1055
  for (const { session: sess, messages: msgs } of allLoaded) {
1054
- if (collected.length >= args.results) {
1056
+ if (collected.length >= scanLimit) {
1055
1057
  early = true;
1056
1058
  break;
1057
1059
  }
1058
- const remaining = args.results - collected.length;
1060
+ const remaining = scanLimit - collected.length;
1059
1061
  const result = scan(
1060
1062
  msgs,
1061
1063
  sess,
@@ -1070,16 +1072,44 @@ Use match:"smart" for fuzzy search when exact wording is uncertain \u2014 it han
1070
1072
  collected.push(...result.results);
1071
1073
  total += result.total;
1072
1074
  }
1073
- const final = collected.slice(0, args.results);
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";
1074
1103
  ctx.metadata({
1075
- title: `Found ${final.length} result${final.length !== 1 ? "s" : ""} for "${args.query}" (${scanned} session${scanned !== 1 ? "s" : ""} searched)`
1104
+ title: `Found ${final2.length} ${unit2}${final2.length !== 1 ? "s" : ""} for "${args.query}" (${scanned} session${scanned !== 1 ? "s" : ""} searched)`
1076
1105
  });
1077
1106
  const out2 = {
1078
1107
  ok: true,
1079
- results: final,
1108
+ results: final2,
1080
1109
  scanned,
1081
- total,
1082
- truncated: early || total > final.length
1110
+ total: outTotal2,
1111
+ truncated: truncated2,
1112
+ group: groupMode
1083
1113
  };
1084
1114
  return JSON.stringify(out2);
1085
1115
  }
@@ -1088,7 +1118,6 @@ Use match:"smart" for fuzzy search when exact wording is uncertain \u2014 it han
1088
1118
  args.query,
1089
1119
  args.type,
1090
1120
  args.role,
1091
- args.results,
1092
1121
  args.explain,
1093
1122
  matchMode,
1094
1123
  args.before,
@@ -1096,57 +1125,49 @@ Use match:"smart" for fuzzy search when exact wording is uncertain \u2014 it han
1096
1125
  args.width
1097
1126
  );
1098
1127
  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) {
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";
1124
1137
  ctx.metadata({
1125
- title: `Found ${final.length} result${final.length !== 1 ? "s" : ""} for "${args.query}" (literal fallback, ${scanned} session${scanned !== 1 ? "s" : ""})`
1138
+ title: `Found ${final2.length} ${unit2}${final2.length !== 1 ? "s" : ""} for "${args.query}" (literal fallback, ${scanned} session${scanned !== 1 ? "s" : ""})`
1126
1139
  });
1127
1140
  const out2 = {
1128
1141
  ok: true,
1129
- results: final,
1142
+ results: final2,
1130
1143
  scanned,
1131
- total,
1132
- truncated: early || total > final.length,
1144
+ total: outTotal2,
1145
+ truncated: truncated2,
1133
1146
  matchMode: "literal",
1134
- degradeKind: "fallback"
1147
+ degradeKind: "fallback",
1148
+ group: groupMode
1135
1149
  };
1136
1150
  return JSON.stringify(out2);
1137
1151
  }
1138
1152
  }
1153
+ const {
1154
+ final,
1155
+ total: outTotal,
1156
+ truncated
1157
+ } = applyGroupAndSlice(smartResult.results, smartResult.total, false);
1158
+ const unit = isGrouped ? "session" : "result";
1139
1159
  ctx.metadata({
1140
- title: `Found ${smartResult.results.length} result${smartResult.results.length !== 1 ? "s" : ""} for "${args.query}" (${matchMode}, ${scanned} session${scanned !== 1 ? "s" : ""})`
1160
+ title: `Found ${final.length} ${unit}${final.length !== 1 ? "s" : ""} for "${args.query}" (${matchMode}, ${scanned} session${scanned !== 1 ? "s" : ""})`
1141
1161
  });
1142
1162
  const out = {
1143
1163
  ok: true,
1144
- results: smartResult.results,
1164
+ results: final,
1145
1165
  scanned,
1146
- total: smartResult.total,
1147
- truncated: smartResult.total > smartResult.results.length,
1166
+ total: outTotal,
1167
+ truncated,
1148
1168
  matchMode: smartResult.matchMode,
1149
- degradeKind: smartResult.degradeKind
1169
+ degradeKind: smartResult.degradeKind,
1170
+ group: groupMode
1150
1171
  };
1151
1172
  return JSON.stringify(out);
1152
1173
  } catch (e) {
@@ -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,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"}
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"}
package/dist/types.d.ts CHANGED
@@ -11,6 +11,7 @@ export type Limits = {
11
11
  export declare const DEFAULTS: Limits;
12
12
  export type MatchMode = "literal" | "smart" | "fuzzy";
13
13
  export type DegradeKind = "none" | "time" | "budget" | "fallback";
14
+ export type GroupMode = "part" | "session";
14
15
  export type SearchResult = {
15
16
  sessionID: string;
16
17
  sessionTitle: string;
@@ -31,6 +32,8 @@ export type SearchResult = {
31
32
  matchedTerms?: string[];
32
33
  /** Present when explain=true */
33
34
  matchReasons?: string[];
35
+ /** Present when group:"session" — number of part-level hits in this session */
36
+ hitCount?: number;
34
37
  };
35
38
  export type SearchOutput = {
36
39
  ok: true;
@@ -42,6 +45,8 @@ export type SearchOutput = {
42
45
  matchMode?: MatchMode;
43
46
  /** What happened during ranking */
44
47
  degradeKind?: DegradeKind;
48
+ /** Which grouping was applied */
49
+ group?: GroupMode;
45
50
  };
46
51
  export type MessageOutput = {
47
52
  ok: true;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,KAAK,2FAMR,CAAC;AAEX,MAAM,MAAM,MAAM,GAAG;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,eAAO,MAAM,QAAQ,EAAE,MAQtB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AACtD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAC;AAElE,MAAM,MAAM,YAAY,GAAG;IACzB,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,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,gCAAgC;IAChC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,mDAAmD;IACnD,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,mCAAmC;IACnC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,IAAI,CAAC;IACT,OAAO,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;QAC3B,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,OAAO,EAAE;QACP,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE;QACP,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;QAC3B,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,EAAE,EAAE,IAAI,CAAC;IACT,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,OAAO,EAAE;QACP,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,IAAI,CAAC;IACT,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,OAAO,EAAE;QACP,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,UAAU,EAAE;QACV,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9C,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3C,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,EAAE,IAAI,CAAC;IACT,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,wBAAgB,MAAM,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAYzC"}
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.8.0",
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",