Skip to content
Merged
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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ here. Format follows [Keep a Changelog][kac].

[kac]: https://keepachangelog.com/en/1.1.0/

## [0.2.0] — 2026-02-13

### Added

- **Custom compaction prompt** (`SEAMLESS_PROMPT_FILE`):
override the default prompt entirely with a
user-supplied file. Tilde expansion supported.
Silent fallback to the default if the file is
missing or unreadable.
- **User Intent section** in the default prompt.
Instructs the summariser to preserve the user's
original requests, clarifications, and stated
constraints verbatim — only direction-shaping
messages, not every exchange.
- **`claude-summarise` CLI command**: summarise any
historical Claude Code session by ID or prefix.
Searches `~/.claude/projects/*/` for transcripts,
runs the compactor, outputs to the standard
sessions directory. Supports `--list`, `--force`,
and prefix matching.

### Fixed

- **Nested session guard**: compactor now strips the
`CLAUDECODE` environment variable when spawning
`claude -p`, so compaction works when triggered
from within a Claude Code session. Previously
failed with a nested-session error.

### Changed

- Output validation relaxed when a custom prompt is
active: section-name check skipped (custom prompts
produce different sections), minimum length check
(500 chars) still applies.
- Default prompt sections renumbered from 5 to 6 to
accommodate User Intent.
- `EXPECTED_SECTIONS` in config.mjs updated to
include `User Intent`.

## [0.1.1] — 2026-02-13

### Fixed
Expand Down
71 changes: 65 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,18 @@ falls back to hook-only operation:

## How it works

The summary is structured into five sections:
The summary is structured into six sections:

1. **Session Summary** — what was requested,
accomplished, and what remains
2. **Technical Context** — file paths, commands,
config values, error messages (verbatim)
3. **Knowledge Extractions** — reusable decisions,
3. **User Intent** — the user's original requests,
clarifications, and constraints (verbatim)
4. **Knowledge Extractions** — reusable decisions,
learnings, patterns, and blockers
4. **Next Steps** — priority-ordered action items
5. **Active Context** — working directory, branch,
5. **Next Steps** — priority-ordered action items
6. **Active Context** — working directory, branch,
key files

## Install
Expand Down Expand Up @@ -369,6 +371,56 @@ variables for tuning:
| `SEAMLESS_MAX_CHARS` | `400000` | Max transcript chars |
| `SEAMLESS_HOOK_TIMEOUT` | `60` | Per-hook timeout (s) |
| `SEAMLESS_DISPLAY_CMD` | — | Custom statusline command |
| `SEAMLESS_PROMPT_FILE` | — | Custom compaction prompt file |

## Custom compaction prompt

By default, seamless-claude uses a built-in prompt
that produces the six-section structure above. To
replace it entirely with your own prompt, set
`SEAMLESS_PROMPT_FILE` to a file path:

```json
{
"env": {
"SEAMLESS_PROMPT_FILE": "~/.seamless-claude/my-prompt.md"
}
}
```

The file content replaces the default prompt
verbatim — the summarisation model receives your
prompt followed by the transcript. Tilde expansion
is supported. If the file is missing or unreadable,
the default prompt is used silently.

When a custom prompt is active, output validation
is relaxed: the section-name check is skipped
(your prompt produces different sections), but the
minimum length check (500 chars) still applies.

## Summarise historical sessions

Got a past session you want a structured summary
of? `claude-summarise` runs the compactor against
any historical transcript:

```bash
claude-summarise 97aac477 # by prefix
claude-summarise --list # show all
claude-summarise 97aa --list # filter by prefix
claude-summarise 97aa --force # re-summarise
```

It searches `~/.claude/projects/*/` for matching
transcripts, resolves the full session ID, and
runs the compactor. If a summary already exists,
it tells you and skips (use `--force` to redo).

The output lands in
`~/.seamless-claude/sessions/{id}.md` — same
format as automatic compaction, so `claude-resume`
can pick it up.

## Cross-session resume

Expand Down Expand Up @@ -431,6 +483,12 @@ exec node \
WRAPPER

chmod +x ~/.claude/bin/claude-resume

# Repeat for claude-summarise
cp ~/.claude/bin/claude-resume \
~/.claude/bin/claude-summarise
sed -i '' 's/claude-resume/claude-summarise/g' \
~/.claude/bin/claude-summarise
```

Then add `~/.claude/bin` to your PATH (if not
Expand Down Expand Up @@ -532,8 +590,9 @@ The compactor handles failure gracefully:
- **Retry**: On failure (timeout, bad exit, empty
result), retries once with a halved transcript
- **Validation**: Checks the summary contains at
least 3 of the 5 expected sections (case-insensitive)
and exceeds 500 characters
least 3 of the 6 expected sections (case-insensitive)
and exceeds 500 characters. When using a custom
prompt, only the length check applies
- **Lockfiles**: Prevents duplicate compactions;
stale locks (>10 min) are automatically cleaned
- **Output cap**: Resume output capped at 200K
Expand Down
59 changes: 59 additions & 0 deletions bin/claude-summarise
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* Summarise a historical Claude Code session.
*
* Usage:
* claude-summarise <id-or-prefix>
* claude-summarise <id-or-prefix> --force
* claude-summarise --list [prefix]
* claude-summarise --help
*/

import { execFileSync } from 'node:child_process'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const ROOT = dirname(
dirname(fileURLToPath(import.meta.url)),
)
const SCRIPT = join(
ROOT, 'scripts', 'summarise.mjs',
)

const USAGE = `\
Usage: claude-summarise [options] <session>

Summarise any historical Claude Code session
by running the seamless-claude compactor against
its transcript.

Options:
--list List available sessions
--force Re-summarise even if summary exists
--help Show this help

Arguments:
session UUID or short prefix (min 8 chars)

Examples:
claude-summarise 97aac477
claude-summarise --list
claude-summarise --list baldyak
claude-summarise 97aac477 --force`

const arg = process.argv[2]

if (arg === '--help' || arg === 'help') {
process.stdout.write(`${USAGE}\n`)
process.exit(0)
}

try {
execFileSync(
process.execPath,
[SCRIPT, ...process.argv.slice(2)],
{ stdio: 'inherit' },
)
} catch (err) {
process.exit(err.status ?? 1)
}
10 changes: 10 additions & 0 deletions lib/claude.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { spawn } from 'node:child_process'

// Build a clean env for spawning claude -p.
// Strips CLAUDECODE so the child process isn't
// blocked by the nested-session guard.
function cleanEnv() {
const env = { ...process.env }
env.CLAUDECODE = undefined
Comment thread
RemoteCTO marked this conversation as resolved.
return env
}

// Runs `claude -p` with input text, returns stdout.
// Rejects on timeout or non-zero exit.
export function runClaude(cmd, inputText, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(cmd[0], cmd.slice(1), {
stdio: ['pipe', 'pipe', 'pipe'],
env: cleanEnv(),
})

let stdout = ''
Expand Down
1 change: 1 addition & 0 deletions lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const MIN_RESULT_LENGTH = 500
export const EXPECTED_SECTIONS = [
'Session Summary',
'Technical Context',
'User Intent',
'Knowledge Extractions',
'Next Steps',
'Active Context',
Expand Down
3 changes: 2 additions & 1 deletion lib/validate.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { EXPECTED_SECTIONS, MIN_RESULT_LENGTH } from './config.mjs'

// Returns true if the compactor result is valid.
export function validateResult(result) {
export function validateResult(result, customPrompt = false) {
if (!result || result.trim().length === 0) {
return false
}
if (result.length < MIN_RESULT_LENGTH) return false
if (customPrompt) return true
const lower = result.toLowerCase()
const found = EXPECTED_SECTIONS.filter((s) =>
lower.includes(s.toLowerCase()),
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"name": "seamless-claude",
"version": "0.1.1",
"version": "0.2.0",
"description": "Zero-downtime compaction for Claude Code. Pre-compacts sessions in the background so you never wait.",
"type": "module",
"bin": {
"claude-resume": "bin/claude-resume"
"claude-resume": "bin/claude-resume",
"claude-summarise": "bin/claude-summarise"
},
"scripts": {
"test": "node --test test/*.test.mjs",
Expand Down
36 changes: 30 additions & 6 deletions scripts/compactor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { existsSync } from 'node:fs'
import { appendFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { runClaude } from '../lib/claude.mjs'
import {
HOOKS_DIR,
Expand Down Expand Up @@ -59,7 +60,15 @@ PRESERVE ALL specific details verbatim:
- Architecture decisions with reasoning
- Environment details (branches, versions)

### 3. Knowledge Extractions
### 3. User Intent
Include the user's original request(s) VERBATIM — \
the exact messages that initiated or redirected work. \
Include any clarifications or corrections \
("no, I meant X", "actually, do Y instead"). \
Include stated constraints or preferences. \
Only direction-shaping messages, not every message.

### 4. Knowledge Extractions
Extract reusable knowledge in these exact formats:
\`\`\`
DECISION: [choice with full reasoning]
Expand All @@ -68,11 +77,11 @@ PATTERN: [code pattern with example]
BLOCKER: [what doesn't work and why]
\`\`\`

### 4. Next Steps
### 5. Next Steps
Specific, actionable items. Priority ordered.
Include any open questions or blockers.

### 5. Active Context
### 6. Active Context
- Working directory
- Git branch
- Active ticket/epic (if mentioned)
Expand All @@ -96,6 +105,21 @@ const SYSTEM_PROMPT =
'else. Just produce the requested structured ' +
'output.'

// Custom prompt file overrides default
let activePrompt = PROMPT
let usingCustomPrompt = false
const promptFile = process.env.SEAMLESS_PROMPT_FILE
if (promptFile) {
try {
const { readFileSync } = await import('node:fs')
const expanded = promptFile.replace(/^~/, homedir())
activePrompt = readFileSync(expanded, 'utf8').trim()
usingCustomPrompt = true
} catch {
// Fall back to default prompt
}
}

let PATHS = null

async function log(msg) {
Expand Down Expand Up @@ -163,11 +187,11 @@ async function main() {
try {
let input
if (attempt === 1) {
input = `${PROMPT}\n\n---\n\nTRANSCRIPT (${conversation.length} entries):\n\n${transcriptText}`
input = `${activePrompt}\n\n---\n\nTRANSCRIPT (${conversation.length} entries):\n\n${transcriptText}`
} else {
const half = Math.floor(transcriptText.length / 2)
const shortened = transcriptText.slice(-half)
input = `${PROMPT}\n\n---\n\nTRANSCRIPT (truncated, retry):\n\n${shortened}`
input = `${activePrompt}\n\n---\n\nTRANSCRIPT (truncated, retry):\n\n${shortened}`
await log(
`Retry with ${input.length} chars (transcript halved)`,
)
Expand All @@ -176,7 +200,7 @@ async function main() {
await log(`Attempt ${attempt}: calling claude -p`)
const res = await runClaude(cmd, input, TIMEOUT)

if (!validateResult(res)) {
if (!validateResult(res, usingCustomPrompt)) {
throw new Error(
`Output failed validation (length=${(res || '').length})`,
)
Expand Down
Loading