Previously the session metadata accumulator started from zero on every reindex pass, so an incremental update would overwrite started_at and message_count with values derived only from the new lines. Now reads the existing session row first and merges new data on top.
413 lines
18 KiB
JavaScript
413 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createRequire } from 'node:module';
|
|
const require = createRequire(import.meta.url);
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const vm = require('node:vm');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
|
|
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
|
const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl');
|
|
const DB_PATH = path.join(CLAUDE_DIR, 'obelisk.sqlite');
|
|
const TEXT_LIMIT = 10000;
|
|
|
|
const SCHEMA = `
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
|
|
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
|
|
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
|
timestamp TEXT, role TEXT, text TEXT, model TEXT,
|
|
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
|
input_tokens INTEGER, output_tokens INTEGER);
|
|
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
|
name TEXT, input_json TEXT, file_path TEXT);
|
|
CREATE TABLE IF NOT EXISTS tool_results (
|
|
tool_use_id TEXT PRIMARY KEY, message_uuid TEXT, session_id TEXT,
|
|
content TEXT, file_path TEXT);
|
|
CREATE TABLE IF NOT EXISTS subagents (
|
|
agent_id TEXT PRIMARY KEY, session_id TEXT, parent_tool_use_id TEXT,
|
|
agent_type TEXT, description TEXT, duration_ms INTEGER, total_tokens INTEGER);
|
|
CREATE TABLE IF NOT EXISTS workflows (
|
|
run_id TEXT PRIMARY KEY, session_id TEXT, task_id TEXT,
|
|
script TEXT, result_json TEXT, timestamp TEXT, agent_count INTEGER DEFAULT 0);
|
|
CREATE TABLE IF NOT EXISTS workflow_agents (
|
|
agent_id TEXT PRIMARY KEY, run_id TEXT, session_id TEXT,
|
|
agent_type TEXT, description TEXT);
|
|
CREATE TABLE IF NOT EXISTS index_state (
|
|
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER);
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_tc_session_name ON tool_calls(session_id, name);
|
|
CREATE INDEX IF NOT EXISTS idx_tc_file ON tool_calls(file_path);
|
|
CREATE INDEX IF NOT EXISTS idx_sa_session ON subagents(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_wf_session ON workflows(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_wa_run ON workflow_agents(run_id);
|
|
`;
|
|
|
|
function openDb() {
|
|
const db = new DatabaseSync(DB_PATH);
|
|
db.exec('PRAGMA journal_mode=WAL');
|
|
db.exec('PRAGMA synchronous=NORMAL');
|
|
db.exec(SCHEMA);
|
|
return db;
|
|
}
|
|
|
|
function trunc(s) {
|
|
return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s;
|
|
}
|
|
|
|
function extractText(content) {
|
|
if (typeof content === 'string') return trunc(content);
|
|
if (!Array.isArray(content)) return null;
|
|
const parts = [];
|
|
for (const b of content) {
|
|
if (b.type === 'text' && b.text) parts.push(b.text);
|
|
else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking);
|
|
}
|
|
return parts.length ? trunc(parts.join('\n')) : null;
|
|
}
|
|
|
|
function filePath(name, input) {
|
|
if (!input) return null;
|
|
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
|
|
}
|
|
|
|
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
|
|
function discoverJsonlFiles() {
|
|
const files = [];
|
|
if (!fs.existsSync(PROJECTS_DIR)) return files;
|
|
for (const proj of fs.readdirSync(PROJECTS_DIR)) {
|
|
const projPath = path.join(PROJECTS_DIR, proj);
|
|
if (!isDir(projPath)) continue;
|
|
for (const f of fs.readdirSync(projPath)) {
|
|
if (f.endsWith('.jsonl'))
|
|
files.push({ path: path.join(projPath, f), sessionId: f.slice(0, -6), project: proj, isSubagent: false });
|
|
}
|
|
for (const sd of fs.readdirSync(projPath)) {
|
|
const saDir = path.join(projPath, sd, 'subagents');
|
|
if (!isDir(saDir)) continue;
|
|
for (const sf of fs.readdirSync(saDir)) {
|
|
if (sf.endsWith('.jsonl'))
|
|
files.push({ path: path.join(saDir, sf), sessionId: sd, project: proj, isSubagent: true, agentId: sf.slice(0, -6) });
|
|
}
|
|
const wfRoot = path.join(saDir, 'workflows');
|
|
if (!isDir(wfRoot)) continue;
|
|
for (const wfDir of fs.readdirSync(wfRoot)) {
|
|
const wfPath = path.join(wfRoot, wfDir);
|
|
if (!isDir(wfPath)) continue;
|
|
for (const wf of fs.readdirSync(wfPath)) {
|
|
if (wf.endsWith('.jsonl'))
|
|
files.push({ path: path.join(wfPath, wf), sessionId: sd, project: proj, isSubagent: true, agentId: wf.slice(0, -6), workflowRunId: wfDir });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function needsReindex(db, fp) {
|
|
const mt = fs.statSync(fp).mtimeMs;
|
|
const row = db.prepare('SELECT mtime, lines_processed FROM index_state WHERE jsonl_path = ?').get(fp);
|
|
if (!row) return { needed: true, skip: 0 };
|
|
return mt > row.mtime ? { needed: true, skip: row.lines_processed } : { needed: false, skip: 0 };
|
|
}
|
|
|
|
function indexJsonl(db, fi) {
|
|
const { needed, skip } = needsReindex(db, fi.path);
|
|
if (!needed) return;
|
|
const lines = fs.readFileSync(fi.path, 'utf8').split('\n').filter(Boolean);
|
|
const mt = fs.statSync(fi.path).mtimeMs;
|
|
|
|
const ins = {
|
|
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
|
|
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,model,is_sidechain,agent_id,input_tokens,output_tokens) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'),
|
|
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
|
|
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path) VALUES (?,?,?,?,?)'),
|
|
idx: db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)'),
|
|
};
|
|
|
|
const existing = !fi.isSubagent ? db.prepare('SELECT * FROM sessions WHERE id = ?').get(fi.sessionId) : null;
|
|
const sm = {
|
|
started_at: existing?.started_at || null,
|
|
ended_at: existing?.ended_at || null,
|
|
git_branch: existing?.git_branch || null,
|
|
version: existing?.version || null,
|
|
title: existing?.title || null,
|
|
n: existing?.message_count || 0,
|
|
};
|
|
|
|
for (let i = skip; i < lines.length; i++) {
|
|
let obj;
|
|
try { obj = JSON.parse(lines[i]); } catch { continue; }
|
|
const sid = fi.sessionId;
|
|
const ts = obj.timestamp || null;
|
|
|
|
if (obj.type === 'ai-title' && obj.aiTitle) { sm.title = obj.aiTitle; continue; }
|
|
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
|
|
if (ts && (!sm.started_at || ts < sm.started_at)) sm.started_at = ts;
|
|
if (ts && (!sm.ended_at || ts > sm.ended_at)) sm.ended_at = ts;
|
|
if (obj.gitBranch) sm.git_branch = obj.gitBranch;
|
|
if (obj.version) sm.version = obj.version;
|
|
sm.n++;
|
|
|
|
const msg = obj.message || {};
|
|
const text = extractText(msg.content);
|
|
const usage = msg.usage || {};
|
|
const aid = fi.isSubagent ? fi.agentId : (obj.agentId || null);
|
|
|
|
if (obj.uuid) {
|
|
ins.msg.run(obj.uuid, sid, obj.type, obj.parentUuid || null, ts,
|
|
msg.role || obj.type, text, msg.model || null,
|
|
obj.isSidechain ? 1 : 0, aid, usage.input_tokens || null, usage.output_tokens || null);
|
|
}
|
|
|
|
if (obj.type === 'assistant' && Array.isArray(msg.content)) {
|
|
for (const b of msg.content) {
|
|
if (b.type === 'tool_use' && b.id)
|
|
ins.tc.run(b.id, obj.uuid, sid, b.name, trunc(JSON.stringify(b.input || {})), filePath(b.name, b.input));
|
|
}
|
|
}
|
|
|
|
if (obj.type === 'user' && Array.isArray(msg.content)) {
|
|
for (const b of msg.content) {
|
|
if (b.type !== 'tool_result' || !b.tool_use_id) continue;
|
|
const rt = typeof b.content === 'string' ? b.content
|
|
: Array.isArray(b.content) ? b.content.map(c => c.text || '').join('\n') : '';
|
|
ins.tr.run(b.tool_use_id, obj.uuid, sid, trunc(rt), obj.toolUseResult?.filePath || null);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!fi.isSubagent) {
|
|
const pp = '/' + fi.project.replace(/-/g, '/').replace(/^\//, '');
|
|
ins.ses.run(fi.sessionId, sm.title, fi.project, pp, sm.started_at, sm.ended_at, sm.git_branch, sm.version, sm.n, fi.path);
|
|
}
|
|
ins.idx.run(fi.path, mt, lines.length);
|
|
}
|
|
|
|
function indexSubagentMeta(db, fi) {
|
|
if (!fi.isSubagent) return;
|
|
const mp = fi.path.replace('.jsonl', '.meta.json');
|
|
if (!fs.existsSync(mp)) return;
|
|
try {
|
|
const meta = JSON.parse(fs.readFileSync(mp, 'utf8'));
|
|
const tok = db.prepare('SELECT COALESCE(SUM(input_tokens),0)+COALESCE(SUM(output_tokens),0) as t FROM messages WHERE agent_id=?').get(fi.agentId);
|
|
const ts = db.prepare('SELECT MIN(timestamp) as t0, MAX(timestamp) as t1 FROM messages WHERE agent_id=?').get(fi.agentId);
|
|
const dur = ts?.t0 && ts?.t1 ? new Date(ts.t1).getTime() - new Date(ts.t0).getTime() : null;
|
|
if (fi.workflowRunId) {
|
|
db.prepare('INSERT OR REPLACE INTO workflow_agents VALUES(?,?,?,?,?)').run(fi.agentId, fi.workflowRunId, fi.sessionId, meta.agentType||null, meta.description||null);
|
|
} else {
|
|
db.prepare('INSERT OR REPLACE INTO subagents VALUES(?,?,?,?,?,?,?)').run(fi.agentId, fi.sessionId, meta.toolUseId||null, meta.agentType||null, meta.description||null, dur, tok?.t||0);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function indexWorkflows(db) {
|
|
if (!fs.existsSync(PROJECTS_DIR)) return;
|
|
for (const proj of fs.readdirSync(PROJECTS_DIR)) {
|
|
const pp = path.join(PROJECTS_DIR, proj);
|
|
if (!isDir(pp)) continue;
|
|
for (const sd of fs.readdirSync(pp)) {
|
|
const wd = path.join(pp, sd, 'workflows');
|
|
if (!isDir(wd)) continue;
|
|
for (const f of fs.readdirSync(wd)) {
|
|
if (!f.endsWith('.json')) continue;
|
|
try {
|
|
const wf = JSON.parse(fs.readFileSync(path.join(wd, f), 'utf8'));
|
|
if (!wf.runId) continue;
|
|
const ac = db.prepare('SELECT COUNT(*) as c FROM workflow_agents WHERE run_id=?').get(wf.runId);
|
|
db.prepare('INSERT OR REPLACE INTO workflows VALUES(?,?,?,?,?,?,?)').run(
|
|
wf.runId, sd, wf.taskId||null, trunc(wf.script||null),
|
|
wf.result ? trunc(JSON.stringify(wf.result)) : null, wf.timestamp||null, ac?.c||0);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function indexHistory(db) {
|
|
if (!fs.existsSync(HISTORY_PATH)) return;
|
|
for (const line of fs.readFileSync(HISTORY_PATH, 'utf8').split('\n').filter(Boolean)) {
|
|
try {
|
|
const o = JSON.parse(line);
|
|
if (o.sessionId && o.title) db.prepare('UPDATE sessions SET title=? WHERE id=? AND title IS NULL').run(o.title, o.sessionId);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function buildIndex() {
|
|
const db = openDb();
|
|
const files = discoverJsonlFiles();
|
|
db.exec('BEGIN');
|
|
try {
|
|
for (const f of files) { indexJsonl(db, f); indexSubagentMeta(db, f); }
|
|
indexWorkflows(db);
|
|
indexHistory(db);
|
|
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
|
db.exec('COMMIT');
|
|
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
|
db.close();
|
|
}
|
|
|
|
// --- Query API ---
|
|
|
|
const ERROR_PATS = ['error','Error','ENOENT','failed','Failed','FAILED','permission denied','Permission denied','EPERM','EACCES','command not found','No such file','Exit code'];
|
|
|
|
function createQueryApi(db) {
|
|
const q = (sql, ...p) => db.prepare(sql).all(...p);
|
|
|
|
const search = (text, opts = {}) => {
|
|
const { limit = 20, sessionId, project, after, before } = opts;
|
|
let where = 'WHERE mf.text MATCH ?';
|
|
const p = [text];
|
|
if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); }
|
|
if (project) { where += ' AND s.project=?'; p.push(project); }
|
|
if (after) { where += ' AND m.timestamp>?'; p.push(after); }
|
|
if (before) { where += ' AND m.timestamp<?'; p.push(before); }
|
|
p.push(limit);
|
|
const rows = db.prepare(`
|
|
SELECT m.uuid,m.session_id,m.text,m.role,m.timestamp,m.model,
|
|
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started
|
|
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
|
|
${where} ORDER BY rank LIMIT ?`).all(...p);
|
|
return rows.map(r => {
|
|
const ctx = db.prepare(
|
|
'SELECT uuid,text,role,timestamp,model FROM messages WHERE session_id=? AND uuid!=? ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6'
|
|
).all(r.session_id, r.uuid, r.timestamp).sort((a,b) => a.timestamp < b.timestamp ? -1 : 1);
|
|
return {
|
|
message: { uuid: r.uuid, text: r.text, role: r.role, timestamp: r.timestamp, model: r.model },
|
|
session: { id: r.s_id, title: r.s_title, project: r.s_project, started_at: r.s_started },
|
|
context: ctx,
|
|
};
|
|
});
|
|
};
|
|
|
|
const context = (uuid) => {
|
|
const msg = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid);
|
|
if (!msg) return null;
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(msg.session_id);
|
|
const chain = [];
|
|
let cur = msg;
|
|
while (cur?.parent_uuid) { cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid); if (cur) chain.unshift(cur); }
|
|
let subagent = msg.agent_id ? db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(msg.agent_id) : null;
|
|
let workflow = null;
|
|
if (msg.agent_id) {
|
|
const wa = db.prepare('SELECT * FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
|
|
if (wa) workflow = db.prepare('SELECT * FROM workflows WHERE run_id=?').get(wa.run_id);
|
|
}
|
|
return { message: msg, parentChain: chain, session, subagent, workflow };
|
|
};
|
|
|
|
const trace = (uuid) => {
|
|
const chain = [];
|
|
let cur = db.prepare('SELECT * FROM messages WHERE uuid=?').get(uuid);
|
|
while (cur) { chain.unshift(cur); cur = cur.parent_uuid ? db.prepare('SELECT * FROM messages WHERE uuid=?').get(cur.parent_uuid) : null; }
|
|
return chain;
|
|
};
|
|
|
|
const thread = (sid) => db.prepare('SELECT * FROM messages WHERE session_id=? ORDER BY timestamp').all(sid);
|
|
|
|
const subagents = (sid) => {
|
|
return db.prepare('SELECT * FROM subagents WHERE session_id=?').all(sid).map(r => {
|
|
const c = db.prepare('SELECT COUNT(*) as c FROM messages WHERE agent_id=?').get(r.agent_id);
|
|
return { ...r, messageCount: c?.c || 0 };
|
|
});
|
|
};
|
|
|
|
const workflows = (sid) => sid
|
|
? db.prepare('SELECT * FROM workflows WHERE session_id=? ORDER BY timestamp').all(sid)
|
|
: db.prepare('SELECT * FROM workflows ORDER BY timestamp DESC').all();
|
|
|
|
const workflowTree = (runId) => {
|
|
const wf = db.prepare('SELECT * FROM workflows WHERE run_id=?').get(runId);
|
|
if (!wf) return null;
|
|
const agents = db.prepare('SELECT * FROM workflow_agents WHERE run_id=?').all(runId).map(a => ({
|
|
...a, messages: db.prepare('SELECT * FROM messages WHERE agent_id=? ORDER BY timestamp').all(a.agent_id),
|
|
}));
|
|
return { ...wf, agents };
|
|
};
|
|
|
|
const fileHistory = (fp) => {
|
|
return db.prepare(
|
|
'SELECT tc.*,s.title as s_title,s.project as s_project FROM tool_calls tc LEFT JOIN sessions s ON s.id=tc.session_id WHERE tc.file_path=? ORDER BY tc.id'
|
|
).all(fp).map(r => ({
|
|
toolCall: { id: r.id, message_uuid: r.message_uuid, name: r.name, input_json: r.input_json },
|
|
session: { id: r.session_id, title: r.s_title, project: r.s_project },
|
|
timestamp: db.prepare('SELECT timestamp FROM messages WHERE uuid=?').get(r.message_uuid)?.timestamp,
|
|
}));
|
|
};
|
|
|
|
const failures = (sid) => {
|
|
const rows = sid
|
|
? db.prepare('SELECT * FROM tool_results WHERE session_id=?').all(sid)
|
|
: db.prepare('SELECT * FROM tool_results').all();
|
|
const out = [];
|
|
for (const r of rows) {
|
|
if (!r.content || !ERROR_PATS.some(p => r.content.includes(p))) continue;
|
|
const tc = db.prepare('SELECT * FROM tool_calls WHERE id=?').get(r.tool_use_id);
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(r.session_id);
|
|
const rm = db.prepare('SELECT * FROM messages WHERE uuid=?').get(r.message_uuid);
|
|
const next = rm?.timestamp ? db.prepare('SELECT * FROM messages WHERE session_id=? AND timestamp>? ORDER BY timestamp LIMIT 3').all(r.session_id, rm.timestamp) : [];
|
|
out.push({ toolCall: tc, result: r, session, nextMessages: next });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const recent = (n = 10) => db.prepare('SELECT * FROM sessions ORDER BY ended_at DESC LIMIT ?').all(n);
|
|
|
|
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, recent };
|
|
}
|
|
|
|
// --- Script executor ---
|
|
|
|
function executeQuery(db, scriptContent) {
|
|
const api = createQueryApi(db);
|
|
const sandbox = {
|
|
...api, JSON, Math, Array, Object, Set, Map, Date, RegExp,
|
|
parseInt, parseFloat, String, Number, Boolean, Error, Promise, console, setTimeout,
|
|
};
|
|
const ctx = vm.createContext(sandbox);
|
|
return vm.runInNewContext(`(async()=>{${scriptContent}})()`, ctx, { timeout: 30000 });
|
|
}
|
|
|
|
// --- CLI ---
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--build') {
|
|
buildIndex();
|
|
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
|
|
return;
|
|
}
|
|
if (args[0] === '--search' && args[1]) {
|
|
buildIndex();
|
|
const db = openDb();
|
|
process.stdout.write(JSON.stringify(createQueryApi(db).search(args.slice(1).join(' ')), null, 2) + '\n');
|
|
db.close();
|
|
return;
|
|
}
|
|
if (args[0] === '--query' && args[1]) {
|
|
buildIndex();
|
|
const db = openDb();
|
|
const script = fs.readFileSync(path.resolve(args[1]), 'utf8');
|
|
executeQuery(db, script)
|
|
.then(r => { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); db.close(); })
|
|
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
|
return;
|
|
}
|
|
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n');
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
main();
|