Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ opencode-acp/
│ ├── logger.ts # Structured logging (logs/acp/)
│ ├── auth.ts # Plugin authentication
│ ├── token-utils.ts # Token counting utilities
│ ├── message-ids.ts # Message ID mapping (raw ↔ mNNNN refs)
│ ├── message-ids.ts # Message ID mapping (raw ↔ mNNNNNN refs)
│ ├── compress-permission.ts # Permission management for compress tool
│ ├── protected-patterns.ts # File pattern protection logic
│ ├── host-permissions.ts # Host-based permission system
Expand Down Expand Up @@ -160,14 +160,14 @@ index.ts (Plugin Entry — registers hooks + tools)
├─► Message Transform Hook (experimental.chat.messages.transform) ← runs EVERY LLM call
│ │
│ ├─► checkSession() → state init, load persisted state
│ ├─► stripHallucinations() → remove stale mNNNN refs from model output
│ ├─► assignMessageRefs() → bidirectional map: raw message IDs ↔ mNNNN refs
│ ├─► stripHallucinations() → remove stale mNNNNN refs from model output
│ ├─► assignMessageRefs() → bidirectional map: raw message IDs ↔ mNNNNN refs
│ ├─► syncCompressionBlocks() → deactivate orphaned blocks (messages deleted externally)
│ ├─► runMajorGC() → age-based block deactivation + truncate oversized summaries
│ ├─► prune() → replace compressed ranges with summary blocks in messages
│ ├─► injectCompressNudges() → add context-limit / turn / iteration nudges
│ │ └─► includes block aging guidance (only when context usage > 50%)
│ ├─► injectMessageIds() → tag every message with mNNNN ref (or BLOCKED)
│ ├─► injectMessageIds() → tag every message with mNNNNN ref (or BLOCKED)
│ ├─► applyAnchoredNudges() → render nudge text into actual messages
│ └─► stripStaleMetadata() → clean up removed messages' metadata
Expand All @@ -179,7 +179,7 @@ index.ts (Plugin Entry — registers hooks + tools)
│ └─► Track compress tool start/complete → attach duration to blocks
├─► Text Complete Hook (experimental.text.complete)
│ └─► Strip hallucinated mNNNN/bN refs from completions
│ └─► Strip hallucinated mNNNNN/bN refs from completions
└─► Compress Tool (registered as "compress")
Expand Down Expand Up @@ -469,6 +469,19 @@ For reference when modifying code — these bugs were real and the fixes are loa
3. Understand the module dependency graph (Section 4.1)
4. Check if the change affects backward compatibility (Section 2.6)

### 5.1.1 Development Workflow

All changes MUST follow this workflow:

1. Create a feature branch from `master`
2. Implement changes
3. Ensure `npm run build` and `npm run typecheck` pass
4. Ensure all tests pass: `npm run test`
5. Commit with descriptive messages
6. Push branch and create a GitHub PR
7. Obtain **dual-agent review** (Sections 5.3 + 5.4) on the PR
8. Merge PR after both reviews pass

### 5.2 After Making Changes

1. `npm run build` must pass
Expand All @@ -477,17 +490,35 @@ For reference when modifying code — these bugs were real and the fixes are loa
4. Deploy locally and test in opencode
5. Update version in `package.json` before publishing

### 5.3 Commit Convention
### 5.3 Code Review (MANDATORY)

All source code changes (files under `lib/`) MUST undergo independent review by **at least 2 separate agents** before merge. This applies to:

- New modules added to `lib/`
- Modified source files
- Changes to shared types, interfaces, or exports

**Review checklist:**

| Category | What to Check |
|----------|---------------|
| **Correctness** | Logic matches intent, no off-by-one errors, edge cases handled |
| **Backward compatibility** | No breaking changes to persisted state format, exported APIs, or internal tags (Section 2.6) |
| **Performance** | No unnecessary CPU/memory overhead, no O(n²) where O(n) suffices |
| **Type safety** | No `as any`, no `@ts-ignore`, no type assertion hacks |
| **State integrity** | State mutations are safe, no lost data on save/load cycle |

### 5.4 Commit Convention

Use descriptive commit messages. Historical examples:
- `fix: aging warning only shows when context usage > 50%`
- `feat: /dcp → /acp command rename with backward compat`
- `chore: bump version to 1.0.1`
- `fix: config migration moved to getConfig() entry point`

### 5.4 Test Review (MANDATORY)
### 5.5 Test Review (MANDATORY)

All new and modified test files MUST undergo independent review by **at least 2 separate agents** before being committed. This requirement applies to:
All new and modified test files MUST undergo independent review by **at least 2 separate agents** before merge (same requirement as Section 5.3 code review). This requirement applies to:

- New test files added to `tests/`
- Modified test files (changed test logic, not just test names)
Expand Down
12 changes: 6 additions & 6 deletions lib/compress/message-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,16 @@ const ISSUE_TEMPLATES: Record<string, [singular: string, plural: string]> = {
"refer to protected messages and cannot be compressed.",
],
"invalid-format": [
"is invalid. Use an injected raw message ID of the form mNNNN.",
"are invalid. Use injected raw message IDs of the form mNNNN.",
"is invalid. Use an injected raw message ID of the form mNNNNN.",
"are invalid. Use injected raw message IDs of the form mNNNNN.",
],
"block-id": [
"is invalid here. Block IDs like bN are not allowed; use an mNNNN message ID instead.",
"are invalid here. Block IDs like bN are not allowed; use mNNNN message IDs instead.",
"is invalid here. Block IDs like bN are not allowed; use an mNNNNN message ID instead.",
"are invalid here. Block IDs like bN are not allowed; use mNNNNN message IDs instead.",
],
"not-in-context": [
"is not available in the current conversation context. Choose an injected mNNNN ID visible in context.",
"are not available in the current conversation context. Choose injected mNNNN IDs visible in context.",
"is not available in the current conversation context. Choose an injected mNNNNN ID visible in context.",
"are not available in the current conversation context. Choose injected mNNNNN IDs visible in context.",
],
protected: [
"refers to a protected message and cannot be compressed.",
Expand Down
2 changes: 1 addition & 1 deletion lib/compress/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function buildSchema() {
tool.schema.object({
messageId: tool.schema
.string()
.describe("Raw message ID to compress (e.g. m0001)"),
.describe("Raw message ID to compress (e.g. m00001)"),
topic: tool.schema
.string()
.describe("Short label (3-5 words) for this one message summary"),
Expand Down
4 changes: 2 additions & 2 deletions lib/compress/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ function buildSchema() {
startId: tool.schema
.string()
.describe(
"Message or block ID marking the beginning of range (e.g. m0001, b2)",
"Message or block ID marking the beginning of range (e.g. m00001, b2)",
),
endId: tool.schema
.string()
.describe("Message or block ID marking the end of range (e.g. m0012, b5)"),
.describe("Message or block ID marking the end of range (e.g. m00012, b5)"),
summary: tool.schema
.string()
.describe("Complete technical summary replacing all content in range"),
Expand Down
4 changes: 2 additions & 2 deletions lib/compress/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ export function resolveBoundaryIds(
const parsedEndId = parseBoundaryId(endId)

if (parsedStartId === null) {
issues.push("startId is invalid. Use an injected message ID (mNNNN) or block ID (bN).")
issues.push("startId is invalid. Use an injected message ID (mNNNNN) or block ID (bN).")
}

if (parsedEndId === null) {
issues.push("endId is invalid. Use an injected message ID (mNNNN) or block ID (bN).")
issues.push("endId is invalid. Use an injected message ID (mNNNNN) or block ID (bN).")
}

if (issues.length > 0) {
Expand Down
8 changes: 4 additions & 4 deletions lib/message-ids.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { SessionState, WithParts } from "./state"
import { isIgnoredUserMessage } from "./messages/query"

const MESSAGE_REF_REGEX = /^m(\d{4})$/
const MESSAGE_REF_REGEX = /^m(\d{4,5})$/
const BLOCK_REF_REGEX = /^b([1-9]\d*)$/
const MESSAGE_ID_TAG_NAME = "dcp-message-id"

const MESSAGE_REF_WIDTH = 4
const MESSAGE_REF_WIDTH = 5
const MESSAGE_REF_MIN_INDEX = 1
export const MESSAGE_REF_MAX_INDEX = 9999
export const MESSAGE_REF_MAX_INDEX = 99999

export type ParsedBoundaryId =
| {
Expand All @@ -28,7 +28,7 @@ export function formatMessageRef(index: number): string {
index > MESSAGE_REF_MAX_INDEX
) {
throw new Error(
`Message ID index out of bounds: ${index}. Supported range is 0-${MESSAGE_REF_MAX_INDEX}.`,
`Message ID index out of bounds: ${index}. Supported range is ${MESSAGE_REF_MIN_INDEX}-${MESSAGE_REF_MAX_INDEX}.`,
)
}
return `m${index.toString().padStart(MESSAGE_REF_WIDTH, "0")}`
Expand Down
8 changes: 4 additions & 4 deletions lib/prompts/compress-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@ If a message contains no significant technical decisions, code changes, or user
MESSAGE IDS
You specify individual raw messages by ID using the injected IDs visible in the conversation:

- \`mNNNN\` IDs identify raw messages
- \`mNNNNN\` IDs identify raw messages

Each message has an ID inside XML metadata tags like \`<dcp-message-id priority="high">m0007</dcp-message-id>\`.
The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message.
Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`.
Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNNN\` value as the \`messageId\`.
The \`priority\` attribute indicates relative context cost. You MUST compress high-priority messages when their full text is no longer necessary for the active task.
If prior compress-tool results are present, always compress and summarize them minimally only as part of a broader compression pass. Do not invoke the compress tool solely to re-compress an earlier compression result.
Messages marked as \`<dcp-message-id>BLOCKED</dcp-message-id>\` cannot be compressed.

Rules:

- Pick each \`messageId\` directly from injected IDs visible in context.
- Only use raw message IDs of the form \`mNNNN\`.
- Ignore XML attributes such as \`priority\` when copying the ID; use only the inner \`mNNNN\` value.
- Only use raw message IDs of the form \`mNNNNN\`.
- Ignore XML attributes such as \`priority\` when copying the ID; use only the inner \`mNNNNN\` value.
- Do not invent IDs. Use only IDs that are present in context.

BATCHING
Expand Down
4 changes: 2 additions & 2 deletions lib/prompts/compress-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Compressed block sections in context are clearly marked with a header:

- \`[Compressed conversation section]\`

Compressed block IDs always use the \`bN\` form (never \`mNNNN\`) and are represented in the same XML metadata tag format.
Compressed block IDs always use the \`bN\` form (never \`mNNNNN\`) and are represented in the same XML metadata tag format.

Rules:

Expand All @@ -41,7 +41,7 @@ When you use compressed block placeholders, write the surrounding summary text s
BOUNDARY IDS
You specify boundaries by ID using the injected IDs visible in the conversation:

- \`mNNNN\` IDs identify raw messages
- \`mNNNNN\` IDs identify raw messages
- \`bN\` IDs identify previously compressed blocks

Each message has an ID inside XML metadata tags like \`<dcp-message-id>...</dcp-message-id>\`.
Expand Down
6 changes: 3 additions & 3 deletions lib/prompts/extensions/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ THE FORMAT OF COMPRESS
topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration"
content: [ // One or more ranges to compress
{
startId: string, // Boundary ID at range start: mNNNN or bN
endId: string, // Boundary ID at range end: mNNNN or bN
startId: string, // Boundary ID at range start: mNNNNN or bN
endId: string, // Boundary ID at range end: mNNNNN or bN
summary: string // Complete technical summary replacing all content in range
}
]
Expand All @@ -26,7 +26,7 @@ THE FORMAT OF COMPRESS
topic: string, // Short label (3-5 words) for the overall batch
content: [ // One or more messages to compress independently
{
messageId: string, // Raw message ID only: mNNNN (ignore metadata attributes like priority)
messageId: string, // Raw message ID only: mNNNNN (ignore metadata attributes like priority)
topic: string, // Short label (3-5 words) for this one message summary
summary: string // Complete technical summary replacing that one message
}
Expand Down
13 changes: 13 additions & 0 deletions lib/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
collectTurnNudgeAnchors,
} from "./utils"
import { getLastUserMessage } from "../messages/query"
import { parseMessageRef, formatMessageRef } from "../message-ids"

export const checkSession = async (
client: any,
Expand Down Expand Up @@ -195,6 +196,18 @@ export async function ensureSessionInitialized(
state.messageIds.byRef.delete(ref)
}
}
// Migrate 4-digit refs (m0001) to 5-digit (m00001) for msgid expansion
for (const [rawId, oldRef] of state.messageIds.byRawId) {
const parsed = parseMessageRef(oldRef)
if (parsed !== null) {
const newRef = formatMessageRef(parsed)
if (newRef !== oldRef) {
state.messageIds.byRawId.set(rawId, newRef)
state.messageIds.byRef.delete(oldRef)
state.messageIds.byRef.set(newRef, rawId)
}
}
}
}
if (persistedAny._persistedLastCompaction !== undefined) {
state.lastCompaction = Math.max(state.lastCompaction, persistedAny._persistedLastCompaction)
Expand Down
Loading
Loading