diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eaf139ae..a77e38da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,7 +13,8 @@ - `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)` - `getEntries()` now excludes the session header (use `getHeader()` separately) - New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()` - - New `appendCustomEntry(customType, data)` for hooks to store custom data + - New `appendCustomEntry(customType, data)` for hooks to store custom data (not in LLM context) + - New `appendCustomMessageEntry(customType, content, display, details?)` for hooks to inject messages into LLM context - **Compaction API**: - `CompactionEntry` and `CompactionResult` are now generic with optional `details?: T` for hook-specific data - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry` diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 8dfa9bbb..b6ac5f45 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -118,14 +118,14 @@ export interface CustomMessageEntry extends SessionEntryBase { ``` Behavior: -- [ ] Participates in context and compaction as user messages (after messageTransformer) -- [ ] Not displayed as user messages in TUI -- [ ] Display options: +- [x] Type definition matching plan +- [x] `appendCustomMessageEntry(customType, content, display, details?)` in SessionManager +- [x] `buildSessionContext()` includes custom_message entries as user messages +- [x] Exported from main index +- [ ] TUI rendering: - `display: false` - hidden entirely - `display: true` - baseline renderer (content with different bg/fg color) - - Custom renderer defined by the hook that contributes it -- [ ] Define injection mechanism for hooks to add CustomMessageEntry -- [ ] Hook registration for custom renderers + - Custom renderer defined by the hook that contributes it (future) See also: `CustomEntry` for storing hook state that does NOT participate in context. diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 79d610c7..02e43194 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,4 +1,4 @@ -import type { AppMessage } from "@mariozechner/pi-agent-core"; +import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import { randomUUID } from "crypto"; import { appendFileSync, @@ -86,6 +86,26 @@ export interface LabelEntry extends SessionEntryBase { label: string | undefined; } +/** + * Custom message entry for hooks to inject messages into LLM context. + * Use customType to identify your hook's entries. + * + * Unlike CustomEntry, this DOES participate in LLM context. + * The content is converted to a user message in buildSessionContext(). + * Use details for hook-specific metadata (not sent to LLM). + * + * display controls TUI rendering: + * - false: hidden entirely + * - true: rendered with distinct styling (different from user messages) + */ +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; + content: (string | Attachment)[]; + details?: T; + display: boolean; +} + /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = | SessionMessageEntry @@ -94,6 +114,7 @@ export type SessionEntry = | CompactionEntry | BranchSummaryEntry | CustomEntry + | CustomMessageEntry | LabelEntry; /** Raw file entry (includes header) */ @@ -140,6 +161,35 @@ export function createSummaryMessage(summary: string, timestamp: string): AppMes }; } +/** Convert CustomMessageEntry content to AppMessage format */ +function createCustomMessage(entry: CustomMessageEntry): AppMessage { + // Convert content array to AppMessage content format + const content = entry.content.map((item) => { + if (typeof item === "string") { + return { type: "text" as const, text: item }; + } + // Attachment - convert to appropriate content type + if (item.type === "image") { + return { + type: "image" as const, + data: item.content, + mimeType: item.mimeType, + }; + } + // Document attachment - use extracted text or indicate document + return { + type: "text" as const, + text: item.extractedText ?? `[Document: ${item.fileName}]`, + }; + }); + + return { + role: "user", + content, + timestamp: new Date(entry.timestamp).getTime(), + }; +} + /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { @@ -308,8 +358,12 @@ export function buildSessionContext( if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } - if (foundFirstKept && entry.type === "message") { - messages.push(entry.message); + if (foundFirstKept) { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push(createCustomMessage(entry)); + } } } @@ -318,15 +372,19 @@ export function buildSessionContext( const entry = path[i]; if (entry.type === "message") { messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push(createCustomMessage(entry)); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); } } } else { - // No compaction - emit all messages, handle branch summaries + // No compaction - emit all messages, handle branch summaries and custom messages for (const entry of path) { if (entry.type === "message") { messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push(createCustomMessage(entry)); } else if (entry.type === "branch_summary") { messages.push(createSummaryMessage(entry.summary, entry.timestamp)); } @@ -623,6 +681,34 @@ export class SessionManager { return entry.id; } + /** + * Append a custom message entry (for hooks) that participates in LLM context. + * @param customType Hook identifier for filtering on reload + * @param content Message content (strings and attachments) + * @param display Whether to show in TUI (true = styled display, false = hidden) + * @param details Optional hook-specific metadata (not sent to LLM) + * @returns Entry id + */ + appendCustomMessageEntry( + customType: string, + content: (string | Attachment)[], + display: boolean, + details?: T, + ): string { + const entry: CustomMessageEntry = { + type: "custom_message", + customType, + content, + display, + details, + id: generateId(this.byId), + parentId: this.leafId || null, + timestamp: new Date().toISOString(), + }; + this._appendEntry(entry); + return entry.id; + } + // ========================================================================= // Tree Traversal // ========================================================================= diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index acfc766a..55909056 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -111,6 +111,8 @@ export { buildSessionContext, type CompactionEntry, CURRENT_SESSION_VERSION, + type CustomEntry, + type CustomMessageEntry, createSummaryMessage, type FileEntry, getLatestCompactionEntry,