opencode-history-search-v2 1.0.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 marioschweiger
4
+ Copyright (c) 2026 joeyism (original opencode-history-search, V1)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # opencode-history-search-v2
2
+
3
+ OpenCode **V2** plugin that gives the AI model a `history_search` tool: search past
4
+ conversation sessions by keyword, fuzzy term, multi-term AND, or file touch history.
5
+
6
+ OpenCode V2 stores sessions in a local SQLite database. This plugin opens that
7
+ database **read-only**, on demand, and runs deterministic searches over it. No
8
+ indexing, no network, no data leaves the machine.
9
+
10
+ Port of [joeyism/opencode-history-search](https://github.com/joeyism/opencode-history-search)
11
+ (V1, for legacy OpenCode) to the V2 plugin SDK and the V2 storage schema.
12
+
13
+ ## Install
14
+
15
+ In `opencode.json` (global or project):
16
+
17
+ ```json
18
+ {
19
+ "plugins": ["opencode-history-search-v2@latest"]
20
+ }
21
+ ```
22
+
23
+ The plugin hot-reloads on config change; no restart needed.
24
+
25
+ ## What the model gets
26
+
27
+ A single tool, `history_search`, e.g. the model can answer:
28
+
29
+ - "What did we decide about the cache bug last week?" → keyword + date search
30
+ - "Which sessions touched `src/auth.ts`?" → file trace
31
+ - "Find the session about both the truck API and the vertex deploy" → multi-term AND
32
+ - "Search every project on this machine for 'postgres migration'" → cross-project
33
+
34
+ ## Options
35
+
36
+ | Option | Type | Description |
37
+ | --- | --- | --- |
38
+ | `query` | string | Keyword, regex (with `regex: true`), or fuzzy term. Required unless `filePath` or `terms` is set. |
39
+ | `terms` | string[] | Multi-term AND: sessions containing **all** terms. 1 term falls back to `query`. |
40
+ | `filePath` | string | Trace which sessions touched this file path. Other filters are ignored. |
41
+ | `searchAllProjects` | boolean | Search all projects on the machine instead of only the current one. |
42
+ | `mode` | `"keyword" \| "fuzzy"` | Exact keyword (default) or typo-tolerant fuzzy matching. |
43
+ | `regex` | boolean | Treat `query` as a regular expression (keyword mode). |
44
+ | `caseSensitive` | boolean | Case-sensitive matching (keyword mode, default false). |
45
+ | `fuzzyThreshold` | number | 0.0–1.0, lower = stricter (fuzzy mode, default 0.4). |
46
+ | `date` | string | `'today'`, `'yesterday'`, `'last N days/weeks/months'`, `'YYYY-MM-DD'`, `'YYYY-MM'`, or `'YYYY-MM-DD to YYYY-MM-DD'`. |
47
+ | `limit` | number | Max results (default 50). Applied **after** date filtering, so `date` + `limit` returns the newest results within the range. |
48
+ | `role` | `"user" \| "assistant"` | Restrict message matches to one role. Title matches are always included. Ignored with `filePath`. |
49
+
50
+ ## Project scoping
51
+
52
+ By default searches are scoped to the current project:
53
+
54
+ - **Git repositories** → the project id is the git root's first-commit hash
55
+ (the same id OpenCode V2 uses).
56
+ - **Non-git directories** → the location id plus the legacy `"global"` bucket,
57
+ so sessions from pre-migration (V1) installs are still found. On a fresh
58
+ install with no legacy sessions the `global` bucket simply matches nothing.
59
+
60
+ `searchAllProjects: true` removes the project filter entirely.
61
+
62
+ ## Where it reads
63
+
64
+ OpenCode's data directory, resolved the same way the OpenCode binary does:
65
+
66
+ | Platform | Data directory |
67
+ | --- | --- |
68
+ | any | `$XDG_DATA_HOME/opencode` (if set) |
69
+ | Linux | `~/.local/share/opencode` |
70
+ | macOS | `~/Library/Application Support/opencode` |
71
+ | Windows | `%LOCALAPPDATA%\opencode` |
72
+
73
+ Database file: `$OPENCODE_DB` (absolute path or name inside the data dir) if
74
+ set, else `opencode.db`, else the newest `opencode-<channel>.db` (OpenCode
75
+ names the DB per channel on versioned installs). Everything is opened
76
+ `readonly`.
77
+
78
+ If the database contains an unexpected schema the tool returns an actionable
79
+ error instead of raw SQL failures; on a machine without any OpenCode history
80
+ yet it returns a friendly "no history found" message.
81
+
82
+ ## Privacy
83
+
84
+ - Local only. No telemetry, no network access.
85
+ - Read-only access to the session database.
86
+ - Nothing is indexed or copied; queries run on demand against SQLite.
87
+
88
+ ## Requirements
89
+
90
+ - OpenCode **V2** (the plugin runs inside OpenCode's bundled Bun runtime;
91
+ `bun:sqlite` is used directly, so there are no native dependencies to build).
92
+ - Linux, macOS, or Windows.
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ bun install
98
+ bun test # unit + integration tests (temp on-disk V2-schema DB)
99
+ bunx tsc --noEmit
100
+ bun run smoke-test.ts
101
+ ```
102
+
103
+ Integration tests seed a temporary `XDG_DATA_HOME` with a real V2-schema
104
+ database, so they never touch your actual history.
105
+
106
+ ## Behavior notes vs V1
107
+
108
+ - Date filtering is applied **before** `limit` (V1 applied `limit` first, so a
109
+ small `limit` combined with a date filter could return "no matches").
110
+ - Reads the V2 `session_v2` / `session_message` tables (parts are inline in
111
+ the message `data` JSON). The frozen legacy `session`/`message`/`part`
112
+ tables are not read: `session_v2` already contains every legacy session id.
113
+
114
+ ## Attribution & license
115
+
116
+ Ported from [joeyism/opencode-history-search](https://github.com/joeyism/opencode-history-search)
117
+ (MIT). Tool description and search semantics carry over; the storage layer was
118
+ rewritten for the V2 schema. Dual copyright, see [LICENSE](./LICENSE).
package/index.ts ADDED
@@ -0,0 +1,248 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { Plugin } from "@opencode/plugin";
4
+ import { searchKeyword } from "./src/search/keyword";
5
+ import { searchFuzzy } from "./src/search/fuzzy";
6
+ import { parseDateFilter, filterByDate } from "./src/search/date-filter";
7
+ import { traceFile } from "./src/search/file-trace";
8
+ import { searchMultiterm } from "./src/search/multiterm-sql";
9
+ import { dbExists, getDbPath } from "./src/storage-sqlite";
10
+ import { jsonStorageExists } from "./src/storage-provider";
11
+ import {
12
+ formatResults,
13
+ formatTraceResults,
14
+ formatMultitermResults,
15
+ } from "./src/format";
16
+ import type { ProjectFilter } from "./src/storage-sqlite";
17
+
18
+ interface HistorySearchArgs {
19
+ query?: string;
20
+ terms?: string[];
21
+ filePath?: string;
22
+ searchAllProjects?: boolean;
23
+ mode?: "keyword" | "fuzzy";
24
+ regex?: boolean;
25
+ caseSensitive?: boolean;
26
+ fuzzyThreshold?: number;
27
+ date?: string;
28
+ limit?: number;
29
+ role?: "user" | "assistant";
30
+ }
31
+
32
+ /**
33
+ * Walk up from `dir` looking for a `.git` entry (directory or file).
34
+ * Mirrors how V2 derives project ids: git worktrees get the repo's root
35
+ * commit hash, non-git directories get their own project id.
36
+ */
37
+ function hasGitRoot(dir: string): boolean {
38
+ let d = dir;
39
+ for (let i = 0; i < 40; i++) {
40
+ try {
41
+ if (fs.existsSync(path.join(d, ".git"))) return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ const parent = path.dirname(d);
46
+ if (parent === d) return false;
47
+ d = parent;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Cap for searches that must run unbounded before a date filter is applied.
54
+ * Bounding the scan keeps worst-case cost finite while guaranteeing the date
55
+ * filter sees enough candidates that `limit` + `date` cannot starve it.
56
+ */
57
+ const PRE_FILTER_LIMIT = 10_000;
58
+
59
+ /**
60
+ * Apply the user's limit AFTER date filtering, so date + limit returns the
61
+ * newest `limit` results within the date range.
62
+ */
63
+ function applyLimit<T>(results: T[], limit?: number): T[] {
64
+ return limit && limit > 0 ? results.slice(0, limit) : results;
65
+ }
66
+
67
+ export default Plugin.define({
68
+ id: "opencode-history-search-v2",
69
+
70
+ async setup(ctx) {
71
+ const locationProjectID: string = ctx.location.project.id;
72
+ const inGit = hasGitRoot(ctx.location.directory);
73
+
74
+ // Non-git locations: V2-era sessions live under the location's own
75
+ // project id, but every pre-migration session (V1 era, and early V2
76
+ // before per-directory projects existed) lives under the legacy
77
+ // "global" project. Search both so "current project only" covers the
78
+ // full history, matching what the V1 plugin searched.
79
+ const resolveProject = (allProjects: boolean): ProjectFilter => {
80
+ if (allProjects) return null;
81
+ return inGit ? [locationProjectID] : [locationProjectID, "global"];
82
+ };
83
+
84
+ await ctx.tool.transform((editor) => {
85
+ editor.add({
86
+ name: "history_search",
87
+ description: `Search through past conversation histories. Use searchAllProjects=true to search ALL projects on this machine. Searches session titles, message content, tool invocations, and file paths.
88
+
89
+ THREE SEARCH MODES:
90
+ 1. SINGLE-TERM (query): Pass query for one search term. Returns per-part matches. Use mode: "fuzzy" for typos, regex: true for patterns.
91
+ 2. MULTI-TERM AND (terms): Pass terms: ["term1", "term2", ...] to find sessions containing ALL terms anywhere in the session (across title, messages, tools, file paths). Returns one result per session with per-term excerpts. Use when the user remembers multiple concepts (e.g., "find sessions about truck, vertex, and gemini"). Requires 2+ terms.
92
+ 3. FILE TRACE (filePath): Pass filePath to find which sessions created or modified a specific file.
93
+
94
+ Supports keyword search, regex patterns, fuzzy search, multi-term AND search, date filtering, and role filtering.`,
95
+
96
+ input: {
97
+ type: "object",
98
+ properties: {
99
+ query: {
100
+ type: "string",
101
+ description:
102
+ "Search query (keyword, regex pattern, or fuzzy search term). Required unless filePath or terms is provided.",
103
+ },
104
+ terms: {
105
+ type: "array",
106
+ items: { type: "string" },
107
+ description:
108
+ 'Array of terms for multi-term AND search. Returns sessions containing ALL terms anywhere in the session (title, messages, tools, file paths). Use when the user wants sessions matching multiple concepts (e.g., ["truck", "vertex", "gemini"]). Requires 2+ terms for multi-term path; 1 term falls back to single-term query. Case-insensitive substring matching.',
109
+ },
110
+ filePath: {
111
+ type: "string",
112
+ description:
113
+ "File path to trace touch history (e.g., 'src/auth.ts'). If provided, query, mode, regex, caseSensitive, fuzzyThreshold, and role are ignored.",
114
+ },
115
+ searchAllProjects: {
116
+ type: "boolean",
117
+ description:
118
+ "Set to true to search ALL projects on this machine across all repositories, not just the current one. Default: false (current repo only). Use when user asks to search globally, across all projects, machine-wide, or everywhere.",
119
+ },
120
+ mode: {
121
+ type: "string",
122
+ enum: ["keyword", "fuzzy"],
123
+ description:
124
+ "Search mode: 'keyword' for exact matches, 'fuzzy' for typo-tolerant matching (default: keyword)",
125
+ },
126
+ regex: {
127
+ type: "boolean",
128
+ description:
129
+ "Treat query as regex pattern (keyword mode only, default: false)",
130
+ },
131
+ caseSensitive: {
132
+ type: "boolean",
133
+ description:
134
+ "Case-sensitive search (keyword mode only, default: false)",
135
+ },
136
+ fuzzyThreshold: {
137
+ type: "number",
138
+ description:
139
+ "Fuzzy match threshold 0.0-1.0 (fuzzy mode only, default: 0.4, lower = stricter)",
140
+ },
141
+ date: {
142
+ type: "string",
143
+ description:
144
+ "Filter by date: 'today', 'yesterday', 'last N days/weeks/months', 'YYYY-MM-DD', 'YYYY-MM', 'YYYY-MM-DD to YYYY-MM-DD'",
145
+ },
146
+ limit: {
147
+ type: "number",
148
+ description:
149
+ "Maximum number of results (default: 50). Applied after date filtering, so date + limit returns the newest results within the date range.",
150
+ },
151
+ role: {
152
+ type: "string",
153
+ enum: ["user", "assistant"],
154
+ description:
155
+ "Filter by message role: 'user' for your messages only, 'assistant' for AI responses only. Ignored if filePath is provided.",
156
+ },
157
+ },
158
+ additionalProperties: false,
159
+ },
160
+
161
+ options: { codemode: true },
162
+
163
+ execute: async (rawArgs: unknown) => {
164
+ const args: HistorySearchArgs = {
165
+ ...((rawArgs as HistorySearchArgs) ?? {}),
166
+ };
167
+
168
+ if (!args.query && !args.filePath && !args.terms) {
169
+ throw new Error(
170
+ "Either 'query', 'terms', or 'filePath' must be provided.",
171
+ );
172
+ }
173
+
174
+ // Fresh install / empty history: answer cleanly instead of
175
+ // opening a database that does not exist yet.
176
+ if (!dbExists() && !jsonStorageExists()) {
177
+ return {
178
+ content: `No OpenCode history found yet (no session database at ${getDbPath()} and no legacy JSON storage detected). Once OpenCode has recorded sessions in this data directory, they will be searchable here.`,
179
+ };
180
+ }
181
+
182
+ if (args.terms !== undefined) {
183
+ if (!Array.isArray(args.terms) || args.terms.length === 0) {
184
+ throw new Error(
185
+ "'terms' must be a non-empty array of strings.",
186
+ );
187
+ }
188
+ if (args.terms.length === 1) {
189
+ args.query = args.terms[0];
190
+ } else {
191
+ const project = resolveProject(!!args.searchAllProjects);
192
+ let matches = await searchMultiterm(project, args.terms, {
193
+ limit: args.date ? PRE_FILTER_LIMIT : args.limit,
194
+ });
195
+ if (args.date) {
196
+ matches = filterByDate(matches, parseDateFilter(args.date));
197
+ matches = applyLimit(matches, args.limit);
198
+ }
199
+ return { content: formatMultitermResults(matches) };
200
+ }
201
+ }
202
+
203
+ const project = resolveProject(!!args.searchAllProjects);
204
+
205
+ if (args.filePath) {
206
+ let matches = await traceFile(project, args.filePath, {
207
+ limit: args.date ? PRE_FILTER_LIMIT : args.limit,
208
+ });
209
+
210
+ if (args.date) {
211
+ matches = filterByDate(matches, parseDateFilter(args.date));
212
+ matches = applyLimit(matches, args.limit);
213
+ }
214
+
215
+ return { content: formatTraceResults(matches) };
216
+ }
217
+
218
+ if (!args.query) {
219
+ throw new Error(
220
+ "'query' is required when 'filePath' is not provided.",
221
+ );
222
+ }
223
+
224
+ let matches =
225
+ args.mode === "fuzzy"
226
+ ? await searchFuzzy(project, args.query, {
227
+ threshold: args.fuzzyThreshold,
228
+ limit: args.date ? PRE_FILTER_LIMIT : args.limit,
229
+ role: args.role,
230
+ })
231
+ : await searchKeyword(project, args.query, {
232
+ regex: args.regex,
233
+ caseSensitive: args.caseSensitive,
234
+ limit: args.date ? PRE_FILTER_LIMIT : args.limit,
235
+ role: args.role,
236
+ });
237
+
238
+ if (args.date) {
239
+ matches = filterByDate(matches, parseDateFilter(args.date));
240
+ matches = applyLimit(matches, args.limit);
241
+ }
242
+
243
+ return { content: formatResults(matches) };
244
+ },
245
+ });
246
+ });
247
+ },
248
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "opencode-history-search-v2",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode V2 plugin: search past conversation sessions by keyword, fuzzy term, multi-term AND, or file touch history. Local, read-only, no network.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "src/**/*.ts",
10
+ "!src/**/*.test.ts",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "keywords": [
15
+ "opencode",
16
+ "opencode-plugin",
17
+ "plugin",
18
+ "history",
19
+ "search",
20
+ "sqlite",
21
+ "memory"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/marioschweiger/opencode-history-search-v2.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/marioschweiger/opencode-history-search-v2/issues"
29
+ },
30
+ "homepage": "https://github.com/marioschweiger/opencode-history-search-v2#readme",
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@opencode/plugin": "^2.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@opencode/plugin": "^2.0.21",
37
+ "bun-types": "^1.4.2",
38
+ "typescript": "^5.9.0"
39
+ },
40
+ "dependencies": {
41
+ "fuse.js": "^7.1.0"
42
+ }
43
+ }
package/src/format.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { SearchMatch } from "./search/keyword";
2
+ import type { FileTraceResult } from "./search/file-trace";
3
+ import type { MultitermSearchMatch } from "./search/multiterm-sql";
4
+
5
+ export function formatResults(matches: SearchMatch[]): string {
6
+ if (matches.length === 0) {
7
+ return "No matches found in conversation history.";
8
+ }
9
+
10
+ const lines: string[] = [
11
+ `Found ${matches.length} matches in conversation history:\n`,
12
+ ];
13
+
14
+ for (const match of matches) {
15
+ const date = new Date(match.timestamp).toISOString().split("T")[0];
16
+ const time = new Date(match.timestamp).toTimeString().split(" ")[0];
17
+
18
+ lines.push(`## ${match.sessionTitle}`);
19
+ lines.push(`- Session ID: ${match.sessionID}`);
20
+ lines.push(`- Project: ${match.projectDirectory}`);
21
+ lines.push(`- Date: ${date} ${time}`);
22
+ lines.push(`- Match Type: ${match.matchType}`);
23
+ lines.push(`- Excerpt: "${match.excerpt}"`);
24
+
25
+ if (match.context && match.context !== match.excerpt) {
26
+ lines.push(`- Context: ...${match.context}...`);
27
+ }
28
+
29
+ lines.push("");
30
+ }
31
+
32
+ return lines.join("\n");
33
+ }
34
+
35
+ export function formatTraceResults(matches: FileTraceResult[]): string {
36
+ if (matches.length === 0) {
37
+ return "No file trace matches found in conversation history.";
38
+ }
39
+
40
+ const lines: string[] = [
41
+ `Found ${matches.length} file trace matches in conversation history:\n`,
42
+ ];
43
+
44
+ for (const match of matches) {
45
+ const date = new Date(match.timestamp).toISOString().split("T")[0];
46
+ const time = new Date(match.timestamp).toTimeString().split(" ")[0];
47
+
48
+ lines.push(`## ${match.sessionTitle}`);
49
+ lines.push(`- Session ID: ${match.sessionID}`);
50
+ lines.push(`- Date: ${date} ${time}`);
51
+ lines.push(`- Status: ${match.firstTouch ? "First seen" : "Later touch"}`);
52
+ lines.push(`- File: ${match.filePath}`);
53
+ if (match.toolName) {
54
+ lines.push(`- Tool: ${match.toolName}`);
55
+ }
56
+
57
+ if (match.userPrompt) {
58
+ lines.push(`- Preceding User Prompt: "${match.userPrompt}"`);
59
+ }
60
+
61
+ lines.push("");
62
+ }
63
+
64
+ return lines.join("\n");
65
+ }
66
+
67
+ export function formatMultitermResults(matches: MultitermSearchMatch[]): string {
68
+ if (matches.length === 0) {
69
+ return "No sessions found in conversation history.";
70
+ }
71
+
72
+ const lines: string[] = [
73
+ `Found ${matches.length} sessions in conversation history:\n`,
74
+ ];
75
+
76
+ for (const match of matches) {
77
+ const date = new Date(match.timestamp).toISOString().split("T")[0];
78
+ const time = new Date(match.timestamp).toTimeString().split(" ")[0];
79
+
80
+ lines.push(`## ${match.sessionTitle}`);
81
+ lines.push(`- Session ID: ${match.sessionID}`);
82
+ lines.push(`- Project: ${match.projectDirectory}`);
83
+ lines.push(`- Date: ${date} ${time}`);
84
+
85
+ if (match.termHits.size > 0) {
86
+ const termList = Array.from(match.termHits.keys()).join(", ");
87
+ lines.push(`- Matched terms: ${termList}`);
88
+ for (const [term, hit] of match.termHits) {
89
+ lines.push(` - ${term}: ${hit.excerpt}`);
90
+ }
91
+ }
92
+
93
+ lines.push("");
94
+ }
95
+
96
+ return lines.join("\n");
97
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,87 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ import fs from "fs";
4
+
5
+ /**
6
+ * Cross-platform resolution of OpenCode's data directory and session
7
+ * database file. Mirrors the logic in the OpenCode binary itself:
8
+ *
9
+ * data dir: $XDG_DATA_HOME (any platform)
10
+ * darwin: ~/Library/Application Support
11
+ * linux: ~/.local/share
12
+ * win32: %LOCALAPPDATA%
13
+ * then the "opencode" subdirectory.
14
+ *
15
+ * db file: $OPENCODE_DB (absolute path or file name, overrides)
16
+ * else `opencode.db`
17
+ * else the newest `opencode-<channel>.db` in the data dir
18
+ * (OpenCode names the DB per channel on versioned installs)
19
+ * else `opencode.db` (may not exist yet; callers handle it)
20
+ */
21
+
22
+ export function getDataDir(
23
+ platform: string = process.platform,
24
+ env: NodeJS.ProcessEnv = process.env,
25
+ ): string {
26
+ const xdg = env.XDG_DATA_HOME;
27
+ if (xdg) return path.join(xdg, "opencode");
28
+ switch (platform) {
29
+ case "darwin":
30
+ return path.join(
31
+ os.homedir(),
32
+ "Library",
33
+ "Application Support",
34
+ "opencode",
35
+ );
36
+ case "win32": {
37
+ const local =
38
+ env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
39
+ return path.join(local, "opencode");
40
+ }
41
+ default:
42
+ return path.join(os.homedir(), ".local", "share", "opencode");
43
+ }
44
+ }
45
+
46
+ export function resolveDbFile(
47
+ dataDir: string,
48
+ env: NodeJS.ProcessEnv = process.env,
49
+ ): string {
50
+ const override = env.OPENCODE_DB;
51
+ if (override) {
52
+ return path.isAbsolute(override)
53
+ ? override
54
+ : path.join(dataDir, override);
55
+ }
56
+
57
+ const stable = path.join(dataDir, "opencode.db");
58
+ if (fs.existsSync(stable)) return stable;
59
+
60
+ let entries: string[];
61
+ try {
62
+ entries = fs.readdirSync(dataDir);
63
+ } catch {
64
+ return stable;
65
+ }
66
+
67
+ let best: string | null = null;
68
+ let bestMtime = 0;
69
+ for (const name of entries) {
70
+ if (!/^opencode-[^/\\]+\.db$/.test(name)) continue;
71
+ const full = path.join(dataDir, name);
72
+ try {
73
+ const mtime = fs.statSync(full).mtimeMs;
74
+ if (mtime > bestMtime) {
75
+ bestMtime = mtime;
76
+ best = full;
77
+ }
78
+ } catch {
79
+ // unreadable entry, skip
80
+ }
81
+ }
82
+ return best ?? stable;
83
+ }
84
+
85
+ export function getDbPath(): string {
86
+ return resolveDbFile(getDataDir());
87
+ }