memhouse 0.0.1 → 0.3.1
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 +72 -3
- package/bin/memhouse.js +559 -0
- package/cache.js +850 -0
- package/ch-ingest.js +329 -0
- package/ch-schema.js +152 -0
- package/clickhouse.js +87 -0
- package/editors/adapter-errors.js +52 -0
- package/editors/antigravity.js +1048 -0
- package/editors/base.js +321 -0
- package/editors/claude.js +386 -0
- package/editors/codebuff.js +338 -0
- package/editors/codex.js +537 -0
- package/editors/commandcode.js +166 -0
- package/editors/copilot-jetbrains.js +207 -0
- package/editors/copilot.js +260 -0
- package/editors/cursor-agent.js +202 -0
- package/editors/cursor.js +498 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +386 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +274 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +432 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +229 -0
- package/index.js +424 -2
- package/mcp-server.js +294 -0
- package/mem-house/DESIGN.md +119 -0
- package/mem-house/delivery/AGENT-INSTALL.md +87 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +83 -0
- package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
- package/mem-house/rls.sql +17 -0
- package/mem-house/schema.sql +145 -0
- package/mem-house/server/queries.js +878 -0
- package/mem-house/server/server.js +228 -0
- package/mem-house/shipper/ship.js +461 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +59 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-CCPh7a2Z.js +75 -0
- package/public/assets/index-DV6ONi_F.css +2 -0
- package/public/index.html +16 -0
- package/relay-client.js +306 -0
- package/relay-server.js +577 -0
- package/server.js +860 -0
- package/share-image.js +367 -0
- package/ui/eslint.config.js +29 -0
- package/ui/index.html +15 -0
- package/ui/package-lock.json +4229 -0
- package/ui/package.json +38 -0
- package/ui/src/App.jsx +364 -0
- package/ui/src/components/ActivityHeatmap.jsx +206 -0
- package/ui/src/components/AiAuditCard.jsx +254 -0
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +226 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/components/EditorDot.jsx +11 -0
- package/ui/src/components/EditorIcon.jsx +117 -0
- package/ui/src/components/KpiCard.jsx +9 -0
- package/ui/src/components/LiveFeed.jsx +128 -0
- package/ui/src/components/LoginScreen.jsx +81 -0
- package/ui/src/components/MessageRenderer.jsx +148 -0
- package/ui/src/components/PageHeader.jsx +11 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/index.css +83 -0
- package/ui/src/lib/api.js +343 -0
- package/ui/src/lib/constants.js +88 -0
- package/ui/src/lib/theme.jsx +23 -0
- package/ui/src/main.jsx +16 -0
- package/ui/src/pages/Artifacts.jsx +575 -0
- package/ui/src/pages/Compare.jsx +232 -0
- package/ui/src/pages/CostAnalysis.jsx +354 -0
- package/ui/src/pages/Dashboard.jsx +529 -0
- package/ui/src/pages/DeepAnalysis.jsx +495 -0
- package/ui/src/pages/GSD.jsx +726 -0
- package/ui/src/pages/MCPs.jsx +744 -0
- package/ui/src/pages/ProjectDetail.jsx +325 -0
- package/ui/src/pages/Projects.jsx +249 -0
- package/ui/src/pages/RelayDashboard.jsx +590 -0
- package/ui/src/pages/RelayUserDetail.jsx +466 -0
- package/ui/src/pages/Sessions.jsx +535 -0
- package/ui/src/pages/Settings.jsx +241 -0
- package/ui/src/pages/SqlViewer.jsx +370 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- package/ui/vite.config.js +18 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const Database = require('better-sqlite3');
|
|
5
|
+
const adapterErrors = require('./adapter-errors');
|
|
6
|
+
|
|
7
|
+
// OpenCode stores data in XDG-style paths across all platforms
|
|
8
|
+
function getOpenCodeStoragePath() {
|
|
9
|
+
return path.join(os.homedir(), '.local', 'share', 'opencode', 'storage');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STORAGE_DIR = getOpenCodeStoragePath();
|
|
13
|
+
const SESSION_DIR = path.join(STORAGE_DIR, 'session');
|
|
14
|
+
const MESSAGE_DIR = path.join(STORAGE_DIR, 'message');
|
|
15
|
+
const PART_DIR = path.join(STORAGE_DIR, 'part');
|
|
16
|
+
|
|
17
|
+
// OpenCode also stores data in a SQLite database
|
|
18
|
+
function getOpenCodeDbPath() {
|
|
19
|
+
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DB_PATH = getOpenCodeDbPath();
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Query SQLite using better-sqlite3
|
|
26
|
+
// ============================================================
|
|
27
|
+
|
|
28
|
+
function queryDb(sql) {
|
|
29
|
+
if (!fs.existsSync(DB_PATH)) return [];
|
|
30
|
+
try {
|
|
31
|
+
const db = new Database(DB_PATH, { readonly: true });
|
|
32
|
+
const rows = db.prepare(sql).all();
|
|
33
|
+
db.close();
|
|
34
|
+
return rows;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// The store exists but could not be read. Returning [] keeps one bad database
|
|
37
|
+
// from killing the scan, but staying quiet would hide missing sessions.
|
|
38
|
+
adapterErrors.record('opencode', e, DB_PATH);
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractModelInfo(data) {
|
|
44
|
+
let modelValue = null;
|
|
45
|
+
let providerValue = null;
|
|
46
|
+
|
|
47
|
+
if (typeof data?.modelID === 'string') {
|
|
48
|
+
modelValue = data.modelID;
|
|
49
|
+
providerValue = typeof data.providerID === 'string' ? data.providerID : null;
|
|
50
|
+
} else if (data?.model && typeof data.model === 'object') {
|
|
51
|
+
modelValue = typeof data.model.modelID === 'string' ? data.model.modelID : null;
|
|
52
|
+
providerValue = typeof data.providerID === 'string'
|
|
53
|
+
? data.providerID
|
|
54
|
+
: (typeof data.model.providerID === 'string' ? data.model.providerID : null);
|
|
55
|
+
} else if (typeof data?.model === 'string') {
|
|
56
|
+
modelValue = data.model;
|
|
57
|
+
providerValue = typeof data.providerID === 'string' ? data.providerID : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { modelValue, providerValue };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractTokenInfo(data) {
|
|
64
|
+
const tokens = data?.tokens && typeof data.tokens === 'object' ? data.tokens : null;
|
|
65
|
+
const cache = tokens?.cache && typeof tokens.cache === 'object' ? tokens.cache : null;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
inputTokens: tokens?.input,
|
|
69
|
+
outputTokens: tokens?.output,
|
|
70
|
+
cacheRead: cache?.read,
|
|
71
|
+
cacheWrite: cache?.write,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getSqliteSessions() {
|
|
76
|
+
return queryDb(
|
|
77
|
+
`SELECT s.id, s.title, s.directory, s.time_created, s.time_updated,
|
|
78
|
+
p.worktree, p.name as project_name,
|
|
79
|
+
(SELECT count(*) FROM message m WHERE m.session_id = s.id) as msg_count
|
|
80
|
+
FROM session s LEFT JOIN project p ON s.project_id = p.id
|
|
81
|
+
ORDER BY s.time_updated DESC`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getSqliteMessages(sessionId) {
|
|
86
|
+
if (!fs.existsSync(DB_PATH)) return [];
|
|
87
|
+
try {
|
|
88
|
+
const db = new Database(DB_PATH, { readonly: true });
|
|
89
|
+
const messages = db.prepare(
|
|
90
|
+
`SELECT m.id as msg_id, m.data as msg_data, m.time_created
|
|
91
|
+
FROM message m WHERE m.session_id = ? ORDER BY m.time_created ASC`
|
|
92
|
+
).all(sessionId);
|
|
93
|
+
|
|
94
|
+
const result = [];
|
|
95
|
+
for (const msg of messages) {
|
|
96
|
+
let msgData;
|
|
97
|
+
// A record we cannot decode is dropped from the transcript. Silently, that
|
|
98
|
+
// yields a TRUNCATED history that looks complete — and a re-ship would replace
|
|
99
|
+
// the full stored transcript with the shortened one. Report it so the shipper
|
|
100
|
+
// refuses to overwrite good data with a partial read.
|
|
101
|
+
try { msgData = JSON.parse(msg.msg_data); }
|
|
102
|
+
catch (e) { adapterErrors.record('opencode', e, `message ${msg.msg_id}`); continue; }
|
|
103
|
+
|
|
104
|
+
const role = msgData.role;
|
|
105
|
+
if (!role) continue;
|
|
106
|
+
|
|
107
|
+
const parts = db.prepare(
|
|
108
|
+
`SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC`
|
|
109
|
+
).all(msg.msg_id);
|
|
110
|
+
|
|
111
|
+
const contentParts = [];
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
let partData;
|
|
114
|
+
try { partData = JSON.parse(part.data); }
|
|
115
|
+
catch (e) { adapterErrors.record('opencode', e, `part of message ${msg.msg_id}`); continue; }
|
|
116
|
+
const type = partData.type;
|
|
117
|
+
|
|
118
|
+
if (type === 'text' && partData.text) {
|
|
119
|
+
contentParts.push(partData.text);
|
|
120
|
+
} else if (type === 'thinking' || type === 'reasoning') {
|
|
121
|
+
if (partData.text) contentParts.push(`[thinking] ${partData.text}`);
|
|
122
|
+
} else if (type === 'tool-call' || type === 'tool_use' || type === 'tool-use' || type === 'tool') {
|
|
123
|
+
const toolName = partData.name || partData.toolName || partData.tool || 'tool';
|
|
124
|
+
let argKeys = '';
|
|
125
|
+
try {
|
|
126
|
+
const input = typeof partData.input === 'string' ? JSON.parse(partData.input) : (partData.input || partData.args || partData.arguments || partData.state?.input || {});
|
|
127
|
+
argKeys = typeof input === 'object' ? Object.keys(input).join(', ') : '';
|
|
128
|
+
} catch {}
|
|
129
|
+
contentParts.push(`[tool-call: ${toolName}(${argKeys})]`);
|
|
130
|
+
} else if (type === 'tool-result' || type === 'tool_result') {
|
|
131
|
+
const preview = (partData.text || partData.output || partData.state?.output || '').substring(0, 500);
|
|
132
|
+
contentParts.push(`[tool-result] ${preview}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const content = contentParts.join('\n');
|
|
137
|
+
if (!content) continue;
|
|
138
|
+
|
|
139
|
+
const { modelValue, providerValue } = extractModelInfo(msgData);
|
|
140
|
+
const { inputTokens, outputTokens, cacheRead, cacheWrite } = extractTokenInfo(msgData);
|
|
141
|
+
|
|
142
|
+
result.push({
|
|
143
|
+
role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role,
|
|
144
|
+
content,
|
|
145
|
+
_model: modelValue,
|
|
146
|
+
_provider: providerValue,
|
|
147
|
+
_inputTokens: inputTokens,
|
|
148
|
+
_outputTokens: outputTokens,
|
|
149
|
+
_cacheRead: cacheRead,
|
|
150
|
+
_cacheWrite: cacheWrite,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
db.close();
|
|
155
|
+
return result;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// Message reads must report too, not just the session scan: an empty transcript
|
|
158
|
+
// is written over the stored one on re-ship.
|
|
159
|
+
adapterErrors.record('opencode', e, DB_PATH);
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============================================================
|
|
165
|
+
// Scan JSON files from OpenCode storage
|
|
166
|
+
// ============================================================
|
|
167
|
+
|
|
168
|
+
function readJson(filePath) {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
171
|
+
} catch { return null; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getAllSessions() {
|
|
175
|
+
const sessions = [];
|
|
176
|
+
if (!fs.existsSync(SESSION_DIR)) return sessions;
|
|
177
|
+
|
|
178
|
+
for (const projectHash of fs.readdirSync(SESSION_DIR)) {
|
|
179
|
+
const projectDir = path.join(SESSION_DIR, projectHash);
|
|
180
|
+
if (!fs.statSync(projectDir).isDirectory()) continue;
|
|
181
|
+
|
|
182
|
+
let files;
|
|
183
|
+
// Discovery-time drop: the project directory is there but will not enumerate, so
|
|
184
|
+
// every session under it vanishes before anything downstream can miss it.
|
|
185
|
+
try { files = fs.readdirSync(projectDir).filter(f => f.startsWith('ses_') && f.endsWith('.json')); }
|
|
186
|
+
catch (e) { adapterErrors.record('opencode', e, projectDir); continue; }
|
|
187
|
+
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const filePath = path.join(projectDir, file);
|
|
190
|
+
const data = readJson(filePath);
|
|
191
|
+
if (data && data.id) {
|
|
192
|
+
sessions.push({ ...data, _filePath: filePath });
|
|
193
|
+
} else {
|
|
194
|
+
// The file was listed, so it exists. Failing to decode it drops the session
|
|
195
|
+
// from discovery entirely — it reaches neither doctor nor the shipper, and
|
|
196
|
+
// nothing downstream can miss what it never saw.
|
|
197
|
+
adapterErrors.record('opencode', new Error('unreadable session metadata file'), filePath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return sessions;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Returns the message count, or null when it could not be determined. Null and 0
|
|
205
|
+
// are NOT interchangeable: bubbleCount feeds the shipper's skip predicate, and a
|
|
206
|
+
// zero standing in for "unknown" makes `prev.bc >= 0` trivially true, so the session
|
|
207
|
+
// is skipped before anything can read it — stale forever, on every incremental pass.
|
|
208
|
+
function getMessageCount(sessionId) {
|
|
209
|
+
const sessionMsgDir = path.join(MESSAGE_DIR, sessionId);
|
|
210
|
+
if (!fs.existsSync(sessionMsgDir)) return 0;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
return fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')).length;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
adapterErrors.record('opencode', e, sessionMsgDir);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getMessagesForSession(sessionId) {
|
|
221
|
+
const sessionMsgDir = path.join(MESSAGE_DIR, sessionId);
|
|
222
|
+
if (!fs.existsSync(sessionMsgDir)) return [];
|
|
223
|
+
|
|
224
|
+
let files;
|
|
225
|
+
try { files = fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')); }
|
|
226
|
+
catch (e) {
|
|
227
|
+
// The directory exists (checked above) but is unreadable — report it, or the
|
|
228
|
+
// empty result overwrites the stored transcript on re-ship.
|
|
229
|
+
adapterErrors.record('opencode', e, sessionMsgDir);
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const rawMsgs = [];
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
const msgPath = path.join(sessionMsgDir, file);
|
|
236
|
+
const msg = readJson(msgPath);
|
|
237
|
+
// The file was listed, so it exists; failing to decode it drops a message from
|
|
238
|
+
// the transcript and would let a re-ship overwrite the stored copy with less.
|
|
239
|
+
if (!msg) { adapterErrors.record('opencode', new Error('unreadable message file'), msgPath); continue; }
|
|
240
|
+
if (!msg.id) continue;
|
|
241
|
+
rawMsgs.push(msg);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Sort by creation time before building output
|
|
245
|
+
rawMsgs.sort((a, b) => (a.time?.created || 0) - (b.time?.created || 0));
|
|
246
|
+
|
|
247
|
+
const messages = [];
|
|
248
|
+
for (const msg of rawMsgs) {
|
|
249
|
+
// Get parts for this message
|
|
250
|
+
const msgPartDir = path.join(PART_DIR, msg.id);
|
|
251
|
+
const parts = [];
|
|
252
|
+
if (fs.existsSync(msgPartDir)) {
|
|
253
|
+
try {
|
|
254
|
+
const partFiles = fs.readdirSync(msgPartDir).filter(f => f.startsWith('prt_') && f.endsWith('.json'));
|
|
255
|
+
for (const partFile of partFiles) {
|
|
256
|
+
const partPath = path.join(msgPartDir, partFile);
|
|
257
|
+
const part = readJson(partPath);
|
|
258
|
+
if (part) parts.push(part);
|
|
259
|
+
else adapterErrors.record('opencode', new Error('unreadable part file'), partPath);
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// The directory exists (checked above) but cannot be enumerated, so none of
|
|
263
|
+
// the per-file records above ran and the message collapses to a bare [role]
|
|
264
|
+
// placeholder. Unreported, that truncation would overwrite stored content.
|
|
265
|
+
adapterErrors.record('opencode', e, msgPartDir);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Build content from parts
|
|
270
|
+
const contentParts = [];
|
|
271
|
+
for (const part of parts) {
|
|
272
|
+
const type = part.type;
|
|
273
|
+
|
|
274
|
+
if (type === 'text' && part.text) {
|
|
275
|
+
contentParts.push(part.text);
|
|
276
|
+
} else if (type === 'thinking' || type === 'reasoning') {
|
|
277
|
+
if (part.text) contentParts.push(`[thinking] ${part.text}`);
|
|
278
|
+
} else if (type === 'tool-call' || type === 'tool_use' || type === 'tool') {
|
|
279
|
+
const toolName = part.name || part.toolName || part.tool || 'tool';
|
|
280
|
+
const args = part.args || part.arguments || part.state?.input || {};
|
|
281
|
+
const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
|
|
282
|
+
contentParts.push(`[tool-call: ${toolName}(${argKeys})]`);
|
|
283
|
+
} else if (type === 'tool-result' || type === 'tool_result') {
|
|
284
|
+
const preview = (part.text || part.output || part.state?.output || '').substring(0, 500);
|
|
285
|
+
contentParts.push(`[tool-result] ${preview}`);
|
|
286
|
+
} else if (type === 'step-start' || type === 'step-finish') {
|
|
287
|
+
// Skip metadata parts
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// If no parts with content, check if message itself has content
|
|
292
|
+
if (contentParts.length === 0 && msg.role) {
|
|
293
|
+
contentParts.push(`[${msg.role}]`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const content = contentParts.join('\n');
|
|
297
|
+
if (content) {
|
|
298
|
+
const { modelValue, providerValue } = extractModelInfo(msg);
|
|
299
|
+
const { inputTokens, outputTokens, cacheRead, cacheWrite } = extractTokenInfo(msg);
|
|
300
|
+
|
|
301
|
+
messages.push({
|
|
302
|
+
role: msg.role || 'assistant',
|
|
303
|
+
content,
|
|
304
|
+
_model: modelValue,
|
|
305
|
+
_provider: providerValue,
|
|
306
|
+
_inputTokens: inputTokens,
|
|
307
|
+
_outputTokens: outputTokens,
|
|
308
|
+
_cacheRead: cacheRead,
|
|
309
|
+
_cacheWrite: cacheWrite,
|
|
310
|
+
_finish: msg.finish,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return messages;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ============================================================
|
|
319
|
+
// Adapter interface
|
|
320
|
+
// ============================================================
|
|
321
|
+
|
|
322
|
+
const name = 'opencode';
|
|
323
|
+
|
|
324
|
+
function getChats() {
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
const chats = [];
|
|
327
|
+
|
|
328
|
+
// 1. JSON file-based sessions (newer storage format)
|
|
329
|
+
const fileSessions = getAllSessions();
|
|
330
|
+
for (const s of fileSessions) {
|
|
331
|
+
seen.add(s.id);
|
|
332
|
+
const msgCount = getMessageCount(s.id);
|
|
333
|
+
chats.push({
|
|
334
|
+
source: 'opencode',
|
|
335
|
+
composerId: s.id,
|
|
336
|
+
name: s.title || null,
|
|
337
|
+
createdAt: s.time?.created || null,
|
|
338
|
+
lastUpdatedAt: s.time?.updated || null,
|
|
339
|
+
mode: s.mode || 'opencode',
|
|
340
|
+
folder: s.directory || null,
|
|
341
|
+
encrypted: false,
|
|
342
|
+
bubbleCount: msgCount ?? 0,
|
|
343
|
+
_countUnknown: msgCount === null,
|
|
344
|
+
_agent: s.agent,
|
|
345
|
+
_model: s.modelID,
|
|
346
|
+
_provider: s.providerID,
|
|
347
|
+
_sessionData: s,
|
|
348
|
+
_storageType: 'file',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 2. SQLite sessions (older/primary store) — add any not already found in files
|
|
353
|
+
const dbSessions = getSqliteSessions();
|
|
354
|
+
for (const row of dbSessions) {
|
|
355
|
+
if (seen.has(row.id)) continue;
|
|
356
|
+
seen.add(row.id);
|
|
357
|
+
chats.push({
|
|
358
|
+
source: 'opencode',
|
|
359
|
+
composerId: row.id,
|
|
360
|
+
name: cleanTitle(row.title),
|
|
361
|
+
createdAt: row.time_created || null,
|
|
362
|
+
lastUpdatedAt: row.time_updated || null,
|
|
363
|
+
mode: 'opencode',
|
|
364
|
+
folder: row.worktree || row.directory || null,
|
|
365
|
+
encrypted: false,
|
|
366
|
+
bubbleCount: row.msg_count || 0,
|
|
367
|
+
_storageType: 'sqlite',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return chats.sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function cleanTitle(title) {
|
|
375
|
+
if (!title) return null;
|
|
376
|
+
if (title.startsWith('New session - ')) return null;
|
|
377
|
+
return title.substring(0, 120) || null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function getMessages(chat) {
|
|
381
|
+
// Prefer file-based messages; fall back to SQLite.
|
|
382
|
+
//
|
|
383
|
+
// Failures on the file path are only failures of the READ if the fallback also
|
|
384
|
+
// comes up empty. An unreadable message directory for a session that opencode.db
|
|
385
|
+
// still holds in full is not a reason to withhold the session — and leaving those
|
|
386
|
+
// entries in the sink would make the shipper do exactly that, forever. So they are
|
|
387
|
+
// retracted once SQLite produces a transcript. Scoped to the file attempt, so any
|
|
388
|
+
// truncation the SQLite reader reports about itself survives.
|
|
389
|
+
const beforeFile = adapterErrors.mark();
|
|
390
|
+
const fileMessages = getMessagesForSession(chat.composerId);
|
|
391
|
+
const afterFile = adapterErrors.mark();
|
|
392
|
+
if (fileMessages.length > 0) return fileMessages;
|
|
393
|
+
|
|
394
|
+
const sqliteMessages = getSqliteMessages(chat.composerId);
|
|
395
|
+
if (sqliteMessages.length > 0) adapterErrors.dropRange(beforeFile, afterFile);
|
|
396
|
+
return sqliteMessages;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const labels = { 'opencode': 'OpenCode' };
|
|
400
|
+
|
|
401
|
+
function getMCPServers() {
|
|
402
|
+
const { parseMcpConfigFile } = require('./base');
|
|
403
|
+
// OpenCode: ~/.config/opencode/opencode.json (mcp key maps to mcpServers format)
|
|
404
|
+
const globalConfig = path.join(os.homedir(), '.config', 'opencode', 'opencode.json');
|
|
405
|
+
const results = [];
|
|
406
|
+
if (fs.existsSync(globalConfig)) {
|
|
407
|
+
try {
|
|
408
|
+
const data = JSON.parse(fs.readFileSync(globalConfig, 'utf-8'));
|
|
409
|
+
const servers = data.mcp || {};
|
|
410
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
411
|
+
if (typeof cfg !== 'object') continue;
|
|
412
|
+
results.push({
|
|
413
|
+
name,
|
|
414
|
+
editor: 'opencode',
|
|
415
|
+
editorLabel: 'OpenCode',
|
|
416
|
+
scope: 'global',
|
|
417
|
+
configPath: globalConfig,
|
|
418
|
+
command: cfg.command || null,
|
|
419
|
+
args: cfg.args || [],
|
|
420
|
+
env: cfg.env ? Object.keys(cfg.env) : [],
|
|
421
|
+
url: cfg.url || null,
|
|
422
|
+
transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
|
|
423
|
+
disabled: false,
|
|
424
|
+
disabledTools: [],
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
} catch {}
|
|
428
|
+
}
|
|
429
|
+
return results;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
module.exports = { name, labels, getChats, getMessages, getMCPServers };
|