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.
|
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
|
## [2.7.0] - 2026-04-23
|
||||||
|
|
||||||
### Added — Observabilidade Fase 6A
|
### Added — Observabilidade Fase 6A
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export function createSessionsRouter(db: SessionsDb): Router {
|
|||||||
const session = db.getSession(parsed.data.id)
|
const session = db.getSession(parsed.data.id)
|
||||||
if (!session) return res.status(404).json({ error: 'Session not found' })
|
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 {
|
try {
|
||||||
const { events } = await parseSessionFile(session.jsonl_path)
|
const { events } = await parseSessionFile(session.jsonl_path)
|
||||||
return res.json({ meta: session, events })
|
return res.json({ meta: session, events })
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env tsx
|
#!/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:
|
* Modos:
|
||||||
* --full Full scan de ~/.claude/projects -> SQLite em ~/.claude-work/sessions.db
|
* --full Full scan de todas as fontes
|
||||||
* --watch Modo incremental (stub; implementação Task 8)
|
* --watch Modo incremental (Claude Code watcher)
|
||||||
|
* --source Filtrar fonte: claude, hermes, opencode (repetível)
|
||||||
*
|
*
|
||||||
* Env:
|
* Env:
|
||||||
* OBSERVABILIDADE_DB Override ao caminho da BD SQLite
|
* OBSERVABILIDADE_DB Override ao caminho da BD SQLite
|
||||||
@@ -16,16 +17,22 @@ async function main(): Promise<void> {
|
|||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
const mode = args.find((a) => a === '--full' || a === '--watch')
|
const mode = args.find((a) => a === '--full' || a === '--watch')
|
||||||
if (!mode) {
|
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)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH
|
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') {
|
if (mode === '--watch') {
|
||||||
console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`)
|
console.log(`[indexer] watch mode em ${PROJECTS_ROOT} -> ${dbPath}`)
|
||||||
await indexAll({ dbPath })
|
await indexAll({ dbPath, sources })
|
||||||
await startWatcher(dbPath)
|
await startWatcher(dbPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -34,6 +41,7 @@ async function main(): Promise<void> {
|
|||||||
let lastLogged = 0
|
let lastLogged = 0
|
||||||
const { indexed, failed } = await indexAll({
|
const { indexed, failed } = await indexAll({
|
||||||
dbPath,
|
dbPath,
|
||||||
|
sources,
|
||||||
onProgress: (done, total) => {
|
onProgress: (done, total) => {
|
||||||
if (done - lastLogged >= 50 || done === total) {
|
if (done - lastLogged >= 50 || done === total) {
|
||||||
console.log(`[indexer] ${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 {
|
export interface ListFilters {
|
||||||
days?: number
|
days?: number
|
||||||
|
source?: string
|
||||||
project?: string
|
project?: string
|
||||||
tool?: string
|
tool?: string
|
||||||
skill?: string
|
skill?: string
|
||||||
@@ -73,9 +74,11 @@ export interface SessionsDb {
|
|||||||
const SCHEMA = `
|
const SCHEMA = `
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
session_id TEXT PRIMARY KEY,
|
session_id TEXT PRIMARY KEY,
|
||||||
project_path TEXT NOT NULL,
|
source TEXT NOT NULL DEFAULT 'claude',
|
||||||
project_slug TEXT NOT NULL,
|
project_path TEXT,
|
||||||
jsonl_path TEXT NOT NULL UNIQUE,
|
project_slug TEXT,
|
||||||
|
jsonl_path TEXT,
|
||||||
|
model TEXT,
|
||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
ended_at TEXT,
|
ended_at TEXT,
|
||||||
duration_sec INTEGER,
|
duration_sec INTEGER,
|
||||||
@@ -83,16 +86,20 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
user_messages INTEGER NOT NULL,
|
user_messages INTEGER NOT NULL,
|
||||||
assistant_msgs INTEGER NOT NULL,
|
assistant_msgs INTEGER NOT NULL,
|
||||||
tool_calls INTEGER NOT NULL,
|
tool_calls INTEGER NOT NULL,
|
||||||
|
input_tokens INTEGER,
|
||||||
|
output_tokens INTEGER,
|
||||||
|
estimated_cost REAL,
|
||||||
first_prompt TEXT,
|
first_prompt TEXT,
|
||||||
tools_used TEXT NOT NULL,
|
tools_used TEXT NOT NULL DEFAULT '[]',
|
||||||
skills_invoked TEXT NOT NULL,
|
skills_invoked TEXT NOT NULL DEFAULT '[]',
|
||||||
outcome TEXT NOT NULL,
|
outcome TEXT NOT NULL,
|
||||||
permission_mode TEXT,
|
permission_mode TEXT,
|
||||||
file_size INTEGER NOT NULL,
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
indexed_at TEXT NOT NULL
|
indexed_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
|
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_project ON sessions(project_slug, started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS patterns (
|
CREATE TABLE IF NOT EXISTS patterns (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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 {
|
function rowToMeta(row: Record<string, unknown>): SessionMeta {
|
||||||
return {
|
return {
|
||||||
session_id: row.session_id as string,
|
session_id: row.session_id as string,
|
||||||
project_path: row.project_path as string,
|
source: (row.source as SessionMeta['source']) ?? 'claude',
|
||||||
project_slug: row.project_slug as string,
|
project_path: (row.project_path as string | null) ?? null,
|
||||||
jsonl_path: row.jsonl_path as string,
|
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,
|
started_at: row.started_at as string,
|
||||||
ended_at: (row.ended_at as string | null) ?? null,
|
ended_at: (row.ended_at as string | null) ?? null,
|
||||||
duration_sec: (row.duration_sec as number | 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,
|
user_messages: row.user_messages as number,
|
||||||
assistant_msgs: row.assistant_msgs as number,
|
assistant_msgs: row.assistant_msgs as number,
|
||||||
tool_calls: row.tool_calls 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,
|
first_prompt: (row.first_prompt as string | null) ?? null,
|
||||||
tools_used: JSON.parse(row.tools_used as string),
|
tools_used: row.tools_used ? JSON.parse(row.tools_used as string) : [],
|
||||||
skills_invoked: JSON.parse(row.skills_invoked as string),
|
skills_invoked: row.skills_invoked ? JSON.parse(row.skills_invoked as string) : [],
|
||||||
outcome: row.outcome as SessionMeta['outcome'],
|
outcome: row.outcome as SessionMeta['outcome'],
|
||||||
permission_mode: (row.permission_mode as string | null) ?? null,
|
permission_mode: (row.permission_mode as string | null) ?? null,
|
||||||
file_size: row.file_size as number,
|
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')
|
parts.push('started_at >= @cutoff')
|
||||||
params.cutoff = cutoff
|
params.cutoff = cutoff
|
||||||
}
|
}
|
||||||
|
if (f.source) {
|
||||||
|
parts.push('source = @source')
|
||||||
|
params.source = f.source
|
||||||
|
}
|
||||||
if (f.project) {
|
if (f.project) {
|
||||||
parts.push('project_slug = @project')
|
parts.push('project_slug = @project')
|
||||||
params.project = f.project
|
params.project = f.project
|
||||||
@@ -191,17 +207,40 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
|||||||
db.pragma('synchronous = NORMAL')
|
db.pragma('synchronous = NORMAL')
|
||||||
db.exec(SCHEMA)
|
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(`
|
const upsertStmt = db.prepare(`
|
||||||
INSERT INTO sessions (session_id, project_path, project_slug, jsonl_path, started_at, ended_at,
|
INSERT INTO sessions (session_id, source, project_path, project_slug, jsonl_path, model,
|
||||||
duration_sec, event_count, user_messages, assistant_msgs, tool_calls, first_prompt,
|
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)
|
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
|
||||||
VALUES (@session_id, @project_path, @project_slug, @jsonl_path, @started_at, @ended_at,
|
VALUES (@session_id, @source, @project_path, @project_slug, @jsonl_path, @model,
|
||||||
@duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls, @first_prompt,
|
@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)
|
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
|
||||||
ON CONFLICT(session_id) DO UPDATE SET
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
project_path = excluded.project_path,
|
project_path = excluded.project_path,
|
||||||
project_slug = excluded.project_slug,
|
project_slug = excluded.project_slug,
|
||||||
jsonl_path = excluded.jsonl_path,
|
jsonl_path = excluded.jsonl_path,
|
||||||
|
model = excluded.model,
|
||||||
started_at = excluded.started_at,
|
started_at = excluded.started_at,
|
||||||
ended_at = excluded.ended_at,
|
ended_at = excluded.ended_at,
|
||||||
duration_sec = excluded.duration_sec,
|
duration_sec = excluded.duration_sec,
|
||||||
@@ -209,6 +248,9 @@ export function openSessionsDb(dbPath: string): SessionsDb {
|
|||||||
user_messages = excluded.user_messages,
|
user_messages = excluded.user_messages,
|
||||||
assistant_msgs = excluded.assistant_msgs,
|
assistant_msgs = excluded.assistant_msgs,
|
||||||
tool_calls = excluded.tool_calls,
|
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,
|
first_prompt = excluded.first_prompt,
|
||||||
tools_used = excluded.tools_used,
|
tools_used = excluded.tools_used,
|
||||||
skills_invoked = excluded.skills_invoked,
|
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 { join } from 'path'
|
||||||
import { parseSessionFile } from './parser.js'
|
import { parseSessionFile } from './parser.js'
|
||||||
import { openSessionsDb, type SessionsDb } from './db.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'
|
import type { SessionMeta } from '../../types/session.js'
|
||||||
|
|
||||||
export const PROJECTS_ROOT = join(homedir(), '.claude', 'projects')
|
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 {
|
export interface IndexAllOptions {
|
||||||
dbPath?: string
|
dbPath?: string
|
||||||
|
sources?: ('claude' | 'hermes' | 'opencode')[]
|
||||||
onProgress?: (done: number, total: number) => void
|
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(
|
export async function indexAll(
|
||||||
options: IndexAllOptions = {},
|
options: IndexAllOptions = {},
|
||||||
): Promise<{ indexed: number; failed: number }> {
|
): Promise<{ indexed: number; failed: number }> {
|
||||||
const db = openSessionsDb(options.dbPath ?? DEFAULT_DB_PATH)
|
const sources = options.sources ?? ['claude', 'hermes', 'opencode']
|
||||||
const files = findAllJsonl()
|
|
||||||
const BATCH = 50
|
|
||||||
let indexed = 0
|
let indexed = 0
|
||||||
let failed = 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[] = []
|
let batch: SessionMeta[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
try {
|
try {
|
||||||
const { meta } = await parseSessionFile(files[i])
|
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)
|
batch.push(meta)
|
||||||
if (batch.length >= BATCH) {
|
if (batch.length >= BATCH) {
|
||||||
db.upsertMany(batch)
|
db.upsertMany(batch)
|
||||||
@@ -93,6 +112,31 @@ export async function indexAll(
|
|||||||
} finally {
|
} finally {
|
||||||
db.close()
|
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 }
|
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 = {
|
const meta: SessionMeta = {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
|
source: 'claude',
|
||||||
project_path: dirname(jsonlPath),
|
project_path: dirname(jsonlPath),
|
||||||
project_slug: slugFromProjectPath(jsonlPath),
|
project_slug: slugFromProjectPath(jsonlPath),
|
||||||
jsonl_path: jsonlPath,
|
jsonl_path: jsonlPath,
|
||||||
|
model: null,
|
||||||
started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(),
|
started_at: firstTs ?? new Date(stats.birthtimeMs).toISOString(),
|
||||||
ended_at: lastTs,
|
ended_at: lastTs,
|
||||||
duration_sec: durationSec,
|
duration_sec: durationSec,
|
||||||
@@ -196,6 +198,9 @@ export async function parseSessionFile(jsonlPath: string): Promise<ParseResult>
|
|||||||
user_messages: userMessages,
|
user_messages: userMessages,
|
||||||
assistant_msgs: assistantMsgs,
|
assistant_msgs: assistantMsgs,
|
||||||
tool_calls: toolCalls,
|
tool_calls: toolCalls,
|
||||||
|
input_tokens: null,
|
||||||
|
output_tokens: null,
|
||||||
|
estimated_cost: null,
|
||||||
first_prompt: firstPrompt,
|
first_prompt: firstPrompt,
|
||||||
tools_used: [...toolsUsed],
|
tools_used: [...toolsUsed],
|
||||||
skills_invoked: [...skillsInvoked],
|
skills_invoked: [...skillsInvoked],
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function parseProblemItem(raw: string): { problema: string; solucao: string } {
|
|||||||
function extractAllLiItems(root: HTMLElement): string[] {
|
function extractAllLiItems(root: HTMLElement): string[] {
|
||||||
return root
|
return root
|
||||||
.querySelectorAll('li')
|
.querySelectorAll('li')
|
||||||
.map((li) => textOf(li))
|
.map((li: HTMLElement) => textOf(li))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import type { SessionMeta } from '../types/session.js'
|
|||||||
function sampleMeta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
function sampleMeta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||||
return {
|
return {
|
||||||
session_id: 's1',
|
session_id: 's1',
|
||||||
|
source: 'claude',
|
||||||
|
model: null,
|
||||||
|
input_tokens: null,
|
||||||
|
output_tokens: null,
|
||||||
|
estimated_cost: null,
|
||||||
project_path: '/tmp/project',
|
project_path: '/tmp/project',
|
||||||
project_slug: 'project',
|
project_slug: 'project',
|
||||||
jsonl_path: '/tmp/project/s1.jsonl',
|
jsonl_path: '/tmp/project/s1.jsonl',
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import type { SessionMeta } from '../types/session.js'
|
|||||||
function meta(overrides: Partial<SessionMeta>): SessionMeta {
|
function meta(overrides: Partial<SessionMeta>): SessionMeta {
|
||||||
return {
|
return {
|
||||||
session_id: 's-' + Math.random().toString(36).slice(2, 10),
|
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_path: '/tmp/project',
|
||||||
project_slug: 'project',
|
project_slug: 'project',
|
||||||
jsonl_path: '/tmp/' + Math.random().toString(36).slice(2) + '.jsonl',
|
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 {
|
function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||||
return {
|
return {
|
||||||
session_id: 's1',
|
session_id: 's1',
|
||||||
|
source: 'claude',
|
||||||
project_path: '/tmp/p',
|
project_path: '/tmp/p',
|
||||||
project_slug: 'p',
|
project_slug: 'p',
|
||||||
jsonl_path: '/tmp/p/s1.jsonl',
|
jsonl_path: '/tmp/p/s1.jsonl',
|
||||||
|
model: null,
|
||||||
started_at: new Date().toISOString(),
|
started_at: new Date().toISOString(),
|
||||||
ended_at: null,
|
ended_at: null,
|
||||||
duration_sec: 60,
|
duration_sec: 60,
|
||||||
@@ -25,6 +27,9 @@ function meta(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
|||||||
user_messages: 2,
|
user_messages: 2,
|
||||||
assistant_msgs: 5,
|
assistant_msgs: 5,
|
||||||
tool_calls: 3,
|
tool_calls: 3,
|
||||||
|
input_tokens: null,
|
||||||
|
output_tokens: null,
|
||||||
|
estimated_cost: null,
|
||||||
first_prompt: 'teste',
|
first_prompt: 'teste',
|
||||||
tools_used: ['Bash'],
|
tools_used: ['Bash'],
|
||||||
skills_invoked: [],
|
skills_invoked: [],
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ export type SessionOutcome = 'completed' | 'interrupted' | 'error' | 'unknown'
|
|||||||
|
|
||||||
export interface SessionMeta {
|
export interface SessionMeta {
|
||||||
session_id: string
|
session_id: string
|
||||||
project_path: string
|
source: 'claude' | 'hermes' | 'opencode'
|
||||||
project_slug: string
|
project_path: string | null
|
||||||
jsonl_path: string
|
project_slug: string | null
|
||||||
|
jsonl_path: string | null
|
||||||
|
model: string | null
|
||||||
started_at: string
|
started_at: string
|
||||||
ended_at: string | null
|
ended_at: string | null
|
||||||
duration_sec: number | null
|
duration_sec: number | null
|
||||||
@@ -12,6 +14,9 @@ export interface SessionMeta {
|
|||||||
user_messages: number
|
user_messages: number
|
||||||
assistant_msgs: number
|
assistant_msgs: number
|
||||||
tool_calls: number
|
tool_calls: number
|
||||||
|
input_tokens: number | null
|
||||||
|
output_tokens: number | null
|
||||||
|
estimated_cost: number | null
|
||||||
first_prompt: string | null
|
first_prompt: string | null
|
||||||
tools_used: string[]
|
tools_used: string[]
|
||||||
skills_invoked: 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",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"shell-quote": "^1.8.4"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -56,7 +59,7 @@
|
|||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.1.9",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
@@ -70,7 +73,7 @@
|
|||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function Sessions() {
|
|||||||
const t = new Set<string>()
|
const t = new Set<string>()
|
||||||
const s = new Set<string>()
|
const s = new Set<string>()
|
||||||
data?.items.forEach((it) => {
|
data?.items.forEach((it) => {
|
||||||
p.add(it.project_slug)
|
p.add(it.project_slug ?? '')
|
||||||
it.tools_used.forEach((x) => t.add(x))
|
it.tools_used.forEach((x) => t.add(x))
|
||||||
it.skills_invoked.forEach((x) => s.add(x))
|
it.skills_invoked.forEach((x) => s.add(x))
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user