Personal knowledge vault plugin — session recording, memory distillation, indexed retrieval

<img src="docs/images/logo.png" alt="Cortexes" width="200">
<h1 align="center">Cortexes</h1>
A personal knowledge vault plugin for Claude Code — session recording, memory distillation, semantic retrieval.
<a href="LICENSE"><img alt="License: Apache 2.0" src="https://img.shields.io/badge/License-Apache_2.0-blue.svg"></a> <a href="https://cortexes.pages.dev"><img alt="Website" src="https://img.shields.io/badge/website-cortexes.pages.dev-000"></a> <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757"> <a href="CONTRIBUTING.md"><img alt="PRs Welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg"></a>
<sub><a href="README.md">English</a> · <a href="README.zh-TW.md">繁體中文</a></sub>
Cortexes turns your working memory into a searchable knowledge base. Every Claude Code session gets automatically recorded when it ends, then can be distilled and retrieved later.
Design philosophy: the vault is the source of truth (plain markdown + git); the vector store is just a rebuildable derived index.
# Add the marketplace, then install the plugin from it
/plugin marketplace add https://github.com/XBlueSky/cortexes.git#plugin
/plugin install cortexes@cortex
The marketplace is named cortex and the plugin inside it is cortexes — hence cortexes@cortex. The marketplace name is kept from 1.x on purpose so that existing marketplace add registrations keep working.
The CLI is published on PyPI as cortex-vec:
# Recommended: isolated tool install via uv
uv tool install cortex-vec
# Or with pip
pip install cortex-vec
Upgrade with uv tool upgrade cortex-vec (or pip install -U cortex-vec) after a plugin update. /cortexes:genesis checks for the CLI and offers this install when it is missing. To run the unreleased development version instead, install from the repo:
uv tool install "git+https://github.com/XBlueSky/cortexes.git@plugin#subdirectory=cortex-vec"
OPENAI_API_KEY is optional. Setting it enables embeddings, and with them semantic (vector) search. Without it nothing breaks: search runs on the local BM25 index, entirely on your machine and with nothing sent to OpenAI — see Environment Variables.
/cortexes:genesis /path/to/your/vault
This sets the vault path and author info, and builds the semantic index.
"save to cortex" → manually save knowledge
"check cortex" → semantic search over the vault
"distill" → extract knowledge from Raw/
"broadcast" → fuse new Raw content into existing pages
2.0.0 renames the plugin from cortex to cortexes. Your vault, config and indexes are untouched — there is no data migration.
/plugin marketplace update cortex (or remove and re-add it).renames mapping (cortex → cortexes), so the rename is carried for you: the update re-points your enabled plugin at cortexes@cortex, and the next session start materializes it at 2.0.0. You do not need to uninstall and reinstall. In between the two steps /plugin may still list the old cortex row annotated Renamed to "cortexes" in the "cortex" marketplace — that is the migration staged, not an error. If you ever do reinstall from scratch, the id is /plugin install cortexes@cortex./cortex:* no longer resolves; every command moved to /cortexes:* (/cortexes:genesis, /cortexes:evolve, /cortexes:distill, /cortexes:query, /cortexes:broadcast, /cortexes:takeoff). Natural-language triggers are unchanged — "存到 cortex" and "查 cortex" still work.Weekly/ directory, move what you still want. Weekly/ is no longer part of the vault taxonomy: 2.0 does not create it, index it, search it, or list it. It was already unreachable — the weekly report skill went in 0.22.0 and Weekly/ left the index back in 0.5.0 — so this changes nothing about what you can find. Cortexes will not move, rewrite, or delete an existing Weekly/; copy anything still worth keeping into Notes/ or Projects/ yourself, at your own pace, and whatever you leave stays where it is. uv tool upgrade cortex-vec # or: pip install -U cortex-vec
cortex-vec 0.10.0 is the version this plugin release ships against. 0.8.0 is what carried the Weekly removal into the CLI — --type weekly is gone from search --help and Weekly/ is no longer classified as a content type — 0.9.0 adds the cosine backfill and the retrieval-filter fixes that 2.1.0's dedup and scoped search depend on, and 0.10.0 adds the quiet stderr and classified exit codes the 2.2.0 query skill reads. Older CLIs keep working, but a scoped search on one still leaks out-of-scope pages and a keyword-only hit still reports 0.00.
~/.cortex/config.json, the vector/BM25 indexes and caches, and the CORTEX_* environment variables all keep their names and paths. No rebuild, no re-index, no config edit.Live docs and changelog: <https://cortexes.pages.dev> (Cloudflare Pages, generated from .cc-marketspec/dist/manifest.json). See site/README.md for local builds.
| Command | Description |
|---|---|
/cortexes:genesis | Initialize the vault — set path, author, rebuild the index |
/cortexes:evolve | Manually save knowledge to Notes or Projects (also writes log.md) |
/cortexes:distill | Distill Raw/ session records into Notes/Projects (map-first navigation + two-stage evaluation + pending-merge exit) |
/cortexes:query | Search the vault — semantic (cortex-vec) with grep and BM25 fallbacks. Running it counts as an explicit request, so it searches even when the session opted out |
/cortexes:broadcast | Fuse newly distilled content into related existing pages (llm-wiki-style ingest) |
/cortexes:takeoff | Hand-off batons — curate temporary, non-git hand-offs for a later session to resume, one per work line ([topic] / resume [topic] / done [topic] subcommands) |
| Skill | Trigger |
|---|---|
| cortex-evolve | "save to cortex", "note this down", "remember this" |
| cortex-distill | "distill", "clean up raw", "distill raw records" |
| cortex-broadcast | "broadcast", "merge pending-merge", "fuse this into the vault" |
| cortex-takeoff | "hand off", "takeoff", "hand off to next session", "context is running low" |
| cortex-query | "check cortex", "have I noted this before", "is this in cortex" |
| Hook | Event | Behavior |
|---|---|---|
| Session Report | SessionEnd | On session end, filters the transcript through a TOML pipeline before writing to Raw/ |
| Memory Injection | SessionStart | Interactive menu — checks vault backlog status and asks what to do next |
| Deck | hooks module (hooks/deck.tsx) | Draws the SessionStart menu as a band above the prompt (digits 1–9 press its buttons) and offers a takeoff hand-off once per context window at 70% use. Without the module the model shows the text menu as before |
Recording is automatic. Every session over 4 KB is written to your vault when it ends — there is no per-session prompt. Set
CORTEX_SKIP_RECORD=1to skip a session, and seePRIVACY.mdfor exactly what is captured, what is excluded, and what (if anything) leaves your machine.
Before writing to Raw/, the SessionEnd hook runs a TOML-driven filter pipeline that strips tool output with no knowledge value (e.g. ls, volume listings, repetitive build logs) — you can write custom filters per slash command so what lands in Raw/ actually carries signal.
cortex repo
├── plugin branch (orphan) ← the Claude Code plugin (this file lives here)
└── main branch ← Obsidian vault data
~/.cortex/
├── config.json ← settings produced by genesis
└── vectorstore/ ← ChromaDB semantic index (local only, not in git)
Raw/YYYY/MM/DD/ ← session dumps (complete, distilled on demand)
Notes/<category>/ ← distilled technical knowledge
Projects/<repo-name>/ ← project notes organized by repo
_index.md ← vault-wide summary index
log.md ← chronological history of evolve/distill
Nothing machine-checks _index.md for consistency (reorganizing pages is the one path that changes the index with no skill involved). See docs/index-audit.md for the audit, and for the regex traps that let a naive check pass while reporting the wrong set.
Every session:
SessionStart → surfaces available memory → user decides whether to load it
...work happens...
session ends → SessionEnd hook → filter → Raw/ (automatic, no prompt)
Anytime:
/cortexes:evolve → Notes/Projects + _index.md + log.md + vector store
/cortexes:query → vector search → precise file reads
Periodically:
/cortexes:distill → Raw → Notes/Projects (+ pending-merge → broadcast)
/cortexes:broadcast → pending-merge → fused into existing Notes/Projects
Hybrid retrieval (0.4.0+) — BM25 + vector dual streams, fused with Reciprocal Rank Fusion (RRF, k=60), default weights w_bm25=0.4 / w_vec=0.6:
~/.cortex/bm25/, kept in sync with ChromaDB by rebuild/upsert/delete.text-embedding-3-small semantic search, dual-vector (document body + bilingual summary), covering cases that are semantically close but lexically different.OPENAI_API_KEY, or offline, this automatically falls back to BM25-only, with no dependency on the skill-layer grep fallback.Other layers:
The vault's semantic indexing tool, built on ChromaDB + OpenAI text-embedding-3-small, paired with gpt-5.4-mini to generate a bilingual summary as a second embedding (dual-vector) to improve recall for mixed Chinese/English queries.
cortex-vec status # view index status
cortex-vec rebuild # full index rebuild
cortex-vec search "nginx certificate" # semantic search
cortex-vec search "oauth" --repo acme-core # filter by repo (narrows Projects/ only; Notes/ always pass)
cortex-vec search "sharing" --type project # filter by type
cortex-vec upsert Notes/Nginx/new.md # add/update a single document
cortex-vec delete Notes/Nginx/old.md # delete a document
cortex-vec search now defaults to BM25 + vector RRF hybrid, balancing exact lexical matches with semantic similarity:
cortex-vec search "nginx certificate" # hybrid (default)
cortex-vec search "nginx certificate" --no-bm25 # vector only (debug/eval)
cortex-vec search "nginx certificate" --no-vector # BM25 only (debug/eval)
cortex-vec status # shows both vector and BM25 entry counts
/cortexes:distill drives these read-only commands to walk a Raw without ever loading the whole file into context. A Raw is parsed once into a gap-free, overlap-free source partition; raw-span is the only reader that returns original text, and every page is hard-capped so an oversized session distills across bounded continuations instead of overflowing context:
cortex-vec distill-queue --root <vault>/Raw --stat # per-Raw projected sizes before a batch
cortex-vec raw-view <raw.md> # budget-bounded L0–L3 projection
cortex-vec distill-plan start <raw.md> # open a coverage/budget plan → plan_id
cortex-vec raw-map <raw.md> --plan-id <id> # navigation cards (kind/size/range/anchors)
cortex-vec raw-span <raw.md> --plan-id <id> --span-id <n> # exact original text, one bounded page
cortex-vec distill-plan status --plan-id <id> # coverage + no-insight gate state
The per-Raw plan lives under $XDG_CACHE_HOME/cortex/distill-plans/ with an active.json pointer enforcing one active Raw at a time (atomic writes, user-only permissions, fail-closed on corruption or identity drift).
SessionEnd fires more than once per conversation (/clear, exit + --resume) and re-filters the same growing transcript each time, so the earlier Raws are strict prefixes of the latest one. The hook now removes those automatically; this command is the manual form, and the only way to clean up a backlog recorded before 1.1.0:
cortex-vec reclaim-superseded --root <vault>/Raw # list the whole queue's duplicates
cortex-vec reclaim-superseded --root <vault>/Raw --apply \
--vault <vault> # remove them (staged with git rm)
Only files in the undistilled queue are candidates — a Raw already carrying a <!-- distilled: --> marker is never touched — and a candidate must be a prefix of its survivor, so the failure mode is "duplicate stays", never "content lost".
# Step 1: have an LLM draft candidate queries; a human reviews and confirms
# gold paths before they can be used
cortex-vec eval propose --queries eval-data/cortex-vault-v1.jsonl
# Step 2: run all adapters, print NDJSON results, and write a markdown scorecard
cortex-vec eval run \
--queries eval-data/cortex-vault-v1.jsonl \
--adapters grep,vector,bm25,hybrid \
--k 5 \
--out docs/benchmarks/$(date +%Y-%m-%d)-cortex-vault-v1.md
Supported adapters: grep / vector / bm25 / hybrid. Metrics: P@5 / R@5 / MRR / hit.
The four enhancements below are all disabled by default and require explicit configuration to enable. Always measure the P@5 / R@5 / MRR lift with cortex-vec eval run before and after enabling any of them, before deciding what's worth defaulting to on and what should be reverted.
Controlled by config retrieval.synonym_weight (0 = off; try 0.7). The BM25 stream boosts documents that match a synonym by that weight, so "OAuth" can match synonyms like "SSO / auth / authorization."
The synonym table lives at cortex-vec/src/cortex_vec/synonyms.py (common Chinese/English technical terms included) and can be extended.
Enabled via cortex-vec search --graph (or config retrieval.graph: true). Treats "wikilink neighbors of matched results" as a third RRF stream fused into the ranking — surfacing notes that are clearly linked from a hit but don't directly match the query themselves (recovering neighbors that vector/BM25 alone would miss).
Tunable parameters: retrieval.graph_hops (propagation hops), retrieval.w_graph (the graph stream's weight in RRF, default 0.3), retrieval.graph_top_k (how many top hits seed the BFS).
Uses rank-based RRF fusion (not additive boosting), so it's neutral for typical queries and doesn't hurt ranking — it only adds recall for "linked" queries. Measured: no difference on a 20-query general corpus with it on/off; R@5 0.50→0.667 on a wikilink-stress corpus.
Enabled via cortex-vec search --rerank (or config retrieval.rerank: true). Calls OpenAI (model set by retrieval.rerank_model, default gpt-5.4-mini) to re-rank the top retrieval.rerank_window (default 15) hybrid results, replacing pure score ranking with LLM relevance judgment. Any failure (API error / timeout) automatically falls back to the original RRF order without affecting search availability.
Controlled by config retrieval.max_per_repo (0 = unlimited), caps how many results from the same repo can appear in the top-k, preventing one large repo from drowning out results from other sources.
retrieval config exampleThe advanced keys under retrieval in ~/.cortex/config.json and their defaults:
{
"retrieval": {
"synonym_weight": 0,
"graph": false,
"graph_hops": 1,
"w_graph": 0.3,
"graph_top_k": 5,
"rerank": false,
"rerank_model": "gpt-5.4-mini",
"rerank_window": 15,
"max_per_repo": 0
}
}
Using it with CLI flags:
# Enable graph-boost + rerank (one-off test)
cortex-vec search "OAuth token" --graph --rerank
# Set synonym_weight, then run eval to confirm the lift
cortex-vec eval run \
--queries eval-data/cortex-vault-v1.jsonl \
--adapters hybrid \
--k 5 \
--out docs/benchmarks/$(date +%Y-%m-%d)-synonym-0.7.md
~/.cortex/config.json (generated by genesis):
{
"vault_path": "/path/to/vault",
"author": "your-name",
"author_email": "you@example.com",
"git": {
"auto_commit": true,
"auto_push": false,
"commit_trailer": ""
}
}
git.commit_trailer (optional, defaults to ""): when non-empty, distill and evolve append it as the last line of their commit messages, for git hosting that requires a specific trailer. Only those two commit kinds — the ones that add new Notes / Projects pages — carry it; the SessionEnd raw commit and broadcast commits do not.
Raw commits need no trailer, because a Raw is already cleaned at capture time of the two things a git host rejects: line breaks are normalized to \n, and credential-shaped strings (GitLab PATs, OpenAI / Anthropic keys, AWS keys, JWTs, session cookies, curl and Authorization header credentials, passwords in URLs, *_TOKEN= environment values) are replaced with REDACTED placeholders. The audit comment at the top of each Raw records redactions=N.
A failing auto_push is no longer silent: the remote's error is appended to ~/.cortex/push-failures.log, and the next session's opening status line reports how many commits are still unpushed.
| Variable | Required | Description |
|---|---|---|
OPENAI_API_KEY | No* | OpenAI API key for text-embedding-3-small. Required for rebuild/upsert/vector search; search automatically falls back to BM25-only without it |
CORTEX_VAULT_PATH | No | Read only by session-start-inject.sh and the takeoff.sh helper. It is not a general vault switch: cortex-vec, the SessionEnd recorder, and the evolve/distill/broadcast skills all resolve the vault from config.json, and the BM25/vector indexes live at a fixed ~/.cortex/ path either way — so pointing it at a second vault would split reads from writes across one shared index. A real multi-vault design is deferred; see #20 |
CORTEX_SKIP_RECORD | No | When set (e.g. =1), the SessionEnd hook skips recording this session into Raw/ — for launcher/probe sessions that carry no distill-worthy content |
CORTEX_NO_CLASSIFIER | No | When set to 1, the transcript filter never calls the LLM classifier; oversized blocks are kept verbatim instead. Disables only the filter's nested classifier calls — it does not affect normal Claude Code session processing, including SessionStart metadata and vault content loaded by commands and skills |
CORTEX_FORCE_RECORD | No | When set (e.g. =1), records a headless session that the sdk-cli entrypoint guard would otherwise drop — for schedulers whose claude -p runs are real work |
CORTEX_FILTER_TIMEOUT | No | Seconds the SessionEnd hook waits for the transcript filter before giving up (default 600). A non-numeric value falls back to the default rather than stubbing every session |
| Package | Purpose |
|---|---|
| ChromaDB | Semantic vector index |
| OpenAI | text-embedding-3-small embedding model |
| python-frontmatter | YAML frontmatter parsing |
| pysqlite3-binary | SQLite 3.35+ compatibility (needed when the system SQLite is too old) |
Install:
uv tool install cortex-vec
Cortexes is local-first: your vault is Markdown on your own disk, the index is local, and the authors receive nothing — there is no server, no account, and no telemetry.
That is not the same as "nothing leaves your machine". Cortexes is a Claude Code plugin, so the vault pages a query, distill, broadcast, or takeoff resume reads become part of the active session and are processed by Anthropic under your own account — ordinary Claude Code processing, not a Cortexes channel. The SessionStart hook additionally puts the repo name, the vault path, and your Notes//Projects/ topic names into that context before the menu appears; page contents are not injected there, but may be loaded later once you pick a menu option, run a command, or make a request that matches a using-cortex signal.
Three flows are initiated by the plugin's own code, all under your control: the transcript filter sends oversized blocks (>12 KB, capped at 5 per session) to Anthropic through your own Claude Code to classify them for compression — CORTEX_NO_CLASSIFIER=1 disables it, and stops only those nested calls, not normal session processing; semantic indexing sends vault page content to OpenAI for embeddings, only if you set OPENAI_API_KEY, and without it retrieval runs on the local BM25 index; and git push to a remote you configured, only if you turn it on (off by default).
PRIVACY.md documents every data flow in full: what a session record contains, what is stripped before writing, where files live, git commit and push behaviour, how to turn each feature off, and how to delete your data.
commands/ Slash commands (/cortexes:*)
skills/ Self-triggering skills
hooks/ SessionStart/SessionEnd lifecycle hooks
cortex-vec/ Python semantic indexing CLI
site/ Static site generator for cortexes.pages.dev
docs/ Design specs, plans, and benchmark reports
scripts/ Dev tooling (e.g. run-checks.sh)
tests/ Plugin-level tests
Contributions are welcome — see CONTRIBUTING.md for dev setup, tests, and the PR process. Please also read the Code of Conduct. Security issues should be reported privately — see SECURITY.md.
See CHANGELOG.md for version history.
Licensed under the Apache License 2.0 — see LICENSE for the full text. Copyright 2026 XBlueSky (see NOTICE).
hooks/deck.tsx 167 lines1/**
2 * @file The cortexes deck: draws the SessionStart menu as a band above the
3 * prompt, cuts the menu block from the model's context where the engine lets
4 * it, and once per context window at 70% use offers a takeoff hand-off.
5 *
6 * session-start-inject.sh owns every fact and every prompt (the deck); this
7 * module only draws and relays. Every failure passes the row on untouched, so
8 * the model shows the text menu as it did before the mod.
9 */
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { Deck } from '../types'
14import { cutMenu, deckPath, modeAfterSubmit, modeForDeck, parseDeck, reminderStep } from './deck-core.ts'
15
16const deck = atom({ plugin: 'cortexes', key: 'deck' } as const, null)
17const mode = atom({ plugin: 'cortexes', key: 'mode' } as const, 'hidden')
18const reminded = atom({ plugin: 'cortexes', key: 'reminded' } as const, false)
19const percent = atom({ plugin: 'cortexes', key: 'percent' } as const, 0)
20
21/** How often the deck file is looked at, in milliseconds. */
22const POLL_MS = 1000
23
24/**
25 * Takes one SessionStart run's deck, once: the context row and the deck file
26 * may both carry it, and a menu the person folded must not come back.
27 */
28async function receive($: EngineInterface, d: Deck): Promise<void> {
29 if (d.stamp !== undefined && (await read($, deck))?.stamp === d.stamp) return
30 await update($, deck, () => d)
31 await update($, mode, () => modeForDeck(d))
32 if (d.source !== 'compact') await update($, reminded, () => false)
33}
34
35export const register: Register = on => {
36 // Only the engine's hook-context row: a prompt or tool output that quotes
37 // the sentinels (a cortex session about cortex) must reach the model whole.
38 // Where an organization seats its policy plugin this hook never runs: the
39 // block stays in the context and the deck file below draws the band.
40 on('session.append', { door: 'hook-context' }, async ($, e, next) => {
41 const content = [...e.message.content]
42 let found: Deck | null = null
43 for (const [i, block] of content.entries()) {
44 if (block.type !== 'text') continue
45 const cut = cutMenu(block.text)
46 if (cut === null) continue
47 content[i] = { ...block, text: cut.text }
48 found = cut.deck
49 break
50 }
51 if (found === null) return next(e)
52 await receive($, found)
53 return next({ ...e, message: { ...e.message, content } })
54 }).catch(($, e, next) => next(e))
55
56 // The deck file: session-start-inject.sh writes one per session id on every
57 // SessionStart (startup, resume, /clear, compaction), which this process
58 // sees no event for, so the file is watched for the whole session.
59 on('session.start', async ($, e, next) => {
60 let seen: number | null = null
61 $.clock.every(POLL_MS, async () => {
62 try {
63 const path = deckPath($.plugin.root, await $.session.id())
64 if (path === null) return
65 const { mtimeMs } = await $.fs.stat(path)
66 if (mtimeMs === seen) return
67 seen = mtimeMs
68 const d = parseDeck(await $.fs.read(path))
69 if (d !== null) await receive($, d)
70 } catch {
71 // No file yet, or it went with SessionEnd: look again next tick.
72 }
73 })
74 return next(e)
75 }).catch(($, e, next) => next(e))
76
77 on('prompt.submit', async ($, e, next) => {
78 await update($, mode, modeAfterSubmit)
79 return next(e)
80 }).catch(($, e, next) => next(e))
81
82 on('session.measure', async ($, e, next) => {
83 let fire = false
84 await update($, reminded, was => {
85 const step = reminderStep(e.context.percent, was)
86 fire = step.fire
87 return step.reminded
88 })
89 if (fire) {
90 const pct = e.context.percent ?? 0
91 await update($, percent, () => pct)
92 $.ui.toast(`context 已用 ${pct}%,可以考慮建立交接`)
93 if ((await read($, deck)) !== null) await update($, mode, () => 'handoff')
94 }
95 return next(e)
96 }).catch(($, e, next) => next(e))
97
98 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99 const d = await read($, deck)
100 const m = await read($, mode)
101 if (e.props.hasSurvey || d === null || m === 'hidden') return next(e)
102 const { Box, Button, Text } = $.ui.resolve(e)
103 // The band folds first: a hand-off band is not folded by prompt.submit.
104 const send = (prompt: string) => async () => {
105 await update($, mode, () => 'hidden')
106 await $.prompt.submit({ text: prompt, asUser: true })
107 }
108 const hide = async () => {
109 await update($, mode, () => 'hidden')
110 }
111 // The title row: the plugin's name, then the repo and the status, gray.
112 const title = (repo: string | null, status: { text: string; warn?: boolean }[]) => (
113 <Box key="title" columnGap={1} flexWrap="wrap">
114 <Text bold color="claude">cortexes</Text>
115 {repo === null ? [] : [<Box key="repo"><Text>{repo}</Text></Box>]}
116 {status.map((s, i) => (
117 <Box key={`status-${i}`}>
118 <Text color={s.warn ? 'warning' : 'subtle'}>{`· ${s.text}`}</Text>
119 </Box>
120 ))}
121 </Box>
122 )
123 const hideButton = <Button key="hide" plain dimColor label="收起" onPress={hide} />
124 // Both bands draw as a gray rounded card, set off from the transcript above.
125 if (m === 'handoff') {
126 const pct = await read($, percent)
127 // No digit hotkey: this band outlives prompts, and a bare digit typed
128 // into the empty composer would press it instead of starting a prompt.
129 return (
130 <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column">
131 {title(null, [{ text: `context 已用 ${pct}%` }])}
132 <Box columnGap={3}>
133 <Button key="handoff" variant="primary" label="建立交接" onPress={send(d.handoff)} />
134 {hideButton}
135 </Box>
136 </Box>
137 )
138 }
139 // Digits 1-9 in deck order: a bare digit in the empty composer presses
140 // the button, as the text menu's numbers were answered.
141 const digit = (i: number) => (i < 9 ? String(i + 1) : undefined)
142 return (
143 <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column">
144 {title(d.repo, d.status)}
145 <Box columnGap={3} flexWrap="wrap">
146 {d.actions.map((a, i) => (
147 <Button key={`action-${i}`} plain hotkey={digit(i)} label={a.label} onPress={send(a.prompt)} />
148 ))}
149 {hideButton}
150 </Box>
151 {d.batons.map((b, i) => (
152 <Box key={`baton-row-${i}`} columnGap={2}>
153 <Button
154 key={`baton-${i}`}
155 plain
156 hotkey={digit(d.actions.length + i)}
157 label={`接續交接 ${b.label}`}
158 onPress={send(b.prompt)}
159 />
160 {b.summary ? [<Box key="summary" flexShrink={1}><Text dimColor wrap="truncate-end">{b.summary}</Text></Box>] : []}
161 </Box>
162 ))}
163 </Box>
164 )
165 }).catch(($, e, next) => next(e))
166}
167hooks/deck-core.ts 118 lines1/**
2 * @file Pure rules of the cortex deck: the fenced menu block that
3 * session-start-inject.sh writes, the deck JSON on its first line, and the
4 * band's state transitions. Nothing here touches `$`, so every rule is
5 * tested without the engine (deck-core.test.ts).
6 */
7import type { Deck, DeckBaton, DeckButton, DeckMode, DeckSource, DeckStatus } from '../types'
8
9/** Opening sentinel of the model-facing menu block. */
10export const MENU_START = '<!-- cortex:menu -->'
11/** Closing sentinel; the cut runs to the last one, so a quoted copy cannot end it early. */
12export const MENU_END = '<!-- /cortex:menu -->'
13const DECK_PREFIX = '<!-- cortex:deck '
14const DECK_SUFFIX = ' -->'
15/** Context use, in percent of the window, that raises the hand-off reminder. */
16export const HANDOFF_THRESHOLD = 70
17
18const SOURCES: readonly DeckSource[] = ['startup', 'resume', 'clear', 'compact']
19
20const isRecord = (x: unknown): x is Record<string, unknown> =>
21 typeof x === 'object' && x !== null && !Array.isArray(x)
22
23const isButton = (x: unknown): x is DeckButton =>
24 isRecord(x) && typeof x.label === 'string' && typeof x.prompt === 'string'
25
26const isBaton = (x: unknown): x is DeckBaton =>
27 isRecord(x) && isButton(x) && (x.summary === undefined || typeof x.summary === 'string')
28
29const isStatus = (x: unknown): x is DeckStatus =>
30 isRecord(x) && typeof x.text === 'string' && (x.warn === undefined || typeof x.warn === 'boolean')
31
32/**
33 * Parses a deck's JSON text.
34 * @param json the text between the deck line's prefix and suffix
35 * @returns the deck, or null when the text is not a version-1 deck
36 */
37export function parseDeck(json: string): Deck | null {
38 let raw: unknown
39 try {
40 raw = JSON.parse(json)
41 } catch {
42 return null
43 }
44 if (!isRecord(raw) || raw.v !== 1) return null
45 const { repo, handoff, status, actions, batons } = raw
46 if (typeof repo !== 'string' || typeof handoff !== 'string') return null
47 if (!Array.isArray(status) || !status.every(isStatus)) return null
48 if (!Array.isArray(actions) || !actions.every(isButton)) return null
49 if (!Array.isArray(batons) || !batons.every(isBaton)) return null
50 const { stamp } = raw
51 if (stamp !== undefined && typeof stamp !== 'string') return null
52 const given = raw.source
53 const source = SOURCES.find(s => s === given) ?? 'startup'
54 return { v: 1, source, repo, handoff, status, actions, batons, ...(stamp === undefined ? {} : { stamp }) }
55}
56
57/**
58 * Where session-start-inject.sh writes a session's deck: the file channel,
59 * read where an organization's policy plugin keeps `session.append` from the
60 * person's plugins and the context block never reaches the mod.
61 * @param root the plugin's directory (`$.plugin.root`, the script's CLAUDE_PLUGIN_ROOT)
62 * @param sessionId the session's id
63 * @returns the file's path, or null for an id that is not a plain file name
64 */
65export function deckPath(root: string, sessionId: string): string | null {
66 if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null
67 return `${root}/.deck/${sessionId}.json`
68}
69
70/** A text block with the menu cut out, and the deck the block carried. */
71export type Cut = { text: string; deck: Deck }
72
73/**
74 * Cuts the fenced menu block out of one text block.
75 *
76 * The block runs from the first MENU_START to the last MENU_END and the
77 * newline after it; the line right after MENU_START must be a version-1 deck.
78 * @returns the cut, or null when anything is missing or malformed, so the
79 * caller passes the text on untouched and the model shows the text menu
80 */
81export function cutMenu(text: string): Cut | null {
82 const start = text.indexOf(MENU_START)
83 if (start < 0) return null
84 const end = text.lastIndexOf(MENU_END)
85 if (end < start) return null
86 const lineStart = start + MENU_START.length + 1
87 if (text[lineStart - 1] !== '\n') return null
88 const lineEnd = text.indexOf('\n', lineStart)
89 if (lineEnd < 0 || lineEnd > end) return null
90 const line = text.slice(lineStart, lineEnd)
91 if (!line.startsWith(DECK_PREFIX) || !line.endsWith(DECK_SUFFIX)) return null
92 const deck = parseDeck(line.slice(DECK_PREFIX.length, line.length - DECK_SUFFIX.length))
93 if (deck === null) return null
94 let cutEnd = end + MENU_END.length
95 if (text[cutEnd] === '\n') cutEnd += 1
96 return { text: text.slice(0, start) + text.slice(cutEnd), deck }
97}
98
99/** The band's mode once a new deck arrives: down after a compaction, else the menu. */
100export const modeForDeck = (deck: Deck): DeckMode => (deck.source === 'compact' ? 'hidden' : 'menu')
101
102/** The band's mode after any prompt is submitted: the menu folds, a reminder stays. */
103export const modeAfterSubmit = (mode: DeckMode): DeckMode => (mode === 'menu' ? 'hidden' : mode)
104
105/** One measurement's effect on the reminder flag. */
106export type ReminderStep = { reminded: boolean; fire: boolean }
107
108/**
109 * Applies one context measurement to the reminder flag: fire on the first
110 * reading at or over the threshold, re-arm once a reading drops below it (a
111 * compaction), and ignore a reading without a percentage (a fresh window).
112 */
113export function reminderStep(percent: number | undefined, reminded: boolean): ReminderStep {
114 if (percent === undefined) return { reminded, fire: false }
115 if (percent < HANDOFF_THRESHOLD) return { reminded: false, fire: false }
116 return { reminded: true, fire: !reminded }
117}
118types/index.d.ts 23 lines1export type DeckSource = 'startup' | 'resume' | 'clear' | 'compact'
2export type DeckStatus = { text: string; warn?: boolean }
3export type DeckButton = { label: string; prompt: string }
4export type DeckBaton = DeckButton & { summary?: string }
5export type Deck = {
6 v: 1
7 source: DeckSource
8 repo: string
9 status: DeckStatus[]
10 actions: DeckButton[]
11 batons: DeckBaton[]
12 handoff: string
13 /** One SessionStart run's mark: the same deck through the context and the file applies once. */
14 stamp?: string
15}
16export type DeckMode = 'menu' | 'handoff' | 'hidden'
17
18declare module 'claude-code' {
19 interface PluginState {
20 cortexes: { deck: Deck | null; mode: DeckMode; reminded: boolean; percent: number }
21 }
22}
23