SLOPSHOPPER

Cortexes

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

newbandtoastprompttimer
★ 24v2.2.0Apache-2.0updated 2026-10-21XBlueSky/cortexes
A shopper browsing a rack in a slop shop
README

<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>

What It Does

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.

  • Automatic capture — a full report (commits, findings, decisions) is generated when a session ends and saved to the vault
  • Semantic search — OpenAI embeddings + ChromaDB, with mixed Chinese/English query support
  • Knowledge distillation — extracts hard-won lessons, internal conventions, and key decisions from raw session dumps
  • Memory injection — detects the current repo at session start and offers to load related memory

Design philosophy: the vault is the source of truth (plain markdown + git); the vector store is just a rebuildable derived index.

Quick Start

1. Install the plugin

# 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.

2. Install the cortex-vec CLI

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.

3. Initialize

/cortexes:genesis /path/to/your/vault

This sets the vault path and author info, and builds the semantic index.

4. Start using it

"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

Upgrading from 1.x to 2.0

2.0.0 renames the plugin from cortex to cortexes. Your vault, config and indexes are untouched — there is no data migration.

  1. Update the marketplace. In Claude Code run /plugin marketplace update cortex (or remove and re-add it).
  2. Start a new session. The marketplace manifest ships a 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.
  3. Use the new command prefix. /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.
  4. If your vault has a 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.
  5. Upgrade the CLI too.
   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.

  1. Nothing else changes. ~/.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.

Website

Live docs and changelog: <https://cortexes.pages.dev> (Cloudflare Pages, generated from .cc-marketspec/dist/manifest.json). See site/README.md for local builds.

Commands & Skills

Commands

CommandDescription
/cortexes:genesisInitialize the vault — set path, author, rebuild the index
/cortexes:evolveManually save knowledge to Notes or Projects (also writes log.md)
/cortexes:distillDistill Raw/ session records into Notes/Projects (map-first navigation + two-stage evaluation + pending-merge exit)
/cortexes:querySearch 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:broadcastFuse newly distilled content into related existing pages (llm-wiki-style ingest)
/cortexes:takeoffHand-off batons — curate temporary, non-git hand-offs for a later session to resume, one per work line ([topic] / resume [topic] / done [topic] subcommands)

Skills (auto-triggered)

SkillTrigger
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"

Hooks

HookEventBehavior
Session ReportSessionEndOn session end, filters the transcript through a TOML pipeline before writing to Raw/
Memory InjectionSessionStartInteractive menu — checks vault backlog status and asks what to do next
Deckhooks 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=1 to skip a session, and see PRIVACY.md for exactly what is captured, what is excluded, and what (if anything) leaves your machine.

Transcript Filter (0.9.0+)

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.

Architecture

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)

Vault Structure

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.

Data Flow

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

Retrieval Strategy

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:

  • BM25 stream — exact lexical matching (function names, repo names, issue IDs), with jieba CJK segmentation for mixed Chinese/English queries. The index persists at ~/.cortex/bm25/, kept in sync with ChromaDB by rebuild/upsert/delete.
  • Vector stream — OpenAI text-embedding-3-small semantic search, dual-vector (document body + bilingual summary), covering cases that are semantically close but lexically different.
  • Degradation strategy — without OPENAI_API_KEY, or offline, this automatically falls back to BM25-only, with no dependency on the skill-layer grep fallback.

Other layers:

  1. Raw search (on demand) — only queries original session records when tracing history

cortex-vec CLI

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

Hybrid Retrieval (0.4.0+)

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

Distillation navigation (1.0.0+)

/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).

Raw snapshot reclaim (1.1.0+)

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".

Retrieval Evaluation

# 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.

Advanced Retrieval (off by default)

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.

Synonym expansion

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.

Wikilink graph-boost

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.

LLM rerank

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.

max-per-repo diversification

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.

Full retrieval config example

The 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

Configuration

~/.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.

Environment Variables

VariableRequiredDescription
OPENAI_API_KEYNo*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_PATHNoRead 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_RECORDNoWhen 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_CLASSIFIERNoWhen 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_RECORDNoWhen 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_TIMEOUTNoSeconds 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

Dependencies

PackagePurpose
ChromaDBSemantic vector index
OpenAItext-embedding-3-small embedding model
python-frontmatterYAML frontmatter parsing
pysqlite3-binarySQLite 3.35+ compatibility (needed when the system SQLite is too old)

Install:

uv tool install cortex-vec

Privacy

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.

Project Structure

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

Contributing

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.

Changelog

See CHANGELOG.md for version history.

License

Licensed under the Apache License 2.0 — see LICENSE for the full text. Copyright 2026 XBlueSky (see NOTICE).

Source 3 files
hooks/deck.tsx 167 lines
1/**
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}
167
hooks/deck-core.ts 118 lines
1/**
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}
118
types/index.d.ts 23 lines
1export 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