Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<p align="center">
<strong>See what your agents actually did.</strong><br>
A self-hosted viewer for your Claude Code session transcripts — read, search,
replay, and audit every session you have ever run. Your transcripts stay on your
machine; nothing is uploaded anywhere.
replay, and audit every session you have ever run. Your transcripts stay within
your self-hosted deployment; nothing is sent to an external service.
</p>

<p align="center">
Expand Down Expand Up @@ -205,6 +205,9 @@ The smaller things you notice after a day of use:

- **Advanced search filters** — narrow a query by session-id prefix, model, or a
date window; filter the session list by project or to pinned-only.
- **Upload a transcript** — use the Upload button to add a local `.jsonl` file to
the viewer; it is indexed and opened automatically, while previous uploads stay
available in the `uploads` project.
- **Copy the resume command** — one button in the info panel yields the exact
`claude --resume` invocation for the session, ready to paste into a terminal.
- **Rich tool rendering** — TodoWrite checklists, AskUserQuestion choices, and Bash
Expand Down Expand Up @@ -241,9 +244,9 @@ picture from the transcripts already on your disk:

## Non-goals

- **Not an editor.** It never rewrites transcript content. The only write path to your
transcripts is the explicit, guarded session delete described in
[Security and your data](#security-and-your-data).
- **Not an editor.** It never rewrites transcript content. Transcript changes are
limited to explicitly uploading a new file or using the guarded session delete
described in [Security and your data](#security-and-your-data).
- **Not a cloud service.** No accounts, no telemetry, no external network calls.
- **Not a Claude Code replacement.** It reads the transcripts Claude Code already
writes to disk.
Expand Down Expand Up @@ -389,6 +392,7 @@ Advanced overrides (for running from source or remote self-hosting):
| `PUID` / `PGID` | *(auto)* | Linux only: the uid/gid the container runs as. Auto-detected from the mounted directory's owner; set explicitly only to override that (e.g. `PUID=0` under an unusual rootless setup). |
| `RECENSA_ALLOWED_HOSTS` | *(none)* | Extra comma-separated hostnames added to the Host/Origin allowlist. Needed behind a reverse proxy or a custom hosts-file name (see [Security](#security-and-your-data)). |
| `RECENSA_WATCH_MODE` | *(auto)* | Watch strategy: `events` (native fs events) or `tick` (polling). Auto-selects `tick` inside Docker (where bind-mount fs events are unreliable) or when `RECENSA_PROJECTS_DIR` is set. |
| `RECENSA_MAX_UPLOAD_BYTES` | `268435456` | Maximum size of a browser-uploaded `.jsonl` transcript (256 MiB). |

## Security and your data

Expand Down Expand Up @@ -416,11 +420,11 @@ Your transcripts:

- **Reading never writes.** The indexer, parser, and reconstruction paths only read your
JSONL.
- **One write path exists: deleting a session.** `DELETE /api/session/:id` physically
removes the session's JSONL file and its subagents directory from disk. This is
irreversible. It is guarded: pinned sessions, sessions that belong to a fork/resume
chain, and individual subagents are all refused. Nothing else writes to your
transcripts.
- **Explicit transcript writes are limited.** Uploading a `.jsonl` file stores it under
the `uploads` project and indexes it. `DELETE /api/session/:id` physically removes
an uploaded or source session's JSONL file and its subagents directory from disk.
This is irreversible. Deletes are guarded: pinned sessions, sessions that belong to
a fork/resume chain, and individual subagents are all refused.
- **Your curation is safe.** Pins, tags, notes, and titles live in a separate `flags.db`
that index rebuilds never touch. The `viewer.db` index is a disposable cache.

Expand Down
20 changes: 20 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
"check": "npm run type-check && npm run lint && npm test"
},
"dependencies": {
"@recensa/claude-session": "^0.1.2",
"busboy": "1.6.0",
"chokidar": "^4.0.3",
"express": "^4.21.2",
"@recensa/claude-session": "^0.1.2",
"ws": "^8.18.0"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function createApp() {
app.use(require('./routes/sessions'));
app.use(require('./routes/flags'));
app.use(require('./routes/search'));
app.use(require('./routes/uploads'));
// Serve the theme-injected index.html (must intercept '/' before the static handler)
app.get(['/', '/index.html'], (_req, res, next) => {
try {
Expand Down
138 changes: 138 additions & 0 deletions backend/src/routes/uploads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use strict';

const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { pipeline } = require('node:stream/promises');
const Busboy = require('busboy');
const { PROJECTS_DIR } = require('../config');
const indexService = require('../services/indexService');
const repo = require('../repositories/sessionRepo');
const { makeStoredFilename } = require('../services/uploadNaming');
const log = require('../log').scope('uploads');

const UPLOAD_PROJECT = 'uploads';
const UPLOAD_DIR = path.join(PROJECTS_DIR, UPLOAD_PROJECT);
const MAX_UPLOAD_BYTES = Number(process.env.RECENSA_MAX_UPLOAD_BYTES || 256 * 1024 * 1024);

class UploadError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}

function waitForIndexed(sessionId, timeoutMs = 30_000) {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const check = () => {
try {
if (repo.getSession(sessionId)) return resolve(true);
} catch {
// The worker may be committing the row; keep polling until the deadline.
}
if (Date.now() >= deadline) return resolve(false);
setTimeout(check, 100);
};
check();
});
}

async function removeIfPresent(filePath) {
if (!filePath) return;
try { await fsp.rm(filePath, { force: true }); } catch { /* best-effort cleanup */ }
}

function parseUpload(req) {
return new Promise((resolve, reject) => {
let parser;
try {
parser = Busboy({
headers: req.headers,
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES },
});
} catch {
reject(new UploadError(400, 'Expected a multipart file upload'));
return;
}

let fileSeen = false;
let parseError = null;
let writeTask = null;
let result = null;

const fail = (err) => { if (!parseError) parseError = err; };

parser.on('file', (fieldName, file, info) => {
if (fieldName !== 'file' || fileSeen) {
file.resume();
fail(new UploadError(400, 'Upload exactly one file in the file field'));
return;
}
fileSeen = true;

let storedFilename;
try {
storedFilename = makeStoredFilename(info.filename, randomUUID());
} catch (err) {
file.resume();
fail(new UploadError(400, err.message));
return;
}

const temporaryPath = path.join(UPLOAD_DIR, `.upload-${randomUUID()}.part`);
const destination = path.join(UPLOAD_DIR, storedFilename);
const output = fs.createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 });
writeTask = pipeline(file, output).then(async () => {
if (file.truncated) throw new UploadError(413, 'File is too large (maximum 256 MB)');
await fsp.rename(temporaryPath, destination);
result = {
destination,
sessionId: storedFilename.slice(0, -'.jsonl'.length),
originalFilename: path.basename(String(info.filename || storedFilename)),
};
}).catch(async (err) => {
await removeIfPresent(temporaryPath);
throw err;
});
});

parser.on('filesLimit', () => fail(new UploadError(400, 'Upload exactly one file')));
parser.on('error', reject);
parser.on('finish', async () => {
try {
if (writeTask) await writeTask;
if (parseError) throw parseError;
if (!result) throw new UploadError(400, 'Choose a .jsonl file');
resolve(result);
} catch (err) {
await removeIfPresent(result?.destination);
reject(err);
}
});

req.pipe(parser);
});
}

async function handleUpload(req, res) {
try {
await fsp.mkdir(UPLOAD_DIR, { recursive: true });
const uploaded = await parseUpload(req);
indexService.ingestPath(uploaded.destination);
const indexed = await waitForIndexed(uploaded.sessionId);
res.status(indexed ? 201 : 202).json({
sessionId: uploaded.sessionId,
filename: uploaded.originalFilename,
project: UPLOAD_PROJECT,
indexed,
});
} catch (err) {
const status = err instanceof UploadError ? err.status : 500;
if (status >= 500) log.error(`upload failed: ${err?.message || err}`);
res.status(status).json({ error: status >= 500 ? 'Upload failed' : err.message });
}
}

module.exports = require('express').Router().post('/api/upload', handleUpload);
24 changes: 24 additions & 0 deletions backend/src/services/uploadNaming.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

const path = require('node:path');

function normalizeUploadStem(originalName) {
const baseName = path.basename(String(originalName || ''));
if (path.extname(baseName).toLowerCase() !== '.jsonl') {
throw new Error('Only .jsonl files can be uploaded');
}
const stem = baseName.slice(0, -'.jsonl'.length)
.normalize('NFKC')
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return stem || 'session';
}

function makeStoredFilename(originalName, suffix) {
const token = String(suffix || '').replace(/[^a-zA-Z0-9_-]/g, '');
if (!token) throw new Error('Upload id is required');
return `${normalizeUploadStem(originalName)}-${token}.jsonl`;
}

module.exports = { normalizeUploadStem, makeStoredFilename };
11 changes: 11 additions & 0 deletions backend/test/uploadService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use strict';

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizeUploadStem, makeStoredFilename } = require('../src/services/uploadNaming');

test('upload filenames are reduced to safe jsonl names without accepting path traversal', () => {
assert.equal(normalizeUploadStem('../../my session.JSONL'), 'my-session');
assert.equal(makeStoredFilename('../../my session.JSONL', 'fixed-id'), 'my-session-fixed-id.jsonl');
assert.throws(() => makeStoredFilename('transcript.txt', 'fixed-id'), /\.jsonl/i);
});
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recensa</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="icon" type="image/png" href="%BASE_URL%favicon.png" />
</head>
<body>
<script>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export function App() {

const handleSelect = useCallback((sessionId: string) => writeHash([sessionId]), []);
const handleHome = useCallback(() => writeHash([]), []);
const handleUploadComplete = useCallback((sessionId: string) => {
setListRefreshKey((n) => n + 1);
writeHash([sessionId]);
}, []);
const handleOpenSubagent = useCallback(
(subagentSessionId: string) => writeHash([...trail, subagentSessionId]),
[trail]
Expand Down Expand Up @@ -289,6 +293,7 @@ export function App() {
onSelect={handleSelect}
onOpenHit={handleOpenHit}
onHome={handleHome}
onUploadComplete={handleUploadComplete}
/>
</div>
<Settings />
Expand Down
Loading