Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c2fe371
docs(conversations): decompose the conversation picker rebuild
Juliusolsson05 Sep 11, 2026
6b042f3
docs(conversations): plan the picker rebuild task by task
Juliusolsson05 Sep 11, 2026
adb96d4
test(corpus): add the conversation corpus redaction policy
Juliusolsson05 Sep 11, 2026
f95ba4b
test(corpus): record the conversation corpus from real provider stores
Juliusolsson05 Sep 11, 2026
623828f
test(corpus): install the corpus under a temp HOME and record the use…
Juliusolsson05 Sep 11, 2026
e2f88c0
feat(conversations): add the shared record and repository family reso…
Juliusolsson05 Sep 11, 2026
eb42d70
feat(conversations): open provider sqlite indexes read-only with a co…
Juliusolsson05 Sep 11, 2026
db212ff
refactor(conversations): move the incremental prompt folder out of se…
Juliusolsson05 Sep 11, 2026
435a5c2
feat(conversations): index Claude prompt history incrementally
Juliusolsson05 Sep 11, 2026
bacc4f6
feat(conversations): discover Claude conversations across worktree pr…
Juliusolsson05 Sep 11, 2026
c66db5b
feat(conversations): list Codex threads from the native sqlite index …
Juliusolsson05 Sep 11, 2026
995b748
feat(conversations): list OpenCode sessions from its database
Juliusolsson05 Sep 11, 2026
53b4bbe
feat(conversations): decide what each injected prompt wrapper means
Juliusolsson05 Sep 11, 2026
8273168
feat(conversations): classify, label with provenance and order by use…
Juliusolsson05 Sep 11, 2026
75129ed
feat(conversations): build one ordered, explained listing from every …
Juliusolsson05 Sep 11, 2026
7e61e26
feat(conversations): remember titles, names and roles per native id i…
Juliusolsson05 Sep 11, 2026
5a7408d
feat(conversations): serve listings, search, prompts and children fro…
Juliusolsson05 Sep 11, 2026
3a7504d
feat(conversations): expose listing, prompts and children over one IP…
Juliusolsson05 Sep 11, 2026
451e725
feat(picker): open one Conversations picker from Resume Session and S…
Juliusolsson05 Sep 11, 2026
2dc2416
feat(picker): list the typed folder's conversations in the path picke…
Juliusolsson05 Sep 11, 2026
f37c41a
feat(picker): list every prompt with relative time in View Prompts an…
Juliusolsson05 Sep 11, 2026
808e959
feat(control): serve native history from the conversation catalog
Juliusolsson05 Sep 11, 2026
b9729bc
perf(conversations): bound discovery and search by what the index doe…
Juliusolsson05 Sep 11, 2026
bf8069c
fix(picker): keep View Prompts newest first
Juliusolsson05 Sep 11, 2026
f3f800f
test(conversations): measure the live picker budget against real stores
Juliusolsson05 Sep 11, 2026
44338d5
perf(conversations): bound the Claude head by bytes and read transcri…
Juliusolsson05 Sep 11, 2026
890151c
refactor(conversations): delete the four transcript listers and the t…
Juliusolsson05 Sep 11, 2026
0a5607c
docs(conversations): record the pull request in the decomposition status
Juliusolsson05 Sep 11, 2026
29544b0
Merge origin/main into feat/session-picker
Juliusolsson05 Sep 12, 2026
7ed3871
fix(conversations): resolve the review findings on relocation, case, …
Juliusolsson05 Sep 12, 2026
8a7b969
chore(conversations): retire comments that pointed at the deleted lis…
Juliusolsson05 Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
12 changes: 9 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,19 @@ temp.txt
# rules below are defensive: if someone reverts the outDir or runs
# tsc with different flags, we still refuse to stage the drift.
#
# We have exactly TWO hand-authored `.d.ts` files tracked under src/
# We have exactly THREE hand-authored `.d.ts` files tracked under src/
# (`src/preload/index.d.ts` augments `window.api`; `src/renderer/src/
# vite-env.d.ts` is vite's default client types). Plus one under
# testing/. Those are explicitly re-included.
# vite-env.d.ts` is vite's default client types; `src/shared/types/
# node-sqlite.d.ts` types Node 24's built-in sqlite that @types/node 20
# lacks). Plus one under testing/. Those are explicitly re-included.
/.tsc-out/
/electron.vite.config.js
/electron.vite.config.d.ts
/src/**/*.js
/src/**/*.d.ts
!/src/preload/index.d.ts
!/src/renderer/src/vite-env.d.ts
!/src/shared/types/node-sqlite.d.ts
/testing/**/*.js
/testing/**/*.mjs
/testing/**/*.d.ts
Expand Down Expand Up @@ -85,3 +87,7 @@ file-dumps/
# Kept hidden + ignored so `git status` from the main checkout never
# tries to track the contents of sibling branches.
.worktrees/

# Unredacted mirror of the conversation corpus (real prompts, titles, paths).
# The committed corpus next to it is the redacted recording.
/testing/fixtures/conversations/local/
539 changes: 539 additions & 0 deletions docs/decomposition/conversations.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ If you are documenting *what is true today and is expected to stay true*, it bel

## Current files

- [../decomposition/conversations.md](../decomposition/conversations.md) — The conversation picker: provider-native indexes, the repository family, the catalog's kinds and label ladder, the ledger, and the recorded corpus that verifies them. The decomposition is the design document; there is no separate design file.
- [agent-code-conventions.md](agent-code-conventions.md) — Machine-wide personal development rules delivered as one deterministic native Agent Skill across registered providers, including provider discovery roots, write-ahead ownership, collision handling, safe uninstall, and recovery.
- [ghost-system.md](ghost-system.md) — The provisional-record bridge between the proxy semantic stream and the durable JSONL transcript. Covers the five-rule render predicate, reconciliation, the orphan TTL, the sidecar shape filter, and the dual-owner relationship with `SemanticStreamingTurn`.
- [provider-switching.md](provider-switching.md) — Cross-provider transcript capacity, native compaction orchestration, Claude plaintext summary carriers, Codex encrypted compaction, target model metadata, locking, and semantic live verification.
5,427 changes: 5,427 additions & 0 deletions docs/superpowers/plans/2026-09-11-conversations.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@
"test:system": "npm run workflow-mcp:build && NODE_ENV=test vitest run --project system",
"test:renderer": "npm run workflow-mcp:build && NODE_ENV=test vitest run --project renderer",
"test:live": "npm run workflow-mcp:build && NODE_ENV=test vitest run --config vitest.live.config.ts --passWithNoTests",
"test:live:conversations": "NODE_ENV=test AGENT_CODE_LIVE_CONVERSATIONS=1 vitest run --config vitest.live.config.ts src/main/conversations/conversations.live.test.ts",
"test:coverage": "npm run workflow-mcp:build && NODE_ENV=test vitest run --coverage",
"test:package": "npm run build && node scripts/verify-build-output.mjs",
"test:contract": "node scripts/check-test-contract.mjs",
"check:worktree-live-fixtures": "tsx --tsconfig tsconfig.web.json scripts/extract-worktree-live-attribution-fixtures.mts --verify-checked-in",
"extract:conversations": "tsx --tsconfig tsconfig.node.json scripts/extract-conversation-corpus.mts",
"check:conversation-fixtures": "tsx --tsconfig tsconfig.node.json scripts/extract-conversation-corpus.mts --verify-checked-in",
"lifecycle:summarize": "tsx --tsconfig tsconfig.node.json scripts/summarize-lifecycle.mts",
"check:keybindings": "tsx --tsconfig tsconfig.web.json scripts/check-command-keybindings.mts",
"check": "npm run test:contract && npm run check:worktree-live-fixtures && npm run check:keybindings && npm run typecheck && npm run check:live-resume-probe && npm test && npm run test:package",
"check": "npm run test:contract && npm run check:worktree-live-fixtures && npm run check:conversation-fixtures && npm run check:keybindings && npm run typecheck && npm run check:live-resume-probe && npm test && npm run test:package",
"audit:production": "npm audit --omit=dev --audit-level=high",
"verify:package:mac": "node scripts/verify-packaged-mac.mjs",
"smoke:package:mac": "node scripts/smoke-packaged-mac.mjs",
Expand Down
216 changes: 216 additions & 0 deletions scripts/conversation-corpus-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { createHash } from 'node:crypto'

// The single publication gate for testing/fixtures/conversations/.
//
// WHY a closed allowlist and not a denylist: the corpus is recorded from the
// author's real transcripts and committed to a public repository. A denylist
// has to enumerate every place a prompt could hide (Codex index titles, Claude
// ai-title records, hook stdout, image URLs, queue-operation content) and is
// wrong the first time a provider adds a field. An allowlist is wrong in the
// safe direction: a new field is hashed until someone adds it here on purpose.
//
// WHY wrapper prefixes are kept verbatim: the catalog's unwrapping rules key
// on exactly these prefixes (docs/decomposition/conversations.md §2.3). A
// fixture that hashed them would prove nothing about the rule that matters.
export const KEEP_VERBATIM_KEYS: ReadonlySet<string> = new Set([
// record structure
'type', 'subtype', 'role', 'kind', 'origin', 'source', 'thread_source', 'originator',
'agent_role', 'agent_nickname', 'history_mode', 'model_provider', 'archived',
'permissionMode', 'isMeta', 'isSidechain', 'isCompactSummary', 'entrypoint', 'userType',
'operation', 'hookEvent', 'hookName', 'exitCode', 'mode', 'atis', 'version', 'v',
// identity and time
'uuid', 'parentUuid', 'promptId', 'leafUuid', 'sessionId', 'session_id', 'id',
'thread_id', 'parent_thread_id', 'child_thread_id', 'forked_from_id', 'messageId',
'message_id', 'parent_id', 'project_id', 'workspace_id',
'timestamp', 'created_at', 'updated_at', 'recency_at', 'created_at_ms', 'updated_at_ms',
'recency_at_ms', 'time_created', 'time_updated', 'time_archived', 'time', 'created',
'durationMs', 'messageCount', 'mtime', 'size', 'fileSize',
// counts and flags
'has_user_event', 'is_pinned', 'tokens_used', 'depth', 'synthetic', 'status',
])

/** Keys whose values are filesystem paths: rewritten, never hashed. */
export const PATH_KEYS: ReadonlySet<string> = new Set([
'cwd', 'project', 'directory', 'worktree', 'rollout_path', 'workingDirectory', 'file',
'path', 'agent_path', 'trackingPath', 'projectDir',
])

/** Keys whose values are git branch names: `main` stays, anything else hashes. */
export const BRANCH_KEYS: ReadonlySet<string> = new Set(['gitBranch', 'git_branch', 'branch'])

/** Wrapper prefixes the catalog keys on. Kept verbatim; the remainder hashes. */
export const KEPT_WRAPPER_PREFIXES: readonly string[] = [
'<orchestration-handoff>',
'<stt note="Speech-to-text; may contain transcription mistakes.">',
'<stt',
'<command-name>',
'<local-command-caveat>',
'<local-command-stdout>',
'<recommended_plugins>',
'<environment_context>',
'<user_instructions>',
'<system-reminder>',
'<task-notification>',
'# AGENTS.md instructions for',
'# Handoff Summary',
'# Portable handoff summary',
// App-authored, not private: the title Agent Code gives an OpenCode Terminal
// session before the user types (src/providers/opencode/runtime/
// opencodeCliSessions.ts). The adapter classifies on it.
'Agent Code terminal session',
]

const PLACEHOLDER_RE = /^p:[0-9a-f]{8}:\d+$/
const BRANCH_PLACEHOLDER_RE = /^b:[0-9a-f]{8}$/

/** Idempotent: `--verify-checked-in` re-runs redaction over the committed
* corpus and requires a byte-identical result, so a placeholder must map to
* itself. */
export function placeholder(text: string): string {
if (PLACEHOLDER_RE.test(text)) return text
// Whitespace-only text stays as it is. There is nothing to hide in an empty
// string, and hashing it would INVENT a value: the Codex index stores '' in
// first_user_message for exec runs, and the catalog read `p:e3b0c442:0` as
// a real first prompt (label source first-prompt) where the unredacted
// store has none (label source cwd). The corpus must keep emptiness, or the
// expectations drafted from the unredacted mirror argue about different data.
if (text.trim() === '') return text
const sha8 = createHash('sha256').update(text).digest('hex').slice(0, 8)
return `p:${sha8}:${text.length}`
}

export type PathRewriter = {
rewrite(path: string): string
/** Numbered `other-N` assignments so a review can see which distinct projects appear. */
others(): Record<string, string>
}

/**
* `/Users/x/Desktop/Development/agent-code` → `/fixture/repo`, its home →
* `/fixture/home`, every other absolute path under home → `/fixture/other-N`
* (first-seen numbering, stable within one extraction run). Paths outside home
* hash entirely: a `/private/var/folders/...` temp path would otherwise leak
* the machine's random folder name.
*/
/** Claude Code's project-directory sanitizer (sessionStoragePortable.ts
* sanitizePath), inlined so this policy stays dependency-free. */
function sanitize(path: string): string {
return path.replace(/[^a-zA-Z0-9]/g, '-')
}

export function createPathRewriter(home: string, repoRoot: string): PathRewriter {
const others = new Map<string, string>()
const strip = (p: string) => p.replace(/\/+$/, '')
const HOME = strip(home)
const REPO = strip(repoRoot)
const rewrite = (raw: string): string => {
const fileScheme = raw.startsWith('file://') ? 'file://' : ''
const path = strip(raw.slice(fileScheme.length))
// Already rewritten (verification pass over the committed corpus).
if (path === '/fixture' || path.startsWith('/fixture/')) return raw
if (path === REPO || path.startsWith(REPO + '/')) return fileScheme + '/fixture/repo' + path.slice(REPO.length)
// darwin's filesystem is case-insensitive and one real transcript records
// the repo with a lowercased segment. It IS this repository; the fixture
// keeps the variance visible as /fixture/REPO so the case rule stays
// exercised by real data.
if (path.toLowerCase() === REPO.toLowerCase() || path.toLowerCase().startsWith(REPO.toLowerCase() + '/')) {
return fileScheme + '/fixture/REPO' + path.slice(REPO.length)
}
if (path === HOME) return fileScheme + '/fixture/home'
if (path.startsWith(HOME + '/')) {
const rest = path.slice(HOME.length + 1)
// Claude project directories are named by the sanitized real cwd, so
// the directory name itself is a path. Translate the segment the same
// way the cwd would be: the repo's own prefix becomes -fixture-repo, and
// anything else under home hashes after the -fixture-home marker.
if (rest.startsWith('.claude/projects/')) {
const after = rest.slice('.claude/projects/'.length)
const slash = after.indexOf('/')
const segment = slash < 0 ? after : after.slice(0, slash)
const remainder = slash < 0 ? '' : after.slice(slash)
const sanitizedRepo = sanitize(REPO)
const sanitizedHome = sanitize(HOME)
const translated = segment === sanitizedRepo || segment.startsWith(sanitizedRepo + '-')
? '-fixture-repo' + segment.slice(sanitizedRepo.length)
: segment.startsWith(sanitizedHome)
? '-fixture-home-' + placeholder(segment.slice(sanitizedHome.length))
: segment.startsWith('-fixture-')
? segment
: '-fixture-external-' + placeholder(segment)
// Inside a project directory only transcript names (uuid.jsonl) and
// Claude's fixed subdirectories are structural; a memory file or a
// tool-result name is content.
const structural = /^([0-9a-f-]{36}(\.jsonl)?|subagents|tool-results|memory|workflows|agent-[0-9a-f]+(\.jsonl|\.meta\.json)?)$/i
const safeRemainder = remainder.split('/').map(s => (s === '' || structural.test(s) || PLACEHOLDER_RE.test(s) ? s : placeholder(s))).join('/')
return fileScheme + '/fixture/home/.claude/projects/' + translated + safeRemainder
}
// Other provider config roots keep their layout so adapters resolve them.
if (rest.startsWith('.claude/') || rest.startsWith('.codex/') || rest.startsWith('.local/share/opencode/') || rest.startsWith('.config/agent-code/')) {
return fileScheme + '/fixture/home/' + rest
}
// Everything else under home is another project: number it by its first
// three segments so a subdirectory maps under the same fixture root.
// The remainder is hashed segment by segment: directory names inside
// someone else's project (`bringdown/bringdown-engine`) are as private
// as the project name itself. Only the family repo keeps real
// subdirectory names, because they are this public repository's own.
const segments = rest.split('/')
const projectKey = segments.slice(0, Math.min(segments.length, 3)).join('/')
let assigned = others.get(projectKey)
if (!assigned) {
assigned = `/fixture/other-${others.size + 1}`
others.set(projectKey, assigned)
}
return fileScheme + assigned + (segments.length > 3 ? '/' + segments.slice(3).map(placeholder).join('/') : '')
}
return fileScheme + '/fixture/external/' + placeholder(path)
}
return { rewrite, others: () => Object.fromEntries(others) }
}

function redactString(key: string, value: string, paths: PathRewriter): string {
if (KEEP_VERBATIM_KEYS.has(key)) return value
if (PATH_KEYS.has(key)) return value.startsWith('/') || value.startsWith('file://') ? paths.rewrite(value) : placeholder(value)
if (BRANCH_KEYS.has(key)) {
// An empty branch (detached HEAD, or a record written before git was
// asked) stays empty for the same reason an empty prompt does: `b:` with
// nothing after it is a value the store never held, and the second pass
// of --verify-checked-in would hash it into a drift.
if (value === 'main' || value.trim() === '' || BRANCH_PLACEHOLDER_RE.test(value)) return value
return 'b:' + placeholder(value).slice(2, 10)
}
for (const prefix of KEPT_WRAPPER_PREFIXES) {
if (value === prefix) return value
if (value.startsWith(prefix)) return prefix + placeholder(value.slice(prefix.length))
}
return placeholder(value)
}

/** Object keys are structure, except when a provider uses a PATH as a key
* (Claude's `trackedFileBackups` is keyed by absolute file path). A key that
* looks like a path is rewritten like a path value; any other key longer
* than a field name could be is hashed. */
function redactKey(key: string, paths: PathRewriter): string {
if (key.startsWith('/') || key.startsWith('file://')) return paths.rewrite(key)
// A relative path or a file name used as a key (`../other-repo/x.ts`,
// `docs/18-integration.md`) names the user's files just as an absolute one
// does. Structural keys never contain a slash or a file extension.
if (key.includes('/') || /\.[a-z0-9]{1,5}$/i.test(key)) return placeholder(key)
if (key.length > 64) return placeholder(key)
return key
}

export function redactValue(key: string, value: unknown, paths: PathRewriter): unknown {
if (typeof value === 'string') return redactString(key, value, paths)
if (Array.isArray(value)) return value.map(item => redactValue(key, item, paths))
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[redactKey(k, paths)] = redactValue(k, v, paths)
return out
}
return value
}

export function redactRecord(record: unknown, paths: PathRewriter): unknown {
return redactValue('', record, paths)
}
Loading