Compare commits
10 Commits
36ac9b6a81
...
ec378ebd28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec378ebd28 | ||
|
|
18245c778e | ||
|
|
8fec8e28f4 | ||
|
|
1b3fcda259 | ||
|
|
472de4ebaf | ||
|
|
c2d7cd53ce | ||
|
|
7db96b025b | ||
|
|
30cfe7e8f1 | ||
|
|
5e1315a20a | ||
|
|
775fbf7c02 |
@@ -3,6 +3,3 @@ if status is-interactive
|
||||
end
|
||||
|
||||
fish_config theme choose "Catppuccin Mocha" --color-theme=dark
|
||||
|
||||
# Added by codebase-memory-mcp install
|
||||
export PATH="/home/alex/dotfiles/.local/bin:$PATH"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# This file contains fish universal variable definitions.
|
||||
# VERSION: 3.0
|
||||
SETUVAR OPENCODE_ENABLE_EXA:1
|
||||
SETUVAR __fish_initialized:4300
|
||||
SETUVAR _fisher_catppuccin_2F_fish_files:\x7e/\x2econfig/fish/themes/Catppuccin\x20Frappe\x2etheme\x1e\x7e/\x2econfig/fish/themes/Catppuccin\x20Macchiato\x2etheme\x1e\x7e/\x2econfig/fish/themes/Catppuccin\x20Mocha\x2etheme\x1e\x7e/\x2econfig/fish/themes/static
|
||||
SETUVAR _fisher_jorgebucaran_2F_fisher_files:\x7e/\x2econfig/fish/functions/fisher\x2efish\x1e\x7e/\x2econfig/fish/completions/fisher\x2efish
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
function c --wraps=opencode --description 'opencode (auto-starts tmux for visual subagent panes)'
|
||||
set -l port (python -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
||||
|
||||
if not set -q TMUX
|
||||
tmux new-session opencode --port $port $argv
|
||||
else
|
||||
opencode --port $port $argv
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
function cc --wraps='opencode --continue' --description 'opencode --continue (auto-starts tmux for visual subagent panes)'
|
||||
set -l port (python -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
||||
|
||||
if not set -q TMUX
|
||||
tmux new-session opencode --port $port --continue $argv
|
||||
else
|
||||
opencode --port $port --continue $argv
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,7 @@ input-field {
|
||||
inner_color = rgba(49, 50, 68, 1.0)
|
||||
font_color = rgba(205, 214, 244, 1.0)
|
||||
fade_on_empty = false
|
||||
placeholder_text = <i>Touch YubiKey or enter password...</i>
|
||||
placeholder_text = <i>...</i>
|
||||
hide_input = false
|
||||
check_color = rgba(166, 227, 161, 1.0)
|
||||
fail_color = rgba(243, 139, 168, 1.0)
|
||||
|
||||
@@ -18,7 +18,7 @@ require("lazy").setup({
|
||||
})
|
||||
|
||||
vim.keymap.set("n", "<leader>e", vim.cmd.Ex)
|
||||
vim.keymap.set("n", "<leader>ww", vim.cmd.w)
|
||||
vim.keymap.set("n", "<leader>w", vim.cmd.w)
|
||||
|
||||
vim.opt.number = true
|
||||
vim.opt.relativenumber = true
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"LuaSnip": { "branch": "master", "commit": "dae4f5aaa3574bd0c2b9dd20fb9542a02c10471c" },
|
||||
"blink.cmp": { "branch": "main", "commit": "f22f66eb7c4d037ed523a78b27ee235b7bc9a1f4" },
|
||||
"catppuccin": { "branch": "main", "commit": "12c004cde3f36cb1d57242f1e6aac46b09a0e5b4" },
|
||||
"cmp-buffer": { "branch": "main", "commit": "b74fab3656eea9de20a9b8116afa3cfc4ec09657" },
|
||||
"cmp-nvim-lsp": { "branch": "main", "commit": "cbc7b02bb99fae35cb42f514762b89b5126651ef" },
|
||||
@@ -18,10 +17,8 @@
|
||||
"nvim-cmp": { "branch": "main", "commit": "da88697d7f45d16852c6b2769dc52387d1ddc45f" },
|
||||
"nvim-lspconfig": { "branch": "master", "commit": "2163c54bb6cfec53e3e555665ada945b8c8331b9" },
|
||||
"nvim-treesitter": { "branch": "main", "commit": "5cb05e1b0fa3c469958a2b26f36b3fe930af221c" },
|
||||
"opencode.nvim": { "branch": "main", "commit": "1088ee70dd997d785a1757d351c07407f0abfc9f" },
|
||||
"pi.nvim": { "branch": "main", "commit": "761cb109ebd466784f219e6e3a43a28f6187d627" },
|
||||
"plenary.nvim": { "branch": "master", "commit": "b9fd5226c2f76c951fc8ed5923d85e4de065e509" },
|
||||
"render-markdown.nvim": { "branch": "main", "commit": "e3c18ddd27a853f85a6f513a864cf4f2982b9f26" },
|
||||
"snacks.nvim": { "branch": "main", "commit": "9912042fc8bca2209105526ac7534e9a0c2071b2" },
|
||||
"telescope-fzf-native.nvim": { "branch": "main", "commit": "6fea601bd2b694c6f2ae08a6c6fab14930c60e2c" },
|
||||
"telescope.nvim": { "branch": "master", "commit": "3333a52ff548ba0a68af6d8da1e54f9cd96e9179" }
|
||||
}
|
||||
|
||||
77
.config/nvim/lua/plugins/pi.lua
Normal file
77
.config/nvim/lua/plugins/pi.lua
Normal file
@@ -0,0 +1,77 @@
|
||||
return {
|
||||
"pablopunk/pi.nvim",
|
||||
opts = {},
|
||||
config = function(_, opts)
|
||||
require("pi").setup(opts)
|
||||
|
||||
local state = {
|
||||
buf = nil,
|
||||
win = nil,
|
||||
}
|
||||
|
||||
local function pane_width()
|
||||
return math.max(50, math.floor(vim.o.columns * 0.35))
|
||||
end
|
||||
|
||||
local function style_pane(win)
|
||||
if not win or not vim.api.nvim_win_is_valid(win) then
|
||||
return
|
||||
end
|
||||
pcall(vim.api.nvim_win_set_width, win, pane_width())
|
||||
vim.wo[win].number = false
|
||||
vim.wo[win].relativenumber = false
|
||||
vim.wo[win].signcolumn = "no"
|
||||
vim.wo[win].winfixwidth = true
|
||||
end
|
||||
|
||||
local function open_pi_pane()
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
vim.api.nvim_set_current_win(state.win)
|
||||
vim.cmd("startinsert")
|
||||
return
|
||||
end
|
||||
|
||||
vim.cmd("botright vsplit")
|
||||
state.win = vim.api.nvim_get_current_win()
|
||||
style_pane(state.win)
|
||||
|
||||
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
|
||||
vim.api.nvim_win_set_buf(state.win, state.buf)
|
||||
else
|
||||
vim.cmd("terminal pi")
|
||||
state.buf = vim.api.nvim_get_current_buf()
|
||||
vim.bo[state.buf].buflisted = false
|
||||
vim.bo[state.buf].bufhidden = "hide"
|
||||
|
||||
vim.api.nvim_create_autocmd({ "BufWipeout", "TermClose" }, {
|
||||
buffer = state.buf,
|
||||
callback = function()
|
||||
state.buf = nil
|
||||
state.win = nil
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
style_pane(state.win)
|
||||
vim.cmd("startinsert")
|
||||
end
|
||||
|
||||
local function toggle_pi_pane()
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
vim.api.nvim_win_close(state.win, true)
|
||||
state.win = nil
|
||||
return
|
||||
end
|
||||
|
||||
open_pi_pane()
|
||||
end
|
||||
|
||||
vim.api.nvim_create_user_command("PiPane", open_pi_pane, { desc = "Open pi in a right side pane" })
|
||||
vim.api.nvim_create_user_command("PiPaneToggle", toggle_pi_pane, { desc = "Toggle pi right side pane" })
|
||||
end,
|
||||
keys = {
|
||||
{ "<leader>p", "<cmd>PiAsk<cr>", desc = "Pi Ask" },
|
||||
{ "<leader>pp", "<cmd>PiPaneToggle<cr>", desc = "Pi Pane" },
|
||||
{ "<leader>ps", "<cmd>PiAskSelection<cr>", mode = "v", desc = "Pi Ask Selection" },
|
||||
},
|
||||
}
|
||||
5
.config/opencode/.gitignore
vendored
5
.config/opencode/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
package.json
|
||||
bun.lock
|
||||
.megamemory/
|
||||
.memory/
|
||||
@@ -1,237 +0,0 @@
|
||||
# OpenCode Global Workflow
|
||||
|
||||
## Operating Model
|
||||
|
||||
- Default to `planner`. Do not implement before there is an approved plan.
|
||||
- `planner` owns discovery, decomposition, verification oracles, risk tracking, and the handoff spec.
|
||||
- `builder` executes the approved spec exactly, delegates focused work to subagents, and escalates back to `planner` instead of improvising when the spec breaks.
|
||||
- Parallelize aggressively for research, exploration, review, and isolated implementation lanes. Do not parallelize code mutation when lanes share files, APIs, schemas, or verification steps.
|
||||
- Use explicit `allow` or `deny` permissions only. Do not rely on `ask`.
|
||||
- Keep `external_directory` denied. Real project repos may use repo-local `/.worktrees`, but this global config must not relax that rule.
|
||||
|
||||
## Agent Roster
|
||||
|
||||
| Agent | Mode | Model | Responsibility |
|
||||
| --- | --- | --- | --- |
|
||||
| `planner` | primary | `github-copilot/gpt-5.4` | Produce approved specs and decide whether execution is ready |
|
||||
| `builder` | primary | `github-copilot/gpt-5.4` | Execute approved specs and integrate delegated work |
|
||||
| `researcher` | subagent | `github-copilot/gpt-5.4` | Deep research, external docs, tradeoff analysis |
|
||||
| `explorer` | subagent | `github-copilot/claude-sonnet-4.6` | Read-only repo inspection; reports facts only, never plans or recommendations |
|
||||
| `reviewer` | subagent | `github-copilot/gpt-5.4` | Critique plans, code, tests, and release readiness |
|
||||
| `coder` | subagent | `github-copilot/gpt-5.3-codex` | Implement narrowly scoped code changes |
|
||||
| `tester` | subagent | `github-copilot/claude-opus-4.6` | Run verification, triage failures, capture evidence |
|
||||
| `librarian` | subagent | `github-copilot/claude-opus-4.6` | Maintain docs, `AGENTS.md`, and memory hygiene |
|
||||
|
||||
## Planner Behavior
|
||||
|
||||
- `planner` must use the `question` tool proactively when scope, defaults, approval criteria, or critical context are ambiguous. Prefer asking over assuming.
|
||||
- `planner` may use bash and Docker commands during planning for context gathering (e.g., `docker compose config`, `docker ps`, inspecting files, checking versions). Do not run builds, installs, tests, deployments, or any implementation-level commands — those belong to builder/tester/coder.
|
||||
|
||||
## Planner -> Builder Contract
|
||||
|
||||
- Every build starts from a memory note under `plans/` with `Status: approved`.
|
||||
- Approved plans must include: objective, scope, constraints, assumptions, concrete task list, parallelization lanes, verification oracle, risks, and open findings.
|
||||
- `builder` must follow the approved plan exactly.
|
||||
- `builder` must stop and escalate back to `planner` when it finds a spec contradiction, a hidden dependency that changes scope, or two failed verification attempts after recording root cause and evidence.
|
||||
|
||||
### Builder Commits
|
||||
|
||||
- `builder` automatically creates git commits at meaningful task checkpoints and at final completion when uncommitted changes remain.
|
||||
- A "meaningful checkpoint" is a completed implementation chunk from the approved plan, not every file save.
|
||||
- Skip commit creation when there are no new changes since the prior checkpoint.
|
||||
- Commit messages should reflect the intent of the completed task from the plan.
|
||||
- Before creating the final completion commit, clean up temporary artifacts generated during the build (e.g., scratch files, screenshots, logs, transient reports, caches). Intended committed deliverables are not cleanup targets.
|
||||
- Standard git safety rules apply: review staged content, respect hooks, no force-push or destructive operations.
|
||||
- Push automation is out of scope; the user decides when to push.
|
||||
|
||||
## Commands
|
||||
|
||||
- `/init` initializes or refreshes repo memory and the project `AGENTS.md`.
|
||||
- `/plan` creates or updates the canonical implementation plan in memory.
|
||||
- `/build` executes the latest approved plan and records execution progress.
|
||||
- `/continue` resumes unfinished planning or execution from memory based on the current primary agent.
|
||||
- Built-in `/sessions` remains available for raw session browsing; custom `/continue` is the workflow-aware resume entrypoint.
|
||||
|
||||
## Memory System (Single: basic-memory)
|
||||
|
||||
Memory uses one persistent system: **basic-memory**.
|
||||
|
||||
- All persistent knowledge is stored in basic-memory notes, split across a **`main` project** (global/shared) and **per-repo projects** (project-specific).
|
||||
- The managed per-repo basic-memory project directory is `<repo>/.memory/`.
|
||||
- Do not edit managed `.memory/*` files directly; use basic-memory MCP tools for all reads/writes.
|
||||
|
||||
### `main` vs per-repo projects
|
||||
|
||||
1. **`main` (global/shared knowledge only)**
|
||||
- Reusable coding patterns
|
||||
- Technology knowledge
|
||||
- User preferences and workflow rules
|
||||
- Cross-project lessons learned
|
||||
|
||||
2. **Per-repo projects (project-specific knowledge only)**
|
||||
- Project overview and architecture notes
|
||||
- Plans, execution logs, decisions, findings, and continuity notes
|
||||
- Project-specific conventions and testing workflows
|
||||
|
||||
**Hard rule:** Never store project-specific plans, decisions, research, gates, or sessions in `main`. Never store cross-project reusable knowledge in a per-repo project.
|
||||
|
||||
### Required per-repo note taxonomy
|
||||
|
||||
- `project/overview` - stack, purpose, important entrypoints
|
||||
- `project/architecture` - major modules, data flow, boundaries
|
||||
- `project/workflows` - local dev, build, test, release commands
|
||||
- `project/testing` - verification entrypoints and expectations
|
||||
- `plans/<slug>` - canonical specs with `Status: draft|approved|blocked|done`
|
||||
- `executions/<slug>` - structured execution log with `Status: in_progress|blocked|done` (see template below)
|
||||
- `decisions/<slug>` - durable project-specific decisions
|
||||
- `findings/<slug>` - open findings ledger with evidence and owner
|
||||
|
||||
### Execution note template (`executions/<slug>`)
|
||||
|
||||
Every execution note must use these literal section names:
|
||||
|
||||
```
|
||||
## Plan
|
||||
- **Source:** plans/<slug>
|
||||
- **Status:** approved
|
||||
|
||||
## Execution State
|
||||
- **Objective:** <one-line goal from the plan>
|
||||
- **Current Phase:** <planning|implementing|integrating|verifying|blocked|done>
|
||||
- **Next Checkpoint:** <next concrete step>
|
||||
- **Blockers:** <none|bullet-friendly summary>
|
||||
- **Last Updated By:** <builder|coder|tester|reviewer|librarian>
|
||||
- **Legacy Note Normalized:** <yes|no>
|
||||
|
||||
## Lane Claims
|
||||
Repeated per lane:
|
||||
|
||||
### Lane: <lane-name>
|
||||
- **Owner:** <builder|coder|tester|reviewer|librarian|unassigned>
|
||||
- **Status:** planned | active | released | blocked | done
|
||||
- **Claimed Files/Areas:** <paths or named workflow surfaces>
|
||||
- **Depends On:** <none|lane names>
|
||||
- **Exit Condition:** <what must be true to release or complete this lane>
|
||||
|
||||
## Last Verified State
|
||||
- **Mode:** none | smoke | full
|
||||
- **Summary:** <one-sentence status>
|
||||
- **Outstanding Risk:** <none|brief risk>
|
||||
- **Related Ledger Entry:** <entry label|none>
|
||||
|
||||
## Verification Ledger
|
||||
Append-only log. Each entry:
|
||||
|
||||
### Entry: <checkpoint-or-step-label>
|
||||
- **Goal:** <what is being verified>
|
||||
- **Mode:** smoke | full
|
||||
- **Command/Check:** <exact command or manual check performed>
|
||||
- **Result:** pass | fail | blocked | not_run
|
||||
- **Key Evidence:** <concise proof: output snippet, hash, assertion count>
|
||||
- **Artifacts:** <paths to logs/screenshots, or `none`>
|
||||
- **Residual Risk:** <known gaps, or `none`>
|
||||
```
|
||||
|
||||
#### Verification summary shape
|
||||
|
||||
Each verification entry (in Last Verified State or Verification Ledger) uses these fields:
|
||||
|
||||
- **Goal** - what is being verified
|
||||
- **Mode** - `smoke` or `full` (see mode rules)
|
||||
- **Command/Check** - exact command or manual check performed
|
||||
- **Result** - `pass`, `fail`, `blocked`, or `not_run`
|
||||
- **Key Evidence** - concise proof (output snippet, hash, assertion count)
|
||||
- **Artifacts** - paths to logs/screenshots if any, or `none`
|
||||
- **Residual Risk** - known gaps, or `none`
|
||||
|
||||
#### Verification mode rules
|
||||
|
||||
- Default to **`smoke`** for intermediate checkpoint proof and isolated lane verification.
|
||||
- Default to **`full`** before any final completion claim or setting execution status to `done`.
|
||||
- If there is only one meaningful verification step, record it as `full` and note there is no separate smoke check.
|
||||
|
||||
#### Compact verification summary behavior
|
||||
|
||||
- The verification ledger shape is the default evidence format for builder/tester/coder handoffs.
|
||||
- Raw logs should stay out of primary context unless a check fails or the user explicitly requests full output.
|
||||
- When raw output is necessary, summarize the failure first and then point to the raw evidence.
|
||||
|
||||
#### Lane-claim lifecycle
|
||||
|
||||
- **Planner** defines intended lanes and claimed files/areas in the approved plan when parallelization is expected.
|
||||
- **Builder** creates or updates lane-claim entries in the execution note before fan-out and marks them `active`, `released`, `done`, or `blocked`.
|
||||
- Overlapping claimed files/areas or sequential verification dependencies **forbid** parallel fan-out.
|
||||
- Claims are advisory markdown metadata, not hard runtime locks.
|
||||
|
||||
#### Reviewer and execution-note ownership
|
||||
|
||||
- `reviewer` is read-only on execution notes; it reports findings via its response message.
|
||||
- `builder` owns all execution-note writes and status transitions.
|
||||
|
||||
#### Legacy execution notes
|
||||
|
||||
Legacy execution notes may be freeform and lack structured sections. `/continue` must degrade gracefully — read what exists, do not invent conflicts or synthesize missing sections without evidence.
|
||||
|
||||
### Per-repo project setup (required)
|
||||
|
||||
Every code repository must have its own dedicated basic-memory project.
|
||||
|
||||
Use `basic-memory_create_memory_project` with:
|
||||
- `project_name`: short kebab-case repo identifier
|
||||
- `project_path`: `<repo-root>/.memory`
|
||||
|
||||
## Skills
|
||||
|
||||
Local skills live under `skills/<name>/SKILL.md` and are loaded on demand via the `skill` tool. See `skills/creating-skills/SKILL.md` for authoring rules.
|
||||
|
||||
### First-Batch Skills
|
||||
|
||||
| Skill | Purpose |
|
||||
| --- | --- |
|
||||
| `systematic-debugging` | Root-cause-first debugging with findings, evidence, and builder escalation |
|
||||
| `verification-before-completion` | Evidence-before-claims verification for tester and builder handoffs |
|
||||
| `brainstorming` | Planner-owned discovery and design refinement ending in memory-backed artifacts |
|
||||
| `writing-plans` | Planner-owned authoring of execution-ready `plans/<slug>` notes |
|
||||
| `dispatching-parallel-agents` | Safe parallelization with strict isolation tests and a single integrator |
|
||||
| `test-driven-development` | Canonical red-green-refactor workflow for code changes |
|
||||
|
||||
### Design & Domain Skills
|
||||
|
||||
| Skill | Purpose |
|
||||
| --- | --- |
|
||||
| `frontend-design` | Distinctive, production-grade frontend UI with high design quality, accessibility, and performance |
|
||||
|
||||
### Ecosystem Skills
|
||||
|
||||
| Skill | Purpose |
|
||||
| --- | --- |
|
||||
| `docker-container-management` | Reusable Docker/compose workflow for builds, tests, and dev in containerized repos |
|
||||
| `python-development` | Python ecosystem defaults: `uv` for packaging, `ruff` for lint/format, `pytest` for tests |
|
||||
| `javascript-typescript-development` | JS/TS ecosystem defaults: `bun` for runtime/packaging, `biome` for lint/format |
|
||||
|
||||
### Agent Skill-Loading Contract
|
||||
|
||||
Agents must proactively load applicable skills when their trigger conditions are met. Do not wait to be told.
|
||||
|
||||
- **`planner`**: `brainstorming` (unclear requests, design work), `writing-plans` (authoring `plans/<slug>`), `dispatching-parallel-agents` (parallel lanes), `systematic-debugging` (unresolved bugs), `test-driven-development` (specifying code tasks), `frontend-design` (frontend UI/UX implementation or redesign), `docker-container-management` (repo uses Docker), `python-development` (Python repo/lane), `javascript-typescript-development` (JS/TS repo/lane).
|
||||
- **`builder`**: `dispatching-parallel-agents` (before parallel fan-out), `systematic-debugging` (bugs, regressions, flaky tests), `verification-before-completion` (before any completion claim), `test-driven-development` (before delegating or performing code changes), `frontend-design` (frontend UI/UX implementation lanes), `docker-container-management` (containerized repo), `python-development` (Python lanes), `javascript-typescript-development` (JS/TS lanes).
|
||||
- **`tester`**: `systematic-debugging` (verification failure diagnosis), `verification-before-completion` (before declaring verification complete), `test-driven-development` (validating red/green cycles), `docker-container-management` (tests run in containers), `python-development` (Python verification), `javascript-typescript-development` (JS/TS verification).
|
||||
- **`reviewer`**: `verification-before-completion` (evaluating completion evidence), `test-driven-development` (reviewing red/green discipline).
|
||||
- **`coder`**: `test-driven-development` (all code tasks), `frontend-design` (frontend component, page, or application implementation lanes), `docker-container-management` (Dockerfiles, compose files, containerized builds), `python-development` (Python code lanes), `javascript-typescript-development` (JS/TS code lanes); other skills when the assigned lane explicitly calls for them.
|
||||
- **`librarian`**: Load relevant skills opportunistically when the assigned task calls for them; do not override planner/builder workflow ownership.
|
||||
|
||||
### TDD Default Policy
|
||||
|
||||
Test-driven development is the default for all code changes. Agents must follow the red-green-refactor cycle unless a narrow exception applies.
|
||||
|
||||
**Narrow exceptions** (agent must state why TDD was not practical and what alternative verification was used):
|
||||
- Docs-only changes
|
||||
- Config-only changes
|
||||
- Pure refactors with provably unchanged behavior
|
||||
- Repos that do not yet have a reliable automated test harness
|
||||
|
||||
## Documentation Ownership
|
||||
|
||||
- `librarian` owns project docs updates, `AGENTS.md` upkeep, and memory note hygiene.
|
||||
- When a workflow, command, or agent contract changes, update the docs in the same task.
|
||||
- Keep command names, agent roster, memory taxonomy, and skill-loading contracts synchronized across `AGENTS.md`, `agents/`, `commands/`, and `skills/`.
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
description: Execution lead that follows approved plans, delegates focused work, and integrates results without drifting from spec
|
||||
mode: primary
|
||||
model: github-copilot/gpt-5.4
|
||||
variant: xhigh
|
||||
temperature: 0.1
|
||||
permission:
|
||||
edit: allow
|
||||
webfetch: allow
|
||||
bash:
|
||||
"*": allow
|
||||
task:
|
||||
"*": deny
|
||||
tester: allow
|
||||
coder: allow
|
||||
reviewer: allow
|
||||
librarian: allow
|
||||
skill:
|
||||
"*": allow
|
||||
permalink: opencode-config/agents/builder
|
||||
---
|
||||
|
||||
You are the execution authority.
|
||||
|
||||
- Proactively load applicable skills when triggers are present:
|
||||
- `dispatching-parallel-agents` before any parallel subagent fan-out.
|
||||
- `systematic-debugging` when bugs, regressions, flaky tests, or unexpected behavior appear.
|
||||
- `verification-before-completion` before completion claims or final handoff.
|
||||
- `test-driven-development` before delegating or performing code changes.
|
||||
- `docker-container-management` when executing tasks in a containerized repo.
|
||||
- `python-development` when executing Python lanes.
|
||||
- `frontend-design` when executing frontend UI/UX implementation lanes.
|
||||
- `javascript-typescript-development` when executing JS/TS lanes.
|
||||
|
||||
- Read the latest approved plan before making changes.
|
||||
- Execute the plan exactly; do not widen scope on your own.
|
||||
- Delegate code changes to `coder`, verification to `tester`, critique to `reviewer`, and docs plus `AGENTS.md` updates to `librarian`.
|
||||
- Use parallel subagents when implementation lanes are isolated and can be verified independently.
|
||||
- Maintain a structured execution note in basic-memory under `executions/<slug>` using the literal sections defined in `AGENTS.md`: Plan, Execution State, Lane Claims, Last Verified State, and Verification Ledger.
|
||||
- Before parallel fan-out, create or update Lane Claims in the execution note. Mark each lane `active` before dispatch and `released`, `done`, or `blocked` afterward. Overlapping claimed files/areas or sequential verification dependencies forbid parallel fan-out.
|
||||
- Record verification evidence in the Verification Ledger using the compact shape: Goal, Mode, Command/Check, Result, Key Evidence, Artifacts, Residual Risk.
|
||||
- Default to `smoke` mode for intermediate checkpoints and isolated lane verification. Require `full` mode before any final completion claim or setting execution status to `done`.
|
||||
- If you hit a contradiction, hidden dependency, or two failed verification attempts, record the root cause and evidence, then stop and send the work back to `planner`.
|
||||
- Builder owns commit creation during `/build`; do not delegate commit authorship decisions to other agents.
|
||||
- Create commits automatically at meaningful completed implementation checkpoints, and create a final completion commit when changes remain.
|
||||
- Before creating the final completion commit, clean up temporary artifacts generated during the build (e.g., scratch files, screenshots, logs, transient reports, caches). Intended committed deliverables are not cleanup targets.
|
||||
- Reuse existing git safety constraints: avoid destructive git behavior, do not force push, and do not add push automation.
|
||||
- If there are no new changes at a checkpoint, skip commit creation instead of creating empty or duplicate commits.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
description: Focused implementation subagent for tightly scoped code changes within an assigned lane
|
||||
mode: subagent
|
||||
model: github-copilot/gpt-5.3-codex
|
||||
variant: xhigh
|
||||
temperature: 0.1
|
||||
permission:
|
||||
edit: allow
|
||||
webfetch: allow
|
||||
bash:
|
||||
"*": allow
|
||||
permalink: opencode-config/agents/coder
|
||||
---
|
||||
|
||||
Implement only the assigned lane.
|
||||
|
||||
- Proactively load `test-driven-development` for code development tasks.
|
||||
- Load `docker-container-management` when the lane involves Dockerfiles, compose files, or containerized builds.
|
||||
- Load `python-development` when the lane involves Python code.
|
||||
- Load `frontend-design` when the lane involves frontend component, page, or application implementation.
|
||||
- Load `javascript-typescript-development` when the lane involves JS/TS code.
|
||||
- Load other local skills only when the assigned lane explicitly calls for them.
|
||||
|
||||
- Follow the provided spec and stay inside the requested scope.
|
||||
- Reuse existing project patterns before introducing new ones.
|
||||
- Report notable assumptions, touched files, and any follow-up needed.
|
||||
- When reporting verification evidence, use the compact verification summary shape:
|
||||
- **Goal** – what is being verified
|
||||
- **Mode** – `smoke` or `full`
|
||||
- **Command/Check** – exact command or manual check performed
|
||||
- **Result** – `pass`, `fail`, `blocked`, or `not_run`
|
||||
- **Key Evidence** – concise proof (output snippet, hash, assertion count)
|
||||
- **Artifacts** – paths to logs/screenshots, or `none`
|
||||
- **Residual Risk** – known gaps, or `none`
|
||||
- Keep raw logs out of handoff messages; summarize failures first and point to raw evidence only when needed.
|
||||
- Clean up temporary artifacts from the assigned lane (e.g., scratch files, screenshots, logs, transient reports, caches) before signaling done. Intended committed deliverables are not cleanup targets.
|
||||
- Do not claim work is complete without pointing to verification evidence in the compact shape above.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: Read-only repo inspector that reports observable facts only — never plans or recommendations
|
||||
mode: subagent
|
||||
model: github-copilot/claude-sonnet-4.6
|
||||
temperature: 0.0
|
||||
tools:
|
||||
write: false
|
||||
edit: false
|
||||
bash: false
|
||||
permission:
|
||||
webfetch: deny
|
||||
permalink: opencode-config/agents/explorer
|
||||
---
|
||||
|
||||
You are a fact-gathering tool, not a planner.
|
||||
|
||||
- Inspect the repository quickly and report only observable facts.
|
||||
- Prefer `glob`, `grep`, `read`, structural search, and memory lookups.
|
||||
- Return file paths, symbols, code relationships, and constraints.
|
||||
- Do not make changes.
|
||||
|
||||
Forbidden output:
|
||||
- Plan drafts, task lists, or implementation steps.
|
||||
- Solution design or architecture proposals.
|
||||
- Speculative recommendations or subjective assessments.
|
||||
- Priority rankings or suggested next actions.
|
||||
|
||||
If a finding has implications for planning, state the fact and stop. Let the caller draw conclusions.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Documentation and memory steward for AGENTS rules, project docs, and continuity notes
|
||||
mode: subagent
|
||||
model: github-copilot/claude-opus-4.6
|
||||
variant: thinking
|
||||
temperature: 0.2
|
||||
tools:
|
||||
bash: false
|
||||
permission:
|
||||
edit: allow
|
||||
webfetch: allow
|
||||
permalink: opencode-config/agents/librarian
|
||||
---
|
||||
|
||||
Own documentation quality and continuity.
|
||||
|
||||
- Load relevant skills opportunistically when assigned documentation or memory tasks call for them.
|
||||
- Do not override planner/builder workflow ownership.
|
||||
|
||||
- Keep `AGENTS.md`, workflow docs, and command descriptions aligned with actual behavior.
|
||||
- Update or create basic-memory notes when project knowledge changes.
|
||||
- Prefer concise, high-signal docs that help future sessions resume quickly.
|
||||
- Flag stale instructions, mismatched agent rosters, and undocumented workflow changes.
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
description: Planning lead that gathers evidence, writes execution-ready specs, and decides when builder can proceed
|
||||
mode: primary
|
||||
model: github-copilot/gpt-5.4
|
||||
variant: xhigh
|
||||
temperature: 0.1
|
||||
tools:
|
||||
write: false
|
||||
edit: false
|
||||
permission:
|
||||
webfetch: allow
|
||||
task:
|
||||
"*": deny
|
||||
researcher: allow
|
||||
explorer: allow
|
||||
reviewer: allow
|
||||
skill:
|
||||
"*": allow
|
||||
permalink: opencode-config/agents/planner
|
||||
---
|
||||
|
||||
You are the planning authority.
|
||||
|
||||
- Proactively load applicable skills when triggers are present:
|
||||
- `brainstorming` for unclear requests, design work, or feature shaping.
|
||||
- `writing-plans` when producing execution-ready `plans/<slug>` notes.
|
||||
- `dispatching-parallel-agents` when considering parallel research or review lanes.
|
||||
- `systematic-debugging` when planning around unresolved bugs or failures.
|
||||
- `test-driven-development` when specifying implementation tasks that mutate code.
|
||||
- `docker-container-management` when a repo uses Docker/docker-compose.
|
||||
- `python-development` when a repo or lane is primarily Python.
|
||||
- `frontend-design` when the task involves frontend UI/UX implementation or redesign.
|
||||
- `javascript-typescript-development` when a repo or lane is primarily JS/TS.
|
||||
|
||||
## Clarification and the `question` tool
|
||||
|
||||
- Use the `question` tool proactively when scope, default choices, approval criteria, or critical context are ambiguous or missing.
|
||||
- Prefer asking over assuming, especially for: target environments, language/tool defaults, acceptance criteria, and whether Docker is required.
|
||||
- Do not hand off a plan that contains unresolved assumptions when a question could resolve them first.
|
||||
|
||||
## Planning-time Docker and bash usage
|
||||
|
||||
- You may run Docker commands during planning for context gathering and inspection (e.g., `docker compose config`, `docker image ls`, `docker ps`, `docker network ls`, checking container health or logs).
|
||||
- You may also run other bash commands for read-only context (e.g., checking file contents, environment state, installed versions).
|
||||
- Do **not** run builds, installs, tests, deployments, or any implementation-level commands — those belong to builder/tester/coder.
|
||||
- If you catch yourself executing implementation steps, stop and delegate to builder.
|
||||
|
||||
- Gather all high-signal context before proposing execution.
|
||||
- Break work into explicit tasks, dependencies, and verification steps.
|
||||
- Use subagents in parallel when research lanes are independent.
|
||||
- Write or update the canonical plan in basic-memory under `plans/<slug>`.
|
||||
- Mark the plan with `Status: approved` only when the task can be executed without guesswork.
|
||||
- Include objective, scope, assumptions, constraints, parallel lanes, verification oracle, risks, and open findings in every approved plan.
|
||||
- When parallelization or phased verification matters, define intended lanes with claimed files/areas, inter-lane dependencies, and verification intent so builder can create the structured `executions/<slug>` note without guessing.
|
||||
- Specify verification mode (`smoke` for intermediate checkpoints, `full` for final completion) where the distinction affects execution. Default to the shared rules in `AGENTS.md` when not otherwise specified.
|
||||
- Never make file changes or implementation edits yourself.
|
||||
- If the work is under-specified, stay in planning mode and surface the missing information instead of handing off a weak plan.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Research specialist for external docs, tradeoff analysis, and evidence gathering
|
||||
mode: subagent
|
||||
model: github-copilot/gpt-5.4
|
||||
variant: xhigh
|
||||
temperature: 0.2
|
||||
tools:
|
||||
write: false
|
||||
edit: false
|
||||
bash: false
|
||||
permission:
|
||||
webfetch: allow
|
||||
permalink: opencode-config/agents/researcher
|
||||
---
|
||||
|
||||
Focus on evidence gathering.
|
||||
|
||||
- Read docs, compare options, and summarize tradeoffs.
|
||||
- Prefer authoritative sources and concrete examples.
|
||||
- Return concise findings with recommendations, risks, and unknowns.
|
||||
- Do not edit files or invent implementation details.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: Critical reviewer for plans, code, test evidence, and release readiness
|
||||
mode: subagent
|
||||
model: github-copilot/gpt-5.4
|
||||
variant: xhigh
|
||||
temperature: 0.1
|
||||
tools:
|
||||
write: false
|
||||
edit: false
|
||||
bash: false
|
||||
permission:
|
||||
webfetch: allow
|
||||
permalink: opencode-config/agents/reviewer
|
||||
---
|
||||
|
||||
Act as a skeptical reviewer.
|
||||
|
||||
- Proactively load applicable skills when triggers are present:
|
||||
- `verification-before-completion` when evaluating completion readiness.
|
||||
- `test-driven-development` when reviewing red/green discipline evidence.
|
||||
|
||||
- Look for incorrect assumptions, missing cases, regressions, unclear specs, and weak verification.
|
||||
- Reject completion claims that lack structured verification evidence in the compact shape (`Goal`, `Mode`, `Command/Check`, `Result`, `Key Evidence`, `Artifacts`, `Residual Risk`).
|
||||
- Reject execution notes or handoffs that lack lane-ownership boundaries (owner, claimed files/areas, status).
|
||||
- Prefer concrete findings over broad advice.
|
||||
- When reviewing a plan, call out ambiguity before execution starts.
|
||||
- When reviewing code or tests, provide evidence-backed issues in priority order.
|
||||
- Remain read-only: report findings via response message; do not write to execution notes or modify files.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
description: Verification specialist for running tests, reproducing failures, and capturing evidence
|
||||
mode: subagent
|
||||
model: github-copilot/claude-opus-4.6
|
||||
variant: thinking
|
||||
temperature: 0.0
|
||||
tools:
|
||||
write: false
|
||||
permission:
|
||||
edit: deny
|
||||
webfetch: allow
|
||||
bash:
|
||||
"*": allow
|
||||
permalink: opencode-config/agents/tester
|
||||
---
|
||||
|
||||
Own verification and failure evidence.
|
||||
|
||||
- Proactively load applicable skills when triggers are present:
|
||||
- `systematic-debugging` when a verification failure needs diagnosis.
|
||||
- `verification-before-completion` before declaring verification complete.
|
||||
- `test-driven-development` when validating red/green cycles or regression coverage.
|
||||
- `docker-container-management` when tests run inside containers.
|
||||
- `python-development` when verifying Python code.
|
||||
- `javascript-typescript-development` when verifying JS/TS code.
|
||||
|
||||
- Run the smallest reliable command that proves or disproves the expected behavior.
|
||||
- Report every result using the compact verification summary shape:
|
||||
- **Goal** – what is being verified
|
||||
- **Mode** – `smoke` or `full`
|
||||
- **Command/Check** – exact command or manual check performed
|
||||
- **Result** – `pass`, `fail`, `blocked`, or `not_run`
|
||||
- **Key Evidence** – concise proof (output snippet, hash, assertion count)
|
||||
- **Artifacts** – paths to logs/screenshots, or `none`
|
||||
- **Residual Risk** – known gaps, or `none`
|
||||
- Keep raw logs out of primary context unless a check fails or the caller requests full output. Summarize the failure first, then point to raw evidence.
|
||||
- Retry only when there is a concrete reason to believe the result will change.
|
||||
- Flag any temporary artifacts observed during verification (e.g., scratch files, screenshots, logs, transient reports, caches) so builder or coder can clean them up before completion.
|
||||
- Do not make code edits.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Execute the latest approved plan
|
||||
agent: builder
|
||||
model: github-copilot/gpt-5.4
|
||||
---
|
||||
|
||||
Execute the latest approved plan for: $ARGUMENTS
|
||||
|
||||
1. Read the latest matching `plans/<slug>` note with `Status: approved`.
|
||||
2. Create or update `executions/<slug>` with the structured sections defined in `AGENTS.md` (Plan, Execution State, Lane Claims, Last Verified State, Verification Ledger). Set `Status: in_progress` before changing code.
|
||||
3. Before parallel fan-out, populate Lane Claims with owner, status, claimed files/areas, dependencies, and exit conditions. Overlapping claimed files/areas or sequential verification dependencies forbid parallel fan-out.
|
||||
4. Delegate implementation to `coder`, verification to `tester`, review to `reviewer`, and docs or memory updates to `librarian` where appropriate.
|
||||
5. Builder owns commit creation during `/build`: create automatic commits at meaningful completed implementation checkpoints.
|
||||
6. Reuse existing git safety rules and avoid destructive git behavior; do not add push automation.
|
||||
7. If no new changes exist at a checkpoint, skip commit creation rather than creating empty or duplicate commits.
|
||||
8. Record verification evidence in the Verification Ledger using the compact shape (Goal, Mode, Command/Check, Result, Key Evidence, Artifacts, Residual Risk). Default to `smoke` for intermediate checkpoints; require `full` before final completion or setting status to `done`.
|
||||
9. Follow the plan exactly. If the plan is contradictory, missing a dependency, or fails verification twice, stop, capture evidence, set the execution note to blocked, and send the work back to `planner`.
|
||||
10. Before creating the final completion commit, clean up temporary artifacts generated during the build (e.g., scratch files, screenshots, logs, transient reports, caches). Intended committed deliverables are not cleanup targets.
|
||||
11. Finish by creating a final completion commit when changes remain, then update Last Verified State and set the execution note to `Status: done` or `Status: blocked` and summarize what changed.
|
||||
|
||||
Automatic commits are required during `/build` as defined above.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
description: Resume unfinished planner or builder workflow from memory
|
||||
model: github-copilot/gpt-5.4
|
||||
---
|
||||
|
||||
Continue the highest-priority unfinished work for this repository.
|
||||
|
||||
1. Inspect basic-memory for incomplete work under `plans/`, `executions/`, `findings/`, and `decisions/`.
|
||||
2. If the current primary agent is `planner`, resume the most relevant plan that is `Status: draft` or `Status: blocked` and drive it toward an approved spec.
|
||||
3. If the current primary agent is `builder`, resume the most relevant execution note that is `Status: in_progress` or `Status: blocked`. If there is no approved plan, stop and hand the work back to `planner`.
|
||||
4. When resuming a structured execution note, read Execution State and report: objective, current phase, next checkpoint, blockers, and last updated by. Check Lane Claims for active/blocked lanes and flag any claim conflicts (overlapping files/areas).
|
||||
5. When the execution note is legacy or freeform (missing structured sections like Execution State or Lane Claims), degrade gracefully: read what exists, infer status from available content, and do not invent conflicts or synthesize missing sections without evidence.
|
||||
6. When the execution note shows only `smoke` verification in the Last Verified State or Verification Ledger and a `full` verification step is still required before completion, surface this explicitly: report that full verification is pending and must run before the execution can be marked `done`.
|
||||
7. Refresh the open findings ledger and update note statuses as you work.
|
||||
8. Return the resumed slug, current status, next checkpoint, any blocker, any lane claim conflicts, and any pending full-verification requirement.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
description: Initialize or refresh project memory and AGENTS.md
|
||||
agent: builder
|
||||
model: github-copilot/gpt-5.4
|
||||
---
|
||||
|
||||
Initialize this repository for the planner/builder workflow.
|
||||
|
||||
1. Verify that a dedicated per-repo basic-memory project exists for the current repository. If it does not, create it at `<repo-root>/.memory` using a short kebab-case project name.
|
||||
2. Gather high-signal project context in parallel: purpose, stack, architecture, entrypoints, build/test commands, coding conventions, and major risks.
|
||||
3. Write or refresh project memory notes under `project/overview`, `project/architecture`, `project/workflows`, and `project/testing`.
|
||||
4. Use `librarian` to create or update the project-root `AGENTS.md` so it matches the repository and documents the important working agreements.
|
||||
5. Record any missing information or open findings under `findings/` instead of guessing.
|
||||
|
||||
Keep the output concise and actionable.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
description: Produce or refresh an execution-ready plan
|
||||
agent: planner
|
||||
model: github-copilot/gpt-5.4
|
||||
---
|
||||
|
||||
Create or update an execution-ready plan for: $ARGUMENTS
|
||||
|
||||
1. Gather the required repo and external context in parallel.
|
||||
2. Use `researcher`, `explorer`, and `reviewer` as needed.
|
||||
3. Write the canonical plan to basic-memory under `plans/<slug>`.
|
||||
4. Include: objective, scope, assumptions, constraints, task breakdown, parallel lanes, verification oracle, risks, and open findings.
|
||||
5. When parallelization or phased verification matters, define intended lanes with claimed files/areas, inter-lane dependencies, and verification intent (including `smoke` vs `full` mode where the distinction affects execution).
|
||||
6. Ensure the plan gives builder enough information to create the structured `executions/<slug>` note without guessing lane ownership, claimed areas, or verification expectations.
|
||||
7. Set `Status: approved` only when `builder` can execute the plan without guesswork. Otherwise leave it blocked and explain why.
|
||||
|
||||
Return the plan slug and the key execution checkpoints.
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json",
|
||||
"enabled": true,
|
||||
"debug": false,
|
||||
"pruneNotification": "detailed",
|
||||
"pruneNotificationType": "chat",
|
||||
"commands": {
|
||||
"enabled": true,
|
||||
"protectedTools": []
|
||||
},
|
||||
"experimental": {
|
||||
"allowSubAgents": true
|
||||
},
|
||||
"manualMode": {
|
||||
"enabled": false,
|
||||
"automaticStrategies": true
|
||||
},
|
||||
"turnProtection": {
|
||||
"enabled": false,
|
||||
"turns": 4
|
||||
},
|
||||
"protectedFilePatterns": [],
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": true,
|
||||
"protectedTools": []
|
||||
},
|
||||
"supersedeWrites": {
|
||||
"enabled": true
|
||||
},
|
||||
"purgeErrors": {
|
||||
"enabled": true,
|
||||
"turns": 4,
|
||||
"protectedTools": []
|
||||
}
|
||||
},
|
||||
"compress": {
|
||||
"maxContextLimit": "80%",
|
||||
"minContextLimit": "50%"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"autoupdate": true,
|
||||
"model": "github-copilot/gpt-5.4",
|
||||
"small_model": "github-copilot/gpt-5-mini",
|
||||
"default_agent": "planner",
|
||||
"enabled_providers": ["github-copilot"],
|
||||
"plugin": ["@tarquinen/opencode-dcp", "./plugins/tmux-panes.ts"],
|
||||
"agent": {
|
||||
"build": {
|
||||
"disable": true
|
||||
},
|
||||
"general": {
|
||||
"disable": true
|
||||
},
|
||||
"explore": {
|
||||
"disable": true
|
||||
},
|
||||
"plan": {
|
||||
"disable": true
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"doom_loop": "allow",
|
||||
"websearch": "allow",
|
||||
"question": "allow",
|
||||
"bash": "allow",
|
||||
"external_directory": "deny"
|
||||
},
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"enabled": true
|
||||
},
|
||||
"gh_grep": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.grep.app",
|
||||
"enabled": true
|
||||
},
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"bunx",
|
||||
"@playwright/mcp@latest",
|
||||
"--headless",
|
||||
"--browser",
|
||||
"chromium"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"basic-memory": {
|
||||
"type": "local",
|
||||
"command": ["uvx", "basic-memory", "mcp"],
|
||||
"enabled": true
|
||||
},
|
||||
"ast-grep": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"uvx",
|
||||
"--from",
|
||||
"git+https://github.com/ast-grep/ast-grep-mcp",
|
||||
"ast-grep-server"
|
||||
],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
31
.config/opencode/package-lock.json
generated
31
.config/opencode/package-lock.json
generated
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.2.15",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.2.15",
|
||||
"zod": "4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.2.15",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { spawn } from "bun"
|
||||
|
||||
/**
|
||||
* tmux-panes plugin
|
||||
*
|
||||
* When opencode spawns a background subagent, this plugin automatically opens
|
||||
* a new tmux pane showing that subagent's live TUI via `opencode attach`.
|
||||
*
|
||||
* Layout:
|
||||
* - First subagent: horizontal 60/40 split — main pane on left, subagent on right
|
||||
* - Additional subagents: stacked vertically in the right column
|
||||
* - Panes close automatically when subagent sessions end
|
||||
*
|
||||
* Only activates when running inside a tmux session (TMUX env var is set).
|
||||
*/
|
||||
|
||||
const isInsideTmux = () => Boolean(process.env.TMUX)
|
||||
const getCurrentPaneId = () => process.env.TMUX_PANE
|
||||
|
||||
const runTmux = async (args: string[]) => {
|
||||
const proc = spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = (items: string[], value: string) => {
|
||||
const idx = items.indexOf(value)
|
||||
if (idx !== -1) items.splice(idx, 1)
|
||||
}
|
||||
|
||||
const plugin: Plugin = async (ctx) => {
|
||||
if (!isInsideTmux()) return {}
|
||||
|
||||
const sessions = new Map<string, string>() // sessionId → tmux paneId
|
||||
const sourcePaneId = getCurrentPaneId()
|
||||
const serverUrl = (ctx.serverUrl?.toString() ?? "").replace(/\/$/, "")
|
||||
|
||||
// Ordered list of pane IDs in the right column.
|
||||
// Empty = no right column yet; length > 0 = right column exists.
|
||||
const rightColumnPanes: string[] = []
|
||||
let paneOps = Promise.resolve()
|
||||
|
||||
const getWindowInfo = async () => {
|
||||
const targetPane = sourcePaneId ?? rightColumnPanes[0]
|
||||
if (!targetPane) return null
|
||||
|
||||
const result = await runTmux([
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
targetPane,
|
||||
"#{window_id} #{window_width}",
|
||||
])
|
||||
|
||||
if (result.exitCode !== 0 || !result.stdout) return null
|
||||
|
||||
const [windowId, widthText] = result.stdout.split(/\s+/, 2)
|
||||
const width = Number(widthText)
|
||||
if (!windowId || Number.isNaN(width)) return null
|
||||
|
||||
return { windowId, width }
|
||||
}
|
||||
|
||||
const applyLayout = async () => {
|
||||
if (rightColumnPanes.length === 0) return
|
||||
|
||||
const windowInfo = await getWindowInfo()
|
||||
if (!windowInfo) return
|
||||
|
||||
const mainWidth = Math.max(1, Math.round(windowInfo.width * 0.6))
|
||||
|
||||
await runTmux([
|
||||
"set-window-option",
|
||||
"-t",
|
||||
windowInfo.windowId,
|
||||
"main-pane-width",
|
||||
String(mainWidth),
|
||||
])
|
||||
await runTmux(["select-layout", "-t", sourcePaneId ?? rightColumnPanes[0], "main-vertical"])
|
||||
}
|
||||
|
||||
const closeSessionPane = async (sessionId: string) => {
|
||||
const paneId = sessions.get(sessionId)
|
||||
if (!paneId) return
|
||||
|
||||
await runTmux(["kill-pane", "-t", paneId])
|
||||
sessions.delete(sessionId)
|
||||
removeItem(rightColumnPanes, paneId)
|
||||
await applyLayout()
|
||||
}
|
||||
|
||||
const enqueuePaneOp = (operation: () => Promise<void>) => {
|
||||
paneOps = paneOps.then(operation).catch(() => {})
|
||||
return paneOps
|
||||
}
|
||||
|
||||
const isTerminalSessionUpdate = (info: any) =>
|
||||
Boolean(info?.time?.archived || info?.time?.compacting)
|
||||
|
||||
return {
|
||||
event: async ({ event }) => {
|
||||
// Spawn a new pane when a subagent session is created
|
||||
if (event.type === "session.created") {
|
||||
const info = (event as any).properties?.info
|
||||
// parentID presence distinguishes subagents from the root session
|
||||
if (!info?.id || !info?.parentID) return
|
||||
const sessionId: string = info.id
|
||||
if (sessions.has(sessionId)) return
|
||||
|
||||
await enqueuePaneOp(async () => {
|
||||
if (sessions.has(sessionId)) return
|
||||
|
||||
const cmd = `opencode attach ${serverUrl} --session ${sessionId}`
|
||||
let args: string[]
|
||||
|
||||
if (rightColumnPanes.length === 0) {
|
||||
const windowInfo = await getWindowInfo()
|
||||
const rightWidth = windowInfo
|
||||
? Math.max(1, Math.round(windowInfo.width * 0.4))
|
||||
: 40
|
||||
|
||||
args = [
|
||||
"split-window",
|
||||
"-h",
|
||||
"-l",
|
||||
String(rightWidth),
|
||||
"-d",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{pane_id}",
|
||||
...(sourcePaneId ? ["-t", sourcePaneId] : []),
|
||||
cmd,
|
||||
]
|
||||
} else {
|
||||
const lastRightPane = rightColumnPanes[rightColumnPanes.length - 1]
|
||||
args = [
|
||||
"split-window",
|
||||
"-v",
|
||||
"-d",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{pane_id}",
|
||||
"-t",
|
||||
lastRightPane,
|
||||
cmd,
|
||||
]
|
||||
}
|
||||
|
||||
const result = await runTmux(args)
|
||||
const paneId = result.stdout
|
||||
|
||||
if (result.exitCode === 0 && paneId) {
|
||||
sessions.set(sessionId, paneId)
|
||||
rightColumnPanes.push(paneId)
|
||||
await applyLayout()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Kill the pane when the subagent session ends
|
||||
if (event.type === "session.deleted") {
|
||||
const info = (event as any).properties?.info
|
||||
if (!info?.id) return
|
||||
await enqueuePaneOp(() => closeSessionPane(info.id))
|
||||
}
|
||||
|
||||
if (event.type === "session.updated") {
|
||||
const info = (event as any).properties?.info
|
||||
if (!info?.id || !sessions.has(info.id) || !isTerminalSessionUpdate(info)) return
|
||||
await enqueuePaneOp(() => closeSessionPane(info.id))
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
const sessionID = (event as any).properties?.sessionID
|
||||
const statusType = (event as any).properties?.status?.type
|
||||
if (!sessionID || !sessions.has(sessionID) || statusType !== "idle") return
|
||||
await enqueuePaneOp(() => closeSessionPane(sessionID))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: brainstorming
|
||||
description: Planner-led discovery workflow for clarifying problem shape, options, and decision-ready direction
|
||||
permalink: opencode-config/skills/brainstorming/skill
|
||||
---
|
||||
|
||||
# Brainstorming
|
||||
|
||||
Use this skill when requests are unclear, options are broad, or design tradeoffs are unresolved.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Clarify objective, constraints, and non-goals.
|
||||
2. Generate multiple viable approaches (not one-path thinking).
|
||||
3. Compare options by risk, complexity, verification cost, and reversibility.
|
||||
4. Identify unknowns that need research before execution.
|
||||
5. Converge on a recommended direction with explicit rationale.
|
||||
|
||||
## Planner Ownership
|
||||
|
||||
- Keep brainstorming in planning mode; do not start implementation.
|
||||
- Use subagents for independent research lanes when needed.
|
||||
- Translate outcomes into memory-backed planning artifacts (`plans/<slug>`, findings/risks).
|
||||
|
||||
## Output
|
||||
|
||||
- Short options table (approach, pros, cons, risks).
|
||||
- Recommended path and why.
|
||||
- Open questions that block approval.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: creating-agents
|
||||
description: Create or update opencode agents in this repository, including dispatch permissions and roster alignment requirements
|
||||
permalink: opencode-config/skills/creating-agents/skill
|
||||
---
|
||||
|
||||
# Creating Agents
|
||||
|
||||
Use this skill when you need to add or revise an agent definition in this repo.
|
||||
|
||||
## Agents vs Skills
|
||||
|
||||
- **Agents** define runtime behavior and permissions in `agents/*.md`.
|
||||
- **Skills** are reusable instruction modules under `skills/*/SKILL.md`.
|
||||
- Do not treat agent creation as skill creation; each has different files, checks, and ownership.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
1. Agent definition file: `agents/<agent-name>.md`
|
||||
2. Operating roster and workflow contract: `AGENTS.md`
|
||||
3. Runtime overrides and provider policy: `opencode.jsonc`
|
||||
4. Workflow entrypoints: `commands/*.md`
|
||||
|
||||
Notes:
|
||||
|
||||
- This repo uses two primary agents: `planner` and `builder`.
|
||||
- Dispatch permissions live in the primary agent that owns the subagent, not in a central dispatcher.
|
||||
- `planner` may dispatch only `researcher`, `explorer`, and `reviewer`.
|
||||
- `builder` may dispatch only `coder`, `tester`, `reviewer`, and `librarian`.
|
||||
|
||||
## Agent File Conventions
|
||||
|
||||
For `agents/<agent-name>.md`:
|
||||
|
||||
- Use frontmatter first, then concise role instructions.
|
||||
- Keep tone imperative and operational.
|
||||
- Define an explicit `model` for every agent and keep it on a GitHub Copilot model.
|
||||
- Use only explicit `allow` or `deny` permissions; do not use `ask`.
|
||||
- Include only the tools and permissions needed for the role.
|
||||
- Keep instructions aligned with the planner -> builder contract in `AGENTS.md`.
|
||||
|
||||
Typical frontmatter fields in this repo include:
|
||||
|
||||
- `description`
|
||||
- `mode`
|
||||
- `model`
|
||||
- `temperature`
|
||||
- `steps`
|
||||
- `tools`
|
||||
- `permission`
|
||||
- `permalink`
|
||||
|
||||
Mirror nearby agent files instead of inventing new metadata patterns.
|
||||
|
||||
## Practical Workflow (Create or Update)
|
||||
|
||||
1. Inspect the relevant primary agent file and at least one comparable peer in `agents/*.md`.
|
||||
2. Create or edit `agents/<agent-name>.md` with matching local structure.
|
||||
3. If the agent is a subagent, update the owning primary agent's `permission.task` allowlist.
|
||||
4. Update `AGENTS.md` so the roster, responsibilities, and workflow rules stay synchronized.
|
||||
5. Review `commands/*.md` if the new agent changes how `/init`, `/plan`, `/build`, or `/continue` should behave.
|
||||
6. Review `opencode.jsonc` for conflicting overrides, disable flags, or provider drift.
|
||||
|
||||
## Manual Verification Checklist (No Validation Script)
|
||||
|
||||
Run this checklist before claiming completion:
|
||||
|
||||
- [ ] `agents/<agent-name>.md` exists and frontmatter is valid and consistent with peers.
|
||||
- [ ] Agent instructions clearly define role, scope, escalation rules, and constraints.
|
||||
- [ ] The owning primary agent includes the correct `permission.task` rule for the subagent.
|
||||
- [ ] `AGENTS.md` roster row exists and matches the agent name, role, and model.
|
||||
- [ ] `commands/*.md` and `opencode.jsonc` still reflect the intended workflow.
|
||||
- [ ] Terminology stays consistent: agents in `agents/*.md`, skills in `skills/*/SKILL.md`.
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: creating-skills
|
||||
description: Create or update opencode skills in this repository using the required SKILL.md format and concise, trigger-focused guidance
|
||||
permalink: opencode-config/skills/creating-skills/skill
|
||||
---
|
||||
|
||||
# Creating Skills
|
||||
|
||||
Use this skill when you need to add or revise an opencode skill under `skills/`.
|
||||
|
||||
## Skills vs OpenAI/Codex Source Model
|
||||
|
||||
- Treat this repo as **opencode-native**.
|
||||
- Do **not** use OpenAI/Codex-specific artifacts such as `agents/openai.yaml`, `init_skill.py`, `quick_validate.py`, or `scripts/references/assets` conventions from the old source model.
|
||||
- A skill is discovered from `skills/*/SKILL.md` only.
|
||||
|
||||
## Required Structure
|
||||
|
||||
1. Create a folder at `skills/<skill-name>/`.
|
||||
2. Add `skills/<skill-name>/SKILL.md`.
|
||||
3. Keep `<skill-name>` equal to frontmatter `name`.
|
||||
|
||||
Frontmatter must contain only:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <skill-name>
|
||||
description: <what it does and when to load>
|
||||
permalink: opencode-config/skills/<skill-name>/skill
|
||||
---
|
||||
```
|
||||
|
||||
## Naming Rules
|
||||
|
||||
- Use lowercase kebab-case.
|
||||
- Keep names short and action-oriented.
|
||||
- Match folder name and `name` exactly.
|
||||
|
||||
## Body Writing Rules
|
||||
|
||||
- Write concise, imperative instructions.
|
||||
- Lead with when to load and the core workflow.
|
||||
- Prefer short checklists over long prose.
|
||||
- Include only repo-relevant guidance.
|
||||
- Keep the planner/builder operating model in mind when a skill touches workflow behavior.
|
||||
|
||||
## Companion Notes (`*.md` in skill folder)
|
||||
|
||||
Add companion markdown files only when detail would bloat `SKILL.md` (examples, deep procedures, edge-case references).
|
||||
|
||||
- Keep `SKILL.md` as the operational entrypoint.
|
||||
- Link companion files directly from `SKILL.md` with clear “when to read” guidance.
|
||||
- Do not create extra docs by default.
|
||||
|
||||
## Practical Workflow (Create or Update)
|
||||
|
||||
1. Inspect 2–3 nearby skills for local style and brevity.
|
||||
2. Pick/update `<skill-name>` and folder path under `skills/`.
|
||||
3. Write or revise `SKILL.md` frontmatter (`name`, `description`, `permalink` only).
|
||||
4. Draft concise body sections: purpose, load conditions, workflow, red flags/checks.
|
||||
5. Add minimal companion `.md` files only if needed; link them from `SKILL.md`.
|
||||
6. Verify discovery path and naming consistency:
|
||||
- file exists at `skills/<name>/SKILL.md`
|
||||
- folder name == frontmatter `name`
|
||||
- no OpenAI/Codex-only artifacts introduced
|
||||
7. If the skill changes agent workflow or command behavior:
|
||||
- Update the **Skills** table, **Agent Skill-Loading Contract**, and **TDD Default Policy** in `AGENTS.md`.
|
||||
- Confirm `commands/*.md` and any affected `agents/*.md` prompts stay aligned.
|
||||
- If the skill involves parallelization, verify it enforces safe-parallelization rules (no parallel mutation on shared files, APIs, schemas, or verification steps).
|
||||
- If the skill involves code changes, verify it references the TDD default policy and its narrow exceptions.
|
||||
|
||||
## Language/Ecosystem Skill Pattern
|
||||
|
||||
When adding a new language or ecosystem skill (e.g., `rust-development`, `go-development`), follow this template:
|
||||
|
||||
1. **Name**: `<language>-development` (kebab-case).
|
||||
2. **Load trigger**: presence of the language's project file(s) or source files as primary source.
|
||||
3. **Defaults table**: one row per concern — package manager, linter/formatter, test runner, type checker (if applicable).
|
||||
4. **Core workflow**: numbered steps for bootstrap, lint, format, test, add-deps, and any lock/check step.
|
||||
5. **Conventions**: 3–5 bullets on config file preferences, execution patterns, and version pinning.
|
||||
6. **Docker integration**: one paragraph on base image and cache strategy.
|
||||
7. **Red flags**: 3–5 bullets on common anti-patterns.
|
||||
8. **AGENTS.md updates**: add the skill to the **Ecosystem Skills** table and add load triggers for `planner`, `builder`, `coder`, and `tester` in the **Agent Skill-Loading Contract**.
|
||||
9. **Agent prompt updates**: add the skill trigger to `agents/planner.md`, `agents/builder.md`, `agents/coder.md`, and `agents/tester.md`.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: dispatching-parallel-agents
|
||||
description: Safely parallelize independent lanes with isolation checks, explicit ownership, and single-agent integration
|
||||
permalink: opencode-config/skills/dispatching-parallel-agents/skill
|
||||
---
|
||||
|
||||
# Dispatching Parallel Agents
|
||||
|
||||
Use this skill before parallel fan-out.
|
||||
|
||||
## Isolation Test (Required)
|
||||
|
||||
Before fan-out, verify that no two lanes share:
|
||||
|
||||
- **Claimed Files/Areas** under active mutation (paths or named workflow surfaces from the lane-claim entries)
|
||||
- APIs or schemas being changed
|
||||
- **Sequential verification dependencies** (verification steps that must run in order across lanes)
|
||||
|
||||
Overlapping claimed files/areas or sequential verification dependencies **forbid** parallel fan-out. Run those lanes sequentially.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Builder creates lane-claim entries** in the execution note before fan-out, recording for each lane: `Owner`, `Status` (→ `active`), `Claimed Files/Areas`, `Depends On`, and `Exit Condition`.
|
||||
2. Run the isolation test above against the claimed files/areas and dependencies. Abort fan-out on any overlap.
|
||||
3. Define lane scope, inputs, and outputs for each subagent.
|
||||
4. Assign a single integrator (usually builder) for merge and final validation.
|
||||
5. Each lane must return **compact verification evidence** in the shared shape (`Goal`, `Mode`, `Command/Check`, `Result`, `Key Evidence`, `Artifacts`, `Residual Risk`) — not just code output.
|
||||
6. Integrate in dependency order; update lane statuses to `released` or `done`.
|
||||
7. Run final end-to-end verification (`full` mode) after integration.
|
||||
|
||||
## Planner/Builder Expectations
|
||||
|
||||
- **Planner**: define intended lanes and claimed files/areas in the approved plan when parallelization is expected.
|
||||
- **Builder**: load this skill before fan-out, create or update lane-claim entries in the execution note, mark them `active`/`released`/`done`/`blocked`, and enforce lane boundaries strictly.
|
||||
- Claims are advisory markdown metadata, not hard runtime locks. Do not invent lockfiles or runtime enforcement.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Two lanes editing the same contract.
|
||||
- Shared test fixtures causing non-deterministic outcomes.
|
||||
- Missing integrator ownership.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: docker-container-management
|
||||
description: Reusable Docker container workflow for build, test, and dev tasks in containerized repos
|
||||
permalink: opencode-config/skills/docker-container-management/skill
|
||||
---
|
||||
|
||||
# Docker Container Management
|
||||
|
||||
Load this skill when a repo uses Docker/docker-compose for builds, tests, or local dev, or when a task involves containerized workflows.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Detect** — look for `Dockerfile`, `docker-compose.yml`/`compose.yml`, or `.devcontainer/` in the repo root.
|
||||
2. **Prefer compose** — use `docker compose` (v2 CLI) over raw `docker run` when a compose file exists.
|
||||
3. **Ephemeral containers** — default to `--rm` for one-off commands. Avoid leaving stopped containers behind.
|
||||
4. **Named volumes over bind-mounts** for caches (e.g., package manager caches). Use bind-mounts only for source code.
|
||||
5. **No host-path writes outside the repo** — all volume mounts must target paths inside the repo root or named volumes. This preserves `external_directory: deny`.
|
||||
|
||||
## Path and Volume Constraints
|
||||
|
||||
- Mount the repo root as the container workdir: `-v "$(pwd):/app" -w /app`.
|
||||
- Never mount host paths outside the repository (e.g., `~/.ssh`, `/var/run/docker.sock`) unless the plan explicitly approves it with a stated reason.
|
||||
- If root-owned artifacts appear after container runs, document cleanup steps (see `main/knowledge/worktree-cleanup-after-docker-owned-artifacts`).
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **planner**: Use Docker during planning for context gathering and inspection (e.g., `docker compose config`, `docker ps`, `docker image ls`, `docker network ls`, checking container health or logs). Do not run builds, installs, tests, deployments, or any implementation-level commands — those belong to builder/tester/coder.
|
||||
- **builder/coder**: Run builds and install steps inside containers. Prefer `docker compose run --rm <service> <cmd>` for one-off tasks.
|
||||
- **tester**: Run test suites inside the same container environment used by CI. Capture container exit codes as verification evidence.
|
||||
- **coder**: When writing Dockerfiles or compose files, keep layers minimal, pin base image tags, and use multi-stage builds when the final image ships.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- `docker run` without `--rm` in automation scripts.
|
||||
- Bind-mounting sensitive host paths (`/etc`, `~/.config`, `/var/run/docker.sock`).
|
||||
- Building images without a `.dockerignore`.
|
||||
- Using `latest` tag for base images in production Dockerfiles.
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
permalink: opencode-config/skills/frontend-design/skill
|
||||
---
|
||||
|
||||
# Frontend Design
|
||||
|
||||
## When to Load
|
||||
|
||||
Load this skill when the task involves building, redesigning, or significantly styling a frontend component, page, or application. Triggers include: user requests for UI/UX implementation, wireframe-to-code work, visual redesigns, and new web interfaces.
|
||||
|
||||
## Design Thinking Checklist
|
||||
|
||||
Before writing code, answer these:
|
||||
|
||||
1. **Purpose** — What problem does this interface solve? Who is the audience?
|
||||
2. **Brand / product context** — Does the project have existing design tokens, a style guide, or brand constraints? Follow them first; extend only where gaps exist.
|
||||
3. **Aesthetic direction** — Commit to a clear direction (e.g., brutally minimal, maximalist, retro-futuristic, editorial, organic, luxury, playful, industrial). Intentionality matters more than intensity.
|
||||
4. **Differentiation** — Identify the single most memorable element of the design.
|
||||
5. **Constraints** — Note framework requirements, performance budgets, and accessibility targets (WCAG AA minimum).
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Produce production-grade, functional code (HTML/CSS/JS, React, Vue, etc.)
|
||||
- [ ] Ensure the result is visually cohesive with a clear aesthetic point of view
|
||||
- [ ] Respect accessibility: semantic HTML, sufficient contrast, keyboard navigation, focus management
|
||||
- [ ] Respect performance: avoid heavy unoptimized assets; prefer CSS-only solutions for animation where practical
|
||||
- [ ] Use CSS variables for color/theme consistency
|
||||
- [ ] Match implementation complexity to the aesthetic vision — maximalist designs need elaborate effects; minimal designs need precision and restraint
|
||||
|
||||
## Aesthetic Guidance
|
||||
|
||||
- **Typography** — Choose distinctive, characterful fonts. Pair a display font with a refined body font. Avoid defaulting to the same choices across projects; vary intentionally.
|
||||
- **Color & Theme** — Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Vary between light and dark themes across projects.
|
||||
- **Motion** — Prioritize high-impact moments: a well-orchestrated page load with staggered reveals creates more delight than scattered micro-interactions. Use CSS animations where possible; use a motion library (e.g., Motion) for complex sequences. Include scroll-triggered and hover effects when they serve the design.
|
||||
- **Spatial Composition** — Explore asymmetry, overlap, diagonal flow, grid-breaking elements, generous negative space, or controlled density.
|
||||
- **Backgrounds & Atmosphere** — Build depth with gradient meshes, noise textures, geometric patterns, layered transparencies, shadows, grain overlays, or other contextual effects rather than defaulting to flat solid colors.
|
||||
|
||||
Avoid converging on the same fonts, color schemes, or layout patterns across generations. Each design should feel context-specific and intentional.
|
||||
|
||||
## TDD & Verification
|
||||
|
||||
Frontend code changes follow the project's TDD default policy. When the skill is loaded alongside `test-driven-development`:
|
||||
|
||||
- Write or update component/visual tests before implementation when a test harness exists.
|
||||
- If no frontend test harness is available, state the exception and describe alternative verification (e.g., manual browser check, screenshot comparison).
|
||||
- Satisfy `verification-before-completion` requirements before claiming the work is done.
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: javascript-typescript-development
|
||||
description: JS/TS ecosystem defaults and workflows using bun for runtime/packaging and biome for linting/formatting
|
||||
permalink: opencode-config/skills/javascript-typescript-development/skill
|
||||
---
|
||||
|
||||
# JavaScript / TypeScript Development
|
||||
|
||||
Load this skill when a repo or lane involves JS/TS code (presence of `package.json`, `tsconfig.json`, or `.ts`/`.tsx`/`.js`/`.jsx` files as primary source).
|
||||
|
||||
## Defaults
|
||||
|
||||
| Concern | Tool | Notes |
|
||||
| --- | --- | --- |
|
||||
| Runtime + package manager | `bun` | Replaces node+npm/yarn/pnpm for most tasks |
|
||||
| Linting + formatting | `biome` | Replaces eslint+prettier |
|
||||
| Test runner | `bun test` | Built-in; use vitest/jest only if repo already configures them |
|
||||
| Type checking | `tsc --noEmit` | Always run before completion claims |
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Bootstrap** — `bun install` to install dependencies.
|
||||
2. **Lint** — `bunx biome check .` before committing.
|
||||
3. **Format** — `bunx biome format . --write` (or `--check` in CI).
|
||||
4. **Test** — `bun test` with the repo's existing config. Follow TDD default policy.
|
||||
5. **Add dependencies** — `bun add <pkg>` (runtime) or `bun add -D <pkg>` (dev).
|
||||
6. **Type check** — `bunx tsc --noEmit` for TS repos.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Prefer `biome.json` for lint/format config. Do not add `.eslintrc` or `.prettierrc` unless the repo already uses them.
|
||||
- Use `bun run <script>` to invoke `package.json` scripts.
|
||||
- Prefer ES modules (`"type": "module"` in `package.json`).
|
||||
- Pin Node/Bun version via `.node-version` or `package.json` `engines` when deploying.
|
||||
|
||||
## Docker Integration
|
||||
|
||||
When the repo runs JS/TS inside Docker, use `oven/bun` as the base image. Mount a named volume for `node_modules` or use `bun install --frozen-lockfile` in CI builds.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Using `npm`/`yarn`/`pnpm` when `bun` is available and the project uses it.
|
||||
- Running `eslint` or `prettier` when `biome` is configured.
|
||||
- Missing `bun.lockb` after dependency changes.
|
||||
- Skipping `tsc --noEmit` in TypeScript repos.
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: python-development
|
||||
description: Python ecosystem defaults and workflows using uv for packaging and ruff for linting/formatting
|
||||
permalink: opencode-config/skills/python-development/skill
|
||||
---
|
||||
|
||||
# Python Development
|
||||
|
||||
Load this skill when a repo or lane involves Python code (presence of `pyproject.toml`, `setup.py`, `requirements*.txt`, or `.py` files as primary source).
|
||||
|
||||
## Defaults
|
||||
|
||||
| Concern | Tool | Notes |
|
||||
| --- | --- | --- |
|
||||
| Package/venv management | `uv` | Replaces pip, pip-tools, and virtualenv |
|
||||
| Linting + formatting | `ruff` | Replaces flake8, isort, black |
|
||||
| Test runner | `pytest` | Unless repo already uses another runner |
|
||||
| Type checking | `pyright` or `mypy` | Use whichever the repo already configures |
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Bootstrap** — `uv sync` (or `uv pip install -e ".[dev]"`) to create/refresh the venv.
|
||||
2. **Lint** — `ruff check .` then `ruff format --check .` before committing.
|
||||
3. **Test** — `pytest` with the repo's existing config. Follow TDD default policy.
|
||||
4. **Add dependencies** — `uv add <pkg>` (runtime) or `uv add --dev <pkg>` (dev). Do not edit `pyproject.toml` dependency arrays by hand.
|
||||
5. **Lock** — `uv lock` after dependency changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Prefer `pyproject.toml` over `setup.py`/`setup.cfg` for new projects.
|
||||
- Keep `ruff` config in `pyproject.toml` under `[tool.ruff]`.
|
||||
- Use `uv run <cmd>` to execute tools inside the managed venv without activating it.
|
||||
- Pin Python version via `.python-version` or `pyproject.toml` `requires-python`.
|
||||
|
||||
## Docker Integration
|
||||
|
||||
When the repo runs Python inside Docker, install dependencies with `uv pip install` inside the container. Mount a named volume for the uv cache to speed up rebuilds.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Using `pip install` directly instead of `uv`.
|
||||
- Running `black` or `isort` when `ruff` is configured.
|
||||
- Missing `uv.lock` after dependency changes.
|
||||
- Editing dependency arrays in `pyproject.toml` by hand instead of using `uv add`.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: Diagnose failures with a hypothesis-first workflow, evidence capture, and escalation rules aligned to planner/builder
|
||||
permalink: opencode-config/skills/systematic-debugging/skill
|
||||
---
|
||||
|
||||
# Systematic Debugging
|
||||
|
||||
Use this skill when tests fail, behavior regresses, or the root cause is unclear.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Define the failure precisely (expected vs actual, where observed, reproducible command).
|
||||
2. Capture a baseline with the smallest reliable repro.
|
||||
3. List 1-3 concrete hypotheses and rank by likelihood.
|
||||
4. Test one hypothesis at a time with targeted evidence collection.
|
||||
5. Isolate the minimal root cause before proposing fixes.
|
||||
6. Verify the fix with focused checks, then relevant regression checks.
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
- Record failing and passing commands.
|
||||
- Keep key logs/errors tied to each hypothesis.
|
||||
- Note why rejected hypotheses were ruled out.
|
||||
|
||||
## Planner/Builder Alignment
|
||||
|
||||
- Planner: use findings to shape bounded implementation tasks and verification oracles.
|
||||
- Builder: if contradictions or hidden dependencies emerge, escalate back to planner.
|
||||
- After two failed verification attempts, stop, record root cause evidence, and escalate.
|
||||
|
||||
## Output
|
||||
|
||||
- Root cause statement.
|
||||
- Fix strategy linked to evidence.
|
||||
- Verification results proving the issue is resolved and not regressed.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: test-driven-development
|
||||
description: Apply red-green-refactor by default for code changes, with narrowly defined exceptions and explicit alternate verification
|
||||
permalink: opencode-config/skills/test-driven-development/skill
|
||||
---
|
||||
|
||||
# Test-Driven Development
|
||||
|
||||
Use this skill for all code changes unless a narrow exception applies.
|
||||
|
||||
## Default Cycle
|
||||
|
||||
1. Red: add or identify a test that fails for the target behavior.
|
||||
2. Green: implement the minimal code change to make the test pass.
|
||||
3. Refactor: improve structure while keeping tests green.
|
||||
4. Re-run focused and relevant regression tests.
|
||||
|
||||
## Narrow Exceptions
|
||||
|
||||
Allowed exceptions only:
|
||||
|
||||
- docs-only changes
|
||||
- config-only changes
|
||||
- pure refactors with provably unchanged behavior
|
||||
- repos without a reliable automated test harness
|
||||
|
||||
When using an exception, state:
|
||||
|
||||
- why TDD was not practical
|
||||
- what alternative verification was used
|
||||
|
||||
## Role Expectations
|
||||
|
||||
- Planner: specify tasks and verification that preserve red-green-refactor intent.
|
||||
- Builder/Coder: follow TDD during implementation or explicitly invoke a valid exception.
|
||||
- Tester/Reviewer: verify that TDD evidence (or justified exception) is present.
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: verification-before-completion
|
||||
description: Require evidence-backed verification before completion claims or final handoff
|
||||
permalink: opencode-config/skills/verification-before-completion/skill
|
||||
---
|
||||
|
||||
# Verification Before Completion
|
||||
|
||||
Use this skill before declaring work done, handing off, or approving readiness.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
1. Re-state the promised outcome and scope boundaries.
|
||||
2. Run the smallest reliable checks that prove requirements are met.
|
||||
3. Run broader regression checks required by project workflow.
|
||||
4. Confirm no known failures are being ignored.
|
||||
5. Clean up temporary artifacts generated during work (e.g., scratch files, screenshots, logs, transient reports, caches). Intended committed deliverables are not cleanup targets.
|
||||
6. Report residual risk, if any, explicitly.
|
||||
|
||||
## Evidence Standard
|
||||
|
||||
Use the compact verification summary shape for every evidence entry:
|
||||
|
||||
- **Goal** – what is being verified
|
||||
- **Mode** – `smoke` (intermediate checkpoints) or `full` (final completion)
|
||||
- **Command/Check** – exact command or manual check performed
|
||||
- **Result** – `pass`, `fail`, `blocked`, or `not_run`
|
||||
- **Key Evidence** – concise proof (output snippet, hash, assertion count)
|
||||
- **Artifacts** – paths to logs/screenshots, or `none`
|
||||
- **Residual Risk** – known gaps, or `none`
|
||||
|
||||
Keep raw logs out of primary context by default. When a check fails, summarize the failure first and then point to the raw evidence. Include full output only when explicitly requested.
|
||||
|
||||
Tie each evidence entry to an acceptance condition from the plan.
|
||||
|
||||
## Role Expectations
|
||||
|
||||
- Builder and tester: no completion claim without verification evidence in the compact shape above.
|
||||
- Reviewer: reject completion claims that lack structured evidence or lane-ownership boundaries.
|
||||
- Coder: include compact-shape verification evidence from the assigned lane before signaling done.
|
||||
|
||||
## If Verification Fails
|
||||
|
||||
- Do not claim partial completion as final.
|
||||
- Return to debugging or implementation with updated hypotheses.
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
name: writing-plans
|
||||
description: Planner workflow for producing execution-ready approved plans with explicit scope, lanes, and verification oracle
|
||||
permalink: opencode-config/skills/writing-plans/skill
|
||||
---
|
||||
|
||||
# Writing Plans
|
||||
|
||||
Use this skill when converting intent into an execution-ready `plans/<slug>` note.
|
||||
|
||||
## Required Plan Shape
|
||||
|
||||
Every approved plan must include:
|
||||
|
||||
- Objective
|
||||
- Scope and out-of-scope boundaries
|
||||
- Constraints and assumptions
|
||||
- Concrete task list
|
||||
- Parallelization lanes and dependency notes
|
||||
- Verification oracle
|
||||
- Risks and open findings
|
||||
|
||||
When parallelization or phased verification matters, each lane must also specify:
|
||||
|
||||
- **Claimed files/areas** — paths or named surfaces the lane owns exclusively.
|
||||
- **Dependencies** — which lanes (if any) must complete first.
|
||||
- **Verification intent** — what will be checked and at what mode (`smoke` for intermediate checkpoints, `full` for final completion). Default to the shared mode rules in `AGENTS.md` when not otherwise specified.
|
||||
|
||||
The plan must give builder enough information to create the structured `executions/<slug>` note (lane claims, ownership, exit conditions, verification ledger shape) without guessing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Gather enough evidence to remove guesswork.
|
||||
2. Decompose work into bounded tasks with clear owners.
|
||||
3. Define verification per task and for final integration.
|
||||
4. Check contract alignment with planner -> builder handoff rules.
|
||||
5. Mark `Status: approved` only when execution can proceed without improvisation.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- No ambiguous acceptance criteria.
|
||||
- No hidden scope expansion.
|
||||
- Verification is specific and runnable.
|
||||
- Lane claims do not overlap in files or verification steps when parallel execution is intended.
|
||||
- Verification mode (`smoke` or `full`) is stated or defaults are unambiguous for each checkpoint.
|
||||
@@ -7,3 +7,5 @@
|
||||
[user]
|
||||
email = alexander@wiesner.com.br
|
||||
name = alex wiesner
|
||||
[init]
|
||||
defaultBranch = main
|
||||
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.worktrees/
|
||||
.pi/npm/
|
||||
.pi/agent/sessions/
|
||||
.pi/agent/auth.json
|
||||
.pi/agent/web-search.json
|
||||
.pi/subagents/
|
||||
.pi/agent/extensions/.pi/
|
||||
.pi/agent/extensions/tmux-subagent/events.jsonl
|
||||
.pi/agent/extensions/tmux-subagent/result.json
|
||||
.pi/agent/extensions/tmux-subagent/stderr.log
|
||||
.pi/agent/extensions/tmux-subagent/stdout.log
|
||||
.pi/agent/extensions/tmux-subagent/transcript.log
|
||||
*bun.lock
|
||||
*node_modules
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: decisions
|
||||
type: note
|
||||
permalink: dotfiles/decisions
|
||||
---
|
||||
|
||||
# Dotfiles Decisions
|
||||
|
||||
## Desktop Environment: Hyprland + Wayland
|
||||
|
||||
- **Decision:** Hyprland as primary compositor, full Wayland stack
|
||||
- **Rationale:** Modern Wayland compositor with tiling, animations, and good HiDPI support
|
||||
- **Constraints:** XWayland needed for legacy apps; special window rule to suppress maximize events
|
||||
- **Input:** Caps Lock remapped to Super (`caps:super`), `alts_toggle` for US/BR layout switching
|
||||
|
||||
## Shell: Fish (not Bash/Zsh)
|
||||
|
||||
- **Decision:** Fish as primary interactive shell; bash/zsh configs retained for compatibility
|
||||
- **Rationale:** Better autocompletion, syntax highlighting, friendly defaults
|
||||
- **Plugin manager:** fisher (minimal, text-file-based)
|
||||
- **No `oh-my-fish`** — prefer minimal plugin set (just catppuccin theme)
|
||||
|
||||
## Theme: Catppuccin Mocha (global)
|
||||
|
||||
- **Decision:** Single colorscheme (Catppuccin Mocha) applied uniformly across all tools
|
||||
- **Rationale:** Consistent visual identity; official plugins available for Fish, Neovim, Kitty
|
||||
- **Other variants installed:** Frappe, Macchiato, Mocha static — available in `fish/themes/` but Mocha is active
|
||||
- **No per-tool theming exceptions** — all tools must use Catppuccin Mocha
|
||||
|
||||
## Editor: Neovim with lazy.nvim
|
||||
|
||||
- **Decision:** Neovim (not VSCode or other) as primary editor
|
||||
- **Plugin manager:** lazy.nvim (not packer, not vim-plug) — auto-bootstrapped from init.lua
|
||||
- **LSP strategy:** mason.nvim for tooling installation + mason-lspconfig for auto-enable; capabilities injected globally via cmp_nvim_lsp
|
||||
- **Format strategy:** conform.nvim with format-on-save (not LSP formatting directly); lsp_fallback=true for unconfigured filetypes
|
||||
- **No treesitter-based formatting** — explicit per-filetype formatters in conform
|
||||
|
||||
## OpenCode: Custom Multi-Agent Config
|
||||
|
||||
- **Decision:** Fully custom multi-agent configuration (not default OpenCode setup)
|
||||
- **10 specialized agents** each with tailored instructions, model, temperature, permissions
|
||||
- **Memory pattern:** `.memory/` directory tracked in git; agents write to `.memory/*` directly
|
||||
- **Permission model:** Full edit for lead/coder/librarian; all others restricted to `.memory/*` writes (instruction-level enforcement, not tool-level)
|
||||
- **AGENTS.md exception:** In the opencode subdir, `AGENTS.md` is NOT a symlink (it's the global OpenCode config file, distinct from the per-project `AGENTS.md` pattern)
|
||||
- See [OpenCode Architecture](research/opencode-architecture.md) for details
|
||||
|
||||
## Waybar CPU Monitor: Ghostty (not Kitty)
|
||||
|
||||
- **Observation:** `cpu` module in Waybar opens `ghostty -e htop` on click — Ghostty may be installed as secondary terminal
|
||||
- **Impact:** Kitty is the primary terminal (SUPER+Return), but Ghostty is referenced in Waybar config
|
||||
|
||||
## Git Credentials: gh CLI
|
||||
|
||||
- **Decision:** Use `gh auth git-credential` as credential helper for GitHub + Gist
|
||||
- **Rationale:** Centralizes auth through GitHub CLI; no plaintext tokens in git config
|
||||
|
||||
## SSH Key Type: Ed25519
|
||||
|
||||
- **Decision:** Ed25519 for SSH key (not RSA)
|
||||
- **Rationale:** Modern, fast, smaller key size
|
||||
|
||||
## No Global `.gitignore` in Dotfiles
|
||||
|
||||
- **Observation:** No global gitignore file visible; tracking is managed per-repo
|
||||
- **Pattern:** Sensitive SSH private key `.ssh/id_ed25519` is tracked — implies this repo may use filesystem permissions for security
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: critic-gate-tmux-shift-enter-fix
|
||||
type: note
|
||||
permalink: dotfiles/decisions/critic-gate-tmux-shift-enter-fix
|
||||
tags:
|
||||
- tmux
|
||||
- critic-gate
|
||||
- approved
|
||||
---
|
||||
|
||||
# Critic Gate: tmux Shift+Enter Fix
|
||||
|
||||
## Verdict
|
||||
- [decision] APPROVED — Plan is minimal, correctly scoped, and non-destructive.
|
||||
|
||||
## Rationale
|
||||
- Single `bind-key -n S-Enter send-keys "\n"` addition to `~/.tmux.conf`.
|
||||
- `extended-keys on` (line 8) and `extkeys` terminal feature (line 9) already present — `S-Enter` will be recognised.
|
||||
- No existing conflicting bindings in `.tmux.conf`.
|
||||
- Config will load cleanly; standard tmux syntax.
|
||||
|
||||
## Assumption Challenges
|
||||
- [finding] `S-Enter` key name is valid because extended-keys is already enabled. ✅
|
||||
- [finding] `send-keys "\n"` sends LF (0x0A). For TUI apps and multi-line tools, this inserts a newline as intended. For bare shell prompts, LF may still trigger accept-line (same as Enter). No shell-side `bindkey` exists in `.zshrc` to differentiate. This is a known limitation, not a blocker — follow-up zle binding may be needed.
|
||||
|
||||
## Files Evaluated
|
||||
- `/home/alex/dotfiles/.tmux.conf` (57 lines, all relevant config)
|
||||
- `/home/alex/dotfiles/.zshrc` (2 lines, no keybindings)
|
||||
|
||||
## Relations
|
||||
- gates [[tmux-shift-enter-fix]]
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
title: doc-coverage-waybar-pomodoro-fix
|
||||
type: note
|
||||
permalink: dotfiles/gates/doc-coverage-waybar-pomodoro-fix
|
||||
tags:
|
||||
- waybar
|
||||
- pomodoro
|
||||
- documentation
|
||||
- doc-coverage
|
||||
---
|
||||
|
||||
# Documentation Coverage: Waybar Pomodoro Visibility Fix
|
||||
|
||||
## Summary
|
||||
|
||||
Documentation coverage reviewed for the waybar pomodoro visibility fix (explicit binary path instead of PATH reliance).
|
||||
|
||||
## Observations
|
||||
|
||||
- [decision] No repo documentation changes needed for the pomodoro visibility fix — this is a personal dotfiles repo with no README, no docs/ directory, and AGENTS.md contains only agent workflow config, not dotfiles-specific documentation
|
||||
- [decision] Changed files (`.config/waybar/config`, `.config/waybar/scripts/pomodoro-preset.sh`) are self-documenting through clear variable naming (`POMODORO_BIN`) and standard Waybar config format
|
||||
- [decision] The plan note `plans/waybar-pomodoro-not-showing` already records full execution context, outcomes, and verification results — no additional knowledge capture needed
|
||||
|
||||
## Surfaces Checked
|
||||
|
||||
| Surface | Exists | Update Needed | Reason |
|
||||
|---|---|---|---|
|
||||
| README | No | N/A | Personal dotfiles repo, no README |
|
||||
| docs/ | No | N/A | No docs directory exists |
|
||||
| AGENTS.md | Yes | No | Contains only agent workflow config, not dotfiles project docs |
|
||||
| Inline docs | Yes (self-documenting) | No | Variable naming and script structure are clear |
|
||||
| Plan note | Yes | No | Already has execution notes and outcomes |
|
||||
|
||||
## Relations
|
||||
|
||||
- documents [[waybar-pomodoro-not-showing]]
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: gate-tmux-shift-enter-fix-review
|
||||
type: note
|
||||
permalink: dotfiles/gates/gate-tmux-shift-enter-fix-review
|
||||
tags:
|
||||
- tmux
|
||||
- review
|
||||
- gate
|
||||
- approved
|
||||
---
|
||||
|
||||
# Gate: tmux Shift+Enter Fix — Correctness Review
|
||||
|
||||
## Verdict
|
||||
- [decision] APPROVED — REVIEW_SCORE: 0
|
||||
|
||||
## Findings
|
||||
- [observation] No CRITICAL or WARNING issues found.
|
||||
- [observation] `bind-key -n S-Enter send-keys "\n"` is semantically correct for the stated intent.
|
||||
- [observation] Prerequisite `extended-keys on` is present and positioned before the binding.
|
||||
- [observation] Terminal features line (`xterm-kitty:extkeys`) enables the terminal to report extended key sequences.
|
||||
- [observation] No conflicting bindings exist in the config.
|
||||
- [observation] Config ordering is correct — prerequisites before the binding.
|
||||
|
||||
## Evidence Checked
|
||||
- [observation] Line 8: `set -s extended-keys on` — enables tmux to recognize modified keys like `S-Enter`.
|
||||
- [observation] Line 9: `set -as terminal-features 'xterm-kitty:extkeys'` — tells tmux the terminal supports extended keys.
|
||||
- [observation] Line 10: `bind-key -n S-Enter send-keys "\n"` — root-table binding, sends literal newline. Correct.
|
||||
- [observation] No other `Enter`-related or `S-Enter` bindings exist that could conflict.
|
||||
- [observation] `-n` flag correctly targets root table (no prefix key needed).
|
||||
|
||||
## Relations
|
||||
- reviews [[plans/tmux-shift-enter-fix]]
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
title: gate-waybar-pomodoro-not-showing-review
|
||||
type: note
|
||||
permalink: dotfiles/gates/gate-waybar-pomodoro-not-showing-review
|
||||
tags:
|
||||
- gate
|
||||
- review
|
||||
- waybar
|
||||
- pomodoro
|
||||
- approved
|
||||
---
|
||||
|
||||
# Gate: Waybar Pomodoro Not Showing — Correctness Review
|
||||
|
||||
## Verdict
|
||||
- [decision] APPROVED — REVIEW_SCORE: 0 #gate #approved
|
||||
|
||||
## Scope
|
||||
- Reviewed `.config/waybar/config`
|
||||
- Reviewed `.config/waybar/scripts/pomodoro-preset.sh`
|
||||
- Cross-referenced plan `[[waybar-pomodoro-not-showing]]` (`memory://plans/waybar-pomodoro-not-showing`)
|
||||
- Confirmed prior critic guidance is reflected in current code
|
||||
|
||||
## Evidence checked
|
||||
- [evidence] `.config/waybar/config:136-139` now uses `$HOME/.local/bin/waybar-module-pomodoro` for `exec`, `on-click`, and `on-click-middle`, while preserving the existing preset script hook for right-click
|
||||
- [evidence] `.config/waybar/scripts/pomodoro-preset.sh:6-10` introduces `POMODORO_BIN="$HOME/.local/bin/waybar-module-pomodoro"` and replaces PATH-dependent lookup with an executable-file guard
|
||||
- [evidence] `.config/waybar/scripts/pomodoro-preset.sh:30-32` routes `set-work`, `set-short`, and `set-long` through the same explicit binary path
|
||||
- [evidence] Repository search found pomodoro binary references only in the expected changed lines, with no stale bare `waybar-module-pomodoro` invocations remaining in `.config/waybar/config` or `.config/waybar/scripts/pomodoro-preset.sh`
|
||||
- [evidence] Fresh verification supplied by lead/coder: `bash -n` on the script passed; `/home/alex/.local/bin/waybar-module-pomodoro --help` succeeded and confirmed required subcommands/options exist
|
||||
|
||||
## Findings
|
||||
- [observation] No correctness defects found in the reviewed change set
|
||||
- [observation] The implementation matches the approved minimal fix for launch-time PATH mismatch and updates all user-triggered pomodoro entry points called out in the plan pre-mortem
|
||||
|
||||
## Related regression checks
|
||||
- [check] `.config/waybar/config:136-139` — no stale bare binary references remain in `exec`, left-click toggle, right-click preset hook, or middle-click reset
|
||||
- [check] `.config/waybar/scripts/pomodoro-preset.sh:6-10,30-32` — helper now uses one consistent binary path for validation and all preset subcommands; no path drift found in changed lines
|
||||
|
||||
## Freshness notes
|
||||
- [finding] Prior critic guidance was confirmed, not contradicted: the old PATH-based guard was removed and replaced with an explicit executable-path check, matching the approved fix direction
|
||||
|
||||
## Relations
|
||||
- gates [[waybar-pomodoro-not-showing]]
|
||||
- related_to [[waybar-pomodoro-not-showing]]
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
title: gate-waybar-pomodoro-not-showing
|
||||
type: note
|
||||
permalink: dotfiles/gates/gate-waybar-pomodoro-not-showing
|
||||
tags:
|
||||
- gate
|
||||
- critic
|
||||
- waybar
|
||||
- pomodoro
|
||||
- approved
|
||||
---
|
||||
|
||||
# Critic Gate: Waybar Pomodoro Not Showing
|
||||
|
||||
## Verdict
|
||||
- [decision] APPROVED — plan is clear, correctly scoped, and adequately de-risked #gate #approved
|
||||
|
||||
## Rationale
|
||||
- [finding] Root cause (bare binary name in PATH-less Waybar launch environment) is the most likely explanation and is well-supported by explorer research
|
||||
- [finding] All 8 bare `waybar-module-pomodoro` references are confined to the two target files: `.config/waybar/config` (3 refs) and `.config/waybar/scripts/pomodoro-preset.sh` (5 refs) — no other source files reference this binary
|
||||
- [finding] Verification steps (bash -n, --help check) adequately gate against the alternative failure mode of a missing binary
|
||||
- [finding] Plan scope is correctly limited to pomodoro-only; no decomposition needed
|
||||
|
||||
## Assumption Challenges
|
||||
- [challenge] Binary path validity → mitigated by plan's --help verification step
|
||||
- [challenge] Completeness of reference coverage → confirmed all 8 references across both files; no others in repo
|
||||
- [challenge] JSONC $HOME expansion → confirmed Waybar does shell-expand $HOME in exec/on-click fields (existing config uses it on lines 94, 138)
|
||||
|
||||
## Coder Guidance
|
||||
- [recommendation] Update or remove the `command -v waybar-module-pomodoro` guard (line 7 of pomodoro-preset.sh) since it checks bare PATH and will fail in the same environment that caused the original bug
|
||||
- [recommendation] Consider using `$HOME/.local/bin/waybar-module-pomodoro` to match existing config style conventions (lines 94, 138 already use `$HOME`)
|
||||
|
||||
## Files Evaluated
|
||||
- `.config/waybar/config` (142 lines)
|
||||
- `.config/waybar/scripts/pomodoro-preset.sh` (33 lines)
|
||||
|
||||
## Relations
|
||||
- gates [[waybar-pomodoro-not-showing]]
|
||||
- related_to [[knowledge]]
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
title: gate-waybar-pomodoro-visibility-fix
|
||||
type: gate
|
||||
permalink: dotfiles/gates/gate-waybar-pomodoro-visibility-fix
|
||||
tags:
|
||||
- waybar
|
||||
- pomodoro
|
||||
- gate
|
||||
- pass
|
||||
---
|
||||
|
||||
# Gate: Waybar Pomodoro Visibility Fix
|
||||
|
||||
**Status:** PASS
|
||||
**Date:** 2026-03-12
|
||||
**Plan ref:** [[waybar-pomodoro-not-showing]]
|
||||
**Scope:** `.config/waybar/config`, `.config/waybar/scripts/pomodoro-preset.sh`
|
||||
|
||||
## Verdict Summary
|
||||
|
||||
The implementation correctly addresses the root cause (PATH mismatch between Hyprland/Waybar environment and interactive shell). All four invocation points for `waybar-module-pomodoro` are now explicit, and no residual bare-binary references remain. Both standard and adversarial checks pass.
|
||||
|
||||
## Standard Pass
|
||||
|
||||
### Acceptance Criteria Verification
|
||||
|
||||
| Criterion | Result |
|
||||
|---|---|
|
||||
| `custom/pomodoro` exec uses explicit path | ✅ Line 136: `$HOME/.local/bin/waybar-module-pomodoro --no-work-icons` |
|
||||
| on-click uses explicit path | ✅ Line 137: `$HOME/.local/bin/waybar-module-pomodoro toggle` |
|
||||
| on-click-middle uses explicit path | ✅ Line 139: `$HOME/.local/bin/waybar-module-pomodoro reset` |
|
||||
| on-click-right still delegates to preset script | ✅ Line 138 unchanged |
|
||||
| Preset script no longer uses PATH-dependent guard | ✅ `[[ ! -x "$POMODORO_BIN" ]]` replaces `command -v` |
|
||||
| Preset script routes all set-* calls through `$POMODORO_BIN` | ✅ Lines 30–32 |
|
||||
| Change is pomodoro-scoped only | ✅ No other modules touched |
|
||||
| Binary syntax check (`bash -n`) passes | ✅ (Lead evidence, exit 0) |
|
||||
| Binary exists and responds to `--help` | ✅ (Lead evidence, exit 0 with usage) |
|
||||
|
||||
### Pre-mortem Risk Tracking
|
||||
|
||||
| Risk | Status |
|
||||
|---|---|
|
||||
| Middle-click reset still using bare name | Resolved — line 139 uses explicit path |
|
||||
| Only one entry point updated | Resolved — all four updated |
|
||||
| Preset helper using `command -v` | Resolved — replaced with `[[ ! -x ... ]]` |
|
||||
| Binary path unstable across sessions | Not triggered — binary confirmed at path |
|
||||
|
||||
## Adversarial Pass
|
||||
|
||||
### Hypotheses
|
||||
|
||||
| # | Hypothesis | Design | Expected failure | Observed |
|
||||
|---|---|---|---|---|
|
||||
| H1 | Empty/corrupt STATE_FILE causes crash | State file exists but empty | `current` reads as `""`, falls to else-branch | Safe: defaults to B-preset (short cycle), no crash |
|
||||
| H2 | Binary missing/non-executable | Guard `[[ ! -x ]]` fires | Exit 1 with stderr | Guard correctly triggers, script exits cleanly |
|
||||
| H3 | `$HOME` unset in Waybar env | `$HOME/.local/bin/...` fails to expand | Module fails silently | Same risk applies to all other modules using `$HOME` (line 94: `custom/uptime`); no regression introduced |
|
||||
| H4 | `set -e` aborts mid-preset (daemon down) | First `set-work` fails → remaining calls skipped | Partial preset applied | Pre-existing behavior; not a regression from this change |
|
||||
| H5 | STATE_FILE lost on reboot (`/tmp`) | Preset reverts to A-cycle | Unexpected preset on first right-click post-reboot | Intentional design, not a regression |
|
||||
| H6 | No bare `pomodoro` left anywhere in config | Grep scan | Old reference found | Zero old references found — clean |
|
||||
|
||||
### Mutation Checks
|
||||
|
||||
| Mutation | Would current tests detect? |
|
||||
|---|---|
|
||||
| One of exec/on-click/on-click-middle reverted to bare name | Yes — structural grep confirms all three use explicit path |
|
||||
| `POMODORO_BIN` guard inverted (`-x` instead of `! -x`) | Yes — guard would skip missing-binary error |
|
||||
| `read -r current` without fallback | Caught — `|| current="A"` handles failure |
|
||||
| `set-work` but not `set-short`/`set-long` through `$POMODORO_BIN` | Yes — all three lines verified |
|
||||
|
||||
**MUTATION_ESCAPES: 0/4**
|
||||
|
||||
## Unverified Aspects (Residual Risk)
|
||||
|
||||
1. **Live Waybar rendering** — Cannot verify the module actually appears on the bar without a running Waybar session. The Lead noted this is impractical in the task context.
|
||||
2. **Binary behavior correctness** — `--help` confirms the binary exists and accepts the right subcommands, but actual timer JSON output format was not sampled. The `return-type: json` config assumes the binary outputs conforming JSON.
|
||||
3. **`$HOME` behavior under Waybar systemd unit** — Low risk (all other `$HOME`-using modules work), but not runtime-confirmed.
|
||||
|
||||
These residual risks are infrastructure-gated (no running Wayland/Waybar session available in this context), not implementation defects.
|
||||
|
||||
## Lesson Checks
|
||||
|
||||
- [confirmed] PATH mismatch is the failure mode for Waybar custom modules — explicit paths are the correct fix pattern.
|
||||
- [confirmed] `[[ ! -x path ]]` guard is the right check for script-invoked binary dependencies.
|
||||
- [not observed] Any silent failures from the old `command -v` approach (fix is in place, no regression).
|
||||
|
||||
## Relations
|
||||
- resolves [[waybar-pomodoro-not-showing]]
|
||||
@@ -1,194 +0,0 @@
|
||||
---
|
||||
title: knowledge
|
||||
type: note
|
||||
permalink: dotfiles/knowledge
|
||||
---
|
||||
|
||||
# Dotfiles Knowledge
|
||||
|
||||
## Project Purpose
|
||||
|
||||
Personal dotfiles for `alex` on a Linux/Wayland desktop. Managed as a bare or normal git repo in `~/dotfiles/`. Covers the full desktop stack: shell, editor, compositor, terminal, status bar, notifications, and AI tooling.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
~/dotfiles/
|
||||
├── .bash_profile / .bashrc / .zshrc / .profile # Legacy/fallback shell configs
|
||||
├── .gitconfig # Git global config (gh credential helper)
|
||||
├── .ssh/ # SSH keys and known_hosts
|
||||
└── .config/
|
||||
├── dunst/ # Notification daemon
|
||||
├── fish/ # Primary shell
|
||||
├── hypr/ # Wayland compositor + screen lock
|
||||
├── kitty/ # Terminal emulator
|
||||
├── nvim/ # Editor (Neovim)
|
||||
├── opencode/ # AI coding assistant (complex subsystem)
|
||||
├── rofi/ # App launcher
|
||||
├── waybar/ # Status bar
|
||||
└── zathura/ # PDF viewer
|
||||
```
|
||||
|
||||
## Desktop Stack
|
||||
|
||||
| Layer | Tool | Notes |
|
||||
|---|---|---|
|
||||
| Compositor | Hyprland | Wayland, tiling, dwindle layout |
|
||||
| Terminal | Kitty | GPU-accelerated |
|
||||
| Shell | Fish | Primary shell |
|
||||
| Editor | Neovim | lazy.nvim plugin manager |
|
||||
| Status bar | Waybar | Bottom layer, top position |
|
||||
| App launcher | Rofi | `rofi -show drun` |
|
||||
| Notifications | Dunst | |
|
||||
| Screen lock | Hyprlock | `SUPER+C` |
|
||||
| Screenshots | Hyprshot | Print=region, Shift+Print=output |
|
||||
| File manager | Thunar | |
|
||||
| Browser | Brave | `SUPER+B` / `SUPER+SHIFT+B` incognito |
|
||||
| Email | Thunderbird | `SUPER+M` |
|
||||
| VPN | ProtonVPN | Auto-started via hyprland exec-once |
|
||||
| Mail bridge | Protonmail Bridge | Auto-started `--no-window` |
|
||||
| PDF viewer | Zathura | |
|
||||
|
||||
## Hyprland Configuration
|
||||
|
||||
File: `.config/hypr/hyprland.conf`
|
||||
|
||||
- **mainMod:** SUPER (`caps:super` — Caps Lock acts as Super)
|
||||
- **Layout:** dwindle (no gaps, border_size=1, rounding=10)
|
||||
- **Keyboard:** `us, br` layouts; `alts_toggle` (Alt+Shift switches layout)
|
||||
- **Animations:** disabled
|
||||
- **Autostart:** waybar, nm-applet, protonmail-bridge --no-window, protonvpn-app
|
||||
|
||||
### Key Bindings
|
||||
```
|
||||
SUPER+Return kitty
|
||||
SUPER+Q kill window
|
||||
SUPER+E thunar
|
||||
SUPER+Space rofi
|
||||
SUPER+F fullscreen
|
||||
SUPER+B/Shift+B brave / brave --incognito
|
||||
SUPER+M thunderbird
|
||||
SUPER+V protonvpn-app
|
||||
SUPER+C hyprlock
|
||||
Print hyprshot -m region
|
||||
Shift+Print hyprshot -m output
|
||||
SUPER+h/j/k/l move focus (vim dirs)
|
||||
SUPER+SHIFT+h/j/k/l move window
|
||||
SUPER+1-9/0 switch workspace
|
||||
SUPER+SHIFT+1-9/0 move to workspace
|
||||
SUPER+S scratchpad toggle
|
||||
SUPER+R resize submap (h/j/k/l = 30px steps)
|
||||
```
|
||||
|
||||
## Theme: Catppuccin Mocha
|
||||
|
||||
Applied uniformly across all tools:
|
||||
|
||||
| Tool | Config file |
|
||||
|---|---|
|
||||
| Hyprland borders | `hyprland.conf` (lavender→mauve active, surface0 inactive) |
|
||||
| Kitty | `kitty/kitty.conf` (full 16-color palette) |
|
||||
| Neovim | `nvim/lua/plugins/colorscheme.lua` (catppuccin/nvim, flavour=mocha) |
|
||||
| Fish | `fish/config.fish` (Catppuccin Mocha theme via fish_config) |
|
||||
| Fish plugin | `fish/fish_plugins` (catppuccin/fish installed via fisher) |
|
||||
|
||||
Key colors: bg=#1e1e2e, fg=#cdd6f4, lavender=#b4befe, mauve=#cba6f7, crust=#11111b, surface0=#313244
|
||||
|
||||
## Shell: Fish
|
||||
|
||||
Files: `.config/fish/`
|
||||
|
||||
- **Plugin manager:** fisher (jorgebucaran/fisher)
|
||||
- **Plugins:** catppuccin/fish
|
||||
- **Theme:** Catppuccin Mocha (set in config.fish)
|
||||
|
||||
### Functions / Aliases
|
||||
| Function | Expands to | Purpose |
|
||||
|---|---|---|
|
||||
| `c` | `opencode` | Launch OpenCode AI assistant |
|
||||
| `cc` | `opencode --continue` | Continue last OpenCode session |
|
||||
| `co` | `copilot` | GitHub Copilot CLI |
|
||||
| `n` | `nvim` | Neovim |
|
||||
|
||||
## Editor: Neovim
|
||||
|
||||
Files: `.config/nvim/`
|
||||
|
||||
- **Entry:** `init.lua` — sets `mapleader=<Space>`, bootstraps lazy.nvim
|
||||
- **Plugins:** all in `lua/plugins/`, auto-loaded via `{ import = 'plugins' }`
|
||||
- **Options:** `number=true`, `relativenumber=true`, `wrap=false`
|
||||
|
||||
### Plugin List
|
||||
|
||||
| Plugin | File | Purpose |
|
||||
|---|---|---|
|
||||
| catppuccin/nvim | colorscheme.lua | Mocha colorscheme, priority=1000 |
|
||||
| nvim-cmp | cmp.lua | Completion engine |
|
||||
| stevearc/conform.nvim | conform.lua | Format on save |
|
||||
| folke/lazydev | lazydev.lua | Neovim Lua dev assistance |
|
||||
| neovim/nvim-lspconfig | lspconfig.lua | LSP client config |
|
||||
| L3MON4D3/LuaSnip | luasnip.lua | Snippet engine |
|
||||
| williamboman/mason.nvim | mason.lua | LSP/tool installer UI |
|
||||
| mason-lspconfig.nvim | mason-lspconfig.lua | Mason+LSP bridge, auto-install |
|
||||
| jay-babu/mason-null-ls | mason-null-ls.lua | Mason+null-ls bridge |
|
||||
| nvimtools/none-ls | none-ls.lua | LSP diagnostics from external tools |
|
||||
| opencode-ai/nvim-opencode | opencode.lua | OpenCode integration |
|
||||
| nvim-telescope/telescope | telescope.lua | Fuzzy finder |
|
||||
| nvim-treesitter | treesitter.lua | Syntax parsing |
|
||||
|
||||
### Keymaps
|
||||
```
|
||||
<leader>e vim.cmd.Ex (file explorer)
|
||||
<leader>ww save file
|
||||
<leader>ff Telescope find_files
|
||||
<leader>fg Telescope live_grep
|
||||
<leader>fb Telescope buffers
|
||||
<leader>fh Telescope help_tags
|
||||
<leader>f Conform format (async)
|
||||
```
|
||||
|
||||
### LSP / Formatting
|
||||
- **mason-lspconfig:** `automatic_installation=true`, `automatic_enable=true`; injects `cmp_nvim_lsp` capabilities to all LSP servers globally
|
||||
- **conform formatters by filetype:**
|
||||
- lua → stylua
|
||||
- js/ts/jsx/tsx/json/yaml/md → prettier
|
||||
- python → ruff_format
|
||||
- go → gofmt
|
||||
- **format_on_save:** timeout_ms=500, lsp_fallback=true
|
||||
- **Diagnostics:** virtual_text, signs, underline; float border=rounded, source=always
|
||||
|
||||
## Status Bar: Waybar
|
||||
|
||||
File: `.config/waybar/config` + `style.css` + `scripts/pomodoro-preset.sh`
|
||||
|
||||
- Layer: bottom, position: top, spacing: 6
|
||||
- **Left:** backlight, wireplumber, custom/pomodoro
|
||||
- **Center:** clock (`{:%H:%M - %a,%d}`, interval=1)
|
||||
- **Right:** tray, bluetooth, temperature, cpu, memory, battery
|
||||
- **Pomodoro:** external `waybar-module-pomodoro` binary; left=toggle, right=preset script, middle=reset
|
||||
- **Custom/music:** playerctl metadata polling (interval=5)
|
||||
- CPU click: `ghostty -e htop` (note: Ghostty not Kitty here)
|
||||
- Bluetooth click: blueman-manager
|
||||
|
||||
## OpenCode AI System
|
||||
|
||||
Files: `.config/opencode/`
|
||||
|
||||
The most complex subsystem. Full multi-agent AI coding assistant configuration.
|
||||
See [OpenCode Architecture](research/opencode-architecture.md) for detailed breakdown.
|
||||
|
||||
**Quick reference:**
|
||||
- Config: `opencode.jsonc` (default_agent=lead, plugin=@tarquinen/opencode-dcp)
|
||||
- Agents: `agents/*.md` (10 agents: lead, coder, reviewer, tester, explorer, researcher, librarian, critic, sme, designer)
|
||||
- Memory: `agents/.memory/` — persistent knowledge for the AI system itself
|
||||
- Instruction files: `.github/copilot-instructions.md` (canonical), `CLAUDE.md` + `.cursorrules` (symlinks); `AGENTS.md` is NOT a symlink (global OpenCode config)
|
||||
- MCP servers: context7 (remote docs), gh_grep (remote code search), playwright (local Chromium)
|
||||
- Skills: `skills/doc-coverage/`, `skills/git-workflow/`, `skills/work-decomposition/`
|
||||
|
||||
## Git Configuration
|
||||
|
||||
File: `.gitconfig`
|
||||
|
||||
- `user.name=alex`, `user.email=misc@wiesner.com.br`
|
||||
- `init.defaultBranch=main`
|
||||
- Credential helper: `!/usr/bin/gh auth git-credential` (GitHub + Gist)
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
title: fix-github-push-large-binary
|
||||
type: note
|
||||
permalink: dotfiles/plans/fix-github-push-large-binary
|
||||
tags:
|
||||
- git
|
||||
- risk
|
||||
- tooling
|
||||
---
|
||||
|
||||
# Fix GitHub Push Rejection for Large Binary
|
||||
|
||||
**Goal:** Remove the oversized `.local/bin/codebase-memory-mcp` blob from local-only history so `main` can push to GitHub successfully.
|
||||
|
||||
## Root cause
|
||||
- [decision] Commit `969140e` added `.local/bin/codebase-memory-mcp` at ~143.51 MB.
|
||||
- [decision] Commit `2643a0a` later removed the file, but the blob still exists in local history, so GitHub rejects the push.
|
||||
|
||||
## Tasks
|
||||
- [x] Rewrite the 4 local-only commits by soft-resetting to `origin/main`.
|
||||
- [x] Recreate a clean commit set without the large binary in history.
|
||||
- [x] Verify no large-file path remains in reachable history.
|
||||
- [x] Retry `git push` and confirm success.
|
||||
## Acceptance criteria
|
||||
- No reachable commit from `HEAD` contains `.local/bin/codebase-memory-mcp`.
|
||||
- `git push` to `origin/main` succeeds without GitHub large-file rejection.
|
||||
|
||||
## Pre-mortem
|
||||
- Most likely failure: recommit accidentally stages a regenerated large binary again.
|
||||
- Fragile assumption: current worktree is clean except for the 4 local-only commits.
|
||||
- Red flag requiring redesign: if the large blob exists in earlier shared history, a broader history rewrite would be required.
|
||||
- Easy-to-miss regression: leaving `.local/bin/codebase-memory-mcp` unignored so it gets re-added later.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: luks-sddm-kwallet-login-integration
|
||||
type: note
|
||||
permalink: dotfiles/plans/luks-sddm-kwallet-login-integration
|
||||
tags:
|
||||
- auth
|
||||
- sddm
|
||||
- kwallet
|
||||
- luks
|
||||
---
|
||||
|
||||
# LUKS / SDDM / KWallet login integration
|
||||
|
||||
## Goal
|
||||
Configure the system so login feels unified across LUKS boot unlock, SDDM, and KWallet.
|
||||
|
||||
## Clarified scope
|
||||
- [decision] User selected **Password login** instead of true SDDM autologin because password login preserves KWallet PAM unlock.
|
||||
- [decision] User selected **Just document commands** instead of expanding repo scope to manage `/etc` files directly.
|
||||
- [decision] Deliverable is repo documentation with exact manual system commands/edits; no tracked `/etc` files will be added in this change.
|
||||
|
||||
## Discovery
|
||||
- Dotfiles repo contains user-space config only; system auth files live outside the repo.
|
||||
- Current system already references `pam_kwallet5.so` in `/etc/pam.d/sddm` and `/etc/pam.d/sddm-autologin`, but the module is missing and silently skipped.
|
||||
- `kwallet-pam` is available in Arch repos and should provide the current PAM module for KWallet 6.
|
||||
- LUKS unlock and SDDM login are independent phases; there is no direct password handoff from initramfs to SDDM.
|
||||
- True SDDM autologin conflicts with password-based KWallet unlock because no login password is available to PAM during autologin.
|
||||
|
||||
## Tasks
|
||||
- [ ] Write documentation for package install and PAM edits needed for SDDM/KWallet integration
|
||||
- [ ] Document wallet initialization and verification steps
|
||||
- [ ] Review documentation for correctness and scope alignment
|
||||
- [ ] Validate documented commands against current system state where possible
|
||||
- [ ] Check documentation coverage/placement in repo
|
||||
|
||||
## Acceptance criteria
|
||||
- README documents the exact package install step and the exact PAM module substitutions needed.
|
||||
- README explicitly states that password login is the chosen model and true SDDM autologin is not part of this setup.
|
||||
- README includes KWallet initialization and verification steps suitable for this Arch + Hyprland + SDDM setup.
|
||||
- Reviewer/tester/librarian passes are recorded before completion.
|
||||
|
||||
## Workstream
|
||||
- Single workstream in the main repo working tree.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
title: tmux-shift-enter-fix
|
||||
type: plan
|
||||
permalink: dotfiles/plans/tmux-shift-enter-fix
|
||||
tags:
|
||||
- tmux
|
||||
- terminal
|
||||
- keybindings
|
||||
---
|
||||
|
||||
# tmux Shift+Enter Fix
|
||||
|
||||
## Goal
|
||||
Inside tmux, pressing Shift+Enter should insert a literal newline instead of submitting the command line.
|
||||
|
||||
## Decision
|
||||
- [decision] Preserve tmux extended-keys support and apply the smallest possible fix at the tmux layer.
|
||||
- [decision] Use `bind-key -n S-Enter send-keys "\n"` in `~/.tmux.conf` instead of disabling `extended-keys` or adding shell-specific bindings.
|
||||
|
||||
## Tasks
|
||||
- [ ] Add a tmux root-table binding for `S-Enter`
|
||||
- [ ] Verify tmux loads the config and exposes the expected binding
|
||||
- [ ] Check documentation coverage for this config tweak
|
||||
|
||||
## Acceptance criteria
|
||||
- `~/.tmux.conf` contains an explicit `S-Enter` binding that sends a newline.
|
||||
- Existing `extended-keys` settings remain enabled.
|
||||
- After sourcing the config, tmux shows the `S-Enter` root binding.
|
||||
|
||||
## Workstream
|
||||
- Single workstream in the main repo working tree.
|
||||
|
||||
## Findings tracker
|
||||
- None.
|
||||
|
||||
## Relations
|
||||
- related_to [[knowledge]]
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title: waybar-pomodoro-not-showing
|
||||
type: note
|
||||
permalink: dotfiles/plans/waybar-pomodoro-not-showing
|
||||
tags:
|
||||
- waybar
|
||||
- pomodoro
|
||||
- plan
|
||||
---
|
||||
|
||||
# Waybar Pomodoro Not Showing Implementation Plan
|
||||
|
||||
> For implementation: use `subagent-driven-development` when subagents are available; otherwise use `executing-plans`.
|
||||
|
||||
**Goal:** Restore the Waybar pomodoro module so it reliably appears on the bar.
|
||||
|
||||
**Architecture:** The `custom/pomodoro` Waybar module depends on an external `waybar-module-pomodoro` binary and a preset helper script. The most likely failure mode is launch-time PATH mismatch between Hyprland/Waybar and the interactive shell, so the minimal fix is to make module and helper invocations explicit and independent of shell PATH.
|
||||
|
||||
**Tech Stack:** JSONC-style Waybar config, shell script, Hyprland/Wayland desktop environment.
|
||||
|
||||
## File map
|
||||
- Modify `.config/waybar/config` — make the pomodoro module invoke the binary explicitly.
|
||||
- Modify `.config/waybar/scripts/pomodoro-preset.sh` — make the preset helper use the same explicit binary path.
|
||||
|
||||
## Task 1: Fix pomodoro command wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `.config/waybar/config`
|
||||
- Modify: `.config/waybar/scripts/pomodoro-preset.sh`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `custom/pomodoro` no longer depends on login-session PATH to find `waybar-module-pomodoro`.
|
||||
- Right-click preset switching still works.
|
||||
- The change is minimal and limited to pomodoro-related wiring.
|
||||
|
||||
**Non-goals:**
|
||||
- Do not refactor unrelated Waybar modules.
|
||||
- Do not add system-wide installation steps or package management changes.
|
||||
|
||||
**Verification:**
|
||||
- Run `bash -n /home/alex/dotfiles/.config/waybar/scripts/pomodoro-preset.sh` successfully.
|
||||
- Run `/home/alex/.local/bin/waybar-module-pomodoro --help` successfully to verify the explicit binary path exists.
|
||||
- Inspect resulting config references to confirm they use the explicit path consistently.
|
||||
|
||||
**Likely regression surfaces:**
|
||||
- Right-click preset command path drift.
|
||||
- Middle-click reset command still using the old bare binary name.
|
||||
|
||||
## Pre-mortem
|
||||
- Most likely failure: only one of the pomodoro entry points is updated, leaving click actions broken.
|
||||
- Fragile assumption: the binary remains at `/home/alex/.local/bin/waybar-module-pomodoro`.
|
||||
- Redesign trigger: if the binary path is unstable across sessions or machines, prefer a Hyprland PATH fix instead.
|
||||
- Easy-to-miss regression: preset helper still using `command -v` and failing under Waybar's environment.
|
||||
|
||||
## Execution notes
|
||||
- Updated `.config/waybar/config` `custom/pomodoro` wiring to use `$HOME/.local/bin/waybar-module-pomodoro` for `exec`, `on-click`, and `on-click-middle`.
|
||||
- Updated `.config/waybar/scripts/pomodoro-preset.sh` to remove PATH reliance by introducing `POMODORO_BIN="$HOME/.local/bin/waybar-module-pomodoro"`, replacing the `command -v` guard with `[[ ! -x "$POMODORO_BIN" ]]`, and routing all `set-work`/`set-short`/`set-long` calls through `"$POMODORO_BIN"`.
|
||||
- Scope remained pomodoro-only; no unrelated Waybar modules or scripts were changed.
|
||||
|
||||
## Outcomes
|
||||
- `bash -n /home/alex/dotfiles/.config/waybar/scripts/pomodoro-preset.sh` passed (no syntax errors).
|
||||
- `/home/alex/.local/bin/waybar-module-pomodoro --help` succeeded and printed usage, confirming explicit-path binary availability.
|
||||
- No practical automated test harness exists here for full Waybar runtime rendering in this task context; verification used the minimal command-level checks above.
|
||||
@@ -1,156 +0,0 @@
|
||||
---
|
||||
title: LUKS SDDM KWallet discovery
|
||||
type: note
|
||||
permalink: dotfiles/research/luks-sddm-kwallet-discovery
|
||||
tags:
|
||||
- sddm
|
||||
- kwallet
|
||||
- luks
|
||||
- pam
|
||||
- arch
|
||||
- hyprland
|
||||
- discovery
|
||||
---
|
||||
|
||||
# LUKS SDDM KWallet discovery
|
||||
|
||||
## System context
|
||||
|
||||
- [fact] Distribution: **Arch Linux** (rolling), NOT NixOS — all configuration is manual files or pacman packages
|
||||
- [fact] Desktop environment: **Hyprland** (Wayland compositor), NOT KDE Plasma
|
||||
- [fact] Display manager: **SDDM** (installed, PAM files present)
|
||||
- [fact] Lock screen: **hyprlock** (Hyprland native, separate from SDDM)
|
||||
- [fact] Default session: `Session=hyprland` (from `~/.dmrc`)
|
||||
- [fact] Boot: **systemd-boot** (`/boot/loader/`), kernel cmdline has `cryptdevice=PARTUUID=1a555ca6-ea08-4128-80cf-fe213664030e:root root=/dev/mapper/root`
|
||||
- [fact] LUKS encryption: **LUKS-encrypted root** (`encrypt` hook in mkinitcpio), initramfs uses classic `encrypt` hook (not `sd-encrypt`)
|
||||
- [fact] Filesystem: **btrfs** with `@` subvolume
|
||||
|
||||
## Current config files inventory
|
||||
|
||||
### Dotfiles repo (`/home/alex/dotfiles`) — user scope only
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `.config/hypr/hyprland.conf` | Hyprland WM config; autostart: waybar + nm-applet; lock bind: `hyprlock` |
|
||||
| `.config/hypr/hyprlock.conf` | hyprlock PAM-auth lock screen; Catppuccin Mocha theme |
|
||||
| `.config/hypr/monitors.conf` | Monitor config |
|
||||
| `.config/hypr/workspaces.conf` | Workspace rules |
|
||||
| `.dmrc` | `Session=hyprland` |
|
||||
| `.gitconfig` | Git identity only |
|
||||
| `.config/fish/`, `.config/nvim/`, etc. | Shell and editor config, not relevant |
|
||||
|
||||
**The dotfiles repo does NOT contain any SDDM, PAM, mkinitcpio, bootloader, or KWallet configuration.** All of those are system-level files managed outside this repo.
|
||||
|
||||
### System-level files (outside dotfiles repo)
|
||||
|
||||
| File | Status | Key contents |
|
||||
|---|---|---|
|
||||
| `/etc/mkinitcpio.conf` | Present | HOOKS include `encrypt` (classic LUKS hook) |
|
||||
| `/boot/loader/entries/2026-03-11_16-58-39_linux.conf` | Present | `cryptdevice=PARTUUID=...` kernel param, LUKS root |
|
||||
| `/boot/loader/loader.conf` | Present | `timeout 3`, no autologin |
|
||||
| `/etc/pam.d/sddm` | Present | Includes `pam_kwallet5.so` (broken — see risks) |
|
||||
| `/etc/pam.d/sddm-autologin` | Present | Includes `pam_kwallet5.so` (broken — see risks) |
|
||||
| `/etc/pam.d/sddm-greeter` | Present | Standard greeter-only config |
|
||||
| `/etc/pam.d/system-auth` | Present | Standard pam_unix, pam_faillock |
|
||||
| `/etc/pam.d/system-login` | Present | Standard, includes pam_u2f.so at top |
|
||||
| `/etc/pam.d/hyprlock` | Present | `auth include login` — delegates to login chain |
|
||||
| `/usr/lib/sddm/sddm.conf.d/default.conf` | Present | No autologin configured; `DisplayServer=x11` (NOT wayland) |
|
||||
| `/etc/sddm.conf.d/` | **MISSING** — no local overrides exist | No user customization of SDDM |
|
||||
| `/etc/sddm.conf` | **MISSING** | No top-level SDDM config file |
|
||||
|
||||
## KDE/KWallet installation state
|
||||
|
||||
- [fact] `kwalletd6` binary is installed: `/usr/bin/kwalletd6`
|
||||
- [fact] `kwallet-query` is installed: `/usr/bin/kwallet-query`
|
||||
- [fact] **`pam_kwallet5.so` does NOT exist** in `/usr/lib/security/` or `/lib/security/`
|
||||
- [fact] **`pam_kwallet6.so` does NOT exist** either — `kwallet-pam` package is NOT installed
|
||||
- [fact] `pam_gnome_keyring.so` IS installed at `/usr/lib/security/`
|
||||
- [fact] No `~/.config/kwalletrc` exists — KWallet has never been initialized for this user
|
||||
- [fact] No `~/.local/share/kwalletd/` directory — no wallet database exists
|
||||
|
||||
## Current PAM configuration for SDDM (detailed)
|
||||
|
||||
### `/etc/pam.d/sddm` (normal login)
|
||||
```
|
||||
auth sufficient pam_u2f.so cue
|
||||
auth include system-login
|
||||
-auth optional pam_gnome_keyring.so
|
||||
-auth optional pam_kwallet5.so ← BROKEN: module not installed
|
||||
|
||||
session optional pam_keyinit.so force revoke
|
||||
session include system-login
|
||||
-session optional pam_gnome_keyring.so auto_start
|
||||
-session optional pam_kwallet5.so auto_start ← BROKEN
|
||||
```
|
||||
|
||||
### `/etc/pam.d/sddm-autologin`
|
||||
```
|
||||
auth sufficient pam_u2f.so cue
|
||||
auth required pam_permit.so
|
||||
-auth optional pam_kwallet5.so ← BROKEN
|
||||
session include system-local-login
|
||||
-session optional pam_kwallet5.so auto_start ← BROKEN
|
||||
```
|
||||
|
||||
Note: The `-` prefix means these lines are silently skipped if the module is missing — not causing errors, but not functioning.
|
||||
|
||||
## SDDM autologin configuration state
|
||||
|
||||
- [fact] SDDM autologin is **NOT configured** — `User=` and `Session=` are empty in default.conf
|
||||
- [fact] SDDM `DisplayServer=x11` in default.conf — **no wayland greeter configured**
|
||||
- [fact] No `/etc/sddm.conf.d/` drop-in directory exists
|
||||
|
||||
## Dependency chain for LUKS → SDDM → KWallet integration
|
||||
|
||||
### Boot-time LUKS (currently working)
|
||||
```
|
||||
systemd-boot → kernel cryptdevice= param → initramfs encrypt hook → LUKS passphrase prompt → root mounted
|
||||
```
|
||||
|
||||
### Login-time (currently: manual SDDM login, no KWallet auto-open)
|
||||
```
|
||||
SDDM greeter → user types password → PAM sddm → pam_unix validates → session started
|
||||
→ pam_kwallet5.so would unlock wallet (BROKEN: module missing)
|
||||
```
|
||||
|
||||
### Target state (proposed)
|
||||
```
|
||||
Boot: LUKS passphrase entered
|
||||
→ system up → SDDM greeter shown
|
||||
→ Option A (autologin): SDDM skips password → session starts → KWallet opened with stored key
|
||||
→ Option B (PAM reuse): SDDM password == user password == KWallet password → pam_kwallet6 unlocks wallet on login
|
||||
```
|
||||
|
||||
## Likely edit points
|
||||
|
||||
### To fix KWallet auto-open via PAM (Option B — recommended)
|
||||
1. **Install `kwallet-pam` package** (AUR: `kwallet-pam` provides `pam_kwallet6.so`) — PREREQUISITE
|
||||
2. **`/etc/pam.d/sddm`** — replace `pam_kwallet5.so` references with `pam_kwallet6.so` in auth and session stacks
|
||||
3. **`/etc/pam.d/sddm-autologin`** — same replacement if autologin is also desired
|
||||
4. **`~/.config/kwalletrc`** — create/configure wallet to use blowfish or GPG encryption; set wallet name
|
||||
5. **Initialize wallet** — run `kwalletd6` or use `kwallet-query` to create the default wallet with the user's login password as the unlock password
|
||||
|
||||
### To configure SDDM for Wayland session (currently X11 default)
|
||||
6. **`/etc/sddm.conf.d/hyprland.conf`** (new file) — set `DisplayServer=wayland` or leave X11 and use Wayland session via `wayland-session` script
|
||||
|
||||
### To configure SDDM autologin (Option A)
|
||||
7. **`/etc/sddm.conf.d/autologin.conf`** (new file) — set `User=alex`, `Session=hyprland`
|
||||
|
||||
### To track these system files in the dotfiles repo
|
||||
8. Add symlinks or a deploy script — system PAM files are outside the current dotfiles scope
|
||||
|
||||
## Risks and ambiguities
|
||||
|
||||
- [risk] **`pam_kwallet5.so` vs `pam_kwallet6.so` mismatch**: PAM files reference kwallet5 module; installed binary is kwalletd6. The `kwallet-pam` package for KF6 provides `pam_kwallet6.so` — this must be installed from AUR or a compatible repo.
|
||||
- [risk] **No KDE Plasma installed**: The system uses Hyprland, not Plasma. KWallet works standalone, but Plasma's system tray integration for wallet prompts won't be present. Apps must use the KWallet D-Bus API directly.
|
||||
- [risk] **SDDM running X11 compositor by default**: The `default.conf` has `DisplayServer=x11`, but the user session is Hyprland (Wayland). SDDM itself can still launch Wayland sessions from an X11 greeter. This works but is a mismatch worth documenting.
|
||||
- [risk] **autologin + KWallet security trade-off**: If autologin is used (Option A), KWallet cannot be unlocked by the user password (there is none at login). The wallet would need to be set to "no password" (plaintext) or use a keyfile — both reduce security.
|
||||
- [risk] **pam_u2f.so at top of system-login and sddm**: U2F is configured as `sufficient` — meaning a hardware key can bypass password entirely. This could bypass KWallet unlock if the wallet password differs from the user password.
|
||||
- [risk] **hyprlock uses `auth include login`**: The lock screen delegates to the `login` PAM chain, which does NOT include kwallet PAM modules. Unlocking hyprlock will NOT re-open the wallet.
|
||||
- [risk] **Dotfiles repo scope boundary**: `/etc/pam.d/`, `/etc/sddm.conf.d/`, `/etc/mkinitcpio.conf`, and `/boot/loader/` are all outside the dotfiles repo. These are system files. Either the dotfiles repo needs to expand its scope (with a deploy script), or these changes must be managed separately.
|
||||
- [risk] **mkinitcpio uses classic `encrypt` hook, not `sd-encrypt`**: The `sd-encrypt` (systemd) hook supports TPM2/FIDO2-bound LUKS keys for automatic unlock; the classic `encrypt` hook does not. If the goal involves TPM2-bound auto-unlock (true single-passphrase boot), migration to `sd-encrypt` would be required.
|
||||
- [ambiguity] **"SDDM login" with LUKS**: LUKS unlock happens at boot (initramfs), before SDDM. There is no mechanism for SDDM to "reuse" the LUKS passphrase directly. The integration point is: user types the same password at SDDM → PAM propagates it to `pam_kwallet6` → wallet unlocked. The LUKS and SDDM passwords are independent unless deliberately set to the same value.
|
||||
|
||||
## Relations
|
||||
- related_to [[Hyprland config]]
|
||||
- related_to [[PAM configuration]]
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title: LUKS SDDM KWallet documentation targets
|
||||
type: note
|
||||
permalink: dotfiles/research/luks-sddm-kwallet-documentation-targets
|
||||
tags:
|
||||
- sddm
|
||||
- kwallet
|
||||
- luks
|
||||
- pam
|
||||
- documentation
|
||||
- edit-points
|
||||
---
|
||||
|
||||
# LUKS SDDM KWallet documentation targets
|
||||
|
||||
## Summary
|
||||
|
||||
User decision: **document exact commands only** (not manage `/etc` files in the repo). This means the deliverable is a new documentation file in the dotfiles repo, not new symlinks or deploy scripts.
|
||||
|
||||
## Repo documentation conventions found
|
||||
|
||||
- [fact] **No README.md, SETUP.md, INSTALL.md, or docs/ directory exists** — the dotfiles repo has no human-facing setup documentation at all
|
||||
- [fact] The only markdown files tracked in git are: `.memory/decisions.md`, `.memory/knowledge.md`, `.memory/research/opencode-architecture.md` — all are basic-memory agent-facing notes, not user-facing docs
|
||||
- [fact] `.config/opencode/AGENTS.md` is the OpenCode agent instruction file (global AI config) — NOT a per-feature setup doc
|
||||
- [convention] There is no established convention for "machine setup" documentation in this repo — **any new docs file will establish the pattern**
|
||||
|
||||
## Best file location for command documentation
|
||||
|
||||
### Option A (Recommended): `README.md` at repo root
|
||||
- **Path:** `/home/alex/dotfiles/README.md`
|
||||
- **Rationale:** Establishes the first user-facing doc for the repo; natural home for setup and system integration notes; visible on any git host
|
||||
- **Section to add:** `## System Setup: KWallet + SDDM PAM integration` with step-by-step commands
|
||||
|
||||
### Option B: `.memory/plans/luks-sddm-kwallet-login-integration.md` (append)
|
||||
- **Path:** `/home/alex/dotfiles/.memory/plans/luks-sddm-kwallet-login-integration.md`
|
||||
- **Rationale:** Already tracks this feature; append a `## Exact commands` section
|
||||
- **Downside:** `.memory/` files are agent-facing, not user-facing; commands buried in plan notes are harder to find later
|
||||
|
||||
### Option C: New dedicated file `SETUP-auth.md` or `docs/auth-setup.md`
|
||||
- **Path:** `/home/alex/dotfiles/SETUP-auth.md`
|
||||
- **Rationale:** Keeps system-setup docs separate from repo README
|
||||
- **Downside:** Fragments documentation without an established convention
|
||||
|
||||
## What the documentation must cover (per plan + discovery)
|
||||
|
||||
Commands for:
|
||||
1. `pacman -S kwallet-pam` OR AUR install of `kwallet-pam` (provides `pam_kwallet6.so`)
|
||||
2. Edit `/etc/pam.d/sddm` — replace `pam_kwallet5.so` with `pam_kwallet6.so` (auth + session lines)
|
||||
3. Edit `/etc/pam.d/sddm-autologin` — same replacement (if needed)
|
||||
4. Create `/etc/sddm.conf.d/` directory if missing
|
||||
5. Initialize KWallet — `kwalletd6` first-run or `kwallet-query` commands
|
||||
6. Verify: `systemctl restart sddm` and login test
|
||||
|
||||
## Risks relevant to documentation
|
||||
|
||||
- [risk] `kwallet-pam` for KF6 may be AUR-only on Arch — exact package name needs verification before documenting
|
||||
- [risk] `/etc/pam.d/` edits require root; if documented as copy-paste commands, must be prefixed with `sudo`
|
||||
- [risk] SDDM autologin is NOT configured and should NOT be added — the password-login model was chosen; docs must not inadvertently suggest autologin setup
|
||||
- [risk] A new `README.md` will be the first user-facing documentation and will set precedent — scope it carefully to avoid bloat
|
||||
|
||||
## Relations
|
||||
- related_to [[LUKS SDDM KWallet discovery]]
|
||||
- related_to [[luks-sddm-kwallet-login-integration]]
|
||||
@@ -1,264 +0,0 @@
|
||||
---
|
||||
title: SDDM KWallet PAM Setup for Hyprland
|
||||
type: note
|
||||
permalink: dotfiles/research/sddm-kwallet-pam-setup-for-hyprland
|
||||
tags:
|
||||
- sddm
|
||||
- kwallet
|
||||
- pam
|
||||
- hyprland
|
||||
- arch
|
||||
- research
|
||||
- authoritative
|
||||
---
|
||||
|
||||
# SDDM KWallet PAM Setup for Hyprland
|
||||
|
||||
## Summary
|
||||
|
||||
Complete, source-verified setup for automatic KWallet unlock on SDDM password login, for a non-Plasma (Hyprland) Arch Linux system.
|
||||
|
||||
## Freshness
|
||||
- confidence: high
|
||||
- last_validated: 2026-03-11
|
||||
- volatility: low (KDE Plasma 6 PAM module is stable; Arch Wiki last edited 2026-03-10)
|
||||
- review_after_days: 90
|
||||
- validation_count: 1
|
||||
- contradiction_count: 0
|
||||
|
||||
## Sources consulted
|
||||
- [source] Arch Wiki "KDE Wallet" — https://wiki.archlinux.org/title/KDE_Wallet (last edited 2026-03-10)
|
||||
- [source] Arch Wiki "SDDM" — https://wiki.archlinux.org/title/SDDM (last edited 2026-03-04)
|
||||
- [source] Arch package database `kwallet-pam` 6.6.2-1 file listing — https://archlinux.org/packages/extra/x86_64/kwallet-pam/files/
|
||||
- [source] Arch package database `kwallet` 6.23.0-1 file listing — https://archlinux.org/packages/extra/x86_64/kwallet/files/
|
||||
- [source] Real-world Hyprland dotfiles from GitHub (wayblueorg/wayblue, AhmedAmrNabil/nix-config)
|
||||
|
||||
## (1) Package to install
|
||||
|
||||
- [fact] Package: **`kwallet-pam`** — in the official **`extra`** repository (NOT AUR)
|
||||
- [fact] Install command: `sudo pacman -S kwallet-pam`
|
||||
- [fact] Current version: **6.6.2-1** (as of 2026-03-03)
|
||||
- [fact] Dependencies: `kwallet`, `pam`, `libgcrypt`, `socat` (all already present or auto-resolved)
|
||||
- [fact] Files installed:
|
||||
- `/usr/lib/security/pam_kwallet5.so` — the PAM module
|
||||
- `/usr/lib/pam_kwallet_init` — session-start helper script
|
||||
- `/etc/xdg/autostart/pam_kwallet_init.desktop` — XDG autostart for Plasma/DE environments
|
||||
- `/usr/lib/systemd/user/plasma-kwallet-pam.service` — systemd user service
|
||||
|
||||
### Critical naming fact
|
||||
- [fact] **The PAM module is `pam_kwallet5.so` even for KDE Frameworks 6 / Plasma 6.** There is no `pam_kwallet6.so`. The "5" in the name is a legacy artifact. The previous discovery note incorrectly stated `pam_kwallet6.so` would be provided — this was wrong.
|
||||
- [fact] The existing `/etc/pam.d/sddm` and `/etc/pam.d/sddm-autologin` files already reference `pam_kwallet5.so` — they just need the package installed; **no module name changes are needed**.
|
||||
|
||||
## (2) PAM configuration
|
||||
|
||||
### Plasma 6 / ksecretd consideration
|
||||
|
||||
The Arch Wiki (section "Configure PAM on Plasma 6 (KF6)", updated 2026-03-10) says Plasma 6 uses `ksecretd` as the secret service daemon. The PAM session line should include `kwalletd=/usr/bin/ksecretd` to point to the new daemon.
|
||||
|
||||
- [fact] `ksecretd` binary is at `/usr/bin/ksecretd` and is shipped by the `kwallet` package (6.23.0-1)
|
||||
- [fact] `kwalletd6` binary is at `/usr/bin/kwalletd6` and is also in the `kwallet` package
|
||||
- [decision] For a non-Plasma Hyprland setup, the question is which daemon to target. The Arch Wiki recommends `kwalletd=/usr/bin/ksecretd` for KF6. Since the user has `kwalletd6` and `ksecretd` both installed via the `kwallet` package, and the Arch Wiki explicitly documents this parameter for KF6, the documentation should use the `kwalletd=/usr/bin/ksecretd` parameter.
|
||||
|
||||
### Recommended `/etc/pam.d/sddm` (password login)
|
||||
|
||||
The file already has the right structure. After installing `kwallet-pam`, the existing lines become functional. However, for Plasma 6 / KF6 compatibility, the session line should add the `kwalletd=` parameter:
|
||||
|
||||
```
|
||||
#%PAM-1.0
|
||||
|
||||
auth sufficient pam_u2f.so cue
|
||||
auth include system-login
|
||||
-auth optional pam_gnome_keyring.so
|
||||
-auth optional pam_kwallet5.so
|
||||
|
||||
account include system-login
|
||||
|
||||
password include system-login
|
||||
|
||||
session optional pam_keyinit.so force revoke
|
||||
session include system-login
|
||||
-session optional pam_gnome_keyring.so auto_start
|
||||
-session optional pam_kwallet5.so auto_start kwalletd=/usr/bin/ksecretd
|
||||
```
|
||||
|
||||
Key points:
|
||||
- [fact] The `-` prefix on `-auth` and `-session` lines means "skip silently if module is missing" — this is already present in the default SDDM PAM files
|
||||
- [fact] The `auth` line captures the user password for later use by the session line
|
||||
- [fact] The `session` line with `auto_start` tells the module to start kwalletd/ksecretd and unlock the wallet
|
||||
- [fact] `kwalletd=/usr/bin/ksecretd` directs the module to use KF6's ksecretd daemon instead of the legacy kwalletd5
|
||||
|
||||
### Recommended `/etc/pam.d/sddm-autologin`
|
||||
|
||||
This file is for SDDM autologin ONLY. Since the chosen model is password login, this file is informational but should still be kept correct:
|
||||
|
||||
```
|
||||
#%PAM-1.0
|
||||
|
||||
auth sufficient pam_u2f.so cue
|
||||
auth required pam_permit.so
|
||||
-auth optional pam_kwallet5.so
|
||||
|
||||
account include system-local-login
|
||||
|
||||
password include system-local-login
|
||||
|
||||
session include system-local-login
|
||||
-session optional pam_kwallet5.so auto_start kwalletd=/usr/bin/ksecretd
|
||||
```
|
||||
|
||||
- [caveat] Autologin skips password entry → PAM has no password to pass to `pam_kwallet5.so` → wallet cannot be unlocked unless LUKS passphrase forwarding is used (see section 5)
|
||||
|
||||
### Minimal edit needed for existing system
|
||||
|
||||
Since the existing `/etc/pam.d/sddm` already has `pam_kwallet5.so` lines, the only change needed is:
|
||||
|
||||
1. Install `kwallet-pam` (makes the module file appear at `/usr/lib/security/pam_kwallet5.so`)
|
||||
2. Add `kwalletd=/usr/bin/ksecretd` to the session line for KF6 compatibility
|
||||
|
||||
The auth line does NOT need the `kwalletd=` parameter — only the session line does.
|
||||
|
||||
## (3) Wallet initialization for non-Plasma (Hyprland) users
|
||||
|
||||
### Step A: Create the wallet
|
||||
|
||||
The wallet **must** be named `kdewallet` (the default name). PAM unlock only works with this specific wallet name.
|
||||
|
||||
**Option 1 — GUI (recommended if kwalletmanager is available):**
|
||||
```bash
|
||||
sudo pacman -S kwalletmanager
|
||||
kwalletmanager6
|
||||
```
|
||||
Then: File > New Wallet > name it `kdewallet` > set password to match login password > choose **blowfish** encryption (NOT GPG).
|
||||
|
||||
**Option 2 — Headless/CLI:**
|
||||
No pure-CLI wallet creation tool exists. The wallet is created automatically when:
|
||||
1. The PAM module is installed and configured
|
||||
2. The user logs in via SDDM with password
|
||||
3. `pam_kwallet_init` runs and kwalletd6/ksecretd starts
|
||||
4. If no wallet exists, kwalletd6 creates one on first access
|
||||
|
||||
For a truly headless init, trigger it by running in the session:
|
||||
```bash
|
||||
# Ensure kwalletd6/ksecretd is running (D-Bus activated)
|
||||
dbus-send --session --dest=org.kde.kwalletd6 --print-reply \
|
||||
/modules/kwalletd6 org.kde.KWallet.open \
|
||||
string:"kdewallet" int64:0 string:"init"
|
||||
```
|
||||
This prompts for the wallet password interactively (Qt dialog).
|
||||
|
||||
### Step B: Ensure wallet password matches login password
|
||||
|
||||
- [requirement] The KWallet password MUST be identical to the Unix user login password. PAM passes the login password to the kwallet module; if they differ, the wallet won't unlock.
|
||||
- [requirement] If the user password is changed later, the wallet password must be updated to match. Use `kwalletmanager6` > Change Password, or delete and recreate the wallet.
|
||||
|
||||
### Step C: kwalletrc configuration
|
||||
|
||||
Create `~/.config/kwalletrc` if it doesn't exist:
|
||||
|
||||
```ini
|
||||
[Wallet]
|
||||
Default Wallet=kdewallet
|
||||
Enabled=true
|
||||
First Use=false
|
||||
|
||||
[org.freedesktop.secrets]
|
||||
apiEnabled=true
|
||||
```
|
||||
|
||||
The `apiEnabled=true` setting enables the org.freedesktop.secrets D-Bus API, allowing libsecret-based apps (Chromium, VSCode, etc.) to use KWallet.
|
||||
|
||||
### Step D: Autostart `pam_kwallet_init` in Hyprland
|
||||
|
||||
The `kwallet-pam` package installs an XDG autostart entry (`/etc/xdg/autostart/pam_kwallet_init.desktop`), but Hyprland does NOT process XDG autostart files by default.
|
||||
|
||||
Add to `~/.config/hypr/hyprland.conf`:
|
||||
```
|
||||
exec-once = /usr/lib/pam_kwallet_init
|
||||
```
|
||||
|
||||
This script reads the PAM-cached credentials and passes them to kwalletd6/ksecretd to unlock the wallet.
|
||||
|
||||
### Step E: D-Bus activation service (optional but recommended)
|
||||
|
||||
Create `~/.local/share/dbus-1/services/org.freedesktop.secrets.service`:
|
||||
```ini
|
||||
[D-BUS Service]
|
||||
Name=org.freedesktop.secrets
|
||||
Exec=/usr/bin/kwalletd6
|
||||
```
|
||||
|
||||
This ensures kwalletd6 auto-starts when any app requests secrets via D-Bus, even before the wallet is explicitly opened.
|
||||
|
||||
## (4) Verification
|
||||
|
||||
### Quick verification after login
|
||||
|
||||
```bash
|
||||
# 1. Check the PAM module is installed
|
||||
ls -la /usr/lib/security/pam_kwallet5.so
|
||||
|
||||
# 2. Check kwalletd6 or ksecretd is running
|
||||
pgrep -a kwalletd6 || pgrep -a ksecretd
|
||||
|
||||
# 3. Check the wallet is open
|
||||
dbus-send --session --dest=org.kde.kwalletd6 --print-reply \
|
||||
/modules/kwalletd6 org.kde.KWallet.isOpen \
|
||||
string:"kdewallet"
|
||||
|
||||
# 4. Check wallet files exist
|
||||
ls -la ~/.local/share/kwalletd/
|
||||
|
||||
# 5. Query the wallet (should return without prompting for password)
|
||||
kwallet-query -l kdewallet
|
||||
|
||||
# 6. Check environment variables set by pam_kwallet_init
|
||||
echo $PAM_KWALLET5_LOGIN
|
||||
```
|
||||
|
||||
### Full integration test
|
||||
1. Log out of Hyprland
|
||||
2. At SDDM greeter, type user password and log in
|
||||
3. After Hyprland starts, run `kwallet-query -l kdewallet` — it should list folders without prompting
|
||||
4. Open a KWallet-aware app (e.g., Chromium with `--password-store=kwallet5`) and verify it stores/retrieves credentials
|
||||
|
||||
### Troubleshooting if wallet doesn't auto-unlock
|
||||
- Check `journalctl --user -u plasma-kwallet-pam.service` for errors
|
||||
- Check `journalctl -b | grep -i kwallet` for PAM-level errors
|
||||
- Verify wallet password matches login password exactly
|
||||
- Verify wallet is named exactly `kdewallet` (not `default` or any other name)
|
||||
- Verify wallet uses blowfish encryption, not GPG
|
||||
|
||||
## (5) Caveats
|
||||
|
||||
### U2F / pam_u2f.so interaction
|
||||
|
||||
- [risk] The existing `/etc/pam.d/sddm` has `auth sufficient pam_u2f.so cue` as the FIRST auth line. When `sufficient` succeeds, PAM skips remaining auth modules — including `pam_kwallet5.so`.
|
||||
- [consequence] If the user authenticates via U2F key only (no password typed), the kwallet module never captures a password → wallet cannot be unlocked automatically.
|
||||
- [mitigation] This is acceptable if U2F is used as a convenience shortcut and the user accepts that wallet won't auto-unlock in that case. The wallet can be manually unlocked later.
|
||||
- [alternative] To make U2F + kwallet work together, change `sufficient` to a two-factor setup where password is always required. But this changes the security model and is out of scope for this documentation.
|
||||
|
||||
### Autologin caveat
|
||||
|
||||
- [risk] SDDM autologin (`pam_permit.so`) provides no password → `pam_kwallet5.so` has nothing to unlock the wallet with.
|
||||
- [fact] The Arch Wiki documents a workaround using `pam_systemd_loadkey.so` for LUKS-encrypted systems: the LUKS passphrase can be forwarded from the initramfs to the PAM stack, allowing wallet unlock even with autologin.
|
||||
- [requirement] This requires: (1) systemd-based initramfs (`sd-encrypt` hook, not classic `encrypt`), (2) `pam_systemd_loadkey.so` line in sddm-autologin, (3) sddm.service override with `KeyringMode=inherit`.
|
||||
- [fact] The current system uses classic `encrypt` hook, NOT `sd-encrypt`, so this workaround is NOT available without migrating the initramfs to systemd hooks.
|
||||
- [decision] Since password login (not autologin) was chosen, this is informational only.
|
||||
|
||||
### Fingerprint reader caveat
|
||||
- [fact] KWallet cannot be unlocked using a fingerprint reader (per Arch Wiki). Similar to U2F — no password is available.
|
||||
|
||||
### GPG encryption caveat
|
||||
- [fact] `kwallet-pam` does NOT work with GPG-encrypted wallets. The wallet MUST use standard blowfish encryption.
|
||||
|
||||
### hyprlock caveat
|
||||
- [fact] hyprlock uses `auth include login` in `/etc/pam.d/hyprlock`. The login PAM chain does NOT include kwallet PAM modules. Unlocking hyprlock will NOT re-open the wallet if it was closed.
|
||||
- [mitigation] Typically the wallet stays open for the session duration. If the wallet is configured with `Leave Open=true` (in kwalletrc or kwalletmanager), it won't close automatically.
|
||||
|
||||
### Password change caveat
|
||||
- [fact] If the user's login password is changed (via `passwd`), the wallet password must be manually updated to match. PAM does not automatically synchronize wallet passwords on password change.
|
||||
|
||||
## Relations
|
||||
- related_to [[LUKS SDDM KWallet discovery]]
|
||||
- related_to [[luks-sddm-kwallet-login-integration]]
|
||||
- related_to [[LUKS SDDM KWallet documentation targets]]
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
title: opencode-architecture
|
||||
type: note
|
||||
permalink: dotfiles/research/opencode-architecture
|
||||
---
|
||||
|
||||
# OpenCode Architecture Research
|
||||
|
||||
## Overview
|
||||
|
||||
The OpenCode multi-agent configuration lives at `.config/opencode/` and is the most complex subsystem in this dotfiles repo.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.config/opencode/
|
||||
├── opencode.jsonc # Main config
|
||||
├── AGENTS.md # Global OpenCode config (NOT a symlink here)
|
||||
├── CLAUDE.md -> .github/copilot-instructions.md (symlink)
|
||||
├── .cursorrules -> .github/copilot-instructions.md (symlink)
|
||||
├── .github/
|
||||
│ └── copilot-instructions.md # Canonical cross-tool instructions
|
||||
├── agents/
|
||||
│ ├── lead.md # Primary orchestrator (mode=primary, temp=0.3)
|
||||
│ ├── coder.md # Implementation agent
|
||||
│ ├── reviewer.md # Code review (read-only)
|
||||
│ ├── tester.md # Testing/validation
|
||||
│ ├── explorer.md # Codebase mapper
|
||||
│ ├── researcher.md # Technical investigator
|
||||
│ ├── librarian.md # Documentation specialist
|
||||
│ ├── critic.md # Plan gate
|
||||
│ ├── sme.md # Domain expert consultant
|
||||
│ └── designer.md # UI/UX specialist
|
||||
├── .memory/
|
||||
│ ├── knowledge.md # OpenCode-specific architecture knowledge
|
||||
│ ├── decisions.md # Agent permission decisions, symlink strategy
|
||||
│ ├── plans/ # Active feature plans
|
||||
│ └── research/ # Research findings
|
||||
└── skills/
|
||||
├── doc-coverage/SKILL.md # Documentation coverage checklist
|
||||
├── git-workflow/SKILL.md # Git commit/worktree/PR procedures
|
||||
└── work-decomposition/SKILL.md # Multi-feature decomposition
|
||||
```
|
||||
|
||||
## opencode.jsonc Key Settings
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"default_agent": "lead",
|
||||
"autoupdate": true,
|
||||
"plugin": "@tarquinen/opencode-dcp",
|
||||
"agents": {
|
||||
"general": { "disabled": true },
|
||||
"explore": { "disabled": true },
|
||||
"plan": { "permissions": { "write": "allow" } }
|
||||
},
|
||||
"permissions": {
|
||||
"websearch": "allow",
|
||||
"question": "allow",
|
||||
"external_directory": "deny"
|
||||
},
|
||||
"mcp": {
|
||||
"context7": { "url": "https://mcp.context7.com/mcp", "type": "remote" },
|
||||
"gh_grep": { "url": "https://mcp.grep.app", "type": "remote" },
|
||||
"playwright": { "command": "npx @playwright/mcp@latest --headless --browser chromium", "type": "local" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Model/Permission Matrix
|
||||
|
||||
| Agent | Model | Full Edit | Notes |
|
||||
|---|---|---|---|
|
||||
| lead | claude-opus-4 | ✅ | Orchestrator, all task types |
|
||||
| coder | gpt-5.3-codex | ✅ | Implementation |
|
||||
| librarian | claude-opus-4.6 | ✅ | Documentation |
|
||||
| reviewer | claude-opus-4.6 | `.memory/*` only | Read-only code review |
|
||||
| tester | claude-sonnet-4.6 | `.memory/*` only | Validation |
|
||||
| explorer | claude-sonnet-4.6 | `.memory/*` only | Codebase mapping |
|
||||
| researcher | claude-opus-4.6 | `.memory/*` only | Technical research |
|
||||
| critic | claude-opus-4.6 | `.memory/*` only | Plan gate |
|
||||
| sme | claude-opus-4.6 | `.memory/*` only | Domain expert |
|
||||
| designer | claude-sonnet-4.6 | `.memory/*` only | UI/UX |
|
||||
|
||||
## Lead Agent Workflow
|
||||
|
||||
Phases: CLARIFY → DISCOVER → CONSULT → PLAN → CRITIC-GATE → EXECUTE → PHASE-WRAP
|
||||
|
||||
- **Tiered quality pipeline:** Tier 1 (full, new features), Tier 2 (standard), Tier 3 (fast, trivial)
|
||||
- **Worktrees:** `.worktrees/<feature-name>` per feature branch
|
||||
- **Retry circuit breaker:** 3 coder rejections → redesign; 5 failures → escalate
|
||||
- **Commit format:** Conventional Commits (`feat:`, `fix:`, `chore:`, etc.)
|
||||
- **Parallelization:** mandatory for independent work
|
||||
|
||||
## Memory Pattern
|
||||
|
||||
- `.memory/` tracked in git for cross-session persistence
|
||||
- Agents with `.memory/*` write permission record directly (instruction-level enforcement)
|
||||
- Structure: `knowledge.md` (architecture), `decisions.md` (design choices), `plans/<feature>.md`, `research/<topic>.md`
|
||||
|
||||
## Cross-Tool Instruction Files
|
||||
|
||||
- `.github/copilot-instructions.md` = single source of truth
|
||||
- `CLAUDE.md` and `.cursorrules` = symlinks
|
||||
- `AGENTS.md` = NOT a symlink in this repo (serves as global OpenCode config)
|
||||
- **Note:** In OTHER projects, `AGENTS.md` should be a symlink. The OpenCode config dir is a special case.
|
||||
|
||||
## Skills
|
||||
|
||||
- **doc-coverage:** Validates canonical instruction file + symlinks; checks README + docs/* coverage
|
||||
- **git-workflow:** Step-by-step git commit, worktree, and PR creation procedures
|
||||
- **work-decomposition:** Splits 3+ feature requests into independent workstreams with separate worktrees
|
||||
@@ -1,148 +0,0 @@
|
||||
---
|
||||
title: waybar-pomodoro-not-showing
|
||||
type: note
|
||||
permalink: dotfiles/research/waybar-pomodoro-not-showing
|
||||
tags:
|
||||
- waybar
|
||||
- pomodoro
|
||||
- debugging
|
||||
- risk
|
||||
---
|
||||
|
||||
# Waybar Pomodoro Not Showing — Research Findings
|
||||
|
||||
## Scope
|
||||
Investigation of why `custom/pomodoro` does not appear on the Waybar status bar.
|
||||
Files inspected: `.config/waybar/config`, `.config/waybar/style.css`, `.config/waybar/scripts/pomodoro-preset.sh`.
|
||||
|
||||
## Module Wiring (as configured)
|
||||
|
||||
### modules-left (config line 5–9)
|
||||
```json
|
||||
"modules-left": [
|
||||
"backlight",
|
||||
"wireplumber",
|
||||
"custom/pomodoro",
|
||||
],
|
||||
```
|
||||
`custom/pomodoro` IS present in `modules-left`.
|
||||
|
||||
### custom/pomodoro definition (config lines 133–140)
|
||||
```json
|
||||
"custom/pomodoro": {
|
||||
"format": "{}",
|
||||
"return-type": "json",
|
||||
"exec": "waybar-module-pomodoro --no-work-icons",
|
||||
"on-click": "waybar-module-pomodoro toggle",
|
||||
"on-click-right": "$HOME/.config/waybar/scripts/pomodoro-preset.sh",
|
||||
"on-click-middle": "waybar-module-pomodoro reset",
|
||||
},
|
||||
```
|
||||
|
||||
### CSS selector (style.css lines 106–109)
|
||||
```css
|
||||
#custom-pomodoro {
|
||||
padding: 0 4px;
|
||||
color: @red;
|
||||
}
|
||||
```
|
||||
Selector is correct and present.
|
||||
|
||||
### Script (scripts/pomodoro-preset.sh)
|
||||
- Guarded by `command -v waybar-module-pomodoro` check (exits 1 if not installed).
|
||||
- Sets work/short/long durations via `waybar-module-pomodoro set-*` subcommands.
|
||||
- Toggle cycles between preset A (50/10/20) and preset B (25/5/15).
|
||||
- **Script itself is logically correct.**
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis (ranked by confidence)
|
||||
|
||||
### 🔴 #1 — `waybar-module-pomodoro` binary not installed / not on PATH (confidence: ~90%)
|
||||
- The `exec` command is `waybar-module-pomodoro --no-work-icons` — a **bare binary name**, resolved from PATH at Waybar launch time.
|
||||
- Waybar inherits the environment of its launcher (Hyprland `exec-once`), which may NOT include the user's shell PATH (`~/.local/bin`, `/usr/local/bin`, etc.).
|
||||
- `fish/config.fish` adds `/home/alex/dotfiles/.local/bin` to PATH, but that is only set in interactive Fish sessions — **Hyprland's exec-once does not source Fish config**.
|
||||
- No package manager manifest, AUR package list, or install script mentions `waybar-module-pomodoro`.
|
||||
- When `exec` fails to start, Waybar hides the module entirely (no fallback text) — the module disappears silently.
|
||||
- **This is the most likely cause.** Verify with: `which waybar-module-pomodoro` in a non-Fish shell, or check `journalctl --user -u waybar` for "Failed to execute".
|
||||
|
||||
### 🟠 #2 — `interval` key absent on custom/pomodoro (confidence: ~65%)
|
||||
- `custom/pomodoro` has NO `interval` key. For a persistent daemon (`waybar-module-pomodoro` runs and writes JSON to stdout continuously), this is correct — Waybar treats it as a long-lived subprocess.
|
||||
- BUT if the binary is supposed to be polled (not a persistent daemon), missing `interval` means Waybar will only run it once and never refresh.
|
||||
- The `return-type: json` combined with no `interval` means Waybar expects the binary to **continuously emit newline-delimited JSON** to stdout. If the binary only emits once and exits, the module will show blank after the first read.
|
||||
- This is a secondary cause contingent on what `waybar-module-pomodoro` actually does. If it is a daemon that stays alive, #1 is the only blocker; if it exits after one line, `interval` is needed.
|
||||
|
||||
### 🟡 #3 — Binary exists but crashes on `--no-work-icons` flag (confidence: ~25%)
|
||||
- The `--no-work-icons` flag may not be a valid flag for the installed version of `waybar-module-pomodoro`.
|
||||
- An unrecognized flag causing the binary to exit with a non-zero code would suppress the module.
|
||||
- Check: `waybar-module-pomodoro --help` or `waybar-module-pomodoro --no-work-icons` manually.
|
||||
|
||||
### 🟡 #4 — Config JSON parse failure (confidence: ~15%)
|
||||
- The config uses tab-indented lines (lines 134–139 use `\t`) while the rest uses spaces — mixed indentation is cosmetically inconsistent but does NOT cause JSON parse errors.
|
||||
- Waybar's parser accepts JSON5/hjson (trailing commas, `//` comments) — both are used in this config and are fine.
|
||||
- No structural JSON error was found in the config.
|
||||
|
||||
### ⚪ #5 — Hyprland not auto-starting Waybar at all (confidence: ~10%)
|
||||
- If `exec-once=waybar` in `hyprland.conf` is missing or commented out, the bar won't show at all (not just the pomodoro module). Not specific to this module.
|
||||
|
||||
---
|
||||
|
||||
## Concrete Edit Points
|
||||
|
||||
### Fix #1 (most likely): Ensure binary is installed and PATH is set in Waybar launch environment
|
||||
|
||||
**Option A — Install the binary system-wide:**
|
||||
Install `waybar-module-pomodoro` via your package manager (e.g. `paru -S waybar-module-pomodoro` on Arch) so it is in `/usr/bin` or `/usr/local/bin`, which is always in Waybar's inherited PATH.
|
||||
|
||||
**Option B — Use absolute path in config:**
|
||||
```diff
|
||||
- "exec": "waybar-module-pomodoro --no-work-icons",
|
||||
- "on-click": "waybar-module-pomodoro toggle",
|
||||
- "on-click-middle": "waybar-module-pomodoro reset",
|
||||
+ "exec": "$HOME/.local/bin/waybar-module-pomodoro --no-work-icons",
|
||||
+ "on-click": "$HOME/.local/bin/waybar-module-pomodoro toggle",
|
||||
+ "on-click-middle": "$HOME/.local/bin/waybar-module-pomodoro reset",
|
||||
```
|
||||
File: `.config/waybar/config`, lines 136–139.
|
||||
|
||||
**Option C — Set PATH in Hyprland env (preferred for Wayland):**
|
||||
Add to `.config/hypr/hyprland.conf`:
|
||||
```
|
||||
env = PATH,$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
```
|
||||
|
||||
### Fix #2 (if binary is a one-shot, not a daemon): Add `interval` key
|
||||
```diff
|
||||
"custom/pomodoro": {
|
||||
"format": "{}",
|
||||
"return-type": "json",
|
||||
+ "interval": 1,
|
||||
"exec": "waybar-module-pomodoro --no-work-icons",
|
||||
```
|
||||
File: `.config/waybar/config`, line 134 (insert after `"return-type": "json",`).
|
||||
|
||||
---
|
||||
|
||||
## Files Involved
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `.config/waybar/config` | Module registration in `modules-left`, `custom/pomodoro` definition |
|
||||
| `.config/waybar/style.css` | `#custom-pomodoro` CSS selector (present, correct) |
|
||||
| `.config/waybar/scripts/pomodoro-preset.sh` | Right-click preset toggler (calls binary) |
|
||||
| `.config/hypr/hyprland.conf` | Waybar autostart + env block (outside Waybar dir) |
|
||||
| `waybar-module-pomodoro` binary | External binary — must be installed and on PATH |
|
||||
|
||||
---
|
||||
|
||||
## Likely Bug Surfaces (Adjacent Risk Areas)
|
||||
|
||||
1. **`custom/uptime`** (config line 89–95): Also uses a bare script path `$HOME/.config/waybar/scripts/uptime.sh`. Same PATH-at-launch issue could affect it if shell env is not inherited. The script exists in the repo (`scripts/` dir shows only `pomodoro-preset.sh`) — **`uptime.sh` is missing from the repo**, meaning this module may also be broken.
|
||||
2. **`custom/music`** (config line 44–52): Uses `playerctl` — same PATH issue; no `playerctl` install evidence in the repo.
|
||||
3. **`hyprland/workspaces`** (config line 22–28): Defined in config but NOT in any of `modules-left`, `modules-center`, or `modules-right` — it is **a dead definition that never renders**.
|
||||
4. **`custom/lock`** (config line 127–131): Defined but also absent from all three module lists — another dead definition.
|
||||
5. **`network`** (config line 60–68): Defined but not in any module list — dead definition.
|
||||
6. **Trailing comma on line 8** of `modules-left`: Benign in Waybar's parser but would break standard JSON parsers if config is ever processed by tools expecting strict JSON.
|
||||
|
||||
## Relations
|
||||
- related_to [[dotfiles/knowledge]]
|
||||
8
.pi/agent/AGENTS.md
Normal file
8
.pi/agent/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# AGENTS
|
||||
|
||||
## User clarification
|
||||
|
||||
- Prefer using the `question` tool when you need a user decision, preference, approval, or missing input before proceeding.
|
||||
- Do not end the turn just to ask for a response if the `question` tool is available and appropriate.
|
||||
- Favor concise multiple-choice options, and rely on the tool's built-in free-text fallback when needed.
|
||||
- Only fall back to a normal conversational question when the `question` tool is unavailable or clearly not a good fit.
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"github-copilot": {
|
||||
"type": "oauth",
|
||||
"refresh": "ghu_j9QHUrVzPLoYOsyjarpzktAFDQWqP31gz2Ac",
|
||||
"access": "tid=af454cc719f9e4daffe9b4892fa4e791;exp=1773665732;sku=plus_monthly_subscriber_quota;proxy-ep=proxy.individual.githubcopilot.com;st=dotcom;chat=1;cit=1;malfil=1;editor_preview_features=1;agent_mode=1;agent_mode_auto_approval=1;mcp=1;ccr=1;8kp=1;ip=137.205.73.18;asn=AS201773:0afe8e842bbf234a7d338ff0c8b279b2ab05f1ebcad969293cf690eee12265c6",
|
||||
"expires": 1773665432000
|
||||
}
|
||||
}
|
||||
353
.pi/agent/extensions/context-manager/index.ts
Normal file
353
.pi/agent/extensions/context-manager/index.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { adjustPolicyForZone } from "./src/config.ts";
|
||||
import { deserializeLatestSnapshot, serializeSnapshot, SNAPSHOT_ENTRY_TYPE, type RuntimeSnapshot } from "./src/persist.ts";
|
||||
import { createEmptyLedger } from "./src/ledger.ts";
|
||||
import { pruneContextMessages } from "./src/prune.ts";
|
||||
import { createContextManagerRuntime } from "./src/runtime.ts";
|
||||
import { registerContextCommands } from "./src/commands.ts";
|
||||
import { buildBranchSummaryFromEntries, buildCompactionSummaryFromPreparation } from "./src/summaries.ts";
|
||||
|
||||
type TrackedMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
type BranchEntry = ReturnType<ExtensionContext["sessionManager"]["getBranch"]>[number];
|
||||
|
||||
function isTextPart(part: unknown): part is { type: "text"; text?: string } {
|
||||
return typeof part === "object" && part !== null && "type" in part && (part as { type?: unknown }).type === "text";
|
||||
}
|
||||
|
||||
function toText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!isTextPart(part)) return "";
|
||||
return typeof part.text === "string" ? part.text : "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isMessageEntry(entry: BranchEntry): entry is Extract<BranchEntry, { type: "message" }> {
|
||||
return entry.type === "message";
|
||||
}
|
||||
|
||||
function isCompactionEntry(entry: BranchEntry): entry is Extract<BranchEntry, { type: "compaction" }> {
|
||||
return entry.type === "compaction";
|
||||
}
|
||||
|
||||
function isBranchSummaryEntry(entry: BranchEntry): entry is Extract<BranchEntry, { type: "branch_summary" }> {
|
||||
return entry.type === "branch_summary";
|
||||
}
|
||||
|
||||
function isTrackedMessage(message: AgentMessage): message is TrackedMessage {
|
||||
return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
|
||||
}
|
||||
|
||||
function createDefaultSnapshot(): RuntimeSnapshot {
|
||||
return {
|
||||
mode: "balanced",
|
||||
lastZone: "green",
|
||||
ledger: createEmptyLedger(),
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageContent(message: AgentMessage): string {
|
||||
return "content" in message ? toText(message.content) : "";
|
||||
}
|
||||
|
||||
function getMessageToolName(message: AgentMessage): string | undefined {
|
||||
return message.role === "toolResult" ? message.toolName : undefined;
|
||||
}
|
||||
|
||||
function rewriteContextMessage(message: { role: string; content: string; original: AgentMessage; distilled?: boolean }): AgentMessage {
|
||||
if (!message.distilled || message.role !== "toolResult") {
|
||||
return message.original;
|
||||
}
|
||||
|
||||
return {
|
||||
...(message.original as Extract<AgentMessage, { role: "toolResult" }>),
|
||||
content: [{ type: "text", text: message.content }],
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function findLatestSnapshotState(branch: BranchEntry[]): { snapshot: RuntimeSnapshot; index: number } | undefined {
|
||||
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
||||
const entry = branch[index]!;
|
||||
if (entry.type !== "custom" || entry.customType !== SNAPSHOT_ENTRY_TYPE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = deserializeLatestSnapshot([entry]);
|
||||
if (snapshot) {
|
||||
return { snapshot, index };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findLatestSessionSnapshot(entries: BranchEntry[]): RuntimeSnapshot | undefined {
|
||||
let latest: RuntimeSnapshot | undefined;
|
||||
let latestFreshness = -Infinity;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "custom" || entry.customType !== SNAPSHOT_ENTRY_TYPE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = deserializeLatestSnapshot([entry]);
|
||||
if (!snapshot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionItems = snapshot.ledger.items.filter((item) => item.scope === "session");
|
||||
const freshness = sessionItems.length > 0 ? Math.max(...sessionItems.map((item) => item.timestamp)) : -Infinity;
|
||||
if (freshness >= latestFreshness) {
|
||||
latest = snapshot;
|
||||
latestFreshness = freshness;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
function createSessionFallbackSnapshot(source?: RuntimeSnapshot): RuntimeSnapshot {
|
||||
return {
|
||||
mode: source?.mode ?? "balanced",
|
||||
lastZone: "green",
|
||||
ledger: {
|
||||
items: structuredClone((source?.ledger.items ?? []).filter((item) => item.scope === "session")),
|
||||
rollingSummary: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function overlaySessionLayer(base: RuntimeSnapshot, latestSessionSnapshot?: RuntimeSnapshot): RuntimeSnapshot {
|
||||
const sessionItems = latestSessionSnapshot?.ledger.items.filter((item) => item.scope === "session") ?? [];
|
||||
if (sessionItems.length === 0) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
ledger: {
|
||||
...base.ledger,
|
||||
items: [
|
||||
...structuredClone(base.ledger.items.filter((item) => item.scope !== "session")),
|
||||
...structuredClone(sessionItems),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function contextManager(pi: ExtensionAPI) {
|
||||
const runtime = createContextManagerRuntime({
|
||||
mode: "balanced",
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
let pendingResumeInjection = false;
|
||||
|
||||
const syncContextWindow = (ctx: Pick<ExtensionContext, "model">) => {
|
||||
runtime.setContextWindow(ctx.model?.contextWindow ?? 200_000);
|
||||
};
|
||||
|
||||
const armResumeInjection = () => {
|
||||
const snapshot = runtime.getSnapshot();
|
||||
pendingResumeInjection = Boolean(snapshot.lastCompactionSummary || snapshot.lastBranchSummary) && runtime.buildResumePacket().trim().length > 0;
|
||||
};
|
||||
|
||||
const replayBranchEntry = (entry: BranchEntry) => {
|
||||
if (isMessageEntry(entry) && isTrackedMessage(entry.message)) {
|
||||
runtime.ingest({
|
||||
entryId: entry.id,
|
||||
role: entry.message.role,
|
||||
text: toText(entry.message.content),
|
||||
timestamp: entry.message.timestamp,
|
||||
isError: entry.message.role === "toolResult" ? entry.message.isError : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCompactionEntry(entry)) {
|
||||
runtime.recordCompactionSummary(entry.summary, entry.id, Date.parse(entry.timestamp));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBranchSummaryEntry(entry)) {
|
||||
runtime.recordBranchSummary(entry.summary, entry.id, Date.parse(entry.timestamp));
|
||||
}
|
||||
};
|
||||
|
||||
const rebuildRuntimeFromBranch = (
|
||||
ctx: Pick<ExtensionContext, "model" | "sessionManager" | "ui">,
|
||||
fallbackSnapshot: RuntimeSnapshot,
|
||||
options?: { preferRuntimeMode?: boolean },
|
||||
) => {
|
||||
syncContextWindow(ctx);
|
||||
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const latestSessionSnapshot = findLatestSessionSnapshot(ctx.sessionManager.getEntries() as BranchEntry[]);
|
||||
const restored = findLatestSnapshotState(branch);
|
||||
const baseSnapshot = restored
|
||||
? overlaySessionLayer(restored.snapshot, latestSessionSnapshot)
|
||||
: createSessionFallbackSnapshot(latestSessionSnapshot ?? fallbackSnapshot);
|
||||
|
||||
runtime.restore({
|
||||
...baseSnapshot,
|
||||
mode: options?.preferRuntimeMode ? fallbackSnapshot.mode : baseSnapshot.mode,
|
||||
});
|
||||
|
||||
const replayEntries = restored ? branch.slice(restored.index + 1) : branch;
|
||||
for (const entry of replayEntries) {
|
||||
replayBranchEntry(entry);
|
||||
}
|
||||
|
||||
const snapshot = runtime.getSnapshot();
|
||||
ctx.ui.setStatus("context-manager", `ctx ${snapshot.lastZone}`);
|
||||
};
|
||||
|
||||
registerContextCommands(pi, {
|
||||
getSnapshot: runtime.getSnapshot,
|
||||
buildPacket: runtime.buildPacket,
|
||||
buildResumePacket: runtime.buildResumePacket,
|
||||
setMode: runtime.setMode,
|
||||
rebuildFromBranch: async (commandCtx) => {
|
||||
rebuildRuntimeFromBranch(commandCtx, runtime.getSnapshot(), { preferRuntimeMode: true });
|
||||
armResumeInjection();
|
||||
},
|
||||
isResumePending: () => pendingResumeInjection,
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildRuntimeFromBranch(ctx, createDefaultSnapshot());
|
||||
armResumeInjection();
|
||||
});
|
||||
|
||||
pi.on("session_tree", async (event, ctx) => {
|
||||
rebuildRuntimeFromBranch(ctx, createDefaultSnapshot());
|
||||
|
||||
if (
|
||||
event.summaryEntry &&
|
||||
!ctx.sessionManager.getBranch().some((entry) => isBranchSummaryEntry(entry) && entry.id === event.summaryEntry.id)
|
||||
) {
|
||||
runtime.recordBranchSummary(event.summaryEntry.summary, event.summaryEntry.id, Date.parse(event.summaryEntry.timestamp));
|
||||
}
|
||||
|
||||
armResumeInjection();
|
||||
|
||||
if (event.summaryEntry) {
|
||||
pi.appendEntry(SNAPSHOT_ENTRY_TYPE, serializeSnapshot(runtime.getSnapshot()));
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event) => {
|
||||
runtime.ingest({
|
||||
entryId: event.toolCallId,
|
||||
role: "toolResult",
|
||||
text: toText(event.content),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
rebuildRuntimeFromBranch(ctx, runtime.getSnapshot(), { preferRuntimeMode: true });
|
||||
|
||||
const usage = ctx.getContextUsage();
|
||||
if (usage?.tokens !== null && usage?.tokens !== undefined) {
|
||||
runtime.observeTokens(usage.tokens);
|
||||
}
|
||||
|
||||
const snapshot = runtime.getSnapshot();
|
||||
pi.appendEntry(SNAPSHOT_ENTRY_TYPE, serializeSnapshot(snapshot));
|
||||
ctx.ui.setStatus("context-manager", `ctx ${snapshot.lastZone}`);
|
||||
});
|
||||
|
||||
pi.on("context", async (event, ctx) => {
|
||||
syncContextWindow(ctx);
|
||||
const snapshot = runtime.getSnapshot();
|
||||
const policy = adjustPolicyForZone(runtime.getPolicy(), snapshot.lastZone);
|
||||
const normalized = event.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: getMessageContent(message),
|
||||
toolName: getMessageToolName(message),
|
||||
original: message,
|
||||
}));
|
||||
|
||||
const pruned = pruneContextMessages(normalized, policy);
|
||||
const nextMessages = pruned.map((message) =>
|
||||
rewriteContextMessage(message as { role: string; content: string; original: AgentMessage; distilled?: boolean }),
|
||||
);
|
||||
const resumeText = pendingResumeInjection ? runtime.buildResumePacket() : "";
|
||||
const packetText = pendingResumeInjection ? "" : runtime.buildPacket().text;
|
||||
const injectedText = resumeText || packetText;
|
||||
|
||||
if (!injectedText) {
|
||||
return { messages: nextMessages };
|
||||
}
|
||||
|
||||
if (resumeText) {
|
||||
pendingResumeInjection = false;
|
||||
}
|
||||
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "custom",
|
||||
customType: resumeText ? "context-manager.resume" : "context-manager.packet",
|
||||
content: injectedText,
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
} as any,
|
||||
...nextMessages,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("session_before_compact", async (event, ctx) => {
|
||||
syncContextWindow(ctx);
|
||||
|
||||
try {
|
||||
return {
|
||||
compaction: {
|
||||
summary: buildCompactionSummaryFromPreparation({
|
||||
messagesToSummarize: event.preparation.messagesToSummarize,
|
||||
turnPrefixMessages: event.preparation.turnPrefixMessages,
|
||||
previousSummary: event.preparation.previousSummary,
|
||||
fileOps: event.preparation.fileOps,
|
||||
customInstructions: event.customInstructions,
|
||||
}),
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: event.preparation.fileOps,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`context-manager compaction fallback: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_before_tree", async (event, ctx) => {
|
||||
syncContextWindow(ctx);
|
||||
if (!event.preparation.userWantsSummary) return;
|
||||
return {
|
||||
summary: {
|
||||
summary: buildBranchSummaryFromEntries({
|
||||
branchLabel: "branch handoff",
|
||||
entriesToSummarize: event.preparation.entriesToSummarize,
|
||||
customInstructions: event.preparation.customInstructions,
|
||||
replaceInstructions: event.preparation.replaceInstructions,
|
||||
commonAncestorId: event.preparation.commonAncestorId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("session_compact", async (event, ctx) => {
|
||||
runtime.recordCompactionSummary(event.compactionEntry.summary, event.compactionEntry.id, Date.parse(event.compactionEntry.timestamp));
|
||||
pi.appendEntry(SNAPSHOT_ENTRY_TYPE, serializeSnapshot(runtime.getSnapshot()));
|
||||
armResumeInjection();
|
||||
ctx.ui.setStatus("context-manager", `ctx ${runtime.getSnapshot().lastZone}`);
|
||||
});
|
||||
}
|
||||
4361
.pi/agent/extensions/context-manager/package-lock.json
generated
Normal file
4361
.pi/agent/extensions/context-manager/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
.pi/agent/extensions/context-manager/package.json
Normal file
18
.pi/agent/extensions/context-manager/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "pi-context-manager-extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.66.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
76
.pi/agent/extensions/context-manager/src/commands.ts
Normal file
76
.pi/agent/extensions/context-manager/src/commands.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { ContextMode } from "./config.ts";
|
||||
import { serializeSnapshot, SNAPSHOT_ENTRY_TYPE, type RuntimeSnapshot } from "./persist.ts";
|
||||
|
||||
interface CommandRuntime {
|
||||
getSnapshot(): RuntimeSnapshot;
|
||||
buildPacket(): { estimatedTokens: number };
|
||||
buildResumePacket(): string;
|
||||
setMode(mode: ContextMode): void;
|
||||
rebuildFromBranch(ctx: ExtensionCommandContext): Promise<void>;
|
||||
isResumePending(): boolean;
|
||||
}
|
||||
|
||||
export function registerContextCommands(pi: ExtensionAPI, runtime: CommandRuntime) {
|
||||
pi.registerCommand("ctx-status", {
|
||||
description: "Show context pressure, packet status, and persisted handoff state",
|
||||
handler: async (_args, ctx) => {
|
||||
const snapshot = runtime.getSnapshot();
|
||||
const packet = runtime.buildPacket();
|
||||
const resumePending = runtime.isResumePending();
|
||||
const contextTokens = ctx.getContextUsage()?.tokens;
|
||||
const nextInjectionTokens = resumePending ? Math.ceil(runtime.buildResumePacket().length / 4) : packet.estimatedTokens;
|
||||
ctx.ui.notify(
|
||||
[
|
||||
`mode=${snapshot.mode}`,
|
||||
`zone=${snapshot.lastZone}`,
|
||||
`contextTokens=${contextTokens ?? "unknown"}`,
|
||||
`packetTokens=${packet.estimatedTokens}`,
|
||||
`nextInjectionTokens=${nextInjectionTokens}`,
|
||||
`resumePending=${resumePending ? "yes" : "no"}`,
|
||||
`compaction=${snapshot.lastCompactionSummary ? "yes" : "no"}`,
|
||||
`branch=${snapshot.lastBranchSummary ? "yes" : "no"}`,
|
||||
].join(" "),
|
||||
"info",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("ctx-memory", {
|
||||
description: "Inspect the active context ledger",
|
||||
handler: async (_args, ctx) => {
|
||||
const snapshot = runtime.getSnapshot();
|
||||
await ctx.ui.editor("Context ledger", JSON.stringify(snapshot.ledger, null, 2));
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("ctx-refresh", {
|
||||
description: "Rebuild runtime state from the current branch and refresh the working packet",
|
||||
handler: async (_args, ctx) => {
|
||||
await runtime.rebuildFromBranch(ctx);
|
||||
const packet = runtime.buildPacket();
|
||||
ctx.ui.notify(`rebuilt runtime from branch (${packet.estimatedTokens} tokens)`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("ctx-compact", {
|
||||
description: "Trigger compaction with optional focus instructions",
|
||||
handler: async (args, ctx) => {
|
||||
ctx.compact({ customInstructions: args.trim() || undefined });
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("ctx-mode", {
|
||||
description: "Switch context mode: conservative | balanced | aggressive",
|
||||
handler: async (args, ctx) => {
|
||||
const value = args.trim() as "conservative" | "balanced" | "aggressive";
|
||||
if (!["conservative", "balanced", "aggressive"].includes(value)) {
|
||||
ctx.ui.notify("usage: /ctx-mode conservative|balanced|aggressive", "warning");
|
||||
return;
|
||||
}
|
||||
runtime.setMode(value);
|
||||
pi.appendEntry(SNAPSHOT_ENTRY_TYPE, serializeSnapshot(runtime.getSnapshot()));
|
||||
ctx.ui.notify(`context mode set to ${value}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
86
.pi/agent/extensions/context-manager/src/config.test.ts
Normal file
86
.pi/agent/extensions/context-manager/src/config.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { adjustPolicyForZone, resolvePolicy, zoneForTokens } from "./config.ts";
|
||||
|
||||
test("resolvePolicy returns the balanced policy for a 200k context window", () => {
|
||||
const policy = resolvePolicy({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
assert.deepEqual(policy, {
|
||||
mode: "balanced",
|
||||
recentUserTurns: 4,
|
||||
packetTokenCap: 1_200,
|
||||
bulkyBytes: 4_096,
|
||||
bulkyLines: 150,
|
||||
yellowAtTokens: 110_000,
|
||||
redAtTokens: 140_000,
|
||||
compactAtTokens: 164_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolvePolicy clamps context windows below 50k before calculating thresholds", () => {
|
||||
const policy = resolvePolicy({ mode: "balanced", contextWindow: 10_000 });
|
||||
|
||||
assert.deepEqual(policy, {
|
||||
mode: "balanced",
|
||||
recentUserTurns: 4,
|
||||
packetTokenCap: 1_200,
|
||||
bulkyBytes: 4_096,
|
||||
bulkyLines: 150,
|
||||
yellowAtTokens: 27_500,
|
||||
redAtTokens: 35_000,
|
||||
compactAtTokens: 41_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("aggressive mode compacts earlier than conservative mode", () => {
|
||||
const aggressive = resolvePolicy({ mode: "aggressive", contextWindow: 200_000 });
|
||||
const conservative = resolvePolicy({ mode: "conservative", contextWindow: 200_000 });
|
||||
|
||||
assert.ok(aggressive.compactAtTokens < conservative.compactAtTokens);
|
||||
});
|
||||
|
||||
test("aggressive mode reduces raw-window and packet budgets compared with conservative mode", () => {
|
||||
const aggressive = resolvePolicy({ mode: "aggressive", contextWindow: 200_000 });
|
||||
const conservative = resolvePolicy({ mode: "conservative", contextWindow: 200_000 });
|
||||
|
||||
assert.ok(aggressive.recentUserTurns < conservative.recentUserTurns);
|
||||
assert.ok(aggressive.packetTokenCap < conservative.packetTokenCap);
|
||||
assert.ok(aggressive.bulkyBytes < conservative.bulkyBytes);
|
||||
assert.ok(aggressive.bulkyLines < conservative.bulkyLines);
|
||||
});
|
||||
|
||||
test("adjustPolicyForZone tightens packet and pruning thresholds in yellow, red, and compact zones", () => {
|
||||
const base = resolvePolicy({ mode: "balanced", contextWindow: 200_000 });
|
||||
const yellow = adjustPolicyForZone(base, "yellow");
|
||||
const red = adjustPolicyForZone(base, "red");
|
||||
const compact = adjustPolicyForZone(base, "compact");
|
||||
|
||||
assert.ok(yellow.packetTokenCap < base.packetTokenCap);
|
||||
assert.ok(yellow.bulkyBytes < base.bulkyBytes);
|
||||
assert.ok(red.packetTokenCap < yellow.packetTokenCap);
|
||||
assert.ok(red.recentUserTurns <= yellow.recentUserTurns);
|
||||
assert.ok(red.bulkyBytes < yellow.bulkyBytes);
|
||||
assert.ok(compact.packetTokenCap < red.packetTokenCap);
|
||||
assert.ok(compact.recentUserTurns <= red.recentUserTurns);
|
||||
assert.ok(compact.bulkyLines < red.bulkyLines);
|
||||
});
|
||||
|
||||
test("zoneForTokens returns green, yellow, red, and compact for the balanced 200k policy", () => {
|
||||
const policy = resolvePolicy({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
assert.equal(zoneForTokens(80_000, policy), "green");
|
||||
assert.equal(zoneForTokens(120_000, policy), "yellow");
|
||||
assert.equal(zoneForTokens(150_000, policy), "red");
|
||||
assert.equal(zoneForTokens(170_000, policy), "compact");
|
||||
});
|
||||
|
||||
test("zoneForTokens uses inclusive balanced 200k thresholds", () => {
|
||||
const policy = resolvePolicy({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
assert.equal(zoneForTokens(109_999, policy), "green");
|
||||
assert.equal(zoneForTokens(110_000, policy), "yellow");
|
||||
assert.equal(zoneForTokens(139_999, policy), "yellow");
|
||||
assert.equal(zoneForTokens(140_000, policy), "red");
|
||||
assert.equal(zoneForTokens(163_999, policy), "red");
|
||||
assert.equal(zoneForTokens(164_000, policy), "compact");
|
||||
});
|
||||
97
.pi/agent/extensions/context-manager/src/config.ts
Normal file
97
.pi/agent/extensions/context-manager/src/config.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type ContextMode = "conservative" | "balanced" | "aggressive";
|
||||
export type ContextZone = "green" | "yellow" | "red" | "compact";
|
||||
|
||||
export interface Policy {
|
||||
mode: ContextMode;
|
||||
recentUserTurns: number;
|
||||
packetTokenCap: number;
|
||||
bulkyBytes: number;
|
||||
bulkyLines: number;
|
||||
yellowAtTokens: number;
|
||||
redAtTokens: number;
|
||||
compactAtTokens: number;
|
||||
}
|
||||
|
||||
export const MODE_PCTS: Record<ContextMode, { yellow: number; red: number; compact: number }> = {
|
||||
conservative: { yellow: 0.60, red: 0.76, compact: 0.88 },
|
||||
balanced: { yellow: 0.55, red: 0.70, compact: 0.82 },
|
||||
aggressive: { yellow: 0.50, red: 0.64, compact: 0.76 },
|
||||
};
|
||||
|
||||
const MODE_SETTINGS: Record<ContextMode, Pick<Policy, "recentUserTurns" | "packetTokenCap" | "bulkyBytes" | "bulkyLines">> = {
|
||||
conservative: {
|
||||
recentUserTurns: 5,
|
||||
packetTokenCap: 1_400,
|
||||
bulkyBytes: 6_144,
|
||||
bulkyLines: 220,
|
||||
},
|
||||
balanced: {
|
||||
recentUserTurns: 4,
|
||||
packetTokenCap: 1_200,
|
||||
bulkyBytes: 4_096,
|
||||
bulkyLines: 150,
|
||||
},
|
||||
aggressive: {
|
||||
recentUserTurns: 3,
|
||||
packetTokenCap: 900,
|
||||
bulkyBytes: 3_072,
|
||||
bulkyLines: 100,
|
||||
},
|
||||
};
|
||||
|
||||
export function resolvePolicy(input: { mode: ContextMode; contextWindow: number }): Policy {
|
||||
const contextWindow = Math.max(input.contextWindow, 50_000);
|
||||
const percentages = MODE_PCTS[input.mode];
|
||||
const settings = MODE_SETTINGS[input.mode];
|
||||
|
||||
return {
|
||||
mode: input.mode,
|
||||
recentUserTurns: settings.recentUserTurns,
|
||||
packetTokenCap: settings.packetTokenCap,
|
||||
bulkyBytes: settings.bulkyBytes,
|
||||
bulkyLines: settings.bulkyLines,
|
||||
yellowAtTokens: Math.floor(contextWindow * percentages.yellow),
|
||||
redAtTokens: Math.floor(contextWindow * percentages.red),
|
||||
compactAtTokens: Math.floor(contextWindow * percentages.compact),
|
||||
};
|
||||
}
|
||||
|
||||
export function adjustPolicyForZone(policy: Policy, zone: ContextZone): Policy {
|
||||
if (zone === "green") {
|
||||
return { ...policy };
|
||||
}
|
||||
|
||||
if (zone === "yellow") {
|
||||
return {
|
||||
...policy,
|
||||
packetTokenCap: Math.max(500, Math.floor(policy.packetTokenCap * 0.9)),
|
||||
bulkyBytes: Math.max(1_536, Math.floor(policy.bulkyBytes * 0.9)),
|
||||
bulkyLines: Math.max(80, Math.floor(policy.bulkyLines * 0.9)),
|
||||
};
|
||||
}
|
||||
|
||||
if (zone === "red") {
|
||||
return {
|
||||
...policy,
|
||||
recentUserTurns: Math.max(2, policy.recentUserTurns - 1),
|
||||
packetTokenCap: Math.max(400, Math.floor(policy.packetTokenCap * 0.75)),
|
||||
bulkyBytes: Math.max(1_024, Math.floor(policy.bulkyBytes * 0.75)),
|
||||
bulkyLines: Math.max(60, Math.floor(policy.bulkyLines * 0.75)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...policy,
|
||||
recentUserTurns: Math.max(1, policy.recentUserTurns - 2),
|
||||
packetTokenCap: Math.max(300, Math.floor(policy.packetTokenCap * 0.55)),
|
||||
bulkyBytes: Math.max(768, Math.floor(policy.bulkyBytes * 0.5)),
|
||||
bulkyLines: Math.max(40, Math.floor(policy.bulkyLines * 0.5)),
|
||||
};
|
||||
}
|
||||
|
||||
export function zoneForTokens(tokens: number, policy: Policy): ContextZone {
|
||||
if (tokens >= policy.compactAtTokens) return "compact";
|
||||
if (tokens >= policy.redAtTokens) return "red";
|
||||
if (tokens >= policy.yellowAtTokens) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
30
.pi/agent/extensions/context-manager/src/distill.test.ts
Normal file
30
.pi/agent/extensions/context-manager/src/distill.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { distillToolResult } from "./distill.ts";
|
||||
|
||||
const noisy = [
|
||||
"Build failed while compiling focus parser",
|
||||
"Error: missing export createFocusMatcher from ./summary-focus.ts",
|
||||
"at src/summaries.ts:44:12",
|
||||
"line filler",
|
||||
"line filler",
|
||||
"line filler",
|
||||
].join("\n");
|
||||
|
||||
test("distillToolResult prioritizes salient failure lines and truncates noise", () => {
|
||||
const distilled = distillToolResult({ toolName: "bash", content: noisy });
|
||||
|
||||
assert.ok(distilled);
|
||||
assert.match(distilled!, /Build failed while compiling focus parser/);
|
||||
assert.match(distilled!, /missing export createFocusMatcher/);
|
||||
assert.ok(distilled!.length < 320);
|
||||
});
|
||||
|
||||
test("distillToolResult falls back to the first meaningful non-empty lines", () => {
|
||||
const distilled = distillToolResult({
|
||||
toolName: "read",
|
||||
content: ["", "src/runtime.ts", "exports createContextManagerRuntime", "", "more noise"].join("\n"),
|
||||
});
|
||||
|
||||
assert.equal(distilled, "[distilled read output] src/runtime.ts; exports createContextManagerRuntime");
|
||||
});
|
||||
47
.pi/agent/extensions/context-manager/src/distill.ts
Normal file
47
.pi/agent/extensions/context-manager/src/distill.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const ERROR_RE = /\b(?:error|failed|failure|missing|undefined|exception)\b/i;
|
||||
const LOCATION_RE = /\b(?:at\s+.+:\d+(?::\d+)?)\b|(?:[A-Za-z0-9_./-]+\.(?:ts|tsx|js|mjs|json|md):\d+(?::\d+)?)/i;
|
||||
const MAX_SUMMARY_LENGTH = 320;
|
||||
const MAX_LINES = 2;
|
||||
|
||||
function unique(lines: string[]): string[] {
|
||||
return lines.filter((line, index) => lines.indexOf(line) === index);
|
||||
}
|
||||
|
||||
function pickSalientLines(content: string): string[] {
|
||||
const lines = content
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const important = unique(lines.filter((line) => ERROR_RE.test(line)));
|
||||
const location = unique(lines.filter((line) => LOCATION_RE.test(line)));
|
||||
const fallback = unique(lines);
|
||||
|
||||
const selected: string[] = [];
|
||||
for (const line of [...important, ...location, ...fallback]) {
|
||||
if (selected.includes(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(line);
|
||||
if (selected.length >= MAX_LINES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function distillToolResult(input: { toolName?: string; content: string }): string | undefined {
|
||||
const picked = pickSalientLines(input.content);
|
||||
if (picked.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prefix = `[distilled ${input.toolName ?? "tool"} output]`;
|
||||
return `${prefix} ${picked.join("; ")}`.slice(0, MAX_SUMMARY_LENGTH);
|
||||
}
|
||||
833
.pi/agent/extensions/context-manager/src/extension.test.ts
Normal file
833
.pi/agent/extensions/context-manager/src/extension.test.ts
Normal file
@@ -0,0 +1,833 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import contextManagerExtension from "../index.ts";
|
||||
import { deserializeLatestSnapshot, SNAPSHOT_ENTRY_TYPE, serializeSnapshot, type RuntimeSnapshot } from "./persist.ts";
|
||||
|
||||
type EventHandler = (event: any, ctx: any) => Promise<any> | any;
|
||||
type RegisteredCommand = { description: string; handler: (args: string, ctx: any) => Promise<void> | void };
|
||||
|
||||
type SessionEntry =
|
||||
| {
|
||||
type: "message";
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
message: any;
|
||||
}
|
||||
| {
|
||||
type: "custom";
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
customType: string;
|
||||
data: unknown;
|
||||
}
|
||||
| {
|
||||
type: "compaction";
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
}
|
||||
| {
|
||||
type: "branch_summary";
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
fromId: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
function createUsage(tokens: number) {
|
||||
return {
|
||||
tokens,
|
||||
contextWindow: 200_000,
|
||||
percent: tokens / 200_000,
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(content: string, timestamp: number) {
|
||||
return {
|
||||
role: "user",
|
||||
content,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(content: string, timestamp: number) {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: content }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createToolResultMessage(content: string, timestamp: number) {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: `tool-${timestamp}`,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: content }],
|
||||
isError: false,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageEntry(id: string, parentId: string | null, message: any): SessionEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: new Date(message.timestamp).toISOString(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function createSnapshotEntry(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
options: {
|
||||
text: string;
|
||||
mode?: RuntimeSnapshot["mode"];
|
||||
lastZone?: RuntimeSnapshot["lastZone"];
|
||||
lastObservedTokens?: number;
|
||||
lastCompactionSummary?: string;
|
||||
lastBranchSummary?: string;
|
||||
ledgerItems?: RuntimeSnapshot["ledger"]["items"];
|
||||
rollingSummary?: string;
|
||||
},
|
||||
): SessionEntry {
|
||||
const {
|
||||
text,
|
||||
mode = "aggressive",
|
||||
lastZone = "red",
|
||||
lastObservedTokens = 150_000,
|
||||
lastCompactionSummary = "existing compaction summary",
|
||||
lastBranchSummary = "existing branch summary",
|
||||
ledgerItems,
|
||||
rollingSummary = "stale ledger",
|
||||
} = options;
|
||||
|
||||
return {
|
||||
type: "custom",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: new Date(1).toISOString(),
|
||||
customType: SNAPSHOT_ENTRY_TYPE,
|
||||
data: serializeSnapshot({
|
||||
mode,
|
||||
lastZone,
|
||||
lastObservedTokens,
|
||||
lastCompactionSummary,
|
||||
lastBranchSummary,
|
||||
ledger: {
|
||||
items: ledgerItems ?? [
|
||||
{
|
||||
id: `goal:session:root-goal:${id}`,
|
||||
kind: "goal",
|
||||
subject: "root-goal",
|
||||
text,
|
||||
scope: "session",
|
||||
sourceEntryId: "old-user",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 1,
|
||||
freshness: 1,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
],
|
||||
rollingSummary,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(initialBranch: SessionEntry[], options?: { usageTokens?: number }) {
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
const handlers = new Map<string, EventHandler>();
|
||||
const appendedEntries: Array<{ customType: string; data: unknown }> = [];
|
||||
const statuses: Array<{ key: string; value: string }> = [];
|
||||
let branch = [...initialBranch];
|
||||
let entries = [...initialBranch];
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 200_000 },
|
||||
sessionManager: {
|
||||
getBranch() {
|
||||
return branch;
|
||||
},
|
||||
getEntries() {
|
||||
return entries;
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
setStatus(key: string, value: string) {
|
||||
statuses.push({ key, value });
|
||||
},
|
||||
notify() {},
|
||||
editor: async () => {},
|
||||
},
|
||||
getContextUsage() {
|
||||
return options?.usageTokens === undefined ? undefined : createUsage(options.usageTokens);
|
||||
},
|
||||
compact() {},
|
||||
};
|
||||
|
||||
contextManagerExtension({
|
||||
registerCommand(name: string, command: RegisteredCommand) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
on(name: string, handler: EventHandler) {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
appendEntry(customType: string, data: unknown) {
|
||||
appendedEntries.push({ customType, data });
|
||||
const entry = {
|
||||
type: "custom" as const,
|
||||
id: `custom-${appendedEntries.length}`,
|
||||
parentId: branch.at(-1)?.id ?? null,
|
||||
timestamp: new Date(10_000 + appendedEntries.length).toISOString(),
|
||||
customType,
|
||||
data,
|
||||
};
|
||||
branch.push(entry);
|
||||
entries.push(entry);
|
||||
},
|
||||
} as any);
|
||||
|
||||
return {
|
||||
commands,
|
||||
handlers,
|
||||
appendedEntries,
|
||||
statuses,
|
||||
ctx,
|
||||
setBranch(nextBranch: SessionEntry[]) {
|
||||
branch = [...nextBranch];
|
||||
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
for (const entry of nextBranch) {
|
||||
byId.set(entry.id, entry);
|
||||
}
|
||||
entries = [...byId.values()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the extension registers the expected hooks and commands", () => {
|
||||
const harness = createHarness([]);
|
||||
|
||||
assert.deepEqual([...harness.commands.keys()].sort(), ["ctx-compact", "ctx-memory", "ctx-mode", "ctx-refresh", "ctx-status"]);
|
||||
assert.deepEqual(
|
||||
[...harness.handlers.keys()].sort(),
|
||||
["context", "session_before_compact", "session_before_tree", "session_compact", "session_start", "session_tree", "tool_result", "turn_end"],
|
||||
);
|
||||
});
|
||||
|
||||
test("turn_end persists a rebuilt snapshot that includes branch user and assistant facts", async () => {
|
||||
const branch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-1", null, { text: "Stale snapshot fact" }),
|
||||
createMessageEntry("user-1", "snapshot-1", createUserMessage("Goal: Fix Task 6\nPrefer keeping the public API stable", 2)),
|
||||
createMessageEntry(
|
||||
"assistant-1",
|
||||
"user-1",
|
||||
createAssistantMessage("Decision: rebuild from ctx.sessionManager.getBranch()\nNext: add integration tests", 3),
|
||||
),
|
||||
createMessageEntry(
|
||||
"tool-1",
|
||||
"assistant-1",
|
||||
createToolResultMessage("Opened .pi/agent/extensions/context-manager/index.ts", 4),
|
||||
),
|
||||
];
|
||||
|
||||
const harness = createHarness(branch, { usageTokens: 120_000 });
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
await harness.handlers.get("turn_end")?.(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 1,
|
||||
message: createAssistantMessage("done", 5),
|
||||
toolResults: [],
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.equal(harness.appendedEntries.length, 1);
|
||||
assert.equal(harness.appendedEntries[0]?.customType, SNAPSHOT_ENTRY_TYPE);
|
||||
|
||||
const snapshot = harness.appendedEntries[0]!.data as any;
|
||||
const activeTexts = snapshot.ledger.items.filter((item: any) => item.active).map((item: any) => item.text);
|
||||
|
||||
assert.equal(snapshot.mode, "aggressive");
|
||||
assert.equal(snapshot.lastCompactionSummary, "existing compaction summary");
|
||||
assert.equal(snapshot.lastBranchSummary, "existing branch summary");
|
||||
assert.equal(snapshot.lastObservedTokens, 120_000);
|
||||
assert.equal(snapshot.lastZone, "yellow");
|
||||
assert.deepEqual(activeTexts, ["Stale snapshot fact", "Fix Task 6", "Prefer keeping the public API stable", "rebuild from ctx.sessionManager.getBranch()", "add integration tests", ".pi/agent/extensions/context-manager/index.ts"]);
|
||||
assert.deepEqual(harness.statuses.at(-1), { key: "context-manager", value: "ctx yellow" });
|
||||
});
|
||||
|
||||
test("session_tree rebuilds runtime from snapshot-only branches before injecting the next packet", async () => {
|
||||
const oldBranch: SessionEntry[] = [createSnapshotEntry("snapshot-old", null, { text: "Old branch goal" })];
|
||||
const newBranch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-new", null, {
|
||||
text: "Snapshot-only branch goal",
|
||||
ledgerItems: [
|
||||
{
|
||||
id: "goal:session:root-goal:snapshot-new",
|
||||
kind: "goal",
|
||||
subject: "root-goal",
|
||||
text: "Snapshot-only branch goal",
|
||||
scope: "session",
|
||||
sourceEntryId: "snapshot-new",
|
||||
sourceType: "user",
|
||||
timestamp: 11,
|
||||
confidence: 1,
|
||||
freshness: 11,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
{
|
||||
id: "decision:branch:branch-decision:snapshot-new",
|
||||
kind: "decision",
|
||||
subject: "branch-decision",
|
||||
text: "Use the snapshot-backed branch state immediately",
|
||||
scope: "branch",
|
||||
sourceEntryId: "snapshot-new",
|
||||
sourceType: "assistant",
|
||||
timestamp: 12,
|
||||
confidence: 0.9,
|
||||
freshness: 12,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
],
|
||||
rollingSummary: "snapshot-only branch state",
|
||||
}),
|
||||
];
|
||||
|
||||
const harness = createHarness(oldBranch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
harness.setBranch(newBranch);
|
||||
await harness.handlers.get("session_tree")?.(
|
||||
{
|
||||
type: "session_tree",
|
||||
oldLeafId: "snapshot-old",
|
||||
newLeafId: "snapshot-new",
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{
|
||||
type: "context",
|
||||
messages: [createUserMessage("What should happen next?", 13)],
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.messages[0]?.role, "custom");
|
||||
assert.equal(result.messages[0]?.customType, "context-manager.resume");
|
||||
assert.match(result.messages[0]?.content, /Snapshot-only branch goal/);
|
||||
assert.match(result.messages[0]?.content, /Use the snapshot-backed branch state immediately/);
|
||||
assert.doesNotMatch(result.messages[0]?.content, /Old branch goal/);
|
||||
assert.deepEqual(harness.statuses.at(-1), { key: "context-manager", value: "ctx red" });
|
||||
});
|
||||
|
||||
test("context keeps a distilled stale tool result visible after pruning bulky output", async () => {
|
||||
const bulkyFailure = [
|
||||
"Build failed while compiling focus parser",
|
||||
"Error: missing export createFocusMatcher from ./summary-focus.ts",
|
||||
...Array.from({ length: 220 }, () => "stack frame"),
|
||||
].join("\n");
|
||||
|
||||
const harness = createHarness([]);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{
|
||||
type: "context",
|
||||
messages: [
|
||||
createUserMessage("turn 1", 1),
|
||||
createToolResultMessage(bulkyFailure, 2),
|
||||
createAssistantMessage("observed turn 1", 3),
|
||||
createUserMessage("turn 2", 4),
|
||||
createAssistantMessage("observed turn 2", 5),
|
||||
createUserMessage("turn 3", 6),
|
||||
createAssistantMessage("observed turn 3", 7),
|
||||
createUserMessage("turn 4", 8),
|
||||
createAssistantMessage("observed turn 4", 9),
|
||||
createUserMessage("turn 5", 10),
|
||||
],
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
const toolResult = result.messages.find((message: any) => message.role === "toolResult");
|
||||
assert.ok(toolResult);
|
||||
assert.match(toolResult.content[0].text, /missing export createFocusMatcher/);
|
||||
assert.ok(toolResult.content[0].text.length < 320);
|
||||
});
|
||||
|
||||
test("session_tree preserves session-scoped facts but drops stale branch handoff metadata on an empty destination branch", async () => {
|
||||
const sourceBranch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-session", null, {
|
||||
text: "Ship the context manager extension",
|
||||
mode: "balanced",
|
||||
lastZone: "yellow",
|
||||
lastObservedTokens: 120_000,
|
||||
lastCompactionSummary: "## Key Decisions\n- Keep summaries deterministic.",
|
||||
lastBranchSummary: "# Handoff for branch\n\n## Key Decisions\n- Do not leak branch-local goals.",
|
||||
}),
|
||||
];
|
||||
|
||||
const harness = createHarness(sourceBranch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
harness.setBranch([]);
|
||||
await harness.handlers.get("session_tree")?.(
|
||||
{
|
||||
type: "session_tree",
|
||||
oldLeafId: "snapshot-session",
|
||||
newLeafId: null,
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue", 30)] },
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.messages[0]?.customType, "context-manager.packet");
|
||||
assert.match(result.messages[0]?.content, /Ship the context manager extension/);
|
||||
assert.doesNotMatch(result.messages[0]?.content, /Do not leak branch-local goals/);
|
||||
assert.doesNotMatch(result.messages[0]?.content, /Keep summaries deterministic/);
|
||||
});
|
||||
|
||||
test("session_tree overlays newer session-scoped facts onto a destination branch with an older snapshot", async () => {
|
||||
const newerSessionSnapshot = createSnapshotEntry("snapshot-newer", null, {
|
||||
text: "Ship the context manager extension",
|
||||
ledgerItems: [
|
||||
{
|
||||
id: "goal:session:root-goal:snapshot-newer",
|
||||
kind: "goal",
|
||||
subject: "root-goal",
|
||||
text: "Ship the context manager extension",
|
||||
scope: "session",
|
||||
sourceEntryId: "snapshot-newer",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 1,
|
||||
freshness: 1,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
{
|
||||
id: "constraint:session:must-session-newer:2",
|
||||
kind: "constraint",
|
||||
subject: "must-session-newer",
|
||||
text: "Prefer concise reports across the whole session.",
|
||||
scope: "session",
|
||||
sourceEntryId: "snapshot-newer",
|
||||
sourceType: "user",
|
||||
timestamp: 2,
|
||||
confidence: 0.9,
|
||||
freshness: 2,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
],
|
||||
lastCompactionSummary: "",
|
||||
lastBranchSummary: "",
|
||||
});
|
||||
const olderBranchSnapshot = createSnapshotEntry("snapshot-older", null, {
|
||||
text: "Ship the context manager extension",
|
||||
ledgerItems: [
|
||||
{
|
||||
id: "goal:session:root-goal:snapshot-older",
|
||||
kind: "goal",
|
||||
subject: "root-goal",
|
||||
text: "Ship the context manager extension",
|
||||
scope: "session",
|
||||
sourceEntryId: "snapshot-older",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 1,
|
||||
freshness: 1,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
],
|
||||
lastCompactionSummary: "",
|
||||
lastBranchSummary: "",
|
||||
});
|
||||
|
||||
const harness = createHarness([newerSessionSnapshot]);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
harness.setBranch([olderBranchSnapshot]);
|
||||
await harness.handlers.get("session_tree")?.(
|
||||
{
|
||||
type: "session_tree",
|
||||
oldLeafId: "snapshot-newer",
|
||||
newLeafId: "snapshot-older",
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue", 32)] },
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.match(result.messages[0]?.content, /Prefer concise reports across the whole session/);
|
||||
});
|
||||
|
||||
test("ctx-refresh preserves session memory without leaking old handoff summaries on a snapshot-less branch", async () => {
|
||||
const sourceBranch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-refresh", null, {
|
||||
text: "Ship the context manager extension",
|
||||
mode: "balanced",
|
||||
lastZone: "yellow",
|
||||
lastObservedTokens: 120_000,
|
||||
lastCompactionSummary: "## Key Decisions\n- Keep summaries deterministic.",
|
||||
lastBranchSummary: "# Handoff for branch\n\n## Key Decisions\n- Do not leak branch-local goals.",
|
||||
}),
|
||||
];
|
||||
|
||||
const harness = createHarness(sourceBranch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
harness.setBranch([]);
|
||||
await harness.commands.get("ctx-refresh")?.handler("", harness.ctx);
|
||||
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue", 31)] },
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.messages[0]?.customType, "context-manager.packet");
|
||||
assert.match(result.messages[0]?.content, /Ship the context manager extension/);
|
||||
assert.doesNotMatch(result.messages[0]?.content, /Do not leak branch-local goals/);
|
||||
assert.doesNotMatch(result.messages[0]?.content, /Keep summaries deterministic/);
|
||||
});
|
||||
|
||||
test("session_start replays default pi compaction blockers into resume state", async () => {
|
||||
const branch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-default", null, {
|
||||
text: "Ship the context manager extension",
|
||||
lastCompactionSummary: undefined,
|
||||
lastBranchSummary: undefined,
|
||||
}),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction-default-1",
|
||||
parentId: "snapshot-default",
|
||||
timestamp: new Date(40).toISOString(),
|
||||
summary: [
|
||||
"## Progress",
|
||||
"### Blocked",
|
||||
"- confirm whether /tree replaceInstructions should override defaults",
|
||||
].join("\n"),
|
||||
firstKeptEntryId: "snapshot-default",
|
||||
tokensBefore: 123_000,
|
||||
},
|
||||
];
|
||||
|
||||
const harness = createHarness(branch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue", 41)] },
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.match(result.messages[0]?.content, /confirm whether \/tree replaceInstructions should override defaults/);
|
||||
});
|
||||
|
||||
test("session_before_compact honors preparation inputs and custom focus", async () => {
|
||||
const harness = createHarness([]);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
const result = await harness.handlers.get("session_before_compact")?.(
|
||||
{
|
||||
type: "session_before_compact",
|
||||
customInstructions: "Focus on decisions and relevant files.",
|
||||
preparation: {
|
||||
messagesToSummarize: [createUserMessage("Decision: keep compaction summaries deterministic", 1)],
|
||||
turnPrefixMessages: [createToolResultMessage("Opened .pi/agent/extensions/context-manager/index.ts", 2)],
|
||||
previousSummary: "## Goal\n- Ship the context manager extension",
|
||||
fileOps: {
|
||||
readFiles: [".pi/agent/extensions/context-manager/index.ts"],
|
||||
modifiedFiles: [".pi/agent/extensions/context-manager/src/summaries.ts"],
|
||||
},
|
||||
tokensBefore: 120_000,
|
||||
firstKeptEntryId: "keep-1",
|
||||
},
|
||||
branchEntries: [],
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.compaction.firstKeptEntryId, "keep-1");
|
||||
assert.equal(result.compaction.tokensBefore, 120_000);
|
||||
assert.match(result.compaction.summary, /keep compaction summaries deterministic/);
|
||||
assert.match(result.compaction.summary, /index.ts/);
|
||||
});
|
||||
|
||||
test("session_before_tree honors abandoned-branch entries and focus text", async () => {
|
||||
const harness = createHarness([]);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
const result = await harness.handlers.get("session_before_tree")?.(
|
||||
{
|
||||
type: "session_before_tree",
|
||||
preparation: {
|
||||
targetId: "target-1",
|
||||
oldLeafId: "old-1",
|
||||
commonAncestorId: "root",
|
||||
userWantsSummary: true,
|
||||
customInstructions: "Focus on goals and decisions.",
|
||||
replaceInstructions: false,
|
||||
entriesToSummarize: [
|
||||
createMessageEntry("user-1", null, createUserMessage("Goal: explore tree handoff", 1)),
|
||||
createMessageEntry("assistant-1", "user-1", createAssistantMessage("Decision: do not leak branch-local goals", 2)),
|
||||
],
|
||||
},
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.ok(result?.summary?.summary);
|
||||
assert.match(result.summary.summary, /explore tree handoff/);
|
||||
assert.match(result.summary.summary, /do not leak branch-local goals/);
|
||||
});
|
||||
|
||||
test("session_compact persists the latest compaction summary into a fresh snapshot and injects a resume packet once", async () => {
|
||||
const harness = createHarness([]);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
await harness.handlers.get("session_compact")?.(
|
||||
{
|
||||
type: "session_compact",
|
||||
fromExtension: true,
|
||||
compactionEntry: {
|
||||
type: "compaction",
|
||||
id: "cmp-1",
|
||||
parentId: "prev",
|
||||
timestamp: new Date(10).toISOString(),
|
||||
summary: "## Key Decisions\n- Keep summaries deterministic.\n\n## Open questions and blockers\n- Verify /tree replaceInstructions behavior.",
|
||||
firstKeptEntryId: "keep-1",
|
||||
tokensBefore: 140_000,
|
||||
},
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.equal(harness.appendedEntries.at(-1)?.customType, SNAPSHOT_ENTRY_TYPE);
|
||||
|
||||
const context = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue", 11)] },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(context.messages[0]?.content, /Keep summaries deterministic/);
|
||||
assert.match(context.messages[0]?.content, /Verify \/tree replaceInstructions behavior/);
|
||||
|
||||
const nextContext = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue again", 12)] },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.equal(nextContext.messages[0]?.customType, "context-manager.packet");
|
||||
assert.doesNotMatch(nextContext.messages[0]?.content ?? "", /## Latest compaction handoff/);
|
||||
});
|
||||
|
||||
test("session_tree replays branch summaries newer than the latest snapshot before the next packet is injected", async () => {
|
||||
const branch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-1", null, { text: "Ship the context manager extension" }),
|
||||
{
|
||||
type: "branch_summary",
|
||||
id: "branch-summary-1",
|
||||
parentId: "snapshot-1",
|
||||
timestamp: new Date(20).toISOString(),
|
||||
fromId: "old-leaf",
|
||||
summary: "# Handoff for branch\n\n## Key Decisions\n- Do not leak branch-local goals.",
|
||||
},
|
||||
];
|
||||
|
||||
const harness = createHarness(branch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
const context = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("what next", 21)] },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(context.messages[0]?.content, /Do not leak branch-local goals/);
|
||||
});
|
||||
|
||||
|
||||
test("session_tree records event summaryEntry before persisting the next snapshot", async () => {
|
||||
const branch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-1", null, {
|
||||
text: "Ship the context manager extension",
|
||||
lastCompactionSummary: "",
|
||||
lastBranchSummary: "",
|
||||
}),
|
||||
];
|
||||
|
||||
const harness = createHarness(branch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
await harness.handlers.get("session_tree")?.(
|
||||
{
|
||||
type: "session_tree",
|
||||
fromExtension: true,
|
||||
summaryEntry: {
|
||||
type: "branch_summary",
|
||||
id: "branch-summary-event",
|
||||
parentId: "snapshot-1",
|
||||
timestamp: new Date(20).toISOString(),
|
||||
fromId: "old-leaf",
|
||||
summary: "# Handoff for branch\n\n## Key Decisions\n- Preserve the latest branch summary from the event payload.",
|
||||
},
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
const snapshot = harness.appendedEntries.at(-1)?.data as RuntimeSnapshot | undefined;
|
||||
assert.match(snapshot?.lastBranchSummary ?? "", /Preserve the latest branch summary/);
|
||||
|
||||
const context = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("what changed", 21)] },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(context.messages[0]?.content, /Preserve the latest branch summary/);
|
||||
});
|
||||
|
||||
test("ctx-status reports mode, zone, packet size, and summary-artifact presence", async () => {
|
||||
const branch = [
|
||||
createSnapshotEntry("snapshot-1", null, {
|
||||
text: "Ship the context manager extension",
|
||||
mode: "balanced",
|
||||
lastZone: "yellow",
|
||||
lastObservedTokens: 120_000,
|
||||
lastCompactionSummary: "## Key Decisions\n- Keep summaries deterministic.",
|
||||
lastBranchSummary: "# Handoff for branch\n\n## Key Decisions\n- Do not leak branch-local goals.",
|
||||
}),
|
||||
];
|
||||
|
||||
const notifications: string[] = [];
|
||||
const harness = createHarness(branch);
|
||||
harness.ctx.ui.notify = (message: string) => notifications.push(message);
|
||||
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
await harness.commands.get("ctx-status")?.handler("", harness.ctx);
|
||||
|
||||
assert.match(notifications.at(-1) ?? "", /mode=balanced/);
|
||||
assert.match(notifications.at(-1) ?? "", /zone=yellow/);
|
||||
assert.match(notifications.at(-1) ?? "", /compaction=yes/);
|
||||
assert.match(notifications.at(-1) ?? "", /branch=yes/);
|
||||
});
|
||||
|
||||
test("ctx-refresh rebuilds runtime from the current branch instead of only re-rendering the packet", async () => {
|
||||
const harness = createHarness([createSnapshotEntry("snapshot-1", null, { text: "Old goal" })]);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
harness.setBranch([
|
||||
createSnapshotEntry("snapshot-2", null, {
|
||||
text: "New branch goal",
|
||||
lastBranchSummary: "# Handoff for branch\n\n## Key Decisions\n- Use the new branch immediately.",
|
||||
}),
|
||||
]);
|
||||
|
||||
await harness.commands.get("ctx-refresh")?.handler("", harness.ctx);
|
||||
const result = await harness.handlers.get("context")?.(
|
||||
{ type: "context", messages: [createUserMessage("continue", 3)] },
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.match(result.messages[0]?.content, /New branch goal/);
|
||||
assert.doesNotMatch(result.messages[0]?.content, /Old goal/);
|
||||
});
|
||||
|
||||
test("ctx-mode persists the updated mode immediately without waiting for turn_end", async () => {
|
||||
const branch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-1", null, {
|
||||
text: "Persist the updated mode",
|
||||
mode: "balanced",
|
||||
lastZone: "green",
|
||||
lastObservedTokens: 90_000,
|
||||
}),
|
||||
];
|
||||
|
||||
const harness = createHarness(branch);
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
assert.equal(deserializeLatestSnapshot(harness.ctx.sessionManager.getBranch())?.mode, "balanced");
|
||||
|
||||
const modeCommand = harness.commands.get("ctx-mode");
|
||||
assert.ok(modeCommand);
|
||||
await modeCommand.handler("aggressive", harness.ctx);
|
||||
|
||||
assert.equal(harness.appendedEntries.length, 1);
|
||||
assert.equal(harness.appendedEntries[0]?.customType, SNAPSHOT_ENTRY_TYPE);
|
||||
assert.equal(deserializeLatestSnapshot(harness.ctx.sessionManager.getBranch())?.mode, "aggressive");
|
||||
});
|
||||
|
||||
test("ctx-mode changes survive turn_end and persist into the next snapshot", async () => {
|
||||
const branch: SessionEntry[] = [
|
||||
createSnapshotEntry("snapshot-1", null, {
|
||||
text: "Persist the updated mode",
|
||||
mode: "balanced",
|
||||
lastZone: "green",
|
||||
lastObservedTokens: 90_000,
|
||||
}),
|
||||
];
|
||||
|
||||
const harness = createHarness(branch, { usageTokens: 105_000 });
|
||||
await harness.handlers.get("session_start")?.({ type: "session_start" }, harness.ctx);
|
||||
|
||||
const modeCommand = harness.commands.get("ctx-mode");
|
||||
assert.ok(modeCommand);
|
||||
await modeCommand.handler("aggressive", harness.ctx);
|
||||
|
||||
await harness.handlers.get("turn_end")?.(
|
||||
{
|
||||
type: "turn_end",
|
||||
turnIndex: 1,
|
||||
message: createAssistantMessage("done", 5),
|
||||
toolResults: [],
|
||||
},
|
||||
harness.ctx,
|
||||
);
|
||||
|
||||
assert.equal(harness.appendedEntries.length, 2);
|
||||
|
||||
const immediateSnapshot = harness.appendedEntries[0]!.data as any;
|
||||
assert.equal(immediateSnapshot.mode, "aggressive");
|
||||
assert.equal(immediateSnapshot.lastObservedTokens, 90_000);
|
||||
assert.equal(immediateSnapshot.lastZone, "green");
|
||||
|
||||
const snapshot = harness.appendedEntries[1]!.data as any;
|
||||
assert.equal(snapshot.mode, "aggressive");
|
||||
assert.equal(snapshot.lastObservedTokens, 105_000);
|
||||
assert.equal(snapshot.lastZone, "yellow");
|
||||
});
|
||||
280
.pi/agent/extensions/context-manager/src/extract.test.ts
Normal file
280
.pi/agent/extensions/context-manager/src/extract.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractCandidates } from "./extract.ts";
|
||||
import { createEmptyLedger, getActiveItems, mergeCandidates } from "./ledger.ts";
|
||||
|
||||
test("extractCandidates pulls goals, constraints, decisions, next steps, and file references", () => {
|
||||
const candidates = extractCandidates({
|
||||
entryId: "u1",
|
||||
role: "user",
|
||||
text: [
|
||||
"Goal: Build a context manager extension for pi.",
|
||||
"We must adapt to the active model context window.",
|
||||
"Decision: keep the MVP quiet and avoid new LLM-facing tools.",
|
||||
"Next: inspect .pi/agent/extensions/web-search/index.ts and docs/extensions.md.",
|
||||
].join("\n"),
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
candidates.map((candidate) => [
|
||||
candidate.kind,
|
||||
candidate.subject,
|
||||
candidate.scope,
|
||||
candidate.sourceEntryId,
|
||||
candidate.sourceType,
|
||||
candidate.timestamp,
|
||||
]),
|
||||
[
|
||||
["goal", "root-goal", "session", "u1", "user", 1],
|
||||
["constraint", "must-u1-0", "branch", "u1", "user", 1],
|
||||
["decision", "decision-u1-0", "branch", "u1", "user", 1],
|
||||
["activeTask", "next-step-u1-0", "branch", "u1", "user", 1],
|
||||
["relevantFile", ".pi/agent/extensions/web-search/index.ts", "branch", "u1", "user", 1],
|
||||
["relevantFile", "docs/extensions.md", "branch", "u1", "user", 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCandidates promotes only the first durable goal to session scope", () => {
|
||||
const firstGoal = extractCandidates(
|
||||
{
|
||||
entryId: "u-goal-1",
|
||||
role: "user",
|
||||
text: "Goal: Ship the context manager extension.",
|
||||
timestamp: 10,
|
||||
},
|
||||
{ hasSessionGoal: false },
|
||||
);
|
||||
|
||||
const branchGoal = extractCandidates(
|
||||
{
|
||||
entryId: "u-goal-2",
|
||||
role: "user",
|
||||
text: "Goal: prototype a branch-local tree handoff.",
|
||||
timestamp: 11,
|
||||
},
|
||||
{ hasSessionGoal: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
firstGoal.map((candidate) => [candidate.kind, candidate.subject, candidate.scope, candidate.text]),
|
||||
[["goal", "root-goal", "session", "Ship the context manager extension."]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
branchGoal.map((candidate) => [candidate.kind, candidate.subject, candidate.scope, candidate.text]),
|
||||
[["goal", "goal-u-goal-2-0", "branch", "prototype a branch-local tree handoff."]],
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeCandidates keeps independently extracted decisions, constraints, and next steps active", () => {
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
...extractCandidates({
|
||||
entryId: "u1",
|
||||
role: "user",
|
||||
text: [
|
||||
"We must adapt to the active model context window.",
|
||||
"Decision: keep snapshots tiny.",
|
||||
"Next: inspect src/extract.ts.",
|
||||
].join("\n"),
|
||||
timestamp: 1,
|
||||
}),
|
||||
...extractCandidates({
|
||||
entryId: "u2",
|
||||
role: "user",
|
||||
text: [
|
||||
"We prefer concise reports across the whole session.",
|
||||
"Decision: persist snapshots after each turn_end.",
|
||||
"Task: add regression coverage.",
|
||||
].join("\n"),
|
||||
timestamp: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
getActiveItems(ledger, "constraint").map((item) => [item.subject, item.sourceEntryId, item.text]),
|
||||
[
|
||||
["must-u1-0", "u1", "We must adapt to the active model context window."],
|
||||
["must-u2-0", "u2", "We prefer concise reports across the whole session."],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getActiveItems(ledger, "decision").map((item) => [item.subject, item.sourceEntryId, item.text]),
|
||||
[
|
||||
["decision-u1-0", "u1", "keep snapshots tiny."],
|
||||
["decision-u2-0", "u2", "persist snapshots after each turn_end."],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getActiveItems(ledger, "activeTask").map((item) => [item.subject, item.sourceEntryId, item.text]),
|
||||
[
|
||||
["next-step-u1-0", "u1", "inspect src/extract.ts."],
|
||||
["next-step-u2-0", "u2", "add regression coverage."],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("user constraints default to branch scope unless they explicitly signal durable session scope", () => {
|
||||
const candidates = extractCandidates({
|
||||
entryId: "u3",
|
||||
role: "user",
|
||||
text: [
|
||||
"We should keep this branch experimental for now.",
|
||||
"We should keep the MVP branch experimental.",
|
||||
"We should rename the context window helper in this module.",
|
||||
"Avoid touching docs/extensions.md.",
|
||||
"Avoid touching docs/extensions.md across the whole session.",
|
||||
"Prefer concise reports across the whole session.",
|
||||
].join("\n"),
|
||||
timestamp: 3,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
candidates
|
||||
.filter((candidate) => candidate.kind === "constraint")
|
||||
.map((candidate) => [candidate.text, candidate.scope, candidate.subject]),
|
||||
[
|
||||
["We should keep this branch experimental for now.", "branch", "must-u3-0"],
|
||||
["We should keep the MVP branch experimental.", "branch", "must-u3-1"],
|
||||
["We should rename the context window helper in this module.", "branch", "must-u3-2"],
|
||||
["Avoid touching docs/extensions.md.", "branch", "must-u3-3"],
|
||||
["Avoid touching docs/extensions.md across the whole session.", "session", "must-u3-4"],
|
||||
["Prefer concise reports across the whole session.", "session", "must-u3-5"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCandidates treats spelled-out do not as a constraint trigger", () => {
|
||||
const candidates = extractCandidates({
|
||||
entryId: "u4",
|
||||
role: "user",
|
||||
text: "Do not add new LLM-facing tools across the whole session.",
|
||||
timestamp: 4,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
candidates.map((candidate) => [candidate.kind, candidate.text, candidate.scope, candidate.subject]),
|
||||
[["constraint", "Do not add new LLM-facing tools across the whole session.", "session", "must-u4-0"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCandidates keeps compaction goals branch-scoped unless they are explicitly session-wide", () => {
|
||||
const candidates = extractCandidates(
|
||||
{
|
||||
entryId: "cmp-goal-1",
|
||||
role: "compaction",
|
||||
text: "## Goal\n- prototype a branch-local tree handoff.",
|
||||
timestamp: 19,
|
||||
},
|
||||
{ hasSessionGoal: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
candidates.map((candidate) => [candidate.kind, candidate.subject, candidate.scope, candidate.text]),
|
||||
[["goal", "goal-cmp-goal-1-0", "branch", "prototype a branch-local tree handoff."]],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCandidates captures blockers from direct lines, tool errors, and structured summaries", () => {
|
||||
const direct = extractCandidates(
|
||||
{
|
||||
entryId: "a-blocked-1",
|
||||
role: "assistant",
|
||||
text: "Blocked: confirm whether /tree summaries should replace instructions.",
|
||||
timestamp: 20,
|
||||
},
|
||||
{ hasSessionGoal: true },
|
||||
);
|
||||
|
||||
const tool = extractCandidates(
|
||||
{
|
||||
entryId: "t-blocked-1",
|
||||
role: "toolResult",
|
||||
text: "Error: missing export createFocusMatcher\nstack...",
|
||||
timestamp: 21,
|
||||
isError: true,
|
||||
},
|
||||
{ hasSessionGoal: true },
|
||||
);
|
||||
|
||||
const summary = extractCandidates(
|
||||
{
|
||||
entryId: "cmp-1",
|
||||
role: "compaction",
|
||||
text: [
|
||||
"## Open questions and blockers",
|
||||
"- Need to confirm whether /tree summaries should replace instructions.",
|
||||
"",
|
||||
"## Relevant files",
|
||||
"- .pi/agent/extensions/context-manager/index.ts",
|
||||
].join("\n"),
|
||||
timestamp: 22,
|
||||
},
|
||||
{ hasSessionGoal: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
direct.map((candidate) => [candidate.kind, candidate.subject, candidate.text]),
|
||||
[["openQuestion", "open-question-a-blocked-1-0", "confirm whether /tree summaries should replace instructions."]],
|
||||
);
|
||||
assert.equal(tool[0]?.kind, "openQuestion");
|
||||
assert.match(tool[0]?.text ?? "", /missing export createFocusMatcher/);
|
||||
assert.deepEqual(
|
||||
summary.map((candidate) => [candidate.kind, candidate.text]),
|
||||
[
|
||||
["openQuestion", "Need to confirm whether /tree summaries should replace instructions."],
|
||||
["relevantFile", ".pi/agent/extensions/context-manager/index.ts"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCandidates parses pi fallback progress and blocked summary sections", () => {
|
||||
const candidates = extractCandidates(
|
||||
{
|
||||
entryId: "cmp-default-1",
|
||||
role: "compaction",
|
||||
text: [
|
||||
"## Constraints and preferences",
|
||||
"- Keep the public API stable.",
|
||||
"",
|
||||
"## Progress",
|
||||
"### In Progress",
|
||||
"- Wire runtime hydration.",
|
||||
"",
|
||||
"### Blocked",
|
||||
"- confirm whether /tree replaceInstructions should override defaults",
|
||||
].join("\n"),
|
||||
timestamp: 23,
|
||||
},
|
||||
{ hasSessionGoal: true },
|
||||
);
|
||||
|
||||
assert.ok(candidates.some((candidate) => candidate.kind === "constraint" && candidate.text === "Keep the public API stable."));
|
||||
assert.ok(candidates.some((candidate) => candidate.kind === "activeTask" && candidate.text === "Wire runtime hydration."));
|
||||
assert.ok(
|
||||
candidates.some(
|
||||
(candidate) => candidate.kind === "openQuestion" && candidate.text === "confirm whether /tree replaceInstructions should override defaults",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("assistant decisions and tool-result file references are extracted as branch facts", () => {
|
||||
const assistant = extractCandidates({
|
||||
entryId: "a1",
|
||||
role: "assistant",
|
||||
text: "Decision: persist snapshots after each turn_end.",
|
||||
timestamp: 2,
|
||||
});
|
||||
|
||||
const tool = extractCandidates({
|
||||
entryId: "t1",
|
||||
role: "toolResult",
|
||||
text: "Updated file: .pi/agent/extensions/context-manager/src/runtime.ts",
|
||||
timestamp: 3,
|
||||
});
|
||||
|
||||
assert.equal(assistant[0]?.kind, "decision");
|
||||
assert.equal(assistant[0]?.subject, "decision-a1-0");
|
||||
assert.equal(assistant[0]?.scope, "branch");
|
||||
assert.equal(tool[0]?.kind, "relevantFile");
|
||||
});
|
||||
314
.pi/agent/extensions/context-manager/src/extract.ts
Normal file
314
.pi/agent/extensions/context-manager/src/extract.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import type { MemoryCandidate, MemoryScope, MemorySourceType } from "./ledger.ts";
|
||||
|
||||
export interface ExtractOptions {
|
||||
hasSessionGoal?: boolean;
|
||||
}
|
||||
|
||||
export interface TranscriptSlice {
|
||||
entryId: string;
|
||||
role: "user" | "assistant" | "toolResult" | "compaction" | "branchSummary";
|
||||
text: string;
|
||||
timestamp: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
const FILE_RE = /(?:\.?\/?[A-Za-z0-9_./-]+\.(?:ts|tsx|js|mjs|json|md))/g;
|
||||
const BRANCH_LOCAL_CONSTRAINT_RE =
|
||||
/\b(?:this|current)\s+(?:branch|task|change|step|file|module|test|command|implementation|worktree)\b|\b(?:for now|right now|in this branch|on this branch|in this file|in this module|here)\b/i;
|
||||
const DURABLE_SESSION_CONSTRAINT_RE =
|
||||
/\b(?:whole|entire|rest of (?:the )?|remaining)\s+(?:session|project|codebase)\b|\bacross (?:the )?(?:whole )?(?:session|project|codebase)\b|\bacross (?:all |every )?branches\b|\b(?:session|project|codebase)[-\s]?wide\b|\bthroughout (?:the )?(?:session|project|codebase)\b/i;
|
||||
const CONSTRAINT_RE = /\b(?:must|should|don't|do not|avoid|prefer)\b/i;
|
||||
const GOAL_RE = /^(goal|session goal|overall goal):/i;
|
||||
const OPEN_QUESTION_RE = /^(?:blocked|blocker|open question|question):/i;
|
||||
const ERROR_LINE_RE = /\b(?:error|failed|failure|missing|undefined|exception)\b/i;
|
||||
|
||||
type SummarySectionKind = "goal" | "constraint" | "decision" | "activeTask" | "openQuestion" | "relevantFile";
|
||||
|
||||
function sourceTypeForRole(role: TranscriptSlice["role"]): MemorySourceType {
|
||||
if (role === "compaction") return "compaction";
|
||||
if (role === "branchSummary") return "branchSummary";
|
||||
return role;
|
||||
}
|
||||
|
||||
function pushCandidate(
|
||||
list: MemoryCandidate[],
|
||||
candidate: Omit<MemoryCandidate, "sourceEntryId" | "sourceType" | "timestamp">,
|
||||
slice: TranscriptSlice,
|
||||
) {
|
||||
list.push({
|
||||
...candidate,
|
||||
sourceEntryId: slice.entryId,
|
||||
sourceType: sourceTypeForRole(slice.role),
|
||||
timestamp: slice.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function createIndexedSubject(prefix: string, slice: TranscriptSlice, index: number): string {
|
||||
return `${prefix}-${slice.entryId}-${index}`;
|
||||
}
|
||||
|
||||
function inferConstraintScope(slice: TranscriptSlice, line: string): MemoryScope {
|
||||
if (slice.role !== "user") {
|
||||
return "branch";
|
||||
}
|
||||
|
||||
if (BRANCH_LOCAL_CONSTRAINT_RE.test(line)) {
|
||||
return "branch";
|
||||
}
|
||||
|
||||
if (DURABLE_SESSION_CONSTRAINT_RE.test(line)) {
|
||||
return "session";
|
||||
}
|
||||
|
||||
if ((line.match(FILE_RE) ?? []).length > 0) {
|
||||
return "branch";
|
||||
}
|
||||
|
||||
return "branch";
|
||||
}
|
||||
|
||||
function nextGoalCandidate(
|
||||
line: string,
|
||||
slice: TranscriptSlice,
|
||||
options: ExtractOptions,
|
||||
index: number,
|
||||
): Omit<MemoryCandidate, "sourceEntryId" | "sourceType" | "timestamp"> {
|
||||
const text = line.replace(GOAL_RE, "").trim();
|
||||
const explicitSessionGoal = /^(session goal|overall goal):/i.test(line);
|
||||
const canSeedSessionGoal = slice.role === "user";
|
||||
const shouldPromoteRootGoal = explicitSessionGoal || (!options.hasSessionGoal && canSeedSessionGoal);
|
||||
|
||||
if (shouldPromoteRootGoal) {
|
||||
return {
|
||||
kind: "goal",
|
||||
subject: "root-goal",
|
||||
text,
|
||||
scope: "session",
|
||||
confidence: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "goal",
|
||||
subject: createIndexedSubject("goal", slice, index),
|
||||
text,
|
||||
scope: "branch",
|
||||
confidence: 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
function nextOpenQuestionCandidate(
|
||||
text: string,
|
||||
slice: TranscriptSlice,
|
||||
index: number,
|
||||
): Omit<MemoryCandidate, "sourceEntryId" | "sourceType" | "timestamp"> {
|
||||
return {
|
||||
kind: "openQuestion",
|
||||
subject: createIndexedSubject("open-question", slice, index),
|
||||
text,
|
||||
scope: "branch",
|
||||
confidence: slice.role === "toolResult" ? 0.85 : 0.8,
|
||||
};
|
||||
}
|
||||
|
||||
function summarySectionToKind(line: string): SummarySectionKind | undefined {
|
||||
const heading = line.replace(/^##\s+/i, "").trim().toLowerCase();
|
||||
|
||||
if (heading === "goal") return "goal";
|
||||
if (heading === "constraints" || heading === "constraints & preferences" || heading === "constraints and preferences") {
|
||||
return "constraint";
|
||||
}
|
||||
if (heading === "decisions" || heading === "key decisions") return "decision";
|
||||
if (heading === "active work" || heading === "next steps" || heading === "current task" || heading === "progress") {
|
||||
return "activeTask";
|
||||
}
|
||||
if (heading === "open questions and blockers" || heading === "open questions / blockers") return "openQuestion";
|
||||
if (heading === "relevant files" || heading === "critical context") return "relevantFile";
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pushRelevantFiles(list: MemoryCandidate[], slice: TranscriptSlice, line: string) {
|
||||
const fileMatches = line.match(FILE_RE) ?? [];
|
||||
for (const match of fileMatches) {
|
||||
pushCandidate(
|
||||
list,
|
||||
{
|
||||
kind: "relevantFile",
|
||||
subject: match,
|
||||
text: match,
|
||||
scope: "branch",
|
||||
confidence: 0.7,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractCandidates(slice: TranscriptSlice, options: ExtractOptions = {}): MemoryCandidate[] {
|
||||
const out: MemoryCandidate[] = [];
|
||||
const lines = slice.text
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let currentSection: SummarySectionKind | undefined;
|
||||
let goalIndex = 0;
|
||||
let decisionIndex = 0;
|
||||
let nextStepIndex = 0;
|
||||
let mustIndex = 0;
|
||||
let openQuestionIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^##\s+/i.test(line)) {
|
||||
currentSection = summarySectionToKind(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^###\s+/i.test(line)) {
|
||||
const subheading = line.replace(/^###\s+/i, "").trim().toLowerCase();
|
||||
if (subheading === "blocked") {
|
||||
currentSection = "openQuestion";
|
||||
} else if (subheading === "in progress" || subheading === "done") {
|
||||
currentSection = "activeTask";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = line.match(/^-\s+(.*)$/)?.[1]?.trim();
|
||||
const isGoal = GOAL_RE.test(line);
|
||||
const isDecision = /^decision:/i.test(line);
|
||||
const isNextStep = /^(next|task):/i.test(line);
|
||||
const isOpenQuestion = OPEN_QUESTION_RE.test(line);
|
||||
|
||||
if (isGoal) {
|
||||
pushCandidate(out, nextGoalCandidate(line, slice, options, goalIndex++), slice);
|
||||
pushRelevantFiles(out, slice, line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOpenQuestion) {
|
||||
pushCandidate(
|
||||
out,
|
||||
nextOpenQuestionCandidate(line.replace(OPEN_QUESTION_RE, "").trim(), slice, openQuestionIndex++),
|
||||
slice,
|
||||
);
|
||||
pushRelevantFiles(out, slice, line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDecision) {
|
||||
pushCandidate(
|
||||
out,
|
||||
{
|
||||
kind: "decision",
|
||||
subject: createIndexedSubject("decision", slice, decisionIndex++),
|
||||
text: line.replace(/^decision:\s*/i, "").trim(),
|
||||
scope: "branch",
|
||||
confidence: 0.9,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
pushRelevantFiles(out, slice, line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNextStep) {
|
||||
pushCandidate(
|
||||
out,
|
||||
{
|
||||
kind: "activeTask",
|
||||
subject: createIndexedSubject("next-step", slice, nextStepIndex++),
|
||||
text: line.replace(/^(next|task):\s*/i, "").trim(),
|
||||
scope: "branch",
|
||||
confidence: 0.8,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
pushRelevantFiles(out, slice, line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet && currentSection === "goal") {
|
||||
pushCandidate(out, nextGoalCandidate(`Goal: ${bullet}`, slice, options, goalIndex++), slice);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet && currentSection === "constraint") {
|
||||
pushCandidate(
|
||||
out,
|
||||
{
|
||||
kind: "constraint",
|
||||
subject: createIndexedSubject("must", slice, mustIndex++),
|
||||
text: bullet,
|
||||
scope: inferConstraintScope(slice, bullet),
|
||||
confidence: 0.8,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet && currentSection === "decision") {
|
||||
pushCandidate(
|
||||
out,
|
||||
{
|
||||
kind: "decision",
|
||||
subject: createIndexedSubject("decision", slice, decisionIndex++),
|
||||
text: bullet,
|
||||
scope: "branch",
|
||||
confidence: 0.9,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet && currentSection === "activeTask") {
|
||||
pushCandidate(
|
||||
out,
|
||||
{
|
||||
kind: "activeTask",
|
||||
subject: createIndexedSubject("next-step", slice, nextStepIndex++),
|
||||
text: bullet,
|
||||
scope: "branch",
|
||||
confidence: 0.8,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet && currentSection === "openQuestion") {
|
||||
pushCandidate(out, nextOpenQuestionCandidate(bullet, slice, openQuestionIndex++), slice);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet && currentSection === "relevantFile") {
|
||||
pushRelevantFiles(out, slice, bullet);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slice.role === "toolResult" && (slice.isError || ERROR_LINE_RE.test(line))) {
|
||||
pushCandidate(out, nextOpenQuestionCandidate(line, slice, openQuestionIndex++), slice);
|
||||
}
|
||||
|
||||
if (CONSTRAINT_RE.test(line)) {
|
||||
pushCandidate(
|
||||
out,
|
||||
{
|
||||
kind: "constraint",
|
||||
subject: createIndexedSubject("must", slice, mustIndex++),
|
||||
text: line,
|
||||
scope: inferConstraintScope(slice, line),
|
||||
confidence: 0.8,
|
||||
},
|
||||
slice,
|
||||
);
|
||||
}
|
||||
|
||||
pushRelevantFiles(out, slice, line);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
132
.pi/agent/extensions/context-manager/src/ledger.test.ts
Normal file
132
.pi/agent/extensions/context-manager/src/ledger.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createEmptyLedger, getActiveItems, mergeCandidates, type MemoryCandidate } from "./ledger.ts";
|
||||
|
||||
const base: Omit<MemoryCandidate, "kind" | "subject" | "text"> = {
|
||||
scope: "branch",
|
||||
sourceEntryId: "u1",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 0.9,
|
||||
};
|
||||
|
||||
test("mergeCandidates adds new active items to an empty ledger", () => {
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
{ ...base, kind: "goal", subject: "root-goal", text: "Build a pi context manager extension" },
|
||||
]);
|
||||
|
||||
assert.equal(getActiveItems(ledger).length, 1);
|
||||
assert.equal(getActiveItems(ledger)[0]?.text, "Build a pi context manager extension");
|
||||
});
|
||||
|
||||
test("mergeCandidates archives older items when a new item supersedes the same subject", () => {
|
||||
const first = mergeCandidates(createEmptyLedger(), [
|
||||
{ ...base, kind: "decision", subject: "persistence", text: "Persist snapshots with appendEntry()", timestamp: 1 },
|
||||
]);
|
||||
|
||||
const second = mergeCandidates(first, [
|
||||
{ ...base, kind: "decision", subject: "persistence", text: "Persist snapshots after each turn_end", timestamp: 2 },
|
||||
]);
|
||||
|
||||
const active = getActiveItems(second, "decision");
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(active[0]?.text, "Persist snapshots after each turn_end");
|
||||
assert.equal(active[0]?.supersedesId, "decision:branch:persistence:1");
|
||||
assert.equal(second.items.find((item) => item.id === "decision:branch:persistence:1")?.active, false);
|
||||
});
|
||||
|
||||
test("mergeCandidates keeps the newest item active when same-slot candidates arrive out of order", () => {
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
{ ...base, kind: "decision", subject: "persistence", text: "Persist snapshots after each turn_end", timestamp: 2 },
|
||||
{ ...base, kind: "decision", subject: "persistence", text: "Persist snapshots with appendEntry()", timestamp: 1 },
|
||||
]);
|
||||
|
||||
const active = getActiveItems(ledger, "decision");
|
||||
const stale = ledger.items.find((item) => item.text === "Persist snapshots with appendEntry()");
|
||||
|
||||
assert.equal(active.length, 1);
|
||||
assert.equal(active[0]?.text, "Persist snapshots after each turn_end");
|
||||
assert.equal(stale?.active, false);
|
||||
assert.equal(stale?.supersedesId, undefined);
|
||||
});
|
||||
|
||||
test("mergeCandidates gives same-slot same-timestamp candidates distinct ids", () => {
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
{ ...base, kind: "decision", subject: "persistence", text: "Persist snapshots with appendEntry()", timestamp: 1 },
|
||||
{ ...base, kind: "decision", subject: "persistence", text: "Persist snapshots after each turn_end", timestamp: 1 },
|
||||
]);
|
||||
|
||||
const ids = ledger.items.map((item) => item.id);
|
||||
const active = getActiveItems(ledger, "decision")[0];
|
||||
const archived = ledger.items.find((item) => item.text === "Persist snapshots with appendEntry()");
|
||||
|
||||
assert.equal(new Set(ids).size, ledger.items.length);
|
||||
assert.equal(active?.text, "Persist snapshots after each turn_end");
|
||||
assert.equal(active?.supersedesId, archived?.id);
|
||||
assert.notEqual(active?.id, active?.supersedesId);
|
||||
});
|
||||
|
||||
test("mergeCandidates keeps same-slot same-timestamp snapshots deterministic regardless of input order", () => {
|
||||
const appendEntryCandidate = {
|
||||
...base,
|
||||
kind: "decision" as const,
|
||||
subject: "persistence",
|
||||
text: "Persist snapshots with appendEntry()",
|
||||
timestamp: 1,
|
||||
};
|
||||
const turnEndCandidate = {
|
||||
...base,
|
||||
kind: "decision" as const,
|
||||
subject: "persistence",
|
||||
text: "Persist snapshots after each turn_end",
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
const forward = mergeCandidates(createEmptyLedger(), [appendEntryCandidate, turnEndCandidate]);
|
||||
const reversed = mergeCandidates(createEmptyLedger(), [turnEndCandidate, appendEntryCandidate]);
|
||||
|
||||
assert.deepEqual(forward, reversed);
|
||||
assert.deepEqual(forward.items, [
|
||||
{
|
||||
...turnEndCandidate,
|
||||
id: "decision:branch:persistence:1",
|
||||
freshness: 1,
|
||||
active: true,
|
||||
supersedesId: "decision:branch:persistence:1:2",
|
||||
},
|
||||
{
|
||||
...appendEntryCandidate,
|
||||
id: "decision:branch:persistence:1:2",
|
||||
freshness: 1,
|
||||
active: false,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("session-scoped memory can coexist with branch-scoped memory for the same kind", () => {
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
{
|
||||
kind: "constraint",
|
||||
subject: "llm-tools",
|
||||
text: "Do not add new LLM-facing tools in the MVP",
|
||||
scope: "session",
|
||||
sourceEntryId: "u1",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 1,
|
||||
},
|
||||
{
|
||||
kind: "constraint",
|
||||
subject: "branch-policy",
|
||||
text: "Keep branch A experimental",
|
||||
scope: "branch",
|
||||
sourceEntryId: "u2",
|
||||
sourceType: "user",
|
||||
timestamp: 2,
|
||||
confidence: 0.8,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(getActiveItems(ledger, "constraint").length, 2);
|
||||
});
|
||||
196
.pi/agent/extensions/context-manager/src/ledger.ts
Normal file
196
.pi/agent/extensions/context-manager/src/ledger.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
export type MemoryKind = "goal" | "constraint" | "decision" | "activeTask" | "openQuestion" | "relevantFile";
|
||||
export type MemoryScope = "branch" | "session";
|
||||
export type MemorySourceType = "user" | "assistant" | "toolResult" | "compaction" | "branchSummary";
|
||||
|
||||
export interface MemoryCandidate {
|
||||
kind: MemoryKind;
|
||||
subject: string;
|
||||
text: string;
|
||||
scope: MemoryScope;
|
||||
sourceEntryId: string;
|
||||
sourceType: MemorySourceType;
|
||||
timestamp: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface MemoryItem extends MemoryCandidate {
|
||||
id: string;
|
||||
freshness: number;
|
||||
active: boolean;
|
||||
supersedesId?: string;
|
||||
}
|
||||
|
||||
export interface LedgerState {
|
||||
items: MemoryItem[];
|
||||
rollingSummary: string;
|
||||
}
|
||||
|
||||
type MemorySlot = Pick<MemoryCandidate, "kind" | "scope" | "subject">;
|
||||
|
||||
export function createEmptyLedger(): LedgerState {
|
||||
return { items: [], rollingSummary: "" };
|
||||
}
|
||||
|
||||
function createId(candidate: MemoryCandidate): string {
|
||||
return `${candidate.kind}:${candidate.scope}:${candidate.subject}:${candidate.timestamp}`;
|
||||
}
|
||||
|
||||
function ensureUniqueId(items: Pick<MemoryItem, "id">[], baseId: string): string {
|
||||
let id = baseId;
|
||||
let suffix = 2;
|
||||
|
||||
while (items.some((item) => item.id === id)) {
|
||||
id = `${baseId}:${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function sameSlot(left: MemorySlot, right: MemorySlot) {
|
||||
return left.kind === right.kind && left.scope === right.scope && left.subject === right.subject;
|
||||
}
|
||||
|
||||
function createSlotKey(slot: MemorySlot): string {
|
||||
return `${slot.kind}\u0000${slot.scope}\u0000${slot.subject}`;
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string): number {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
function compareSameTimestampCandidates(
|
||||
left: Pick<MemoryCandidate, "text" | "sourceType" | "sourceEntryId" | "confidence">,
|
||||
right: Pick<MemoryCandidate, "text" | "sourceType" | "sourceEntryId" | "confidence">
|
||||
): number {
|
||||
// Exact-timestamp ties should resolve the same way no matter which candidate is processed first.
|
||||
const textComparison = compareStrings(left.text, right.text);
|
||||
if (textComparison !== 0) {
|
||||
return textComparison;
|
||||
}
|
||||
|
||||
const sourceTypeComparison = compareStrings(left.sourceType, right.sourceType);
|
||||
if (sourceTypeComparison !== 0) {
|
||||
return sourceTypeComparison;
|
||||
}
|
||||
|
||||
const sourceEntryIdComparison = compareStrings(left.sourceEntryId, right.sourceEntryId);
|
||||
if (sourceEntryIdComparison !== 0) {
|
||||
return sourceEntryIdComparison;
|
||||
}
|
||||
|
||||
if (left.confidence !== right.confidence) {
|
||||
return left.confidence > right.confidence ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function candidateSupersedesPrevious(candidate: MemoryCandidate, previous?: MemoryItem): boolean {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate.timestamp !== previous.timestamp) {
|
||||
return candidate.timestamp > previous.timestamp;
|
||||
}
|
||||
|
||||
return compareSameTimestampCandidates(candidate, previous) < 0;
|
||||
}
|
||||
|
||||
function compareSlotItems(left: MemoryItem, right: MemoryItem): number {
|
||||
if (left.timestamp !== right.timestamp) {
|
||||
return right.timestamp - left.timestamp;
|
||||
}
|
||||
|
||||
return compareSameTimestampCandidates(left, right);
|
||||
}
|
||||
|
||||
function normalizeSlotItems(items: MemoryItem[], slot: MemorySlot): MemoryItem[] {
|
||||
const slotIndices: number[] = [];
|
||||
const slotItems: MemoryItem[] = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (!sameSlot(item, slot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
slotIndices.push(index);
|
||||
slotItems.push(item);
|
||||
});
|
||||
|
||||
if (slotItems.length <= 1) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const sortedSlotItems = [...slotItems].sort(compareSlotItems);
|
||||
const slotIds = new Map<string, number>();
|
||||
const sortedSlotItemsWithIds = sortedSlotItems.map((item) => {
|
||||
const baseId = createId(item);
|
||||
const nextSlotIdCount = (slotIds.get(baseId) ?? 0) + 1;
|
||||
slotIds.set(baseId, nextSlotIdCount);
|
||||
|
||||
return {
|
||||
item,
|
||||
id: nextSlotIdCount === 1 ? baseId : `${baseId}:${nextSlotIdCount}`,
|
||||
};
|
||||
});
|
||||
|
||||
const normalizedSlotItems = sortedSlotItemsWithIds.map(({ item, id }, index) => ({
|
||||
...item,
|
||||
id,
|
||||
freshness: index === 0 ? item.timestamp : sortedSlotItemsWithIds[index - 1]!.item.timestamp,
|
||||
active: index === 0,
|
||||
supersedesId: sortedSlotItemsWithIds[index + 1]?.id,
|
||||
}));
|
||||
|
||||
const normalizedItems = [...items];
|
||||
slotIndices.forEach((slotIndex, index) => {
|
||||
normalizedItems[slotIndex] = normalizedSlotItems[index]!;
|
||||
});
|
||||
|
||||
return normalizedItems;
|
||||
}
|
||||
|
||||
export function mergeCandidates(state: LedgerState, candidates: MemoryCandidate[]): LedgerState {
|
||||
let items = [...state.items];
|
||||
const affectedSlots = new Map<string, MemorySlot>();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const previousIndex = items.findIndex((item) => item.active && sameSlot(item, candidate));
|
||||
const previous = previousIndex === -1 ? undefined : items[previousIndex];
|
||||
const candidateIsNewest = candidateSupersedesPrevious(candidate, previous);
|
||||
|
||||
if (previous && candidateIsNewest) {
|
||||
items[previousIndex] = { ...previous, active: false, freshness: candidate.timestamp };
|
||||
}
|
||||
|
||||
items.push({
|
||||
...candidate,
|
||||
id: ensureUniqueId(items, createId(candidate)),
|
||||
freshness: candidate.timestamp,
|
||||
active: candidateIsNewest,
|
||||
supersedesId: candidateIsNewest ? previous?.id : undefined,
|
||||
});
|
||||
|
||||
affectedSlots.set(createSlotKey(candidate), {
|
||||
kind: candidate.kind,
|
||||
scope: candidate.scope,
|
||||
subject: candidate.subject,
|
||||
});
|
||||
}
|
||||
|
||||
for (const slot of affectedSlots.values()) {
|
||||
items = normalizeSlotItems(items, slot);
|
||||
}
|
||||
|
||||
return { ...state, items };
|
||||
}
|
||||
|
||||
export function getActiveItems(state: LedgerState, kind?: MemoryKind): MemoryItem[] {
|
||||
return state.items.filter((item) => item.active && (kind ? item.kind === kind : true));
|
||||
}
|
||||
130
.pi/agent/extensions/context-manager/src/packet.test.ts
Normal file
130
.pi/agent/extensions/context-manager/src/packet.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolvePolicy } from "./config.ts";
|
||||
import { buildContextPacket } from "./packet.ts";
|
||||
import { createEmptyLedger, mergeCandidates, type MemoryCandidate } from "./ledger.ts";
|
||||
|
||||
const baseCandidate: Omit<MemoryCandidate, "kind" | "subject" | "text"> = {
|
||||
scope: "session",
|
||||
sourceEntryId: "seed",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 1,
|
||||
};
|
||||
|
||||
function estimateTokens(text: string) {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function memory(candidate: Pick<MemoryCandidate, "kind" | "subject" | "text"> & Partial<Omit<MemoryCandidate, "kind" | "subject" | "text">>): MemoryCandidate {
|
||||
return {
|
||||
...baseCandidate,
|
||||
...candidate,
|
||||
sourceEntryId: candidate.sourceEntryId ?? candidate.subject,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPolicy(packetTokenCap: number) {
|
||||
return {
|
||||
...resolvePolicy({ mode: "balanced", contextWindow: 200_000 }),
|
||||
packetTokenCap,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildContextPacket keeps top-ranked facts from a section when the cap is tight", () => {
|
||||
const expected = [
|
||||
"## Active goal",
|
||||
"- Keep packets compact.",
|
||||
"",
|
||||
"## Constraints",
|
||||
"- Preserve the highest-priority constraint.",
|
||||
"",
|
||||
"## Key decisions",
|
||||
"- Render selected sections in stable order.",
|
||||
].join("\n");
|
||||
const policy = buildPolicy(estimateTokens(expected));
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
memory({ kind: "goal", subject: "goal", text: "Keep packets compact." }),
|
||||
memory({ kind: "constraint", subject: "constraint-a", text: "Preserve the highest-priority constraint.", confidence: 1, timestamp: 3 }),
|
||||
memory({
|
||||
kind: "constraint",
|
||||
subject: "constraint-b",
|
||||
text: "Avoid dropping every constraint just because one extra bullet is too large for a tight packet cap.",
|
||||
confidence: 0.6,
|
||||
timestamp: 2,
|
||||
}),
|
||||
memory({
|
||||
kind: "decision",
|
||||
subject: "decision-a",
|
||||
text: "Render selected sections in stable order.",
|
||||
confidence: 0.9,
|
||||
timestamp: 4,
|
||||
sourceType: "assistant",
|
||||
}),
|
||||
]);
|
||||
|
||||
const packet = buildContextPacket(ledger, policy);
|
||||
|
||||
assert.equal(packet.text, expected);
|
||||
assert.equal(packet.estimatedTokens, policy.packetTokenCap);
|
||||
});
|
||||
|
||||
test("buildContextPacket uses cross-kind weights when only one lower-priority section can fit", () => {
|
||||
const expected = [
|
||||
"## Active goal",
|
||||
"- Keep the agent moving.",
|
||||
"",
|
||||
"## Current task",
|
||||
"- Fix packet trimming.",
|
||||
].join("\n");
|
||||
const policy = buildPolicy(estimateTokens(expected));
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
memory({ kind: "goal", subject: "goal", text: "Keep the agent moving." }),
|
||||
memory({
|
||||
kind: "decision",
|
||||
subject: "decision-a",
|
||||
text: "Keep logs concise.",
|
||||
confidence: 1,
|
||||
timestamp: 2,
|
||||
sourceType: "assistant",
|
||||
}),
|
||||
memory({
|
||||
kind: "activeTask",
|
||||
subject: "task-a",
|
||||
text: "Fix packet trimming.",
|
||||
confidence: 1,
|
||||
timestamp: 2,
|
||||
sourceType: "assistant",
|
||||
}),
|
||||
]);
|
||||
|
||||
const packet = buildContextPacket(ledger, policy);
|
||||
|
||||
assert.equal(packet.text, expected);
|
||||
assert.equal(packet.estimatedTokens, policy.packetTokenCap);
|
||||
});
|
||||
|
||||
test("buildContextPacket keeps a goal ahead of newer low-priority facts at realistic timestamp scales", () => {
|
||||
const expected = [
|
||||
"## Active goal",
|
||||
"- Keep the agent on track.",
|
||||
].join("\n");
|
||||
const policy = buildPolicy(estimateTokens(expected));
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
memory({ kind: "goal", subject: "goal", text: "Keep the agent on track.", timestamp: 1_000_000 }),
|
||||
memory({
|
||||
kind: "relevantFile",
|
||||
subject: "runtime-file",
|
||||
text: "src/runtime.ts",
|
||||
timestamp: 10_000_000,
|
||||
confidence: 1,
|
||||
sourceType: "assistant",
|
||||
scope: "branch",
|
||||
}),
|
||||
]);
|
||||
|
||||
const packet = buildContextPacket(ledger, policy);
|
||||
|
||||
assert.equal(packet.text, expected);
|
||||
assert.equal(packet.estimatedTokens, policy.packetTokenCap);
|
||||
});
|
||||
91
.pi/agent/extensions/context-manager/src/packet.ts
Normal file
91
.pi/agent/extensions/context-manager/src/packet.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Policy } from "./config.ts";
|
||||
import { getActiveItems, type LedgerState, type MemoryItem, type MemoryKind } from "./ledger.ts";
|
||||
|
||||
const SECTION_ORDER: Array<{ kind: MemoryKind; title: string }> = [
|
||||
{ kind: "goal", title: "Active goal" },
|
||||
{ kind: "constraint", title: "Constraints" },
|
||||
{ kind: "decision", title: "Key decisions" },
|
||||
{ kind: "activeTask", title: "Current task" },
|
||||
{ kind: "relevantFile", title: "Relevant files" },
|
||||
{ kind: "openQuestion", title: "Open questions / blockers" },
|
||||
];
|
||||
|
||||
const WEIGHTS: Record<MemoryKind, number> = {
|
||||
goal: 100,
|
||||
constraint: 90,
|
||||
decision: 80,
|
||||
activeTask: 85,
|
||||
relevantFile: 60,
|
||||
openQuestion: 70,
|
||||
};
|
||||
|
||||
const SECTION_INDEX = new Map(SECTION_ORDER.map((section, index) => [section.kind, index]));
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function compareByPriority(left: MemoryItem, right: MemoryItem): number {
|
||||
const weightDifference = WEIGHTS[right.kind] - WEIGHTS[left.kind];
|
||||
if (weightDifference !== 0) {
|
||||
return weightDifference;
|
||||
}
|
||||
|
||||
if (left.confidence !== right.confidence) {
|
||||
return right.confidence - left.confidence;
|
||||
}
|
||||
|
||||
const sectionDifference = SECTION_INDEX.get(left.kind)! - SECTION_INDEX.get(right.kind)!;
|
||||
if (sectionDifference !== 0) {
|
||||
return sectionDifference;
|
||||
}
|
||||
|
||||
if (left.freshness !== right.freshness) {
|
||||
return right.freshness - left.freshness;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function sortByPriority(items: MemoryItem[]) {
|
||||
return [...items].sort(compareByPriority);
|
||||
}
|
||||
|
||||
function renderPacket(itemsByKind: Map<MemoryKind, MemoryItem[]>, selectedIds: Set<string>) {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const section of SECTION_ORDER) {
|
||||
const items = itemsByKind.get(section.kind)?.filter((item) => selectedIds.has(item.id)) ?? [];
|
||||
if (items.length === 0) continue;
|
||||
|
||||
lines.push(`## ${section.title}`, ...items.map((item) => `- ${item.text}`), "");
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function buildContextPacket(ledger: LedgerState, policy: Policy): { text: string; estimatedTokens: number } {
|
||||
const itemsByKind = new Map<MemoryKind, MemoryItem[]>();
|
||||
for (const section of SECTION_ORDER) {
|
||||
itemsByKind.set(section.kind, sortByPriority(getActiveItems(ledger, section.kind)));
|
||||
}
|
||||
|
||||
const candidates = sortByPriority(getActiveItems(ledger));
|
||||
const selectedIds = new Set<string>();
|
||||
let text = "";
|
||||
|
||||
for (const item of candidates) {
|
||||
const tentativeSelectedIds = new Set(selectedIds);
|
||||
tentativeSelectedIds.add(item.id);
|
||||
|
||||
const tentative = renderPacket(itemsByKind, tentativeSelectedIds);
|
||||
if (estimateTokens(tentative) > policy.packetTokenCap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedIds.add(item.id);
|
||||
text = tentative;
|
||||
}
|
||||
|
||||
return { text, estimatedTokens: estimateTokens(text) };
|
||||
}
|
||||
67
.pi/agent/extensions/context-manager/src/persist.test.ts
Normal file
67
.pi/agent/extensions/context-manager/src/persist.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { deserializeLatestSnapshot, SNAPSHOT_ENTRY_TYPE, serializeSnapshot } from "./persist.ts";
|
||||
|
||||
function createSnapshot(lastZone: "green" | "yellow" | "red" | "compact", lastCompactionSummary: string) {
|
||||
return serializeSnapshot({
|
||||
mode: "balanced",
|
||||
lastZone,
|
||||
lastCompactionSummary,
|
||||
lastBranchSummary: undefined,
|
||||
ledger: {
|
||||
items: [
|
||||
{
|
||||
id: `goal:session:root:${lastCompactionSummary}`,
|
||||
kind: "goal",
|
||||
subject: "root",
|
||||
text: `Goal ${lastCompactionSummary}`,
|
||||
scope: "session",
|
||||
sourceEntryId: "u1",
|
||||
sourceType: "user",
|
||||
timestamp: 1,
|
||||
confidence: 1,
|
||||
freshness: 1,
|
||||
active: true,
|
||||
supersedesId: undefined,
|
||||
},
|
||||
],
|
||||
rollingSummary: "summary",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("deserializeLatestSnapshot restores the newest matching custom entry", () => {
|
||||
const first = createSnapshot("yellow", "old");
|
||||
const second = createSnapshot("red", "new");
|
||||
|
||||
const restored = deserializeLatestSnapshot([
|
||||
{ type: "custom", customType: SNAPSHOT_ENTRY_TYPE, data: first },
|
||||
{ type: "custom", customType: SNAPSHOT_ENTRY_TYPE, data: second },
|
||||
]);
|
||||
|
||||
assert.equal(restored?.lastZone, "red");
|
||||
assert.equal(restored?.lastCompactionSummary, "new");
|
||||
});
|
||||
|
||||
test("deserializeLatestSnapshot skips malformed newer entries and clones the accepted snapshot", () => {
|
||||
const valid = createSnapshot("yellow", "valid");
|
||||
|
||||
const restored = deserializeLatestSnapshot([
|
||||
{ type: "custom", customType: SNAPSHOT_ENTRY_TYPE, data: valid },
|
||||
{
|
||||
type: "custom",
|
||||
customType: SNAPSHOT_ENTRY_TYPE,
|
||||
data: {
|
||||
mode: "balanced",
|
||||
lastZone: "red",
|
||||
ledger: { items: "not-an-array", rollingSummary: "broken" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(restored, valid);
|
||||
assert.notStrictEqual(restored, valid);
|
||||
|
||||
restored!.ledger.items[0]!.text = "mutated";
|
||||
assert.equal(valid.ledger.items[0]!.text, "Goal valid");
|
||||
});
|
||||
142
.pi/agent/extensions/context-manager/src/persist.ts
Normal file
142
.pi/agent/extensions/context-manager/src/persist.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { ContextMode, ContextZone } from "./config.ts";
|
||||
import type { LedgerState, MemoryItem, MemoryKind, MemoryScope, MemorySourceType } from "./ledger.ts";
|
||||
|
||||
export const SNAPSHOT_ENTRY_TYPE = "context-manager.snapshot";
|
||||
|
||||
export interface RuntimeSnapshot {
|
||||
mode: ContextMode;
|
||||
lastZone: ContextZone;
|
||||
lastObservedTokens?: number;
|
||||
lastCompactionSummary?: string;
|
||||
lastBranchSummary?: string;
|
||||
ledger: LedgerState;
|
||||
}
|
||||
|
||||
const CONTEXT_MODES = new Set<ContextMode>(["conservative", "balanced", "aggressive"]);
|
||||
const CONTEXT_ZONES = new Set<ContextZone>(["green", "yellow", "red", "compact"]);
|
||||
const MEMORY_KINDS = new Set<MemoryKind>(["goal", "constraint", "decision", "activeTask", "openQuestion", "relevantFile"]);
|
||||
const MEMORY_SCOPES = new Set<MemoryScope>(["branch", "session"]);
|
||||
const MEMORY_SOURCE_TYPES = new Set<MemorySourceType>(["user", "assistant", "toolResult", "compaction", "branchSummary"]);
|
||||
|
||||
export function serializeSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot {
|
||||
return structuredClone(snapshot);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): value is string | undefined {
|
||||
return value === undefined || typeof value === "string";
|
||||
}
|
||||
|
||||
function parseMemoryItem(value: unknown): MemoryItem | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.id !== "string" ||
|
||||
!MEMORY_KINDS.has(value.kind as MemoryKind) ||
|
||||
typeof value.subject !== "string" ||
|
||||
typeof value.text !== "string" ||
|
||||
!MEMORY_SCOPES.has(value.scope as MemoryScope) ||
|
||||
typeof value.sourceEntryId !== "string" ||
|
||||
!MEMORY_SOURCE_TYPES.has(value.sourceType as MemorySourceType) ||
|
||||
!isFiniteNumber(value.timestamp) ||
|
||||
!isFiniteNumber(value.confidence) ||
|
||||
!isFiniteNumber(value.freshness) ||
|
||||
typeof value.active !== "boolean" ||
|
||||
!isOptionalString(value.supersedesId)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
kind: value.kind as MemoryKind,
|
||||
subject: value.subject,
|
||||
text: value.text,
|
||||
scope: value.scope as MemoryScope,
|
||||
sourceEntryId: value.sourceEntryId,
|
||||
sourceType: value.sourceType as MemorySourceType,
|
||||
timestamp: value.timestamp,
|
||||
confidence: value.confidence,
|
||||
freshness: value.freshness,
|
||||
active: value.active,
|
||||
supersedesId: value.supersedesId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLedgerState(value: unknown): LedgerState | undefined {
|
||||
if (!isRecord(value) || !Array.isArray(value.items) || typeof value.rollingSummary !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: MemoryItem[] = [];
|
||||
for (const item of value.items) {
|
||||
const parsed = parseMemoryItem(item);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
items.push(parsed);
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
rollingSummary: value.rollingSummary,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntimeSnapshot(value: unknown): RuntimeSnapshot | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!CONTEXT_MODES.has(value.mode as ContextMode) ||
|
||||
!CONTEXT_ZONES.has(value.lastZone as ContextZone) ||
|
||||
!isOptionalString(value.lastCompactionSummary) ||
|
||||
!isOptionalString(value.lastBranchSummary) ||
|
||||
(value.lastObservedTokens !== undefined && !isFiniteNumber(value.lastObservedTokens))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ledger = parseLedgerState(value.ledger);
|
||||
if (!ledger) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
mode: value.mode as ContextMode,
|
||||
lastZone: value.lastZone as ContextZone,
|
||||
lastCompactionSummary: value.lastCompactionSummary,
|
||||
lastBranchSummary: value.lastBranchSummary,
|
||||
ledger,
|
||||
};
|
||||
|
||||
if (value.lastObservedTokens !== undefined) {
|
||||
snapshot.lastObservedTokens = value.lastObservedTokens;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function deserializeLatestSnapshot(entries: Array<{ type: string; customType?: string; data?: unknown }>): RuntimeSnapshot | undefined {
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index]!;
|
||||
if (entry.type !== "custom" || entry.customType !== SNAPSHOT_ENTRY_TYPE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = parseRuntimeSnapshot(entry.data);
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
131
.pi/agent/extensions/context-manager/src/prune.test.ts
Normal file
131
.pi/agent/extensions/context-manager/src/prune.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolvePolicy } from "./config.ts";
|
||||
import { pruneContextMessages } from "./prune.ts";
|
||||
|
||||
const bulky = "line\n".repeat(300);
|
||||
const boundaryBulky = "boundary\n".repeat(300);
|
||||
const thresholdWithTrailingNewline = "threshold\n".repeat(150);
|
||||
|
||||
function buildPolicy(recentUserTurns = 4) {
|
||||
return {
|
||||
...resolvePolicy({ mode: "balanced", contextWindow: 200_000 }),
|
||||
recentUserTurns,
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneContextMessages replaces old bulky tool results with distilled summaries instead of deleting them", () => {
|
||||
const policy = buildPolicy(2);
|
||||
const bulkyFailure = [
|
||||
"Build failed while compiling focus parser",
|
||||
"Error: missing export createFocusMatcher from ./summary-focus.ts",
|
||||
...Array.from({ length: 220 }, () => "stack frame"),
|
||||
].join("\n");
|
||||
const messages = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "toolResult", toolName: "bash", content: bulkyFailure },
|
||||
{ role: "assistant", content: "observed turn 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "observed turn 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
];
|
||||
|
||||
const pruned = pruneContextMessages(messages, policy);
|
||||
|
||||
const distilled = pruned.find((message) => message.role === "toolResult");
|
||||
assert.ok(distilled);
|
||||
assert.match(distilled!.content, /missing export createFocusMatcher/);
|
||||
assert.doesNotMatch(distilled!.content, /stack frame\nstack frame\nstack frame/);
|
||||
});
|
||||
|
||||
test("aggressive mode distills an older bulky tool result sooner than conservative mode", () => {
|
||||
const conservative = resolvePolicy({ mode: "conservative", contextWindow: 200_000 });
|
||||
const aggressive = resolvePolicy({ mode: "aggressive", contextWindow: 200_000 });
|
||||
const messages = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "toolResult", toolName: "read", content: bulky },
|
||||
{ role: "assistant", content: "after turn 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "after turn 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
{ role: "assistant", content: "after turn 3" },
|
||||
{ role: "user", content: "turn 4" },
|
||||
];
|
||||
|
||||
const conservativePruned = pruneContextMessages(messages, conservative);
|
||||
const aggressivePruned = pruneContextMessages(messages, aggressive);
|
||||
|
||||
assert.equal(conservativePruned[1]?.content, bulky);
|
||||
assert.notEqual(aggressivePruned[1]?.content, bulky);
|
||||
assert.match(aggressivePruned[1]?.content ?? "", /^\[distilled read output\]/);
|
||||
});
|
||||
|
||||
test("pruneContextMessages keeps recent bulky tool results inside the recent-turn window", () => {
|
||||
const policy = buildPolicy(2);
|
||||
const messages = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "assistant", content: "observed turn 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "toolResult", toolName: "read", content: bulky },
|
||||
{ role: "assistant", content: "observed turn 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
];
|
||||
|
||||
const pruned = pruneContextMessages(messages, policy);
|
||||
|
||||
assert.deepEqual(pruned, messages);
|
||||
});
|
||||
|
||||
test("pruneContextMessages keeps old non-bulky tool results outside the recent-turn window", () => {
|
||||
const policy = buildPolicy(2);
|
||||
const messages = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "toolResult", toolName: "read", content: "short output" },
|
||||
{ role: "assistant", content: "observed turn 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "observed turn 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
];
|
||||
|
||||
const pruned = pruneContextMessages(messages, policy);
|
||||
|
||||
assert.deepEqual(pruned, messages);
|
||||
});
|
||||
|
||||
test("pruneContextMessages keeps exactly-150-line tool results with a trailing newline", () => {
|
||||
const policy = buildPolicy(2);
|
||||
const messages = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "toolResult", toolName: "read", content: thresholdWithTrailingNewline },
|
||||
{ role: "assistant", content: "after threshold output" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "after turn 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
];
|
||||
|
||||
const pruned = pruneContextMessages(messages, policy);
|
||||
|
||||
assert.deepEqual(pruned, messages);
|
||||
});
|
||||
|
||||
test("pruneContextMessages honors the recent-user-turn boundary", () => {
|
||||
const policy = buildPolicy(2);
|
||||
const messages = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "toolResult", toolName: "read", content: bulky },
|
||||
{ role: "assistant", content: "after turn 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "toolResult", toolName: "read", content: boundaryBulky },
|
||||
{ role: "assistant", content: "after turn 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
];
|
||||
|
||||
const pruned = pruneContextMessages(messages, policy);
|
||||
|
||||
assert.equal(pruned[1]?.role, "toolResult");
|
||||
assert.match(pruned[1]?.content ?? "", /^\[distilled read output\]/);
|
||||
assert.deepEqual(
|
||||
pruned.map((message) => message.content),
|
||||
["turn 1", pruned[1]!.content, "after turn 1", "turn 2", boundaryBulky, "after turn 2", "turn 3"]
|
||||
);
|
||||
});
|
||||
54
.pi/agent/extensions/context-manager/src/prune.ts
Normal file
54
.pi/agent/extensions/context-manager/src/prune.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Policy } from "./config.ts";
|
||||
import { distillToolResult } from "./distill.ts";
|
||||
|
||||
export interface ContextMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
toolName?: string;
|
||||
original?: unknown;
|
||||
distilled?: boolean;
|
||||
}
|
||||
|
||||
function isBulky(content: string, policy: Policy) {
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
const parts = content.split("\n");
|
||||
const lines = content.endsWith("\n") ? parts.length - 1 : parts.length;
|
||||
return bytes > policy.bulkyBytes || lines > policy.bulkyLines;
|
||||
}
|
||||
|
||||
export function pruneContextMessages(messages: ContextMessage[], policy: Policy): ContextMessage[] {
|
||||
let seenUserTurns = 0;
|
||||
const keep = new Set<number>();
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]!;
|
||||
keep.add(index);
|
||||
if (message.role === "user") {
|
||||
seenUserTurns += 1;
|
||||
if (seenUserTurns >= policy.recentUserTurns) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next: ContextMessage[] = [];
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (keep.has(index) || message.role !== "toolResult" || !isBulky(message.content, policy)) {
|
||||
next.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const distilled = distillToolResult({ toolName: message.toolName, content: message.content });
|
||||
if (!distilled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
next.push({
|
||||
...message,
|
||||
content: distilled,
|
||||
distilled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
178
.pi/agent/extensions/context-manager/src/runtime.test.ts
Normal file
178
.pi/agent/extensions/context-manager/src/runtime.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SNAPSHOT_ENTRY_TYPE, deserializeLatestSnapshot } from "./persist.ts";
|
||||
import { createContextManagerRuntime } from "./runtime.ts";
|
||||
|
||||
test("runtime ingests transcript slices and updates pressure state", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
runtime.ingest({ entryId: "u1", role: "user", text: "Goal: Build a pi context manager. Next: wire hooks.", timestamp: 1 });
|
||||
runtime.observeTokens(150_000);
|
||||
|
||||
const packet = runtime.buildPacket();
|
||||
assert.match(packet.text, /Build a pi context manager/);
|
||||
assert.equal(runtime.getSnapshot().lastZone, "red");
|
||||
});
|
||||
|
||||
test("runtime keeps the session root goal while allowing later branch-local goals", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
runtime.ingest({ entryId: "u-root-goal", role: "user", text: "Goal: Ship the context manager extension.", timestamp: 10 });
|
||||
runtime.ingest({ entryId: "u-branch-goal", role: "user", text: "Goal: prototype a branch-local tree handoff.", timestamp: 11 });
|
||||
|
||||
const packet = runtime.buildPacket();
|
||||
assert.match(packet.text, /Ship the context manager extension/);
|
||||
assert.match(packet.text, /prototype a branch-local tree handoff/);
|
||||
assert.equal(
|
||||
runtime
|
||||
.getSnapshot()
|
||||
.ledger.items.filter((item) => item.active && item.kind === "goal" && item.scope === "session").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("recordCompactionSummary and recordBranchSummary update snapshot state and resume output", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
runtime.ingest({ entryId: "u-artifact-1", role: "user", text: "Goal: Ship the context manager extension.", timestamp: 20 });
|
||||
runtime.recordCompactionSummary(
|
||||
"## Key Decisions\n- Keep summaries deterministic.\n\n## Open questions and blockers\n- Verify /tree replaceInstructions behavior.",
|
||||
);
|
||||
runtime.recordBranchSummary("# Handoff for branch\n\n## Key Decisions\n- Do not leak branch-local goals.");
|
||||
|
||||
const snapshot = runtime.getSnapshot();
|
||||
assert.match(snapshot.lastCompactionSummary ?? "", /Keep summaries deterministic/);
|
||||
assert.match(snapshot.lastBranchSummary ?? "", /Do not leak branch-local goals/);
|
||||
assert.match(runtime.buildResumePacket(), /Verify \/tree replaceInstructions behavior/);
|
||||
assert.match(runtime.buildResumePacket(), /Do not leak branch-local goals/);
|
||||
});
|
||||
|
||||
test("buildPacket tightens the live packet after pressure reaches the compact zone", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
runtime.restore({
|
||||
mode: "balanced",
|
||||
lastZone: "green",
|
||||
ledger: {
|
||||
rollingSummary: "",
|
||||
items: [
|
||||
{ id: "goal:session:root-goal:1", kind: "goal", subject: "root-goal", text: "Ship the context manager extension with deterministic handoffs and predictable branch-boundary behavior.", scope: "session", sourceEntryId: "u1", sourceType: "user", timestamp: 1, confidence: 1, freshness: 1, active: true },
|
||||
{ id: "constraint:session:must-1:1", kind: "constraint", subject: "must-1", text: "Keep the public API stable while hardening branch-boundary state carryover, fallback summary replay, and resume injection behavior.", scope: "session", sourceEntryId: "u1", sourceType: "user", timestamp: 2, confidence: 0.9, freshness: 2, active: true },
|
||||
{ id: "decision:branch:decision-1:1", kind: "decision", subject: "decision-1", text: "Persist summary artifacts, replay them after the latest snapshot, and surface them through the next hidden resume packet before normal packet injection resumes.", scope: "branch", sourceEntryId: "a1", sourceType: "assistant", timestamp: 3, confidence: 0.9, freshness: 3, active: true },
|
||||
{ id: "activeTask:branch:task-1:1", kind: "activeTask", subject: "task-1", text: "Verify mode-dependent pruning, packet tightening under pressure, and snapshot-less branch rehydration without stale handoff leakage.", scope: "branch", sourceEntryId: "a2", sourceType: "assistant", timestamp: 4, confidence: 0.8, freshness: 4, active: true },
|
||||
{ id: "openQuestion:branch:question-1:1", kind: "openQuestion", subject: "question-1", text: "Confirm whether default pi fallback summaries preserve blockers and active work end to end when custom compaction falls back.", scope: "branch", sourceEntryId: "a3", sourceType: "assistant", timestamp: 5, confidence: 0.8, freshness: 5, active: true },
|
||||
{ id: "relevantFile:branch:file-1:1", kind: "relevantFile", subject: "file-1", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-1.ts", scope: "branch", sourceEntryId: "t1", sourceType: "toolResult", timestamp: 6, confidence: 0.7, freshness: 6, active: true },
|
||||
{ id: "relevantFile:branch:file-2:1", kind: "relevantFile", subject: "file-2", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-2.ts", scope: "branch", sourceEntryId: "t2", sourceType: "toolResult", timestamp: 7, confidence: 0.7, freshness: 7, active: true },
|
||||
{ id: "relevantFile:branch:file-3:1", kind: "relevantFile", subject: "file-3", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-3.ts", scope: "branch", sourceEntryId: "t3", sourceType: "toolResult", timestamp: 8, confidence: 0.7, freshness: 8, active: true },
|
||||
{ id: "relevantFile:branch:file-4:1", kind: "relevantFile", subject: "file-4", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-4.ts", scope: "branch", sourceEntryId: "t4", sourceType: "toolResult", timestamp: 9, confidence: 0.7, freshness: 9, active: true },
|
||||
{ id: "relevantFile:branch:file-5:1", kind: "relevantFile", subject: "file-5", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-5.ts", scope: "branch", sourceEntryId: "t5", sourceType: "toolResult", timestamp: 10, confidence: 0.7, freshness: 10, active: true },
|
||||
{ id: "relevantFile:branch:file-6:1", kind: "relevantFile", subject: "file-6", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-6.ts", scope: "branch", sourceEntryId: "t6", sourceType: "toolResult", timestamp: 11, confidence: 0.7, freshness: 11, active: true },
|
||||
{ id: "relevantFile:branch:file-7:1", kind: "relevantFile", subject: "file-7", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-7.ts", scope: "branch", sourceEntryId: "t7", sourceType: "toolResult", timestamp: 12, confidence: 0.7, freshness: 12, active: true },
|
||||
{ id: "relevantFile:branch:file-8:1", kind: "relevantFile", subject: "file-8", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-8.ts", scope: "branch", sourceEntryId: "t8", sourceType: "toolResult", timestamp: 13, confidence: 0.7, freshness: 13, active: true },
|
||||
{ id: "relevantFile:branch:file-9:1", kind: "relevantFile", subject: "file-9", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-9.ts", scope: "branch", sourceEntryId: "t9", sourceType: "toolResult", timestamp: 14, confidence: 0.7, freshness: 14, active: true },
|
||||
{ id: "relevantFile:branch:file-10:1", kind: "relevantFile", subject: "file-10", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-10.ts", scope: "branch", sourceEntryId: "t10", sourceType: "toolResult", timestamp: 15, confidence: 0.7, freshness: 15, active: true },
|
||||
{ id: "relevantFile:branch:file-11:1", kind: "relevantFile", subject: "file-11", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-11.ts", scope: "branch", sourceEntryId: "t11", sourceType: "toolResult", timestamp: 16, confidence: 0.7, freshness: 16, active: true },
|
||||
{ id: "relevantFile:branch:file-12:1", kind: "relevantFile", subject: "file-12", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-12.ts", scope: "branch", sourceEntryId: "t12", sourceType: "toolResult", timestamp: 17, confidence: 0.7, freshness: 17, active: true },
|
||||
{ id: "relevantFile:branch:file-13:1", kind: "relevantFile", subject: "file-13", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-13.ts", scope: "branch", sourceEntryId: "t13", sourceType: "toolResult", timestamp: 18, confidence: 0.7, freshness: 18, active: true },
|
||||
{ id: "relevantFile:branch:file-14:1", kind: "relevantFile", subject: "file-14", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-14.ts", scope: "branch", sourceEntryId: "t14", sourceType: "toolResult", timestamp: 19, confidence: 0.7, freshness: 19, active: true },
|
||||
{ id: "relevantFile:branch:file-15:1", kind: "relevantFile", subject: "file-15", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-15.ts", scope: "branch", sourceEntryId: "t15", sourceType: "toolResult", timestamp: 20, confidence: 0.7, freshness: 20, active: true },
|
||||
{ id: "relevantFile:branch:file-16:1", kind: "relevantFile", subject: "file-16", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-16.ts", scope: "branch", sourceEntryId: "t16", sourceType: "toolResult", timestamp: 21, confidence: 0.7, freshness: 21, active: true },
|
||||
{ id: "relevantFile:branch:file-17:1", kind: "relevantFile", subject: "file-17", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-17.ts", scope: "branch", sourceEntryId: "t17", sourceType: "toolResult", timestamp: 22, confidence: 0.7, freshness: 22, active: true },
|
||||
{ id: "relevantFile:branch:file-18:1", kind: "relevantFile", subject: "file-18", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-18.ts", scope: "branch", sourceEntryId: "t18", sourceType: "toolResult", timestamp: 23, confidence: 0.7, freshness: 23, active: true },
|
||||
{ id: "relevantFile:branch:file-19:1", kind: "relevantFile", subject: "file-19", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-19.ts", scope: "branch", sourceEntryId: "t19", sourceType: "toolResult", timestamp: 24, confidence: 0.7, freshness: 24, active: true },
|
||||
{ id: "relevantFile:branch:file-20:1", kind: "relevantFile", subject: "file-20", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-20.ts", scope: "branch", sourceEntryId: "t20", sourceType: "toolResult", timestamp: 25, confidence: 0.7, freshness: 25, active: true },
|
||||
{ id: "relevantFile:branch:file-21:1", kind: "relevantFile", subject: "file-21", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-21.ts", scope: "branch", sourceEntryId: "t21", sourceType: "toolResult", timestamp: 26, confidence: 0.7, freshness: 26, active: true },
|
||||
{ id: "relevantFile:branch:file-22:1", kind: "relevantFile", subject: "file-22", text: "src/context-manager/very/long/path/for/runtime/index-and-hook-wiring-reference-file-22.ts", scope: "branch", sourceEntryId: "t22", sourceType: "toolResult", timestamp: 27, confidence: 0.7, freshness: 27, active: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const before = runtime.buildPacket();
|
||||
runtime.observeTokens(170_000);
|
||||
const after = runtime.buildPacket();
|
||||
|
||||
assert.ok(after.estimatedTokens < before.estimatedTokens);
|
||||
});
|
||||
|
||||
test("runtime recomputes lastZone when setContextWindow and setMode change policy", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
runtime.observeTokens(150_000);
|
||||
assert.equal(runtime.getSnapshot().lastZone, "red");
|
||||
|
||||
runtime.setContextWindow(300_000);
|
||||
assert.equal(runtime.getSnapshot().lastZone, "green");
|
||||
|
||||
runtime.setMode("aggressive");
|
||||
assert.equal(runtime.getSnapshot().lastZone, "yellow");
|
||||
});
|
||||
|
||||
test("restore recomputes lastZone against the receiving runtime policy", () => {
|
||||
const source = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
source.observeTokens(150_000);
|
||||
|
||||
const target = createContextManagerRuntime({ mode: "balanced", contextWindow: 500_000 });
|
||||
target.restore(source.getSnapshot());
|
||||
|
||||
assert.equal(target.getSnapshot().lastZone, "green");
|
||||
});
|
||||
|
||||
test("restore resets legacy lastZone when the snapshot lacks lastObservedTokens", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 500_000 });
|
||||
|
||||
runtime.restore({
|
||||
mode: "balanced",
|
||||
lastZone: "red",
|
||||
ledger: { items: [], rollingSummary: "" },
|
||||
});
|
||||
|
||||
const restored = runtime.getSnapshot();
|
||||
assert.equal(restored.lastZone, "green");
|
||||
assert.equal(restored.lastObservedTokens, undefined);
|
||||
});
|
||||
|
||||
test("legacy snapshot deserialization plus restore clears stale lastZone", () => {
|
||||
const snapshot = deserializeLatestSnapshot([
|
||||
{
|
||||
type: "custom",
|
||||
customType: SNAPSHOT_ENTRY_TYPE,
|
||||
data: {
|
||||
mode: "balanced",
|
||||
lastZone: "red",
|
||||
ledger: { items: [], rollingSummary: "" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.ok(snapshot);
|
||||
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 500_000 });
|
||||
runtime.restore(snapshot);
|
||||
|
||||
assert.equal(runtime.getSnapshot().lastZone, "green");
|
||||
});
|
||||
|
||||
test("getPolicy returns a clone and restore detaches from external snapshot objects", () => {
|
||||
const runtime = createContextManagerRuntime({ mode: "balanced", contextWindow: 200_000 });
|
||||
|
||||
const policy = runtime.getPolicy();
|
||||
policy.packetTokenCap = 1;
|
||||
policy.redAtTokens = 1;
|
||||
|
||||
const currentPolicy = runtime.getPolicy();
|
||||
assert.equal(currentPolicy.packetTokenCap, 1_200);
|
||||
assert.equal(currentPolicy.redAtTokens, 140_000);
|
||||
|
||||
const snapshot = runtime.getSnapshot();
|
||||
snapshot.mode = "aggressive";
|
||||
snapshot.ledger.rollingSummary = "before restore";
|
||||
|
||||
runtime.restore(snapshot);
|
||||
|
||||
snapshot.mode = "conservative";
|
||||
snapshot.ledger.rollingSummary = "mutated after restore";
|
||||
|
||||
const restored = runtime.getSnapshot();
|
||||
assert.equal(restored.mode, "aggressive");
|
||||
assert.equal(restored.ledger.rollingSummary, "before restore");
|
||||
});
|
||||
127
.pi/agent/extensions/context-manager/src/runtime.ts
Normal file
127
.pi/agent/extensions/context-manager/src/runtime.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { adjustPolicyForZone, resolvePolicy, zoneForTokens, type ContextMode, type Policy } from "./config.ts";
|
||||
import { extractCandidates, type TranscriptSlice } from "./extract.ts";
|
||||
import { createEmptyLedger, getActiveItems, mergeCandidates } from "./ledger.ts";
|
||||
import { buildContextPacket } from "./packet.ts";
|
||||
import { buildBranchSummary, buildCompactionSummary, buildResumePacket as renderResumePacket } from "./summaries.ts";
|
||||
import type { RuntimeSnapshot } from "./persist.ts";
|
||||
|
||||
function syncSnapshotZone(snapshot: RuntimeSnapshot, policy: Policy): RuntimeSnapshot {
|
||||
if (snapshot.lastObservedTokens === undefined) {
|
||||
return snapshot.lastZone === "green"
|
||||
? snapshot
|
||||
: {
|
||||
...snapshot,
|
||||
lastZone: "green",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
lastZone: zoneForTokens(snapshot.lastObservedTokens, policy),
|
||||
};
|
||||
}
|
||||
|
||||
export function createContextManagerRuntime(input: { mode?: ContextMode; contextWindow: number }) {
|
||||
let contextWindow = input.contextWindow;
|
||||
let policy = resolvePolicy({ mode: input.mode ?? "balanced", contextWindow });
|
||||
let snapshot: RuntimeSnapshot = {
|
||||
mode: policy.mode,
|
||||
lastZone: "green",
|
||||
ledger: createEmptyLedger(),
|
||||
};
|
||||
|
||||
function applyPolicy(nextPolicy: Policy) {
|
||||
policy = nextPolicy;
|
||||
snapshot = syncSnapshotZone(snapshot, policy);
|
||||
}
|
||||
|
||||
function hasSessionGoal() {
|
||||
return getActiveItems(snapshot.ledger, "goal").some((item) => item.scope === "session" && item.subject === "root-goal");
|
||||
}
|
||||
|
||||
function ingest(slice: TranscriptSlice) {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
ledger: mergeCandidates(snapshot.ledger, extractCandidates(slice, { hasSessionGoal: hasSessionGoal() })),
|
||||
};
|
||||
}
|
||||
|
||||
function observeTokens(tokens: number) {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
lastObservedTokens: tokens,
|
||||
lastZone: zoneForTokens(tokens, policy),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPacket() {
|
||||
return buildContextPacket(snapshot.ledger, adjustPolicyForZone(policy, snapshot.lastZone));
|
||||
}
|
||||
|
||||
function mergeArtifact(role: "compaction" | "branchSummary", text: string, entryId: string, timestamp: number) {
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
lastCompactionSummary: role === "compaction" ? text : snapshot.lastCompactionSummary,
|
||||
lastBranchSummary: role === "branchSummary" ? text : snapshot.lastBranchSummary,
|
||||
ledger: mergeCandidates(snapshot.ledger, extractCandidates({ entryId, role, text, timestamp }, { hasSessionGoal: hasSessionGoal() })),
|
||||
};
|
||||
}
|
||||
|
||||
function recordCompactionSummary(text: string, entryId = `compaction-${Date.now()}`, timestamp = Date.now()) {
|
||||
mergeArtifact("compaction", text, entryId, timestamp);
|
||||
}
|
||||
|
||||
function recordBranchSummary(text: string, entryId = `branch-${Date.now()}`, timestamp = Date.now()) {
|
||||
mergeArtifact("branchSummary", text, entryId, timestamp);
|
||||
}
|
||||
|
||||
function buildResumePacket() {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (snapshot.lastCompactionSummary) {
|
||||
lines.push("## Latest compaction handoff", snapshot.lastCompactionSummary, "");
|
||||
}
|
||||
|
||||
if (snapshot.lastBranchSummary) {
|
||||
lines.push("## Latest branch handoff", snapshot.lastBranchSummary, "");
|
||||
}
|
||||
|
||||
const livePacket = renderResumePacket(snapshot.ledger);
|
||||
if (livePacket) {
|
||||
lines.push(livePacket);
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function setContextWindow(nextContextWindow: number) {
|
||||
contextWindow = Math.max(nextContextWindow, 50_000);
|
||||
applyPolicy(resolvePolicy({ mode: snapshot.mode, contextWindow }));
|
||||
}
|
||||
|
||||
function setMode(mode: ContextMode) {
|
||||
snapshot = { ...snapshot, mode };
|
||||
applyPolicy(resolvePolicy({ mode, contextWindow }));
|
||||
}
|
||||
|
||||
function restore(next: RuntimeSnapshot) {
|
||||
snapshot = structuredClone(next);
|
||||
applyPolicy(resolvePolicy({ mode: snapshot.mode, contextWindow }));
|
||||
}
|
||||
|
||||
return {
|
||||
ingest,
|
||||
observeTokens,
|
||||
buildPacket,
|
||||
buildCompactionSummary: () => buildCompactionSummary(snapshot.ledger),
|
||||
buildBranchSummary: (label: string) => buildBranchSummary(snapshot.ledger, label),
|
||||
buildResumePacket,
|
||||
recordCompactionSummary,
|
||||
recordBranchSummary,
|
||||
setContextWindow,
|
||||
setMode,
|
||||
getPolicy: () => structuredClone(policy),
|
||||
getSnapshot: () => structuredClone(snapshot),
|
||||
restore,
|
||||
};
|
||||
}
|
||||
139
.pi/agent/extensions/context-manager/src/summaries.test.ts
Normal file
139
.pi/agent/extensions/context-manager/src/summaries.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createEmptyLedger, mergeCandidates } from "./ledger.ts";
|
||||
import {
|
||||
buildBranchSummary,
|
||||
buildBranchSummaryFromEntries,
|
||||
buildCompactionSummary,
|
||||
buildCompactionSummaryFromPreparation,
|
||||
buildResumePacket,
|
||||
} from "./summaries.ts";
|
||||
|
||||
const ledger = mergeCandidates(createEmptyLedger(), [
|
||||
{ kind: "goal", subject: "root-goal", text: "Build a pi context manager", scope: "session", sourceEntryId: "u1", sourceType: "user", timestamp: 1, confidence: 1 },
|
||||
{ kind: "constraint", subject: "must-u1-0", text: "Must adapt to the active model context window.", scope: "session", sourceEntryId: "u1", sourceType: "user", timestamp: 1, confidence: 0.9 },
|
||||
{ kind: "decision", subject: "decision-a1-0", text: "Keep the MVP quiet.", scope: "branch", sourceEntryId: "a1", sourceType: "assistant", timestamp: 2, confidence: 0.9 },
|
||||
{ kind: "activeTask", subject: "next-step-a2-0", text: "Wire hooks into pi.", scope: "branch", sourceEntryId: "a2", sourceType: "assistant", timestamp: 3, confidence: 0.8 },
|
||||
{ kind: "relevantFile", subject: "runtime-ts", text: "src/runtime.ts", scope: "branch", sourceEntryId: "a3", sourceType: "assistant", timestamp: 4, confidence: 0.7 },
|
||||
]);
|
||||
|
||||
test("buildCompactionSummary renders the exact section order and content", () => {
|
||||
const summary = buildCompactionSummary(ledger);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
[
|
||||
"## Goal",
|
||||
"- Build a pi context manager",
|
||||
"",
|
||||
"## Constraints",
|
||||
"- Must adapt to the active model context window.",
|
||||
"",
|
||||
"## Decisions",
|
||||
"- Keep the MVP quiet.",
|
||||
"",
|
||||
"## Active work",
|
||||
"- Wire hooks into pi.",
|
||||
"",
|
||||
"## Relevant files",
|
||||
"- src/runtime.ts",
|
||||
"",
|
||||
"## Next steps",
|
||||
"- Wire hooks into pi.",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("buildBranchSummary renders the handoff header and sections in order", () => {
|
||||
const summary = buildBranchSummary(ledger, "experimental branch");
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
[
|
||||
"# Handoff for experimental branch",
|
||||
"",
|
||||
"## Goal",
|
||||
"- Build a pi context manager",
|
||||
"",
|
||||
"## Decisions",
|
||||
"- Keep the MVP quiet.",
|
||||
"",
|
||||
"## Active work",
|
||||
"- Wire hooks into pi.",
|
||||
"",
|
||||
"## Relevant files",
|
||||
"- src/runtime.ts",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCompactionSummaryFromPreparation uses preparation messages, previous summary, file ops, and focus text", () => {
|
||||
const previousSummary = [
|
||||
"## Goal",
|
||||
"- Ship the context manager extension",
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"- Keep the public API stable.",
|
||||
].join("\n");
|
||||
|
||||
const summary = buildCompactionSummaryFromPreparation({
|
||||
messagesToSummarize: [
|
||||
{ role: "user", content: "Decision: keep compaction summaries deterministic", timestamp: 1 },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Blocked: verify /tree replaceInstructions behavior" }], timestamp: 2 },
|
||||
],
|
||||
turnPrefixMessages: [
|
||||
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "Opened .pi/agent/extensions/context-manager/index.ts" }], isError: false, timestamp: 3 },
|
||||
],
|
||||
previousSummary,
|
||||
fileOps: {
|
||||
readFiles: [".pi/agent/extensions/context-manager/index.ts"],
|
||||
modifiedFiles: [".pi/agent/extensions/context-manager/src/summaries.ts"],
|
||||
},
|
||||
customInstructions: "Focus on decisions, blockers, and relevant files.",
|
||||
});
|
||||
|
||||
assert.match(summary, /## Key Decisions/);
|
||||
assert.match(summary, /keep compaction summaries deterministic/);
|
||||
assert.match(summary, /## Open questions and blockers/);
|
||||
assert.match(summary, /verify \/tree replaceInstructions behavior/);
|
||||
assert.match(summary, /<read-files>[\s\S]*index.ts[\s\S]*<\/read-files>/);
|
||||
assert.match(summary, /<modified-files>[\s\S]*src\/summaries.ts[\s\S]*<\/modified-files>/);
|
||||
});
|
||||
|
||||
test("buildBranchSummaryFromEntries uses only the abandoned branch entries and custom focus", () => {
|
||||
const summary = buildBranchSummaryFromEntries({
|
||||
branchLabel: "abandoned branch",
|
||||
entriesToSummarize: [
|
||||
{ type: "message", id: "user-1", parentId: null, timestamp: new Date(1).toISOString(), message: { role: "user", content: "Goal: explore tree handoff" } },
|
||||
{ type: "message", id: "assistant-1", parentId: "user-1", timestamp: new Date(2).toISOString(), message: { role: "assistant", content: [{ type: "text", text: "Decision: do not leak branch-local goals" }] } },
|
||||
],
|
||||
customInstructions: "Focus on goals and decisions.",
|
||||
replaceInstructions: false,
|
||||
commonAncestorId: "root",
|
||||
});
|
||||
|
||||
assert.match(summary, /# Handoff for abandoned branch/);
|
||||
assert.match(summary, /explore tree handoff/);
|
||||
assert.match(summary, /do not leak branch-local goals/);
|
||||
});
|
||||
|
||||
test("buildResumePacket renders restart guidance in the expected order", () => {
|
||||
const summary = buildResumePacket(ledger);
|
||||
|
||||
assert.equal(
|
||||
summary,
|
||||
[
|
||||
"## Goal",
|
||||
"- Build a pi context manager",
|
||||
"",
|
||||
"## Current task",
|
||||
"- Wire hooks into pi.",
|
||||
"",
|
||||
"## Constraints",
|
||||
"- Must adapt to the active model context window.",
|
||||
"",
|
||||
"## Key decisions",
|
||||
"- Keep the MVP quiet.",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
251
.pi/agent/extensions/context-manager/src/summaries.ts
Normal file
251
.pi/agent/extensions/context-manager/src/summaries.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { createEmptyLedger, getActiveItems, mergeCandidates, type LedgerState } from "./ledger.ts";
|
||||
import { extractCandidates, type TranscriptSlice } from "./extract.ts";
|
||||
|
||||
function lines(title: string, items: string[]) {
|
||||
if (items.length === 0) return [`## ${title}`, "- none", ""];
|
||||
return [`## ${title}`, ...items.map((item) => `- ${item}`), ""];
|
||||
}
|
||||
|
||||
function isTextPart(part: unknown): part is { type: "text"; text?: string } {
|
||||
return typeof part === "object" && part !== null && "type" in part && (part as { type?: unknown }).type === "text";
|
||||
}
|
||||
|
||||
function toText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!isTextPart(part)) return "";
|
||||
return typeof part.text === "string" ? part.text : "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
type FocusKind = "goal" | "constraint" | "decision" | "activeTask" | "openQuestion" | "relevantFile";
|
||||
type SummarySection = { kind: FocusKind; title: string; items: string[] };
|
||||
|
||||
function hasSessionGoal(ledger: LedgerState) {
|
||||
return getActiveItems(ledger, "goal").some((item) => item.scope === "session" && item.subject === "root-goal");
|
||||
}
|
||||
|
||||
function buildLedgerFromSlices(slices: TranscriptSlice[], previousSummary?: string) {
|
||||
let ledger = createEmptyLedger();
|
||||
|
||||
if (previousSummary) {
|
||||
ledger = mergeCandidates(
|
||||
ledger,
|
||||
extractCandidates(
|
||||
{
|
||||
entryId: "previous-summary",
|
||||
role: "compaction",
|
||||
text: previousSummary,
|
||||
timestamp: 0,
|
||||
},
|
||||
{ hasSessionGoal: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const slice of slices) {
|
||||
ledger = mergeCandidates(ledger, extractCandidates(slice, { hasSessionGoal: hasSessionGoal(ledger) }));
|
||||
}
|
||||
|
||||
return ledger;
|
||||
}
|
||||
|
||||
function parseFocus(customInstructions?: string): Set<FocusKind> {
|
||||
const text = (customInstructions ?? "").toLowerCase();
|
||||
const focus = new Set<FocusKind>();
|
||||
|
||||
if (/\bgoal/.test(text)) focus.add("goal");
|
||||
if (/\bconstraint|preference/.test(text)) focus.add("constraint");
|
||||
if (/\bdecision/.test(text)) focus.add("decision");
|
||||
if (/\btask|next step|progress/.test(text)) focus.add("activeTask");
|
||||
if (/\bblocker|blocked|open question/.test(text)) focus.add("openQuestion");
|
||||
if (/\bfile|read-files|modified-files/.test(text)) focus.add("relevantFile");
|
||||
|
||||
return focus;
|
||||
}
|
||||
|
||||
function buildStructuredSections(ledger: LedgerState): SummarySection[] {
|
||||
return [
|
||||
{ kind: "goal", title: "Goal", items: getActiveItems(ledger, "goal").map((item) => item.text) },
|
||||
{
|
||||
kind: "constraint",
|
||||
title: "Constraints & Preferences",
|
||||
items: getActiveItems(ledger, "constraint").map((item) => item.text),
|
||||
},
|
||||
{
|
||||
kind: "activeTask",
|
||||
title: "Progress",
|
||||
items: getActiveItems(ledger, "activeTask").map((item) => item.text),
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
title: "Key Decisions",
|
||||
items: getActiveItems(ledger, "decision").map((item) => item.text),
|
||||
},
|
||||
{
|
||||
kind: "openQuestion",
|
||||
title: "Open questions and blockers",
|
||||
items: getActiveItems(ledger, "openQuestion").map((item) => item.text),
|
||||
},
|
||||
{
|
||||
kind: "relevantFile",
|
||||
title: "Critical Context",
|
||||
items: getActiveItems(ledger, "relevantFile").map((item) => item.text),
|
||||
},
|
||||
{
|
||||
kind: "activeTask",
|
||||
title: "Next Steps",
|
||||
items: getActiveItems(ledger, "activeTask").map((item) => item.text),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function sortSectionsForFocus(sections: SummarySection[], focus: Set<FocusKind>): SummarySection[] {
|
||||
if (focus.size === 0) {
|
||||
return sections;
|
||||
}
|
||||
|
||||
return [
|
||||
...sections.filter((section) => focus.has(section.kind)),
|
||||
...sections.filter((section) => !focus.has(section.kind)),
|
||||
];
|
||||
}
|
||||
|
||||
function unique(values: string[]) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function fileTag(name: "read-files" | "modified-files", values: string[]) {
|
||||
if (values.length === 0) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
return [`<${name}>`, ...values, `</${name}>`, ""];
|
||||
}
|
||||
|
||||
function renderStructuredSummary(
|
||||
ledger: LedgerState,
|
||||
options?: {
|
||||
header?: string;
|
||||
focus?: Set<FocusKind>;
|
||||
readFiles?: string[];
|
||||
modifiedFiles?: string[];
|
||||
},
|
||||
) {
|
||||
const sections = sortSectionsForFocus(buildStructuredSections(ledger), options?.focus ?? new Set());
|
||||
return [
|
||||
...(options?.header ? [options.header, ""] : []),
|
||||
...sections.flatMap((section) => lines(section.title, section.items)),
|
||||
...fileTag("read-files", unique(options?.readFiles ?? [])),
|
||||
...fileTag("modified-files", unique(options?.modifiedFiles ?? [])),
|
||||
].join("\n").trim();
|
||||
}
|
||||
|
||||
function messageToSlice(message: any, entryId: string, timestampFallback: number): TranscriptSlice | undefined {
|
||||
if (!message || typeof message !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (message.role !== "user" && message.role !== "assistant" && message.role !== "toolResult") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
entryId,
|
||||
role: message.role,
|
||||
text: toText(message.content),
|
||||
timestamp: typeof message.timestamp === "number" ? message.timestamp : timestampFallback,
|
||||
isError: message.role === "toolResult" ? message.isError : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCompactionSummary(ledger: LedgerState): string {
|
||||
return [
|
||||
...lines("Goal", getActiveItems(ledger, "goal").map((item) => item.text)),
|
||||
...lines("Constraints", getActiveItems(ledger, "constraint").map((item) => item.text)),
|
||||
...lines("Decisions", getActiveItems(ledger, "decision").map((item) => item.text)),
|
||||
...lines("Active work", getActiveItems(ledger, "activeTask").map((item) => item.text)),
|
||||
...lines("Relevant files", getActiveItems(ledger, "relevantFile").map((item) => item.text)),
|
||||
...lines("Next steps", getActiveItems(ledger, "activeTask").map((item) => item.text)),
|
||||
].join("\n").trim();
|
||||
}
|
||||
|
||||
export function buildBranchSummary(ledger: LedgerState, branchLabel: string): string {
|
||||
return [
|
||||
`# Handoff for ${branchLabel}`,
|
||||
"",
|
||||
...lines("Goal", getActiveItems(ledger, "goal").map((item) => item.text)),
|
||||
...lines("Decisions", getActiveItems(ledger, "decision").map((item) => item.text)),
|
||||
...lines("Active work", getActiveItems(ledger, "activeTask").map((item) => item.text)),
|
||||
...lines("Relevant files", getActiveItems(ledger, "relevantFile").map((item) => item.text)),
|
||||
].join("\n").trim();
|
||||
}
|
||||
|
||||
export function buildResumePacket(ledger: LedgerState): string {
|
||||
return [
|
||||
...lines("Goal", getActiveItems(ledger, "goal").map((item) => item.text)),
|
||||
...lines("Current task", getActiveItems(ledger, "activeTask").map((item) => item.text)),
|
||||
...lines("Constraints", getActiveItems(ledger, "constraint").map((item) => item.text)),
|
||||
...lines("Key decisions", getActiveItems(ledger, "decision").map((item) => item.text)),
|
||||
].join("\n").trim();
|
||||
}
|
||||
|
||||
export function buildCompactionSummaryFromPreparation(input: {
|
||||
messagesToSummarize: any[];
|
||||
turnPrefixMessages: any[];
|
||||
previousSummary?: string;
|
||||
fileOps?: { readFiles?: string[]; modifiedFiles?: string[] };
|
||||
customInstructions?: string;
|
||||
}): string {
|
||||
const slices = [...input.messagesToSummarize, ...input.turnPrefixMessages]
|
||||
.map((message, index) => messageToSlice(message, `compaction-${index}`, index))
|
||||
.filter((slice): slice is TranscriptSlice => Boolean(slice));
|
||||
|
||||
const ledger = buildLedgerFromSlices(slices, input.previousSummary);
|
||||
return renderStructuredSummary(ledger, {
|
||||
focus: parseFocus(input.customInstructions),
|
||||
readFiles: input.fileOps?.readFiles,
|
||||
modifiedFiles: input.fileOps?.modifiedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBranchSummaryFromEntries(input: {
|
||||
branchLabel: string;
|
||||
entriesToSummarize: Array<{ type: string; id: string; timestamp: string; message?: any; summary?: string }>;
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
commonAncestorId?: string | null;
|
||||
}): string {
|
||||
const slices = input.entriesToSummarize.flatMap((entry) => {
|
||||
if (entry.type === "message") {
|
||||
const slice = messageToSlice(entry.message, entry.id, Date.parse(entry.timestamp));
|
||||
return slice ? [slice] : [];
|
||||
}
|
||||
|
||||
if (entry.type === "compaction" && typeof entry.summary === "string") {
|
||||
return [{ entryId: entry.id, role: "compaction", text: entry.summary, timestamp: Date.parse(entry.timestamp) } satisfies TranscriptSlice];
|
||||
}
|
||||
|
||||
if (entry.type === "branch_summary" && typeof entry.summary === "string") {
|
||||
return [{ entryId: entry.id, role: "branchSummary", text: entry.summary, timestamp: Date.parse(entry.timestamp) } satisfies TranscriptSlice];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
const ledger = buildLedgerFromSlices(slices);
|
||||
return renderStructuredSummary(ledger, {
|
||||
header: `# Handoff for ${input.branchLabel}`,
|
||||
focus: parseFocus(input.customInstructions),
|
||||
});
|
||||
}
|
||||
50
.pi/agent/extensions/dev-tools/index.ts
Normal file
50
.pi/agent/extensions/dev-tools/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { createCommandFormatterRunner } from "./src/formatting/command-runner.ts";
|
||||
import { createCommandDiagnosticsBackend } from "./src/diagnostics/command-backend.ts";
|
||||
import { createLspClientManager } from "./src/diagnostics/lsp-client.ts";
|
||||
import { createSetupSuggestTool } from "./src/tools/setup-suggest.ts";
|
||||
import { probeProject } from "./src/project-probe.ts";
|
||||
import { createFormattedWriteTool } from "./src/tools/write.ts";
|
||||
import { createFormattedEditTool } from "./src/tools/edit.ts";
|
||||
import { createDevToolsRuntime } from "./src/runtime.ts";
|
||||
|
||||
export default function devTools(pi: ExtensionAPI) {
|
||||
const cwd = process.cwd();
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR ?? `${process.env.HOME}/.pi/agent`;
|
||||
|
||||
const runtime = createDevToolsRuntime({
|
||||
cwd,
|
||||
agentDir,
|
||||
formatterRunner: createCommandFormatterRunner({
|
||||
execCommand: async (command, args, options) => {
|
||||
const result = await pi.exec(command, args, { timeout: options.timeout });
|
||||
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
},
|
||||
}),
|
||||
commandBackend: createCommandDiagnosticsBackend({
|
||||
execCommand: async (command, args, options) => {
|
||||
const result = await pi.exec(command, args, { timeout: options.timeout });
|
||||
return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||
},
|
||||
}),
|
||||
lspBackend: createLspClientManager(),
|
||||
probeProject,
|
||||
});
|
||||
|
||||
pi.registerTool(createFormattedEditTool(cwd, runtime));
|
||||
pi.registerTool(createFormattedWriteTool(cwd, runtime));
|
||||
pi.registerTool(createSetupSuggestTool({
|
||||
suggestSetup: async () => {
|
||||
const probe = await probeProject({ cwd });
|
||||
return probe.summary;
|
||||
},
|
||||
}));
|
||||
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const block = runtime.getPromptBlock();
|
||||
if (!block) return;
|
||||
return {
|
||||
systemPrompt: `${event.systemPrompt}\n\n${block}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
4386
.pi/agent/extensions/dev-tools/package-lock.json
generated
Normal file
4386
.pi/agent/extensions/dev-tools/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
.pi/agent/extensions/dev-tools/package.json
Normal file
23
.pi/agent/extensions/dev-tools/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "pi-dev-tools-extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tsx --test src/*.test.ts src/**/*.test.ts"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.66.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
37
.pi/agent/extensions/dev-tools/src/config.test.ts
Normal file
37
.pi/agent/extensions/dev-tools/src/config.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mergeDevToolsConfig } from "./config.ts";
|
||||
|
||||
test("mergeDevToolsConfig lets project defaults override global defaults and replace same-name profiles", () => {
|
||||
const merged = mergeDevToolsConfig(
|
||||
{
|
||||
defaults: { formatTimeoutMs: 8000, maxDiagnosticsPerFile: 10 },
|
||||
profiles: [
|
||||
{
|
||||
name: "typescript",
|
||||
match: ["**/*.ts"],
|
||||
workspaceRootMarkers: ["package.json"],
|
||||
formatter: { kind: "command", command: ["prettier", "--write", "{file}"] },
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaults: { formatTimeoutMs: 3000 },
|
||||
profiles: [
|
||||
{
|
||||
name: "typescript",
|
||||
match: ["src/**/*.ts"],
|
||||
workspaceRootMarkers: ["tsconfig.json"],
|
||||
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(merged.defaults.formatTimeoutMs, 3000);
|
||||
assert.equal(merged.defaults.maxDiagnosticsPerFile, 10);
|
||||
assert.deepEqual(merged.profiles.map((profile) => profile.name), ["typescript"]);
|
||||
assert.deepEqual(merged.profiles[0]?.match, ["src/**/*.ts"]);
|
||||
});
|
||||
38
.pi/agent/extensions/dev-tools/src/config.ts
Normal file
38
.pi/agent/extensions/dev-tools/src/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import { DevToolsConfigSchema, type DevToolsConfig } from "./schema.ts";
|
||||
|
||||
export function mergeDevToolsConfig(globalConfig?: DevToolsConfig, projectConfig?: DevToolsConfig): DevToolsConfig {
|
||||
const defaults = {
|
||||
...(globalConfig?.defaults ?? {}),
|
||||
...(projectConfig?.defaults ?? {}),
|
||||
};
|
||||
|
||||
const globalProfiles = new Map((globalConfig?.profiles ?? []).map((profile) => [profile.name, profile]));
|
||||
const mergedProfiles = [...(projectConfig?.profiles ?? [])];
|
||||
|
||||
for (const profile of globalProfiles.values()) {
|
||||
if (!mergedProfiles.some((candidate) => candidate.name === profile.name)) {
|
||||
mergedProfiles.push(profile);
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults, profiles: mergedProfiles };
|
||||
}
|
||||
|
||||
function readConfigIfPresent(path: string): DevToolsConfig | undefined {
|
||||
if (!existsSync(path)) return undefined;
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!Value.Check(DevToolsConfigSchema, parsed)) {
|
||||
const [firstError] = [...Value.Errors(DevToolsConfigSchema, parsed)];
|
||||
throw new Error(`Invalid dev-tools config at ${path}: ${firstError?.message ?? "validation failed"}`);
|
||||
}
|
||||
return parsed as DevToolsConfig;
|
||||
}
|
||||
|
||||
export function loadDevToolsConfig(cwd: string, agentDir: string): DevToolsConfig | undefined {
|
||||
const globalPath = resolve(agentDir, "dev-tools.json");
|
||||
const projectPath = resolve(cwd, ".pi/dev-tools.json");
|
||||
return mergeDevToolsConfig(readConfigIfPresent(globalPath), readConfigIfPresent(projectPath));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createCommandDiagnosticsBackend } from "./command-backend.ts";
|
||||
|
||||
test("eslint-json parser returns normalized diagnostics", async () => {
|
||||
const backend = createCommandDiagnosticsBackend({
|
||||
execCommand: async () => ({
|
||||
code: 1,
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
filePath: "/repo/src/app.ts",
|
||||
messages: [
|
||||
{
|
||||
ruleId: "no-console",
|
||||
severity: 2,
|
||||
message: "Unexpected console statement.",
|
||||
line: 2,
|
||||
column: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await backend.collect({
|
||||
absolutePath: "/repo/src/app.ts",
|
||||
workspaceRoot: "/repo",
|
||||
backend: {
|
||||
kind: "command",
|
||||
parser: "eslint-json",
|
||||
command: ["eslint", "--format", "json", "{file}"],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
assert.equal(result.items[0]?.severity, "error");
|
||||
assert.equal(result.items[0]?.message, "Unexpected console statement.");
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DiagnosticsConfig } from "../schema.ts";
|
||||
import type { DiagnosticsState, NormalizedDiagnostic } from "./types.ts";
|
||||
|
||||
function parseEslintJson(stdout: string): NormalizedDiagnostic[] {
|
||||
const parsed = JSON.parse(stdout) as Array<any>;
|
||||
return parsed.flatMap((entry) =>
|
||||
(entry.messages ?? []).map((message: any) => ({
|
||||
severity: message.severity === 2 ? "error" : "warning",
|
||||
message: message.message,
|
||||
line: message.line,
|
||||
column: message.column,
|
||||
source: "eslint",
|
||||
code: message.ruleId ?? undefined,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCommandDiagnosticsBackend(deps: {
|
||||
execCommand: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; timeout?: number },
|
||||
) => Promise<{ code: number; stdout: string; stderr: string }>;
|
||||
}) {
|
||||
return {
|
||||
async collect(input: {
|
||||
absolutePath: string;
|
||||
workspaceRoot: string;
|
||||
backend: Extract<DiagnosticsConfig, { kind: "command" }>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<DiagnosticsState> {
|
||||
const [command, ...args] = input.backend.command.map((part) => part.replaceAll("{file}", input.absolutePath));
|
||||
const result = await deps.execCommand(command, args, { cwd: input.workspaceRoot, timeout: input.timeoutMs });
|
||||
|
||||
try {
|
||||
if (input.backend.parser === "eslint-json") {
|
||||
return { status: "ok", items: parseEslintJson(result.stdout) };
|
||||
}
|
||||
return { status: "unavailable", items: [], message: `Unsupported diagnostics parser: ${input.backend.parser}` };
|
||||
} catch (error) {
|
||||
return { status: "unavailable", items: [], message: (error as Error).message };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLspClientManager } from "./lsp-client.ts";
|
||||
|
||||
test("collectForFile sends initialize + didOpen and resolves publishDiagnostics", async () => {
|
||||
const notifications: Array<{ method: string; params: any }> = [];
|
||||
|
||||
const manager = createLspClientManager({
|
||||
createConnection: async () => ({
|
||||
async initialize() {},
|
||||
async openTextDocument(params) {
|
||||
notifications.push({ method: "textDocument/didOpen", params });
|
||||
},
|
||||
async collectDiagnostics() {
|
||||
return [
|
||||
{
|
||||
severity: "error",
|
||||
message: "Type 'number' is not assignable to type 'string'.",
|
||||
line: 1,
|
||||
column: 7,
|
||||
source: "tsserver",
|
||||
},
|
||||
];
|
||||
},
|
||||
async dispose() {},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await manager.collectForFile({
|
||||
key: "typescript:/repo",
|
||||
absolutePath: "/repo/src/app.ts",
|
||||
workspaceRoot: "/repo",
|
||||
languageId: "typescript",
|
||||
text: "const x: string = 1\n",
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
});
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
assert.equal(result.items[0]?.source, "tsserver");
|
||||
assert.equal(notifications[0]?.method, "textDocument/didOpen");
|
||||
});
|
||||
102
.pi/agent/extensions/dev-tools/src/diagnostics/lsp-client.ts
Normal file
102
.pi/agent/extensions/dev-tools/src/diagnostics/lsp-client.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as rpc from "vscode-jsonrpc/node";
|
||||
import type { DiagnosticsState } from "./types.ts";
|
||||
|
||||
const INITIALIZE = new rpc.RequestType<any, any, void, void>("initialize");
|
||||
const DID_OPEN = new rpc.NotificationType<any, void>("textDocument/didOpen");
|
||||
const INITIALIZED = new rpc.NotificationType<any, void>("initialized");
|
||||
const PUBLISH_DIAGNOSTICS = new rpc.NotificationType<any, void>("textDocument/publishDiagnostics");
|
||||
|
||||
type LspConnection = {
|
||||
initialize(): Promise<void>;
|
||||
openTextDocument(params: any): Promise<void>;
|
||||
collectDiagnostics(): Promise<DiagnosticsState["items"]>;
|
||||
dispose(): Promise<void>;
|
||||
};
|
||||
|
||||
export function createLspClientManager(deps: {
|
||||
createConnection?: (input: { workspaceRoot: string; command: string[] }) => Promise<LspConnection>;
|
||||
} = {}) {
|
||||
const clients = new Map<string, LspConnection>();
|
||||
|
||||
async function defaultCreateConnection(input: { workspaceRoot: string; command: string[] }): Promise<LspConnection> {
|
||||
const [command, ...args] = input.command;
|
||||
const child = spawn(command, args, {
|
||||
cwd: input.workspaceRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const connection = rpc.createMessageConnection(
|
||||
new rpc.StreamMessageReader(child.stdout),
|
||||
new rpc.StreamMessageWriter(child.stdin),
|
||||
);
|
||||
|
||||
let lastDiagnostics: DiagnosticsState["items"] = [];
|
||||
connection.onNotification(PUBLISH_DIAGNOSTICS, (params: any) => {
|
||||
lastDiagnostics = (params.diagnostics ?? []).map((diagnostic: any) => ({
|
||||
severity: diagnostic.severity === 1 ? "error" : diagnostic.severity === 2 ? "warning" : "info",
|
||||
message: diagnostic.message,
|
||||
line: diagnostic.range?.start?.line !== undefined ? diagnostic.range.start.line + 1 : undefined,
|
||||
column: diagnostic.range?.start?.character !== undefined ? diagnostic.range.start.character + 1 : undefined,
|
||||
source: diagnostic.source ?? "lsp",
|
||||
code: diagnostic.code ? String(diagnostic.code) : undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
connection.listen();
|
||||
await connection.sendRequest(INITIALIZE, {
|
||||
processId: process.pid,
|
||||
rootUri: pathToFileURL(input.workspaceRoot).href,
|
||||
capabilities: {},
|
||||
});
|
||||
connection.sendNotification(INITIALIZED, {});
|
||||
|
||||
return {
|
||||
async initialize() {},
|
||||
async openTextDocument(params: any) {
|
||||
connection.sendNotification(DID_OPEN, params);
|
||||
},
|
||||
async collectDiagnostics() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return lastDiagnostics;
|
||||
},
|
||||
async dispose() {
|
||||
connection.dispose();
|
||||
child.kill();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async collectForFile(input: {
|
||||
key: string;
|
||||
absolutePath: string;
|
||||
workspaceRoot: string;
|
||||
languageId: string;
|
||||
text: string;
|
||||
command: string[];
|
||||
}): Promise<DiagnosticsState> {
|
||||
let client = clients.get(input.key);
|
||||
if (!client) {
|
||||
client = await (deps.createConnection ?? defaultCreateConnection)({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
command: input.command,
|
||||
});
|
||||
clients.set(input.key, client);
|
||||
await client.initialize();
|
||||
}
|
||||
|
||||
await client.openTextDocument({
|
||||
textDocument: {
|
||||
uri: pathToFileURL(input.absolutePath).href,
|
||||
languageId: input.languageId,
|
||||
version: 1,
|
||||
text: input.text,
|
||||
},
|
||||
});
|
||||
|
||||
return { status: "ok", items: await client.collectDiagnostics() };
|
||||
},
|
||||
};
|
||||
}
|
||||
19
.pi/agent/extensions/dev-tools/src/diagnostics/types.ts
Normal file
19
.pi/agent/extensions/dev-tools/src/diagnostics/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface NormalizedDiagnostic {
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
source: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticsState {
|
||||
status: "ok" | "unavailable";
|
||||
items: NormalizedDiagnostic[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CapabilityGap {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
16
.pi/agent/extensions/dev-tools/src/extension.test.ts
Normal file
16
.pi/agent/extensions/dev-tools/src/extension.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import devToolsExtension from "../index.ts";
|
||||
|
||||
test("the extension entrypoint registers edit, write, and setup suggestion tools", () => {
|
||||
const registeredTools: string[] = [];
|
||||
|
||||
devToolsExtension({
|
||||
registerTool(tool: { name: string }) {
|
||||
registeredTools.push(tool.name);
|
||||
},
|
||||
on() {},
|
||||
} as any);
|
||||
|
||||
assert.deepEqual(registeredTools.sort(), ["dev_tools_suggest_setup", "edit", "write"]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createCommandFormatterRunner } from "./command-runner.ts";
|
||||
|
||||
test("formatFile expands {file} and executes in the workspace root", async () => {
|
||||
let captured: { command: string; args: string[]; cwd?: string } | undefined;
|
||||
|
||||
const runner = createCommandFormatterRunner({
|
||||
execCommand: async (command, args, options) => {
|
||||
captured = { command, args, cwd: options.cwd };
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runner.formatFile({
|
||||
absolutePath: "/repo/src/app.ts",
|
||||
workspaceRoot: "/repo",
|
||||
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
|
||||
});
|
||||
|
||||
assert.equal(result.status, "formatted");
|
||||
assert.deepEqual(captured, {
|
||||
command: "biome",
|
||||
args: ["format", "--write", "/repo/src/app.ts"],
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FormatterConfig } from "../schema.ts";
|
||||
|
||||
export function createCommandFormatterRunner(deps: {
|
||||
execCommand: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; timeout?: number },
|
||||
) => Promise<{ code: number; stdout: string; stderr: string }>;
|
||||
}) {
|
||||
return {
|
||||
async formatFile(input: {
|
||||
absolutePath: string;
|
||||
workspaceRoot: string;
|
||||
formatter: FormatterConfig;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const [command, ...args] = input.formatter.command.map((part) => part.replaceAll("{file}", input.absolutePath));
|
||||
const result = await deps.execCommand(command, args, {
|
||||
cwd: input.workspaceRoot,
|
||||
timeout: input.timeoutMs,
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
return {
|
||||
status: "failed" as const,
|
||||
message: (result.stderr || result.stdout || `formatter exited with ${result.code}`).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "formatted" as const };
|
||||
},
|
||||
};
|
||||
}
|
||||
26
.pi/agent/extensions/dev-tools/src/profiles.test.ts
Normal file
26
.pi/agent/extensions/dev-tools/src/profiles.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveProfileForPath } from "./profiles.ts";
|
||||
|
||||
test("resolveProfileForPath finds the first matching profile and nearest workspace root", () => {
|
||||
const result = resolveProfileForPath(
|
||||
{
|
||||
defaults: {},
|
||||
profiles: [
|
||||
{
|
||||
name: "typescript",
|
||||
match: ["src/**/*.ts"],
|
||||
workspaceRootMarkers: ["package.json", "tsconfig.json"],
|
||||
formatter: { kind: "command", command: ["biome", "format", "--write", "{file}"] },
|
||||
diagnostics: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"/repo/src/app.ts",
|
||||
"/repo",
|
||||
["/repo/package.json", "/repo/src/app.ts"],
|
||||
);
|
||||
|
||||
assert.equal(result?.profile.name, "typescript");
|
||||
assert.equal(result?.workspaceRoot, "/repo");
|
||||
});
|
||||
47
.pi/agent/extensions/dev-tools/src/profiles.ts
Normal file
47
.pi/agent/extensions/dev-tools/src/profiles.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import type { DevToolsConfig, DevToolsProfile } from "./schema.ts";
|
||||
|
||||
export interface ResolvedProfileMatch {
|
||||
profile: DevToolsProfile;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
export function resolveProfileForPath(
|
||||
config: DevToolsConfig,
|
||||
absolutePath: string,
|
||||
cwd: string,
|
||||
knownPaths: string[] = [],
|
||||
): ResolvedProfileMatch | undefined {
|
||||
const normalizedPath = resolve(absolutePath);
|
||||
const relativePath = relative(cwd, normalizedPath).replace(/\\/g, "/");
|
||||
|
||||
for (const profile of config.profiles) {
|
||||
if (!profile.match.some((pattern) => picomatch(pattern)(relativePath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceRoot = findWorkspaceRoot(normalizedPath, cwd, profile.workspaceRootMarkers, knownPaths);
|
||||
return { profile, workspaceRoot };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findWorkspaceRoot(filePath: string, cwd: string, markers: string[], knownPaths: string[]): string {
|
||||
let current = dirname(filePath);
|
||||
const root = resolve(cwd);
|
||||
|
||||
while (current.startsWith(root)) {
|
||||
for (const marker of markers) {
|
||||
if (knownPaths.includes(resolve(current, marker))) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
const next = dirname(current);
|
||||
if (next === current) break;
|
||||
current = next;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
14
.pi/agent/extensions/dev-tools/src/project-probe.test.ts
Normal file
14
.pi/agent/extensions/dev-tools/src/project-probe.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { probeProject } from "./project-probe.ts";
|
||||
|
||||
test("probeProject recognizes a TypeScript workspace and suggests biome + tsserver", async () => {
|
||||
const result = await probeProject({
|
||||
cwd: "/repo",
|
||||
exists: async (path) => ["/repo/package.json", "/repo/tsconfig.json"].includes(path),
|
||||
});
|
||||
|
||||
assert.equal(result.ecosystem, "typescript");
|
||||
assert.match(result.summary, /Biome/);
|
||||
assert.match(result.summary, /typescript-language-server/);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user