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,170 @@
|
|
|
1
|
+
import { getDbPath, projectFilterParts, type ProjectFilter } from "../storage-sqlite";
|
|
2
|
+
|
|
3
|
+
export interface MultitermSqlOptions {
|
|
4
|
+
limit: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface MultitermSqlResult {
|
|
8
|
+
sql: string;
|
|
9
|
+
binds: any[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MultitermSearchMatch {
|
|
13
|
+
sessionID: string;
|
|
14
|
+
sessionTitle: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
projectDirectory: string;
|
|
17
|
+
termHits: Map<string, { partID: string; matchType: string; excerpt: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Multi-term AND search over OpenCode V2 history.
|
|
22
|
+
*
|
|
23
|
+
* V2 schema: sessions in `session_v2`, message content (including tool calls
|
|
24
|
+
* and file paths) embedded in `session_message.data`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export function buildMultitermSql(
|
|
28
|
+
terms: string[],
|
|
29
|
+
projectID: ProjectFilter,
|
|
30
|
+
options: MultitermSqlOptions,
|
|
31
|
+
): MultitermSqlResult {
|
|
32
|
+
if (terms.length === 0) {
|
|
33
|
+
throw new Error("terms array must contain at least one term");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const conditions = terms
|
|
37
|
+
.map(
|
|
38
|
+
(_, i) =>
|
|
39
|
+
`MAX(CASE WHEN m.data LIKE ? ESCAPE '\\' OR s.title LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END) AS match_${i}`,
|
|
40
|
+
)
|
|
41
|
+
.join(", ");
|
|
42
|
+
|
|
43
|
+
const having = terms
|
|
44
|
+
.map((_, i) => `match_${i} = 1`)
|
|
45
|
+
.join(" AND ");
|
|
46
|
+
|
|
47
|
+
const { clause, binds: projectBinds } = projectFilterParts(projectID);
|
|
48
|
+
|
|
49
|
+
const sql = `
|
|
50
|
+
SELECT s.id AS session_id, s.title, s.directory, s.time_updated, ${conditions}
|
|
51
|
+
FROM session_v2 s
|
|
52
|
+
JOIN session_message m ON m.session_id = s.id
|
|
53
|
+
WHERE 1=1
|
|
54
|
+
${clause}
|
|
55
|
+
GROUP BY s.id
|
|
56
|
+
HAVING ${having}
|
|
57
|
+
ORDER BY s.time_updated DESC
|
|
58
|
+
LIMIT ${options.limit}
|
|
59
|
+
`.trim();
|
|
60
|
+
|
|
61
|
+
const binds: any[] = [];
|
|
62
|
+
for (const term of terms) {
|
|
63
|
+
const escaped = term.replace(/[%_\\]/g, "\\$&");
|
|
64
|
+
binds.push(`%${escaped}%`);
|
|
65
|
+
binds.push(`%${escaped}%`);
|
|
66
|
+
}
|
|
67
|
+
binds.push(...projectBinds);
|
|
68
|
+
|
|
69
|
+
return { sql, binds };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function searchMultiterm(
|
|
73
|
+
projectID: ProjectFilter,
|
|
74
|
+
terms: string[],
|
|
75
|
+
options?: { limit?: number },
|
|
76
|
+
): Promise<MultitermSearchMatch[]> {
|
|
77
|
+
const { Database } = await import("bun:sqlite");
|
|
78
|
+
const dbPath = getDbPath();
|
|
79
|
+
const db = new Database(dbPath, { readonly: true });
|
|
80
|
+
try {
|
|
81
|
+
const { sql, binds } = buildMultitermSql(terms, projectID, {
|
|
82
|
+
limit: options?.limit ?? 50,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
type Row = {
|
|
86
|
+
session_id: string;
|
|
87
|
+
title: string | null;
|
|
88
|
+
directory: string;
|
|
89
|
+
time_updated: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const rows = db.query(sql).all(...binds) as Row[];
|
|
93
|
+
|
|
94
|
+
if (rows.length === 0) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Phase 2: load the matched sessions' messages once and locate a
|
|
99
|
+
// per-term excerpt (first occurrence, ±50 chars) in JavaScript.
|
|
100
|
+
const sessionIDs = rows.map((r) => r.session_id);
|
|
101
|
+
const placeholders = sessionIDs.map(() => "?").join(",");
|
|
102
|
+
const followRows = db
|
|
103
|
+
.query(
|
|
104
|
+
`SELECT session_id, id AS message_id, data
|
|
105
|
+
FROM session_message
|
|
106
|
+
WHERE session_id IN (${placeholders})
|
|
107
|
+
AND (type = 'user' OR type = 'assistant')
|
|
108
|
+
ORDER BY time_created ASC`,
|
|
109
|
+
)
|
|
110
|
+
.all(...sessionIDs) as Array<{
|
|
111
|
+
session_id: string;
|
|
112
|
+
message_id: string;
|
|
113
|
+
data: string;
|
|
114
|
+
}>;
|
|
115
|
+
|
|
116
|
+
const messagesBySession = new Map<string, Array<{ id: string; data: string }>>();
|
|
117
|
+
for (const row of followRows) {
|
|
118
|
+
if (!messagesBySession.has(row.session_id)) {
|
|
119
|
+
messagesBySession.set(row.session_id, []);
|
|
120
|
+
}
|
|
121
|
+
messagesBySession.get(row.session_id)!.push({ id: row.message_id, data: row.data });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const termHitsBySession = new Map<
|
|
125
|
+
string,
|
|
126
|
+
Map<string, { partID: string; matchType: string; excerpt: string }>
|
|
127
|
+
>();
|
|
128
|
+
|
|
129
|
+
for (const sessionID of sessionIDs) {
|
|
130
|
+
const sessionMap = new Map<
|
|
131
|
+
string,
|
|
132
|
+
{ partID: string; matchType: string; excerpt: string }
|
|
133
|
+
>();
|
|
134
|
+
termHitsBySession.set(sessionID, sessionMap);
|
|
135
|
+
|
|
136
|
+
const messages = messagesBySession.get(sessionID) ?? [];
|
|
137
|
+
for (const message of messages) {
|
|
138
|
+
if (sessionMap.size >= terms.length) break;
|
|
139
|
+
const lower = message.data.toLowerCase();
|
|
140
|
+
for (const term of terms) {
|
|
141
|
+
if (sessionMap.size >= terms.length) break;
|
|
142
|
+
if (sessionMap.has(term)) continue;
|
|
143
|
+
const termLower = term.toLowerCase();
|
|
144
|
+
const idx = lower.indexOf(termLower);
|
|
145
|
+
if (idx === -1) continue;
|
|
146
|
+
const start = Math.max(0, idx - 50);
|
|
147
|
+
const end = Math.min(
|
|
148
|
+
message.data.length,
|
|
149
|
+
idx + termLower.length + 50,
|
|
150
|
+
);
|
|
151
|
+
sessionMap.set(term, {
|
|
152
|
+
partID: message.id,
|
|
153
|
+
matchType: "data",
|
|
154
|
+
excerpt: message.data.slice(start, end),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return rows.map((row) => ({
|
|
161
|
+
sessionID: row.session_id,
|
|
162
|
+
sessionTitle: row.title || "",
|
|
163
|
+
timestamp: row.time_updated,
|
|
164
|
+
projectDirectory: row.directory,
|
|
165
|
+
termHits: termHitsBySession.get(row.session_id) ?? new Map(),
|
|
166
|
+
}));
|
|
167
|
+
} finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { getDataDir } from "./paths";
|
|
4
|
+
import {
|
|
5
|
+
dbExists,
|
|
6
|
+
listSessionsSqlite,
|
|
7
|
+
listMessagesSqlite,
|
|
8
|
+
listPartsSqlite,
|
|
9
|
+
type ProjectFilter,
|
|
10
|
+
} from "./storage-sqlite";
|
|
11
|
+
import {
|
|
12
|
+
listSessions as listSessionsJSON,
|
|
13
|
+
listMessages as listMessagesJSON,
|
|
14
|
+
listParts as listPartsJSON,
|
|
15
|
+
getStorageDir,
|
|
16
|
+
} from "./storage";
|
|
17
|
+
import type { Session, Message, Part } from "./storage";
|
|
18
|
+
|
|
19
|
+
// Evaluated per call (not at module load) so a database created after the
|
|
20
|
+
// plugin loaded is picked up without a restart.
|
|
21
|
+
function useSqlite(): boolean {
|
|
22
|
+
return dbExists();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the legacy JSON storage directory has any content at all. */
|
|
26
|
+
export function jsonStorageExists(): boolean {
|
|
27
|
+
const dir = path.join(getDataDir(), "storage");
|
|
28
|
+
for (const sub of ["session", "message", "part"]) {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.readdirSync(path.join(dir, sub)).length > 0) return true;
|
|
31
|
+
} catch {
|
|
32
|
+
// absent or unreadable, keep looking
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function* listSessions(
|
|
39
|
+
projectID: ProjectFilter,
|
|
40
|
+
): AsyncGenerator<Session> {
|
|
41
|
+
if (useSqlite()) {
|
|
42
|
+
yield* listSessionsSqlite(projectID);
|
|
43
|
+
} else {
|
|
44
|
+
// JSON fallback only understands a single project id.
|
|
45
|
+
yield* listSessionsJSON(Array.isArray(projectID) ? projectID[0] : projectID);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function* listMessages(
|
|
50
|
+
sessionID: string,
|
|
51
|
+
role?: "user" | "assistant",
|
|
52
|
+
): AsyncGenerator<Message> {
|
|
53
|
+
if (useSqlite()) {
|
|
54
|
+
yield* listMessagesSqlite(sessionID, role);
|
|
55
|
+
} else {
|
|
56
|
+
yield* listMessagesJSON(sessionID, role);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function* listParts(messageID: string): AsyncGenerator<Part> {
|
|
61
|
+
if (useSqlite()) {
|
|
62
|
+
yield* listPartsSqlite(messageID);
|
|
63
|
+
} else {
|
|
64
|
+
yield* listPartsJSON(messageID);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { getStorageDir };
|
|
69
|
+
export type { Session, Message, Part };
|
|
70
|
+
export type { ProjectFilter };
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { getDbPath } from "./paths";
|
|
3
|
+
import type { Session, Message, Part } from "./storage";
|
|
4
|
+
|
|
5
|
+
export { getDbPath } from "./paths";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenCode V2 SQLite backend.
|
|
9
|
+
*
|
|
10
|
+
* V2 stores live sessions in the `session_v2` and `session_message` tables of
|
|
11
|
+
* `~/.local/share/opencode/opencode.db`. The legacy V1 `session`/`message`/`part`
|
|
12
|
+
* tables are a frozen copy (pre-migration); `session_v2` already contains every
|
|
13
|
+
* legacy session id, so reading the V2 tables covers all eras.
|
|
14
|
+
*
|
|
15
|
+
* Message rows carry their parts inline:
|
|
16
|
+
* user: { time, text, files? }
|
|
17
|
+
* assistant: { time, agent, model, content: [ {type:"text"|"tool"|"reasoning"|...} ] }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A single project id, a list of project ids (e.g. current + legacy "global"),
|
|
21
|
+
* or null for "all projects". */
|
|
22
|
+
export type ProjectFilter = string | string[] | null;
|
|
23
|
+
|
|
24
|
+
export function dbExists(): boolean {
|
|
25
|
+
try {
|
|
26
|
+
return Bun.file(getDbPath()).size > 0;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function openDb(): Database {
|
|
33
|
+
const db = new Database(getDbPath(), { readonly: true });
|
|
34
|
+
// Schema-drift guard: if a future OpenCode renames the V2 tables, fail
|
|
35
|
+
// with an actionable message instead of a raw SQL error.
|
|
36
|
+
const tables = db
|
|
37
|
+
.query(
|
|
38
|
+
`SELECT name FROM sqlite_master
|
|
39
|
+
WHERE type = 'table' AND name IN ('session_v2', 'session_message')`,
|
|
40
|
+
)
|
|
41
|
+
.all() as Array<{ name: string }>;
|
|
42
|
+
if (tables.length < 2) {
|
|
43
|
+
db.close();
|
|
44
|
+
throw new Error(
|
|
45
|
+
`OpenCode history database at ${getDbPath()} does not contain the expected V2 tables (session_v2, session_message). This OpenCode version may use an unsupported schema.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build a SQL project filter fragment plus its bind values. */
|
|
52
|
+
export function projectFilterParts(
|
|
53
|
+
filter: ProjectFilter,
|
|
54
|
+
alias = "s",
|
|
55
|
+
): { clause: string; binds: string[] } {
|
|
56
|
+
if (filter === null) return { clause: "", binds: [] };
|
|
57
|
+
const ids = Array.isArray(filter) ? filter : [filter];
|
|
58
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
59
|
+
return { clause: `AND ${alias}.project_id IN (${placeholders})`, binds: ids };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function* listSessionsSqlite(
|
|
63
|
+
projectID: ProjectFilter,
|
|
64
|
+
db?: Database,
|
|
65
|
+
): AsyncGenerator<Session> {
|
|
66
|
+
const shouldClose = db === undefined;
|
|
67
|
+
const _db = db ?? openDb();
|
|
68
|
+
try {
|
|
69
|
+
const { clause, binds } = projectFilterParts(projectID);
|
|
70
|
+
const rows = _db
|
|
71
|
+
.query(
|
|
72
|
+
`SELECT s.id, s.project_id, s.title, s.directory, s.time_created, s.time_updated
|
|
73
|
+
FROM session_v2 s
|
|
74
|
+
WHERE 1=1 ${clause}
|
|
75
|
+
ORDER BY time_updated DESC`,
|
|
76
|
+
)
|
|
77
|
+
.all(...binds) as Array<{
|
|
78
|
+
id: string;
|
|
79
|
+
project_id: string;
|
|
80
|
+
title: string | null;
|
|
81
|
+
directory: string;
|
|
82
|
+
time_created: number;
|
|
83
|
+
time_updated: number;
|
|
84
|
+
}>;
|
|
85
|
+
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
yield {
|
|
88
|
+
id: row.id,
|
|
89
|
+
projectID: row.project_id,
|
|
90
|
+
title: row.title || "",
|
|
91
|
+
directory: row.directory,
|
|
92
|
+
time: { created: row.time_created, updated: row.time_updated },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
if (shouldClose) _db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function* listMessagesSqlite(
|
|
101
|
+
sessionID: string,
|
|
102
|
+
role?: "user" | "assistant",
|
|
103
|
+
db?: Database,
|
|
104
|
+
): AsyncGenerator<Message> {
|
|
105
|
+
const shouldClose = db === undefined;
|
|
106
|
+
const _db = db ?? openDb();
|
|
107
|
+
try {
|
|
108
|
+
const rows = _db
|
|
109
|
+
.query(
|
|
110
|
+
`SELECT id, session_id, type, seq, time_created, data
|
|
111
|
+
FROM session_message WHERE session_id = ?
|
|
112
|
+
ORDER BY seq ASC`,
|
|
113
|
+
)
|
|
114
|
+
.all(sessionID) as Array<{
|
|
115
|
+
id: string;
|
|
116
|
+
session_id: string;
|
|
117
|
+
type: string;
|
|
118
|
+
seq: number;
|
|
119
|
+
time_created: number;
|
|
120
|
+
data: string;
|
|
121
|
+
}>;
|
|
122
|
+
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
// Only user/assistant messages carry searchable conversation content.
|
|
125
|
+
// (idle, compaction, synthetic, system, agent-switched are skipped.)
|
|
126
|
+
if (row.type !== "user" && row.type !== "assistant") continue;
|
|
127
|
+
if (role && row.type !== role) continue;
|
|
128
|
+
|
|
129
|
+
let data: any;
|
|
130
|
+
try {
|
|
131
|
+
data = JSON.parse(row.data);
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
yield {
|
|
137
|
+
id: row.id,
|
|
138
|
+
sessionID: row.session_id,
|
|
139
|
+
role: row.type,
|
|
140
|
+
agent: data.agent || "",
|
|
141
|
+
time: { created: row.time_created },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
if (shouldClose) _db.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Normalize a V2 tool part's `state.content` (string or structured) to text. */
|
|
150
|
+
function toolOutputText(state: any): string {
|
|
151
|
+
if (state == null) return "";
|
|
152
|
+
if (typeof state.content === "string") return state.content;
|
|
153
|
+
if (state.content !== undefined) {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.stringify(state.content);
|
|
156
|
+
} catch {
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function* listPartsSqlite(
|
|
164
|
+
messageID: string,
|
|
165
|
+
db?: Database,
|
|
166
|
+
): AsyncGenerator<Part> {
|
|
167
|
+
const shouldClose = db === undefined;
|
|
168
|
+
const _db = db ?? openDb();
|
|
169
|
+
try {
|
|
170
|
+
const row = _db
|
|
171
|
+
.query(
|
|
172
|
+
`SELECT id, session_id, type, data
|
|
173
|
+
FROM session_message WHERE id = ?`,
|
|
174
|
+
)
|
|
175
|
+
.get(messageID) as
|
|
176
|
+
| { id: string; session_id: string; type: string; data: string }
|
|
177
|
+
| null;
|
|
178
|
+
|
|
179
|
+
if (!row) return;
|
|
180
|
+
|
|
181
|
+
let data: any;
|
|
182
|
+
try {
|
|
183
|
+
data = JSON.parse(row.data);
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (row.type === "user") {
|
|
189
|
+
// User content is a flat text field (plus optional file attachments).
|
|
190
|
+
if (typeof data.text === "string" && data.text) {
|
|
191
|
+
yield {
|
|
192
|
+
id: `${row.id}#text`,
|
|
193
|
+
messageID: row.id,
|
|
194
|
+
sessionID: row.session_id,
|
|
195
|
+
type: "text",
|
|
196
|
+
text: data.text,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (row.type !== "assistant") return;
|
|
203
|
+
|
|
204
|
+
const content = Array.isArray(data.content) ? data.content : [];
|
|
205
|
+
for (let i = 0; i < content.length; i++) {
|
|
206
|
+
const p = content[i];
|
|
207
|
+
if (!p || typeof p !== "object") continue;
|
|
208
|
+
|
|
209
|
+
if (p.type === "text" && typeof p.text === "string" && p.text) {
|
|
210
|
+
yield {
|
|
211
|
+
id: `${row.id}#text${i}`,
|
|
212
|
+
messageID: row.id,
|
|
213
|
+
sessionID: row.session_id,
|
|
214
|
+
type: "text",
|
|
215
|
+
text: p.text,
|
|
216
|
+
};
|
|
217
|
+
} else if (p.type === "tool") {
|
|
218
|
+
const state = p.state || {};
|
|
219
|
+
const errorText =
|
|
220
|
+
state.error && typeof state.error.message === "string"
|
|
221
|
+
? state.error.message
|
|
222
|
+
: state.error
|
|
223
|
+
? JSON.stringify(state.error)
|
|
224
|
+
: "";
|
|
225
|
+
yield {
|
|
226
|
+
id: typeof p.id === "string" ? p.id : `${row.id}#tool${i}`,
|
|
227
|
+
messageID: row.id,
|
|
228
|
+
sessionID: row.session_id,
|
|
229
|
+
type: "tool",
|
|
230
|
+
tool: typeof p.name === "string" ? p.name : undefined,
|
|
231
|
+
state: {
|
|
232
|
+
input: state.input,
|
|
233
|
+
output: toolOutputText(state) + (errorText ? `\n${errorText}` : ""),
|
|
234
|
+
title: state.title,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
} else if (p.type === "patch" && Array.isArray(p.files)) {
|
|
238
|
+
yield {
|
|
239
|
+
id: `${row.id}#patch${i}`,
|
|
240
|
+
messageID: row.id,
|
|
241
|
+
sessionID: row.session_id,
|
|
242
|
+
type: "patch",
|
|
243
|
+
files: p.files.filter((f: any) => typeof f === "string"),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// reasoning, step-start, step-finish, compaction, file, subtask, etc.
|
|
247
|
+
// are not searched (matches V1 behaviour, which skipped reasoning parts).
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
if (shouldClose) _db.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { Glob } from "bun";
|
|
4
|
+
import { getDataDir } from "./paths";
|
|
5
|
+
|
|
6
|
+
export interface Session {
|
|
7
|
+
id: string;
|
|
8
|
+
projectID: string;
|
|
9
|
+
title: string;
|
|
10
|
+
directory: string;
|
|
11
|
+
time: { created: number; updated: number };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Message {
|
|
15
|
+
id: string;
|
|
16
|
+
sessionID: string;
|
|
17
|
+
role: "user" | "assistant";
|
|
18
|
+
agent: string;
|
|
19
|
+
time: { created: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Part {
|
|
23
|
+
id: string;
|
|
24
|
+
messageID: string;
|
|
25
|
+
sessionID: string;
|
|
26
|
+
type: "text" | "tool" | "file" | "patch";
|
|
27
|
+
text?: string;
|
|
28
|
+
tool?: string;
|
|
29
|
+
state?: {
|
|
30
|
+
input?: any;
|
|
31
|
+
output?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
};
|
|
34
|
+
files?: string[]; // For patch parts — list of modified file paths
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getStorageDir(): Promise<string> {
|
|
38
|
+
return path.join(getDataDir(), "storage");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function* listSessions(
|
|
42
|
+
projectID: string | null,
|
|
43
|
+
): AsyncGenerator<Session> {
|
|
44
|
+
const storageDir = await getStorageDir();
|
|
45
|
+
const sessionDir = path.join(storageDir, "session");
|
|
46
|
+
|
|
47
|
+
if (projectID !== null) {
|
|
48
|
+
const projectDir = path.join(sessionDir, projectID);
|
|
49
|
+
try {
|
|
50
|
+
for await (const file of new Glob("*.json").scan({ cwd: projectDir })) {
|
|
51
|
+
try {
|
|
52
|
+
const content = await Bun.file(path.join(projectDir, file)).json();
|
|
53
|
+
yield content as Session;
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let entries: string[];
|
|
65
|
+
try {
|
|
66
|
+
entries = fs.readdirSync(sessionDir);
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const projectDir = path.join(sessionDir, entry);
|
|
73
|
+
let stat: fs.Stats;
|
|
74
|
+
try {
|
|
75
|
+
stat = fs.statSync(projectDir);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!stat.isDirectory()) continue;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
for await (const file of new Glob("*.json").scan({ cwd: projectDir })) {
|
|
83
|
+
try {
|
|
84
|
+
const content = await Bun.file(path.join(projectDir, file)).json();
|
|
85
|
+
yield content as Session;
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function* listMessages(
|
|
97
|
+
sessionID: string,
|
|
98
|
+
role?: "user" | "assistant",
|
|
99
|
+
): AsyncGenerator<Message> {
|
|
100
|
+
const storageDir = await getStorageDir();
|
|
101
|
+
const messageDir = path.join(storageDir, "message", sessionID.trim());
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
for await (const file of new Glob("*.json").scan({ cwd: messageDir })) {
|
|
105
|
+
try {
|
|
106
|
+
const content = await Bun.file(path.join(messageDir, file)).json();
|
|
107
|
+
if (role && content.role !== role) continue;
|
|
108
|
+
yield content as Message;
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function* listParts(messageID: string): AsyncGenerator<Part> {
|
|
119
|
+
const storageDir = await getStorageDir();
|
|
120
|
+
const partDir = path.join(storageDir, "part", messageID.trim());
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
for await (const file of new Glob("*.json").scan({ cwd: partDir })) {
|
|
124
|
+
try {
|
|
125
|
+
const content = await Bun.file(path.join(partDir, file)).json();
|
|
126
|
+
yield content as Part;
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|