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 +22 -0
- package/README.md +118 -0
- package/index.ts +248 -0
- package/package.json +43 -0
- package/src/format.ts +97 -0
- package/src/paths.ts +87 -0
- package/src/search/date-filter.ts +132 -0
- package/src/search/file-trace.ts +189 -0
- package/src/search/fuzzy.ts +158 -0
- package/src/search/keyword.ts +146 -0
- package/src/search/multiterm-sql.ts +170 -0
- package/src/storage-provider.ts +70 -0
- package/src/storage-sqlite.ts +252 -0
- package/src/storage.ts +134 -0
|
@@ -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
|
+
}
|