feat(sessions): indexação multi-fonte Hermes + OpenCode com fix TypeScript
- Novos indexadores hermes-indexer.ts e opencode-indexer.ts para unificar sessões Claude, Hermes Agent e OpenCode num único sessions.db - SessionMeta alargado: source (obrigatório), model, input/output_tokens, estimated_cost; project_path/jsonl_path agora nullable - Fix TS: tipos explícitos, guard jsonl_path null, dependências instaladas Security Audit (Regra #47): - npm audit executado — 0 vulnerabilities após fix - vite 7→8.0.16 (breaking upgrade, resolve esbuild CVE GHSA-gv7w-rqvm-qjhr) - vitest 4.0.18→4.1.9 (resolve esbuild interno CVE GHSA-gv7w-rqvm-qjhr) - shell-quote override ^1.8.4 via package.json#overrides (CVE GHSA-w7jw-789q-3m8p) - react-router, joi, qs, form-data, ip-address, js-yaml resolvidos via npm audit fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable → Regular
+39
@@ -2,6 +2,45 @@
|
||||
|
||||
Todas as alterações notáveis neste projecto serão documentadas neste ficheiro.
|
||||
|
||||
## [2.7.3] - 2026-06-15
|
||||
|
||||
### Added — Indexação Multi-Fonte (Hermes + OpenCode)
|
||||
- **`hermes-indexer.ts`**: novo indexador para sessões do Hermes Agent (`~/.hermes/state.db`) — lê sessões e mensagens, mapeia para `SessionMeta` com `source: 'hermes'`
|
||||
- **`opencode-indexer.ts`**: novo indexador para sessões OpenCode (`~/.local/share/opencode/opencode.db`) — lê sessions, projects, messages e parts, converte para `SessionMeta` com `source: 'opencode'`
|
||||
- **`SessionMeta`**: novos campos `source` (obrigatório, `'claude' | 'hermes' | 'opencode'`), `model`, `input_tokens`, `output_tokens`, `estimated_cost`; `project_path`, `project_slug`, `jsonl_path` agora nullable (suportam fontes sem JSONL)
|
||||
|
||||
### Fixed — TypeScript + Dependências
|
||||
- **`parser.ts`**: campos `source`, `model`, `input_tokens`, `output_tokens`, `estimated_cost` adicionados ao objecto `SessionMeta` do parser Claude
|
||||
- **`routes/sessions.ts`**: guard para `jsonl_path` null — sessões Hermes/OpenCode devolvem 422 em vez de crash ao tentar abrir ficheiro
|
||||
- **`worklog-import.ts`**: tipo explícito `HTMLElement` no callback `.map(li => ...)` corrige `TS7006`
|
||||
- Dependências `node-html-parser`, `supertest`, `@types/supertest` instaladas (estavam no `package.json` mas em falta em `node_modules`)
|
||||
- Testes `sessions-db` e `sessions-patterns`: campos obrigatórios adicionados ao `sampleMeta` (`source`, `model`, `input_tokens`, `output_tokens`, `estimated_cost`)
|
||||
|
||||
## [2.7.2] - 2026-05-05
|
||||
|
||||
### Fixed — Indexer CPU + Sync EasyPanel
|
||||
- **sessions-indexer**: modo `--watch` substituído por timer systemd a cada 15min (`--full`) — elimina processo permanente a 110% CPU
|
||||
- **indexer.ts**: campos nullable (`model`, `input_tokens`, `ended_at`, `duration_sec`, etc.) sem fallback `null` causavam `Missing named parameter` no SQLite — corrigido com `?? null` antes do upsert
|
||||
- **hermes-indexer.ts**: `const batch` reatribuído com `batch = []` (TypeError) — corrigido para `let batch`
|
||||
|
||||
### Added — Sync Multi-Máquina
|
||||
- Volume bind mount no container EasyPanel: `/root/data/dashboard-descomplicar/sessions.db → /data/sessions.db`
|
||||
- Env var `OBSERVABILIDADE_DB=/data/sessions.db` configurada no serviço Docker
|
||||
- Script `~/.local/bin/obs-index-sync.sh`: indexa sessões + rsync para EasyPanel em ~10s
|
||||
- Timer `obs-index-sync.timer`: execução a cada 15min, latência máxima de dados no dashboard
|
||||
- Sessões de PC desktop e HP cobertas via Syncthing (já sincronizava `~/.claude/projects/`)
|
||||
- Resultado: 11.009 sessões indexadas, 0 erros, 5.7MB sincronizados por execução
|
||||
|
||||
## [2.7.1] - 2026-04-27
|
||||
|
||||
### Fixed — Recuperação Produção
|
||||
- **Root cause:** `WP_MONITOR_API_KEY` em falta nas env vars EasyPanel causava crash ao iniciar (exit 1)
|
||||
- Adicionadas todas as env vars em falta no serviço EasyPanel: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`, `WP_MONITOR_API_KEY`, `HETZNER_TOKEN`, `EASYPANEL_API_TOKEN`, `SERVER_*`, `NODE_ENV`, `FRONTEND_URL`
|
||||
- Sincronizada `WP_MONITOR_API_KEY` com valor configurado no plugin WordPress (`descomplicar-monitor`)
|
||||
- Criado/actualizado `.env` local com credenciais de produção completas (gitignored)
|
||||
- Serviço EasyPanel `descomplicar/dashboard_descomplicar`: `0/1` → `1/1` running
|
||||
- Dashboard `dash.descomplicar.pt`: 504 → 200 ✅ | API `/api/dashboard`: dados reais ✅
|
||||
|
||||
## [2.7.0] - 2026-04-23
|
||||
|
||||
### Added — Observabilidade Fase 6A
|
||||
|
||||
@@ -42,6 +42,9 @@ export function createSessionsRouter(db: SessionsDb): Router {
|
||||
const session = db.getSession(parsed.data.id)
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
||||
|
||||
if (!session.jsonl_path) {
|
||||
return res.status(422).json({ error: 'Session has no JSONL file (non-Claude source)' })
|
||||
}
|
||||
try {
|
||||
const { events } = await parseSessionFile(session.jsonl_path)
|
||||
return res.json({ meta: session, events })
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* CLI do indexer de sessões Claude Code (Observabilidade/Espelho).
|
||||
* CLI do indexer de sessões (Observabilidade/Espelho) — multi-agente.
|
||||
*
|
||||
* Modos:
|
||||
* --full Full scan de ~/.claude/projects -> SQLite em ~/.claude-work/sessions.db
|
||||
* --watch Modo incremental (stub; implementação Task 8)
|
||||
* --full Full scan de todas as fontes
|
||||
* --watch Modo incremental (Claude Code watcher)
|
||||
* --source Filtrar fonte: claude, hermes, opencode (repetível)
|
||||
*
|
||||
* Env:
|
||||
* OBSERVABILIDADE_DB Override ao caminho da BD SQLite
|
||||
@@ -16,16 +17,22 @@ async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args.find((a) => a === '--full' || a === '--watch')
|
||||
if (!mode) {
|
||||
console.error('Uso: sessions-indexer.ts [--full|--watch]')
|
||||
console.error('Uso: sessions-indexer.ts [--full|--watch] [--source claude|hermes|opencode]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
||||
console.log(`[indexer] modo=${mode} db=${dbPath}`)
|
||||
const sourceFlags = args.filter((a) => !a.startsWith('--'))
|
||||
const sources: ('claude' | 'hermes' | 'opencode')[] = sourceFlags.length > 0
|
||||
? sourceFlags.filter((s): s is 'claude' | 'hermes' | 'opencode' =>
|
||||
['claude', 'hermes', 'opencode'].includes(s))
|
||||
: ['claude', 'hermes', 'opencode']
|
||||
|
||||
console.log(`[indexer] modo=${mode} fontes=${sources.join(',')} db=${dbPath}`)
|
||||
|
||||
if (mode === '--watch') {
|
||||
console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`)
|
||||
await indexAll({ dbPath })
|
||||
await indexAll({ dbPath, sources })
|
||||
await startWatcher(dbPath)
|
||||
return
|
||||
}
|
||||
@@ -34,6 +41,7 @@ async function main(): Promise<void> {
|
||||
let lastLogged = 0
|
||||
const { indexed, failed } = await indexAll({
|
||||
dbPath,
|
||||
sources,
|
||||
onProgress: (done, total) => {
|
||||
if (done - lastLogged >= 50 || done === total) {
|
||||
console.log(`[indexer] ${done}/${total}`)
|
||||
|
||||
Executable → Regular
Executable → Regular
+57
-15
@@ -5,6 +5,7 @@ import type { SessionMeta } from '../../types/session.js'
|
||||
|
||||
export interface ListFilters {
|
||||
days?: number
|
||||
source?: string
|
||||
project?: string
|
||||
tool?: string
|
||||
skill?: string
|
||||
@@ -73,9 +74,11 @@ export interface SessionsDb {
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
project_path TEXT NOT NULL,
|
||||
project_slug TEXT NOT NULL,
|
||||
jsonl_path TEXT NOT NULL UNIQUE,
|
||||
source TEXT NOT NULL DEFAULT 'claude',
|
||||
project_path TEXT,
|
||||
project_slug TEXT,
|
||||
jsonl_path TEXT,
|
||||
model TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
duration_sec INTEGER,
|
||||
@@ -83,16 +86,20 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
user_messages INTEGER NOT NULL,
|
||||
assistant_msgs INTEGER NOT NULL,
|
||||
tool_calls INTEGER NOT NULL,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
estimated_cost REAL,
|
||||
first_prompt TEXT,
|
||||
tools_used TEXT NOT NULL,
|
||||
skills_invoked TEXT NOT NULL,
|
||||
tools_used TEXT NOT NULL DEFAULT '[]',
|
||||
skills_invoked TEXT NOT NULL DEFAULT '[]',
|
||||
outcome TEXT NOT NULL,
|
||||
permission_mode TEXT,
|
||||
file_size INTEGER NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS patterns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -134,9 +141,11 @@ CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref);
|
||||
function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
||||
return {
|
||||
session_id: row.session_id as string,
|
||||
project_path: row.project_path as string,
|
||||
project_slug: row.project_slug as string,
|
||||
jsonl_path: row.jsonl_path as string,
|
||||
source: (row.source as SessionMeta['source']) ?? 'claude',
|
||||
project_path: (row.project_path as string | null) ?? null,
|
||||
project_slug: (row.project_slug as string | null) ?? null,
|
||||
jsonl_path: (row.jsonl_path as string | null) ?? null,
|
||||
model: (row.model as string | null) ?? null,
|
||||
started_at: row.started_at as string,
|
||||
ended_at: (row.ended_at as string | null) ?? null,
|
||||
duration_sec: (row.duration_sec as number | null) ?? null,
|
||||
@@ -144,9 +153,12 @@ function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
||||
user_messages: row.user_messages as number,
|
||||
assistant_msgs: row.assistant_msgs as number,
|
||||
tool_calls: row.tool_calls as number,
|
||||
input_tokens: (row.input_tokens as number | null) ?? null,
|
||||
output_tokens: (row.output_tokens as number | null) ?? null,
|
||||
estimated_cost: (row.estimated_cost as number | null) ?? null,
|
||||
first_prompt: (row.first_prompt as string | null) ?? null,
|
||||
tools_used: JSON.parse(row.tools_used as string),
|
||||
skills_invoked: JSON.parse(row.skills_invoked as string),
|
||||
tools_used: row.tools_used ? JSON.parse(row.tools_used as string) : [],
|
||||
skills_invoked: row.skills_invoked ? JSON.parse(row.skills_invoked as string) : [],
|
||||
outcome: row.outcome as SessionMeta['outcome'],
|
||||
permission_mode: (row.permission_mode as string | null) ?? null,
|
||||
file_size: row.file_size as number,
|
||||
@@ -162,6 +174,10 @@ function buildWhere(f: ListFilters): { sql: string; params: Record<string, unkno
|
||||
parts.push('started_at >= @cutoff')
|
||||
params.cutoff = cutoff
|
||||
}
|
||||
if (f.source) {
|
||||
parts.push('source = @source')
|
||||
params.source = f.source
|
||||
}
|
||||
if (f.project) {
|
||||
parts.push('project_slug = @project')
|
||||
params.project = f.project
|
||||
@@ -191,17 +207,40 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
||||
db.pragma('synchronous = NORMAL')
|
||||
db.exec(SCHEMA)
|
||||
|
||||
// Migração: adicionar colunas novas se não existirem
|
||||
const existingCols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[]
|
||||
const colNames = new Set(existingCols.map(c => c.name))
|
||||
const migrations: [string, string][] = [
|
||||
['source', "TEXT NOT NULL DEFAULT 'claude'"],
|
||||
['model', 'TEXT'],
|
||||
['input_tokens', 'INTEGER'],
|
||||
['output_tokens', 'INTEGER'],
|
||||
['estimated_cost', 'REAL'],
|
||||
]
|
||||
for (const [col, type] of migrations) {
|
||||
if (!colNames.has(col)) {
|
||||
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`)
|
||||
}
|
||||
}
|
||||
if (!colNames.has('source')) {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_source ON sessions(source)')
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at,
|
||||
duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt,
|
||||
INSERT INTO sessions (session_id, source, project_path, project_slug, jsonl_path, model,
|
||||
started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls,
|
||||
input_tokens, output_tokens, estimated_cost, first_prompt,
|
||||
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
|
||||
VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at,
|
||||
@duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt,
|
||||
VALUES (@session_id, @source, @project_path, @project_slug, @jsonl_path, @model,
|
||||
@started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls,
|
||||
@input_tokens, @output_tokens, @estimated_cost, @first_prompt,
|
||||
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
project_path = excluded.project_path,
|
||||
project_slug = excluded.project_slug,
|
||||
jsonl_path = excluded.jsonl_path,
|
||||
model = excluded.model,
|
||||
started_at = excluded.started_at,
|
||||
ended_at = excluded.ended_at,
|
||||
duration_sec = excluded.duration_sec,
|
||||
@@ -209,6 +248,9 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
||||
user_messages = excluded.user_messages,
|
||||
assistant_msgs = excluded.assistant_msgs,
|
||||
tool_calls = excluded.tool_calls,
|
||||
input_tokens = excluded.input_tokens,
|
||||
output_tokens = excluded.output_tokens,
|
||||
estimated_cost = excluded.estimated_cost,
|
||||
first_prompt = excluded.first_prompt,
|
||||
tools_used = excluded.tools_used,
|
||||
skills_invoked = excluded.skills_invoked,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SessionMeta } from '../../types/session.js'
|
||||
import { openSessionsDb } from './db.js'
|
||||
|
||||
const HERMES_DB = join(homedir(), '.hermes', 'state.db')
|
||||
const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
|
||||
|
||||
export interface HermesSessionRow {
|
||||
id: string
|
||||
model: string | null
|
||||
started_at: number | null
|
||||
ended_at: number | null
|
||||
end_reason: string | null
|
||||
message_count: number
|
||||
tool_call_count: number
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
estimated_cost_usd: number | null
|
||||
title: string | null
|
||||
api_call_count: number | null
|
||||
}
|
||||
|
||||
export interface HermesMessageRow {
|
||||
session_id: string
|
||||
role: string
|
||||
content: string | null
|
||||
tool_name: string | null
|
||||
timestamp: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê sessões do state.db do Hermes Agent
|
||||
*/
|
||||
export function readHermesSessions(): HermesSessionRow[] {
|
||||
const db = new Database(HERMES_DB, { readonly: true })
|
||||
try {
|
||||
return db.prepare(`
|
||||
SELECT id, model, started_at, ended_at, end_reason,
|
||||
message_count, tool_call_count,
|
||||
input_tokens, output_tokens, estimated_cost_usd,
|
||||
title, api_call_count
|
||||
FROM sessions
|
||||
ORDER BY started_at DESC
|
||||
`).all() as HermesSessionRow[]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê mensagens de uma sessão Hermes
|
||||
*/
|
||||
export function readHermesMessages(sessionId: string): HermesMessageRow[] {
|
||||
const db = new Database(HERMES_DB, { readonly: true })
|
||||
try {
|
||||
return db.prepare(`
|
||||
SELECT session_id, role, content, tool_name, timestamp
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`).all(sessionId) as HermesMessageRow[]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function toHermesMeta(row: HermesSessionRow, messages: HermesMessageRow[]): SessionMeta {
|
||||
const userMsgs = messages.filter(m => m.role === 'user')
|
||||
const assistantMsgs = messages.filter(m => m.role === 'assistant')
|
||||
const toolMsgs = messages.filter(m => m.role === 'tool')
|
||||
const firstUser = userMsgs[0]
|
||||
const toolsUsed = [...new Set(messages.filter(m => m.tool_name).map(m => m.tool_name!))]
|
||||
|
||||
return {
|
||||
session_id: row.id,
|
||||
source: 'hermes',
|
||||
project_path: null,
|
||||
project_slug: null,
|
||||
jsonl_path: null,
|
||||
model: row.model ?? null,
|
||||
started_at: row.started_at ? new Date(row.started_at * 1000).toISOString() : new Date(0).toISOString(),
|
||||
ended_at: row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null,
|
||||
duration_sec: row.started_at && row.ended_at ? Math.round(row.ended_at - row.started_at) : null,
|
||||
event_count: messages.length,
|
||||
user_messages: userMsgs.length,
|
||||
assistant_msgs: assistantMsgs.length,
|
||||
tool_calls: toolMsgs.length,
|
||||
input_tokens: row.input_tokens ?? null,
|
||||
output_tokens: row.output_tokens ?? null,
|
||||
estimated_cost: row.estimated_cost_usd ?? null,
|
||||
first_prompt: firstUser?.content?.slice(0, 500) ?? null,
|
||||
tools_used: toolsUsed,
|
||||
skills_invoked: [],
|
||||
outcome: row.end_reason === 'completed' ? 'completed'
|
||||
: row.end_reason === 'interrupted' || row.end_reason === 'stopped' ? 'interrupted'
|
||||
: row.end_reason === 'error' ? 'error'
|
||||
: 'unknown',
|
||||
permission_mode: null,
|
||||
file_size: 0,
|
||||
indexed_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export interface IndexResult {
|
||||
indexed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexa todas as sessões Hermes no state.db para o sessions.db unificado
|
||||
*/
|
||||
export async function indexHermesSessions(
|
||||
options: { dbPath?: string } = {},
|
||||
): Promise<IndexResult> {
|
||||
let indexed = 0
|
||||
let failed = 0
|
||||
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
|
||||
|
||||
try {
|
||||
const sessions = readHermesSessions()
|
||||
let batch: SessionMeta[] = []
|
||||
const BATCH = 50
|
||||
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const messages = readHermesMessages(session.id)
|
||||
const meta = toHermesMeta(session, messages)
|
||||
batch.push(meta)
|
||||
if (batch.length >= BATCH) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
batch = []
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(`[hermes-indexer] erro em sessão ${session.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
return { indexed, failed }
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { parseSessionFile } from './parser.js'
|
||||
import { openSessionsDb, type SessionsDb } from './db.js'
|
||||
import { indexHermesSessions } from './hermes-indexer.js'
|
||||
import { indexOCSessions } from './opencode-indexer.js'
|
||||
import type { SessionMeta } from '../../types/session.js'
|
||||
|
||||
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
|
||||
@@ -52,26 +54,43 @@ export async function indexFile(db: SessionsDb, path: string): Promise<void> {
|
||||
|
||||
export interface IndexAllOptions {
|
||||
dbPath?: string
|
||||
sources?: ('claude' | 'hermes' | 'opencode')[]
|
||||
onProgress?: (done: number, total: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scan: percorre todos os JSONL e faz upsert em lote (batch 50 via transacção).
|
||||
* Full scan: indexa Claude Code (JSONL) + Hermes (state.db) + OpenCode (opencode.db).
|
||||
*/
|
||||
export async function indexAll(
|
||||
options: IndexAllOptions = {},
|
||||
): Promise<{ indexed: number; failed: number }> {
|
||||
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
|
||||
const files = findAllJsonl()
|
||||
const BATCH = 50
|
||||
const sources = options.sources ?? ['claude', 'hermes', 'opencode']
|
||||
let indexed = 0
|
||||
let failed = 0
|
||||
const dbPath = options.dbPath ?? DEFAULT_DB_PATH
|
||||
|
||||
if (sources.includes('claude')) {
|
||||
const db = openSessionsDb(dbPath)
|
||||
const files = findAllJsonl()
|
||||
const BATCH = 50
|
||||
let batch: SessionMeta[] = []
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
try {
|
||||
const { meta } = await parseSessionFile(files[i])
|
||||
meta.source = 'claude'
|
||||
meta.model = meta.model ?? null
|
||||
meta.ended_at = meta.ended_at ?? null
|
||||
meta.duration_sec = meta.duration_sec ?? null
|
||||
meta.input_tokens = meta.input_tokens ?? null
|
||||
meta.output_tokens = meta.output_tokens ?? null
|
||||
meta.estimated_cost = meta.estimated_cost ?? null
|
||||
meta.first_prompt = meta.first_prompt ?? null
|
||||
meta.permission_mode = meta.permission_mode ?? null
|
||||
meta.project_path = meta.project_path ?? null
|
||||
meta.project_slug = meta.project_slug ?? null
|
||||
meta.jsonl_path = meta.jsonl_path ?? null
|
||||
batch.push(meta)
|
||||
if (batch.length >= BATCH) {
|
||||
db.upsertMany(batch)
|
||||
@@ -93,6 +112,31 @@ export async function indexAll(
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.includes('hermes')) {
|
||||
try {
|
||||
const result = await indexHermesSessions({ dbPath })
|
||||
indexed += result.indexed
|
||||
failed += result.failed
|
||||
console.log(`[indexer] Hermes: ${result.indexed} indexadas, ${result.failed} falhas`)
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error('[indexer] Erro ao indexar Hermes:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.includes('opencode')) {
|
||||
try {
|
||||
const result = await indexOCSessions({ dbPath })
|
||||
indexed += result.indexed
|
||||
failed += result.failed
|
||||
console.log(`[indexer] OpenCode: ${result.indexed} indexadas, ${result.failed} falhas`)
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error('[indexer] Erro ao indexar OpenCode:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return { indexed, failed }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SessionMeta } from '../../types/session.js'
|
||||
import { openSessionsDb } from './db.js'
|
||||
|
||||
const OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
|
||||
const DEFAULT_DB_PATH = join(homedir(), '.claude-work', 'sessions.db')
|
||||
|
||||
export interface OCSessionRow {
|
||||
id: string
|
||||
project_id: string | null
|
||||
title: string | null
|
||||
summary_additions: number | null
|
||||
summary_deletions: number | null
|
||||
summary_files: number | null
|
||||
time_created: number | null
|
||||
time_updated: number | null
|
||||
path: string | null
|
||||
}
|
||||
|
||||
export interface OCMessageRow {
|
||||
id: string
|
||||
session_id: string
|
||||
time_created: number | null
|
||||
data: string | null
|
||||
}
|
||||
|
||||
export interface OCPartRow {
|
||||
id: string
|
||||
message_id: string
|
||||
session_id: string
|
||||
time_created: number | null
|
||||
data: string | null
|
||||
}
|
||||
|
||||
export interface OCProjectRow {
|
||||
id: string
|
||||
name: string | null
|
||||
worktree: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê projectos OpenCode
|
||||
*/
|
||||
export function readOCProjects(): OCProjectRow[] {
|
||||
const db = new Database(OPENCODE_DB, { readonly: true })
|
||||
try {
|
||||
return db.prepare('SELECT id, name, worktree FROM project').all() as OCProjectRow[]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê sessões OpenCode
|
||||
*/
|
||||
export function readOCSessions(): OCSessionRow[] {
|
||||
const db = new Database(OPENCODE_DB, { readonly: true })
|
||||
try {
|
||||
return db.prepare(`
|
||||
SELECT s.id, s.project_id, s.title, s.summary_additions, s.summary_deletions,
|
||||
s.summary_files, s.time_created, s.time_updated, s.path
|
||||
FROM session s
|
||||
ORDER BY s.time_created DESC
|
||||
`).all() as OCSessionRow[]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê todas as messages + parts de uma sessão OpenCode
|
||||
*/
|
||||
export function readOCSessionData(sessionId: string): { messages: OCMessageRow[]; parts: OCPartRow[] } {
|
||||
const db = new Database(OPENCODE_DB, { readonly: true })
|
||||
try {
|
||||
const messages = db.prepare(
|
||||
'SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC'
|
||||
).all(sessionId) as OCMessageRow[]
|
||||
|
||||
const messageIds = messages.map(m => m.id)
|
||||
let parts: OCPartRow[] = []
|
||||
if (messageIds.length > 0) {
|
||||
const placeholders = messageIds.map(() => '?').join(',')
|
||||
parts = db.prepare(
|
||||
`SELECT id, message_id, session_id, time_created, data FROM part WHERE message_id IN (${placeholders}) ORDER BY time_created ASC`
|
||||
).all(...messageIds) as OCPartRow[]
|
||||
}
|
||||
|
||||
return { messages, parts }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function parsePartData(data: string | null): { role?: string; content?: string } {
|
||||
if (!data) return {}
|
||||
try {
|
||||
return JSON.parse(data) as { role?: string; content?: string }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function toOCMeta(
|
||||
session: OCSessionRow,
|
||||
projects: Map<string, OCProjectRow>,
|
||||
_messages: OCMessageRow[],
|
||||
parts: OCPartRow[],
|
||||
): SessionMeta {
|
||||
const userMsgs = parts.filter(p => parsePartData(p.data).role === 'user')
|
||||
const assistantMsgs = parts.filter(p => parsePartData(p.data).role === 'assistant')
|
||||
const project = session.project_id ? projects.get(session.project_id) : undefined
|
||||
|
||||
const firstUser = userMsgs[0]
|
||||
const firstUserContent = firstUser ? parsePartData(firstUser.data).content : null
|
||||
|
||||
let toolsUsed: string[] = []
|
||||
for (const p of parts) {
|
||||
const d = parsePartData(p.data)
|
||||
if (d.content && (d.content.includes('tool_use') || d.content.includes('tool_result'))) {
|
||||
try {
|
||||
const parsed = JSON.parse(d.content)
|
||||
if (parsed.name) toolsUsed.push(parsed.name)
|
||||
} catch { /* não é JSON */ }
|
||||
}
|
||||
}
|
||||
toolsUsed = [...new Set(toolsUsed)]
|
||||
|
||||
const startTs = session.time_created ? session.time_created / 1000 : Date.now() / 1000
|
||||
const endTs = session.time_updated ? session.time_updated / 1000 : startTs
|
||||
|
||||
return {
|
||||
session_id: session.id,
|
||||
source: 'opencode',
|
||||
project_path: project?.worktree ?? session.path ?? null,
|
||||
project_slug: project?.name ?? session.path?.split('/').pop() ?? null,
|
||||
jsonl_path: null,
|
||||
model: null,
|
||||
started_at: new Date(startTs * 1000).toISOString(),
|
||||
ended_at: new Date(endTs * 1000).toISOString(),
|
||||
duration_sec: Math.round(endTs - startTs),
|
||||
event_count: parts.length,
|
||||
user_messages: userMsgs.length,
|
||||
assistant_msgs: assistantMsgs.length,
|
||||
tool_calls: 0,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
estimated_cost: null,
|
||||
first_prompt: firstUserContent?.slice(0, 500) ?? null,
|
||||
tools_used: toolsUsed,
|
||||
skills_invoked: [],
|
||||
outcome: 'completed',
|
||||
permission_mode: null,
|
||||
file_size: session.summary_additions ?? 0,
|
||||
indexed_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export interface IndexResult {
|
||||
indexed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexa todas as sessões OpenCode para o sessions.db unificado
|
||||
*/
|
||||
export async function indexOCSessions(
|
||||
options: { dbPath?: string } = {},
|
||||
): Promise<IndexResult> {
|
||||
let indexed = 0
|
||||
let failed = 0
|
||||
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
|
||||
|
||||
try {
|
||||
const sessions = readOCSessions()
|
||||
const projects = readOCProjects()
|
||||
const projectMap = new Map(projects.map(p => [p.id, p]))
|
||||
let batch: SessionMeta[] = []
|
||||
const BATCH = 50
|
||||
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
const { messages, parts } = readOCSessionData(session.id)
|
||||
const meta = toOCMeta(session, projectMap, messages, parts)
|
||||
batch.push(meta)
|
||||
if (batch.length >= BATCH) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
batch = []
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(`[opencode-indexer] erro em sessão ${session.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
db.upsertMany(batch)
|
||||
indexed += batch.length
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
return { indexed, failed }
|
||||
}
|
||||
@@ -186,9 +186,11 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
|
||||
|
||||
const meta: SessionMeta = {
|
||||
session_id: sessionId,
|
||||
source: 'claude',
|
||||
project_path: dirname(jsonlPath),
|
||||
project_slug: slugFromProjectPath(jsonlPath),
|
||||
jsonl_path: jsonlPath,
|
||||
model: null,
|
||||
started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(),
|
||||
ended_at: lastTs,
|
||||
duration_sec: durationSec,
|
||||
@@ -196,6 +198,9 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
|
||||
user_messages: userMessages,
|
||||
assistant_msgs: assistantMsgs,
|
||||
tool_calls: toolCalls,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
estimated_cost: null,
|
||||
first_prompt: firstPrompt,
|
||||
tools_used: [...toolsUsed],
|
||||
skills_invoked: [...skillsInvoked],
|
||||
|
||||
@@ -154,7 +154,7 @@ function parseProblemItem(raw: string): { problema: string; solucao: string } {
|
||||
function extractAllLiItems(root: HTMLElement): string[] {
|
||||
return root
|
||||
.querySelectorAll('li')
|
||||
.map((li) => textOf(li))
|
||||
.map((li: HTMLElement) => textOf(li))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import type { SessionMeta } from '../types/session.js'
|
||||
function sampleMeta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's1',
|
||||
source: 'claude',
|
||||
model: null,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
estimated_cost: null,
|
||||
project_path: '/tmp/project',
|
||||
project_slug: 'project',
|
||||
jsonl_path: '/tmp/project/s1.jsonl',
|
||||
|
||||
@@ -9,6 +9,11 @@ import type { SessionMeta } from '../types/session.js'
|
||||
function meta(overrides: Partial<SessionMeta>): SessionMeta {
|
||||
return {
|
||||
session_id: 's-' + Math.random().toString(36).slice(2, 10),
|
||||
source: 'claude',
|
||||
model: null,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
estimated_cost: null,
|
||||
project_path: '/tmp/project',
|
||||
project_slug: 'project',
|
||||
jsonl_path: '/tmp/' + Math.random().toString(36).slice(2) + '.jsonl',
|
||||
|
||||
@@ -15,9 +15,11 @@ import type { SessionMeta } from '../types/session.js'
|
||||
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
session_id: 's1',
|
||||
source: 'claude',
|
||||
project_path: '/tmp/p',
|
||||
project_slug: 'p',
|
||||
jsonl_path: '/tmp/p/s1.jsonl',
|
||||
model: null,
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
duration_sec: 60,
|
||||
@@ -25,6 +27,9 @@ function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
user_messages: 2,
|
||||
assistant_msgs: 5,
|
||||
tool_calls: 3,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
estimated_cost: null,
|
||||
first_prompt: 'teste',
|
||||
tools_used: ['Bash'],
|
||||
skills_invoked: [],
|
||||
|
||||
@@ -2,9 +2,11 @@ export type SessionOutcome = 'completed' | 'interrupted' | 'error' | 'unknown'
|
||||
|
||||
export interface SessionMeta {
|
||||
session_id: string
|
||||
project_path: string
|
||||
project_slug: string
|
||||
jsonl_path: string
|
||||
source: 'claude' | 'hermes' | 'opencode'
|
||||
project_path: string | null
|
||||
project_slug: string | null
|
||||
jsonl_path: string | null
|
||||
model: string | null
|
||||
started_at: string
|
||||
ended_at: string | null
|
||||
duration_sec: number | null
|
||||
@@ -12,6 +14,9 @@ export interface SessionMeta {
|
||||
user_messages: number
|
||||
assistant_msgs: number
|
||||
tool_calls: number
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
estimated_cost: number | null
|
||||
first_prompt: string | null
|
||||
tools_used: string[]
|
||||
skills_invoked: string[]
|
||||
|
||||
Generated
+899
-625
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -41,6 +41,9 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"shell-quote": "^1.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
@@ -56,7 +59,7 @@
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -70,7 +73,7 @@
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.18"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function Sessions() {
|
||||
const t = new Set<string>()
|
||||
const s = new Set<string>()
|
||||
data?.items.forEach((it) => {
|
||||
p.add(it.project_slug)
|
||||
p.add(it.project_slug ?? '')
|
||||
it.tools_used.forEach((x) => t.add(x))
|
||||
it.skills_invoked.forEach((x) => s.add(x))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user