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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Date filtering utilities for search results
3
+ * Supports natural language dates: "today", "yesterday", "last N days/weeks/months"
4
+ * Supports ISO dates: "YYYY-MM-DD"
5
+ * Supports date ranges: "YYYY-MM-DD to YYYY-MM-DD"
6
+ */
7
+
8
+ export interface DateRange {
9
+ start: Date;
10
+ end: Date;
11
+ }
12
+
13
+ /**
14
+ * Parse a date filter string into a DateRange
15
+ * @param filter Date filter string (e.g., "today", "last 7 days", "2024-01-15", "2024-01-01 to 2024-01-31")
16
+ * @returns DateRange object with start and end dates (inclusive)
17
+ * @throws Error if the filter format is invalid
18
+ */
19
+ export function parseDateFilter(filter: string): DateRange {
20
+ const normalized = filter.trim().toLowerCase();
21
+
22
+ // Today: 00:00:00 to 23:59:59 today
23
+ if (normalized === "today") {
24
+ const start = new Date();
25
+ start.setHours(0, 0, 0, 0);
26
+ const end = new Date();
27
+ end.setHours(23, 59, 59, 999);
28
+ return { start, end };
29
+ }
30
+
31
+ // Yesterday: 00:00:00 to 23:59:59 yesterday
32
+ if (normalized === "yesterday") {
33
+ const start = new Date();
34
+ start.setDate(start.getDate() - 1);
35
+ start.setHours(0, 0, 0, 0);
36
+ const end = new Date(start);
37
+ end.setHours(23, 59, 59, 999);
38
+ return { start, end };
39
+ }
40
+
41
+ // "last N days/weeks/months" - from N units ago until now
42
+ const relativeMatch = normalized.match(/^last (\d+) (day|week|month)s?$/);
43
+ if (relativeMatch && relativeMatch[1] && relativeMatch[2]) {
44
+ const count = parseInt(relativeMatch[1], 10);
45
+ const unit = relativeMatch[2];
46
+ const end = new Date();
47
+ const start = new Date();
48
+
49
+ if (unit === "day") {
50
+ start.setDate(start.getDate() - count);
51
+ } else if (unit === "week") {
52
+ start.setDate(start.getDate() - count * 7);
53
+ } else if (unit === "month") {
54
+ start.setMonth(start.getMonth() - count);
55
+ }
56
+
57
+ start.setHours(0, 0, 0, 0);
58
+ return { start, end };
59
+ }
60
+
61
+ // Date range: "YYYY-MM-DD to YYYY-MM-DD"
62
+ const rangeMatch = normalized.match(
63
+ /^(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})$/,
64
+ );
65
+ if (rangeMatch && rangeMatch[1] && rangeMatch[2]) {
66
+ const start = new Date(rangeMatch[1]);
67
+ const end = new Date(rangeMatch[2]);
68
+ end.setHours(23, 59, 59, 999); // Include entire end date
69
+
70
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) {
71
+ throw new Error(`Invalid date range: ${filter}`);
72
+ }
73
+
74
+ if (start > end) {
75
+ throw new Error(`Start date must be before end date: ${filter}`);
76
+ }
77
+
78
+ return { start, end };
79
+ }
80
+
81
+ // Single date: "YYYY-MM-DD" or "YYYY-MM" (month)
82
+ const isoMatch = normalized.match(/^(\d{4}-\d{2}(?:-\d{2})?)$/);
83
+ if (isoMatch && isoMatch[1]) {
84
+ const dateStr = isoMatch[1];
85
+
86
+ // Month only (YYYY-MM)
87
+ if (dateStr.match(/^\d{4}-\d{2}$/)) {
88
+ const start = new Date(`${dateStr}-01`);
89
+ const end = new Date(start);
90
+ end.setMonth(end.getMonth() + 1);
91
+ end.setDate(0); // Last day of month
92
+ end.setHours(23, 59, 59, 999);
93
+
94
+ if (isNaN(start.getTime())) {
95
+ throw new Error(`Invalid date: ${filter}`);
96
+ }
97
+
98
+ return { start, end };
99
+ }
100
+
101
+ // Full date (YYYY-MM-DD)
102
+ const start = new Date(dateStr);
103
+ const end = new Date(dateStr);
104
+ end.setHours(23, 59, 59, 999);
105
+
106
+ if (isNaN(start.getTime())) {
107
+ throw new Error(`Invalid date: ${filter}`);
108
+ }
109
+
110
+ return { start, end };
111
+ }
112
+
113
+ throw new Error(
114
+ `Unrecognized date filter format: ${filter}. Supported formats: "today", "yesterday", "last N days/weeks/months", "YYYY-MM-DD", "YYYY-MM", "YYYY-MM-DD to YYYY-MM-DD"`,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Filter search results by date range
120
+ * @param results Array of search results with timestamp field (milliseconds since epoch)
121
+ * @param dateRange DateRange to filter by
122
+ * @returns Filtered array containing only results within the date range
123
+ */
124
+ export function filterByDate<T extends { timestamp: number }>(
125
+ results: T[],
126
+ dateRange: DateRange,
127
+ ): T[] {
128
+ return results.filter((result) => {
129
+ const timestamp = new Date(result.timestamp);
130
+ return timestamp >= dateRange.start && timestamp <= dateRange.end;
131
+ });
132
+ }
@@ -0,0 +1,189 @@
1
+ import { getDbPath, projectFilterParts, type ProjectFilter } from "../storage-sqlite";
2
+
3
+ export interface FileTraceResult {
4
+ sessionID: string;
5
+ sessionTitle: string;
6
+ timestamp: number;
7
+ firstTouch: boolean;
8
+ userPrompt: string | null;
9
+ toolName: string | null;
10
+ filePath: string;
11
+ }
12
+
13
+ export interface TraceFileOptions {
14
+ limit?: number;
15
+ }
16
+
17
+ interface Candidate {
18
+ sessionID: string;
19
+ sessionTitle: string;
20
+ messageID: string;
21
+ timestamp: number;
22
+ toolName: string | null;
23
+ filePath: string;
24
+ }
25
+
26
+ /**
27
+ * Trace which sessions created or modified a file.
28
+ *
29
+ * V2 schema: parts are embedded in `session_message.data`. Assistant tool
30
+ * parts live in `data.content[]` as { type:"tool", name, state:{input} }.
31
+ * New V2 write/edit inputs use `input.path`; migrated legacy parts may keep
32
+ * `input.filePath`. Patch parts carry `files: string[]`.
33
+ */
34
+ export async function traceFile(
35
+ projectID: ProjectFilter,
36
+ filePath: string,
37
+ options?: TraceFileOptions,
38
+ ): Promise<FileTraceResult[]> {
39
+ const { Database } = await import("bun:sqlite");
40
+ const dbPath = getDbPath();
41
+ const db = new Database(dbPath, { readonly: true });
42
+ try {
43
+ const normalizedQuery = filePath.replace(/\\/g, "/");
44
+ const isExactPath = normalizedQuery.includes("/");
45
+ const likePattern = `%${normalizedQuery}%`;
46
+
47
+ const { clause, binds } = projectFilterParts(projectID);
48
+
49
+ // Broad SQL pre-filter: assistant messages whose JSON payload mentions the
50
+ // path, then exact/basename matching is refined in TypeScript.
51
+ const sql = `
52
+ SELECT
53
+ s.id AS session_id,
54
+ s.title AS session_title,
55
+ m.id AS message_id,
56
+ m.time_created AS message_time,
57
+ m.data AS message_data
58
+ FROM session_v2 s
59
+ JOIN session_message m ON m.session_id = s.id
60
+ WHERE m.type = 'assistant'
61
+ AND m.data LIKE ? ESCAPE '\\'
62
+ ${clause}
63
+ ORDER BY m.time_created DESC
64
+ `;
65
+
66
+ const rows = db.query(sql).all(...[likePattern, ...binds]) as Array<{
67
+ session_id: string;
68
+ session_title: string | null;
69
+ message_id: string;
70
+ message_time: number;
71
+ message_data: string;
72
+ }>;
73
+
74
+ const candidates: Candidate[] = [];
75
+
76
+ for (const row of rows) {
77
+ let data: any;
78
+ try {
79
+ data = JSON.parse(row.message_data);
80
+ } catch {
81
+ continue;
82
+ }
83
+
84
+ const content = Array.isArray(data.content) ? data.content : [];
85
+ for (const p of content) {
86
+ if (!p || typeof p !== "object") continue;
87
+
88
+ if (p.type === "tool" && (p.name === "write" || p.name === "edit")) {
89
+ const input = p.state?.input || {};
90
+ const touched =
91
+ typeof input.path === "string"
92
+ ? input.path
93
+ : typeof input.filePath === "string"
94
+ ? input.filePath
95
+ : null;
96
+ if (touched && isMatch(touched, normalizedQuery, isExactPath)) {
97
+ candidates.push({
98
+ sessionID: row.session_id,
99
+ sessionTitle: row.session_title || "",
100
+ messageID: row.message_id,
101
+ timestamp: row.message_time,
102
+ toolName: typeof p.name === "string" ? p.name : null,
103
+ filePath: touched,
104
+ });
105
+ }
106
+ } else if (p.type === "patch" && Array.isArray(p.files)) {
107
+ for (const f of p.files) {
108
+ if (typeof f === "string" && isMatch(f, normalizedQuery, isExactPath)) {
109
+ candidates.push({
110
+ sessionID: row.session_id,
111
+ sessionTitle: row.session_title || "",
112
+ messageID: row.message_id,
113
+ timestamp: row.message_time,
114
+ toolName: null,
115
+ filePath: f,
116
+ });
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Deduplicate by sessionID + messageID + filePath. SQL is ordered by
125
+ // message_time DESC, so the first occurrence per key wins.
126
+ const seen = new Set<string>();
127
+ const deduped: Candidate[] = [];
128
+ for (const c of candidates) {
129
+ const key = `${c.sessionID}|${c.messageID}|${c.filePath}`;
130
+ if (!seen.has(key)) {
131
+ seen.add(key);
132
+ deduped.push(c);
133
+ }
134
+ }
135
+
136
+ // firstTouch = the earliest touch overall.
137
+ let earliestTime = Infinity;
138
+ let earliestIndex = -1;
139
+ for (let i = 0; i < deduped.length; i++) {
140
+ const candidate = deduped[i];
141
+ if (candidate.timestamp < earliestTime) {
142
+ earliestTime = candidate.timestamp;
143
+ earliestIndex = i;
144
+ }
145
+ }
146
+
147
+ const findUserPrompt = db.query(
148
+ `SELECT data FROM session_message
149
+ WHERE session_id = ? AND type = 'user' AND time_created < ?
150
+ ORDER BY seq DESC
151
+ LIMIT 1`,
152
+ );
153
+
154
+ const limit = options?.limit ?? 50;
155
+
156
+ return deduped.slice(0, limit).map((c, idx) => {
157
+ const promptRow = findUserPrompt.get(
158
+ c.sessionID,
159
+ c.timestamp,
160
+ ) as { data: string } | undefined;
161
+ let userPrompt: string | null = null;
162
+ if (promptRow) {
163
+ try {
164
+ userPrompt = JSON.parse(promptRow.data).text || null;
165
+ } catch {
166
+ userPrompt = null;
167
+ }
168
+ }
169
+ return {
170
+ sessionID: c.sessionID,
171
+ sessionTitle: c.sessionTitle,
172
+ timestamp: c.timestamp,
173
+ firstTouch: idx === earliestIndex,
174
+ userPrompt,
175
+ toolName: c.toolName,
176
+ filePath: c.filePath,
177
+ };
178
+ });
179
+ } finally {
180
+ db.close();
181
+ }
182
+ }
183
+
184
+ function isMatch(path: string, query: string, isExact: boolean): boolean {
185
+ if (isExact) {
186
+ return path === query;
187
+ }
188
+ return path === query || path.endsWith(`/${query}`);
189
+ }
@@ -0,0 +1,158 @@
1
+ import Fuse from "fuse.js";
2
+ import type { Session, Message, Part, ProjectFilter } from "../storage-provider";
3
+ import { listSessions, listMessages, listParts } from "../storage-provider";
4
+ import type { SearchMatch } from "./keyword";
5
+
6
+ interface SearchableItem {
7
+ session: Session;
8
+ content: string;
9
+ type: "title" | "message" | "tool" | "filepath";
10
+ messageID?: string;
11
+ partID?: string;
12
+ timestamp: number;
13
+ }
14
+
15
+ export async function searchFuzzy(
16
+ projectID: ProjectFilter,
17
+ query: string,
18
+ options: {
19
+ threshold?: number; // 0.0 = exact, 1.0 = anything (default: 0.4)
20
+ limit?: number;
21
+ role?: "user" | "assistant";
22
+ } = {},
23
+ ): Promise<SearchMatch[]> {
24
+ const threshold = options.threshold ?? 0.4;
25
+ const limit = options.limit ?? 50;
26
+
27
+ // Build searchable index
28
+ const items: SearchableItem[] = [];
29
+
30
+ try {
31
+ for await (const session of listSessions(projectID)) {
32
+ // Index session title
33
+ items.push({
34
+ session,
35
+ content: session.title,
36
+ type: "title",
37
+ timestamp: session.time.updated,
38
+ });
39
+
40
+ // Index messages
41
+ try {
42
+ for await (const message of listMessages(session.id, options.role)) {
43
+ try {
44
+ for await (const part of listParts(message.id)) {
45
+ if (part.type === "text" && part.text) {
46
+ items.push({
47
+ session,
48
+ content: part.text,
49
+ type: "message",
50
+ messageID: message.id,
51
+ partID: part.id,
52
+ timestamp: message.time.created,
53
+ });
54
+ }
55
+
56
+ if (part.type === "tool" && part.tool) {
57
+ items.push({
58
+ session,
59
+ content: part.tool + " " + (part.state?.title || ""),
60
+ type: "tool",
61
+ messageID: message.id,
62
+ partID: part.id,
63
+ timestamp: message.time.created,
64
+ });
65
+ }
66
+
67
+ // Index file paths from tool inputs/outputs
68
+ if (part.type === "tool" && part.state) {
69
+ const inputStr = JSON.stringify(part.state.input || {});
70
+ const outputStr = part.state.output || "";
71
+ const combined = inputStr + " " + outputStr;
72
+
73
+ // Extract file path patterns
74
+ const pathMatches = combined.match(/(?:\/[^/\s]+)+/g);
75
+ if (pathMatches) {
76
+ for (const path of pathMatches) {
77
+ items.push({
78
+ session,
79
+ content: path,
80
+ type: "filepath",
81
+ messageID: message.id,
82
+ partID: part.id,
83
+ timestamp: message.time.created,
84
+ });
85
+ }
86
+ }
87
+ }
88
+
89
+ // Index file paths from patch parts
90
+ if (part.type === "patch" && part.files) {
91
+ for (const filePath of part.files) {
92
+ items.push({
93
+ session,
94
+ content: filePath,
95
+ type: "filepath",
96
+ messageID: message.id,
97
+ partID: part.id,
98
+ timestamp: message.time.created,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ } catch {
104
+ // Skip corrupt part files
105
+ continue;
106
+ }
107
+ }
108
+ } catch {
109
+ // Skip sessions with missing message directory
110
+ continue;
111
+ }
112
+ }
113
+ } catch {
114
+ // Handle missing sessions directory
115
+ return [];
116
+ }
117
+
118
+ // Perform fuzzy search
119
+ const fuse = new Fuse(items, {
120
+ keys: ["content"],
121
+ threshold,
122
+ includeScore: true,
123
+ includeMatches: true,
124
+ ignoreLocation: true, // Don't bias toward beginning of string
125
+ minMatchCharLength: 2,
126
+ });
127
+
128
+ const results = fuse.search(query, { limit });
129
+
130
+ return results
131
+ .map((result) => {
132
+ const matchedText = result.matches?.[0]?.value || result.item.content;
133
+ const matchIndex = result.matches?.[0]?.indices?.[0]?.[0] || 0;
134
+
135
+ // Extract context around the match
136
+ const contextStart = Math.max(0, matchIndex - 100);
137
+ const contextEnd = Math.min(matchedText.length, matchIndex + 200);
138
+ const context = matchedText.slice(contextStart, contextEnd);
139
+
140
+ // Extract excerpt (the matched portion)
141
+ const excerptStart = Math.max(0, matchIndex - 20);
142
+ const excerptEnd = Math.min(matchedText.length, matchIndex + 80);
143
+ const excerpt = matchedText.slice(excerptStart, excerptEnd);
144
+
145
+ return {
146
+ sessionID: result.item.session.id,
147
+ sessionTitle: result.item.session.title,
148
+ timestamp: result.item.timestamp,
149
+ matchType: result.item.type,
150
+ excerpt: excerpt || query,
151
+ context: context || excerpt,
152
+ messageID: result.item.messageID,
153
+ partID: result.item.partID,
154
+ projectDirectory: result.item.session.directory,
155
+ };
156
+ })
157
+ .sort((a, b) => b.timestamp - a.timestamp);
158
+ }
@@ -0,0 +1,146 @@
1
+ import type { Session, Message, Part, ProjectFilter } from "../storage-provider";
2
+ import { listSessions, listMessages, listParts } from "../storage-provider";
3
+
4
+ export interface SearchMatch {
5
+ sessionID: string;
6
+ sessionTitle: string;
7
+ timestamp: number;
8
+ matchType: "title" | "message" | "tool" | "filepath";
9
+ excerpt: string;
10
+ context: string;
11
+ messageID?: string;
12
+ partID?: string;
13
+ projectDirectory: string;
14
+ }
15
+
16
+ export async function searchKeyword(
17
+ projectID: ProjectFilter,
18
+ query: string,
19
+ options: {
20
+ regex?: boolean;
21
+ caseSensitive?: boolean;
22
+ limit?: number;
23
+ role?: "user" | "assistant";
24
+ } = {},
25
+ ): Promise<SearchMatch[]> {
26
+ const results: SearchMatch[] = [];
27
+ const pattern = options.regex
28
+ ? new RegExp(query, options.caseSensitive ? "" : "i")
29
+ : new RegExp(
30
+ query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
31
+ options.caseSensitive ? "" : "i",
32
+ );
33
+
34
+ const limit = options.limit || 50;
35
+
36
+ for await (const session of listSessions(projectID)) {
37
+ if (results.length >= limit) break;
38
+
39
+ if (pattern.test(session.title)) {
40
+ results.push({
41
+ sessionID: session.id,
42
+ sessionTitle: session.title,
43
+ timestamp: session.time.updated,
44
+ matchType: "title",
45
+ excerpt: session.title,
46
+ context: session.title,
47
+ projectDirectory: session.directory,
48
+ });
49
+ if (results.length >= limit) break;
50
+ }
51
+
52
+ for await (const message of listMessages(session.id, options.role)) {
53
+ if (results.length >= limit) break;
54
+
55
+ for await (const part of listParts(message.id)) {
56
+ if (results.length >= limit) break;
57
+
58
+ if (part.type === "text" && part.text && pattern.test(part.text)) {
59
+ const match = part.text.match(pattern);
60
+ const matchIndex = match ? part.text.indexOf(match[0]) : 0;
61
+ const contextStart = Math.max(0, matchIndex - 100);
62
+ const contextEnd = Math.min(
63
+ part.text.length,
64
+ matchIndex + match![0].length + 100,
65
+ );
66
+ const context = part.text.slice(contextStart, contextEnd);
67
+
68
+ results.push({
69
+ sessionID: session.id,
70
+ sessionTitle: session.title,
71
+ timestamp: message.time.created,
72
+ matchType: "message",
73
+ excerpt: match ? match[0] : query,
74
+ context: context,
75
+ messageID: message.id,
76
+ partID: part.id,
77
+ projectDirectory: session.directory,
78
+ });
79
+ if (results.length >= limit) break;
80
+ }
81
+
82
+ if (results.length >= limit) break;
83
+ if (part.type === "tool" && part.tool && pattern.test(part.tool)) {
84
+ results.push({
85
+ sessionID: session.id,
86
+ sessionTitle: session.title,
87
+ timestamp: message.time.created,
88
+ matchType: "tool",
89
+ excerpt: part.tool,
90
+ context: part.state?.title || part.tool,
91
+ messageID: message.id,
92
+ partID: part.id,
93
+ projectDirectory: session.directory,
94
+ });
95
+ if (results.length >= limit) break;
96
+ }
97
+
98
+ if (results.length >= limit) break;
99
+ if (part.type === "tool" && part.state) {
100
+ const inputStr = JSON.stringify(part.state.input || {});
101
+ const outputStr = part.state.output || "";
102
+
103
+ if (pattern.test(inputStr) || pattern.test(outputStr)) {
104
+ const matched = pattern.exec(inputStr) || pattern.exec(outputStr);
105
+ if (matched) {
106
+ results.push({
107
+ sessionID: session.id,
108
+ sessionTitle: session.title,
109
+ timestamp: message.time.created,
110
+ matchType: "filepath",
111
+ excerpt: matched[0],
112
+ context: part.state.title || part.tool || "",
113
+ messageID: message.id,
114
+ partID: part.id,
115
+ projectDirectory: session.directory,
116
+ });
117
+ if (results.length >= limit) break;
118
+ }
119
+ }
120
+ }
121
+
122
+ // Search patch parts for file paths
123
+ if (results.length < limit && part.type === "patch" && part.files) {
124
+ for (const filePath of part.files) {
125
+ if (results.length >= limit) break;
126
+ if (pattern.test(filePath)) {
127
+ results.push({
128
+ sessionID: session.id,
129
+ sessionTitle: session.title,
130
+ timestamp: message.time.created,
131
+ matchType: "filepath",
132
+ excerpt: filePath,
133
+ context: `Modified file: ${filePath}`,
134
+ messageID: message.id,
135
+ partID: part.id,
136
+ projectDirectory: session.directory,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ return results.sort((a, b) => b.timestamp - a.timestamp);
146
+ }