mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9656ed59 | |||
| f9bdcdd785 | |||
| cf05b9fa69 | |||
| aaa4016ae8 | |||
| ff5c568cb7 | |||
| 0ed61d1ba2 | |||
| 2dbfb50080 | |||
| d569297c30 | |||
| 4f1c0348b4 | |||
| bc0847e3ed | |||
| 6a81fc384c | |||
| ed4590ca18 | |||
| 09cc4a2ffb | |||
| c0ebd84d7e | |||
| 20a6cfe1a6 | |||
| 36b78ea91c | |||
| 406c160c9f | |||
| 968aedd5c5 | |||
| d4053b2d35 | |||
| 85e11be2b9 | |||
| 5b0083efae | |||
| 60b9ca75c8 | |||
| 8da39c7a9d | |||
| e8c95a62ec | |||
| 56d7ef9a68 | |||
| 7427221640 | |||
| 9991c95ff6 | |||
| f7139f0844 | |||
| 465edfabf1 | |||
| 6a80f4b047 | |||
| b21347c12b | |||
| ffb0477251 | |||
| a1b9bf4fbc | |||
| a762e863d1 | |||
| 222bae28de | |||
| 53aa6d3c31 | |||
| 034dfe8d91 | |||
| e3ebdbfe9f | |||
| fdebd304f6 | |||
| 77d60abf5f | |||
| 560f63f96b | |||
| 11361abc91 | |||
| e071095422 | |||
| 909d6c7767 | |||
| c697f63c67 | |||
| 25b673fbb7 | |||
| acaa829df4 | |||
| fe6ce36e09 | |||
| 3cc6dff7ae | |||
| 1c748f3ee2 | |||
| ca68b7f55c | |||
| 023589a905 | |||
| 24f807fed0 | |||
| 097235e43b | |||
| 14bbb76cf1 |
@@ -5,28 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [1.5.5] - 2026-05-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Section-aware greedy line accumulator for hot session state rendering that fits whole lines instead of truncating mid-line.
|
|
||||||
- `accountHotSessionStateRender()` accounting function returning prompt text, omitted items, and char budget for v2 evidence extension.
|
|
||||||
- Exported types for render accounting diagnostics: `HotStateSection`, `HotStateOmissionReason`, `HotStateOmittedItem`, `HotSessionStateRenderAccounting`.
|
|
||||||
- 9 direct unit tests for hot session state rendering covering empty state, ranking, section caps, char budget, boundary conditions, header suppression, wrapper parity, and newline counting.
|
|
||||||
- Two-layer omission model: section caps first (`section_cap`), then char budget (`char_budget`), with clear per-item omission reasons.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Hot session state rendering no longer uses blunt `.slice(0, maxRenderedChars)` prompt truncation.
|
|
||||||
- Header-only sections are suppressed: a section is only rendered if at least one entry line fits the char budget.
|
|
||||||
- `renderHotSessionState()` is now a delegation wrapper to `accountHotSessionStateRender().prompt`, preserving backward compatibility.
|
|
||||||
- Fixed `docs/configuration.md` active-file ranking formula: was `count * action_weight * recency_decay` with weights 4/3/2/1, now correctly `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending.
|
|
||||||
- Clarified `README.md` system prompt injection order: workspace memory and hot state index positions depend on whether workspace memory state is available.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Hot session state prompt could be truncated mid-line under the old `.slice()` budget enforcement.
|
|
||||||
|
|
||||||
## [1.5.4] - 2026-05-02
|
## [1.5.4] - 2026-05-02
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -85,13 +85,11 @@ OpenCode Working Memory adds durable memory without making extra LLM/API calls.
|
|||||||
▼
|
▼
|
||||||
┌──────────────────────────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ ⚡ Prompt Context │
|
│ ⚡ Prompt Context │
|
||||||
│ system[1]*: frozen workspace memory │
|
│ system[1]: frozen workspace memory │
|
||||||
│ system[2+]*: hot session state │
|
│ system[2+]: hot session state │
|
||||||
└──────────────────────────────────────┘
|
└──────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
\* Conceptually, workspace memory is pushed first when it is non-empty, and hot session state is pushed after workspace memory. If workspace memory is empty, hot state may be the first plugin-added system message. Actual `system[]` indices also depend on OpenCode and other plugins, so `system[1]` / `system[2+]` is a simplified model.
|
|
||||||
|
|
||||||
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
**Zero extra API calls:** OpenCode Working Memory does not call the model on its own. Memory extraction is folded into OpenCode's built-in compaction request.
|
||||||
|
|
||||||
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
**Cache-friendly layout:** durable workspace memory is rendered as a stable frozen snapshot for the session, while fast-changing hot session state is appended separately. Compaction starts a new cache epoch, refreshing the workspace snapshot after pending memories are promoted.
|
||||||
|
|||||||
@@ -1,43 +1,5 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
## 1.5.5 (2026-05-05)
|
|
||||||
|
|
||||||
### Hot State Rendering Health
|
|
||||||
|
|
||||||
This release replaces blunt prompt truncation with a section-aware greedy renderer that fits whole lines and suppresses empty sections, plus render accounting types for future diagnostics.
|
|
||||||
|
|
||||||
> **Good rendering is selective too.** If a section doesn't fit, omit it — don't cut it in half.
|
|
||||||
|
|
||||||
### What Changed
|
|
||||||
|
|
||||||
- **Whole-line rendering**: the hot session state prompt no longer truncates mid-line with `.slice(0, maxRenderedChars)`. A greedy line accumulator fits complete lines and stops when the next line would exceed the 700-char budget.
|
|
||||||
- **Header-only sections suppressed**: a section heading is only rendered if at least one entry fits alongside it. No more orphaned `active_files:` headers with no content underneath.
|
|
||||||
- **Render accounting**: `accountHotSessionStateRender()` now returns `prompt`, `omitted[]`, and `maxRenderedChars` so future v2 diagnostics can surface which items fell out and why.
|
|
||||||
- **Two-layer omission model**: section caps (`section_cap`) trim per-type overflow first, then the char budget (`char_budget`) trims anything that doesn't fit. Each omitted item carries its reason and section.
|
|
||||||
- **Backward-compatible wrapper**: `renderHotSessionState()` delegates to the accounting function and returns just the prompt string, preserving existing plugin behavior.
|
|
||||||
|
|
||||||
### Docs Fixes
|
|
||||||
|
|
||||||
- **Active-file ranking formula corrected**: `docs/configuration.md` now matches the actual implementation — `ACTION_WEIGHT[action] + count * 3` with weights edit 50, write 45, grep 30, read 20, tie-break by `lastSeen` descending. The old docs claimed `count * action_weight * recency_decay` with weights 4/3/2/1.
|
|
||||||
- **Injection order clarified**: README now explains that workspace memory and hot state system-prompt positions depend on whether workspace memory state is available.
|
|
||||||
|
|
||||||
### Not Included Yet
|
|
||||||
|
|
||||||
- Evidence events for omitted items are deferred to v2, pending a `memory-diag` consumer to avoid unused storage litter.
|
|
||||||
|
|
||||||
### Upgrade Notes
|
|
||||||
|
|
||||||
- No configuration changes required.
|
|
||||||
- Existing workspace memory files remain compatible.
|
|
||||||
- Hot session state rendering behavior is intentionally different (cleaner output, no mid-line cuts), but the same information is presented when it fits within the budget.
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
|
|
||||||
- `npm run typecheck` — `TYPECHECK_PASS`
|
|
||||||
- `npm test` — 356 tests passing, `TEST_PASS`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.5.4 (2026-05-02)
|
## 1.5.4 (2026-05-02)
|
||||||
|
|
||||||
### Memory Diagnostics Surface Cleanup
|
### Memory Diagnostics Surface Cleanup
|
||||||
|
|||||||
+6
-23
@@ -87,33 +87,16 @@ const HOT_STATE_LIMITS = {
|
|||||||
|
|
||||||
## Active File Scoring
|
## Active File Scoring
|
||||||
|
|
||||||
Files are ranked by action type plus repeated access count:
|
Files are ranked by action type:
|
||||||
|
|
||||||
| Action | Weight | Description |
|
| Action | Weight | Description |
|
||||||
|--------|--------|-------------|
|
|--------|--------|-------------|
|
||||||
| `edit` | 50 | File modified |
|
| `write` | 4 | File created/overwritten |
|
||||||
| `write` | 45 | File created/overwritten |
|
| `edit` | 3 | File modified |
|
||||||
| `grep` | 30 | Grep searched in file |
|
| `read` | 2 | File read |
|
||||||
| `read` | 20 | File read |
|
| `grep` | 1 | Grep searched in file |
|
||||||
|
|
||||||
Score formula: `ACTION_WEIGHT[action] + count * 3`
|
Score formula: `count * action_weight * recency_decay`
|
||||||
|
|
||||||
When scores tie, the most recent `lastSeen` timestamp sorts first. There is no recency decay factor in the score itself.
|
|
||||||
|
|
||||||
## Hot Session State Truncation
|
|
||||||
|
|
||||||
Hot state rendering applies section caps before the character budget:
|
|
||||||
|
|
||||||
| Section | Rendered cap |
|
|
||||||
|---------|--------------|
|
|
||||||
| `active_files` | 8 |
|
|
||||||
| `open_errors` | 3 |
|
|
||||||
| `recent_decisions` | 8 |
|
|
||||||
| `pending_memories` | 6 |
|
|
||||||
|
|
||||||
After section caps, the renderer applies the 700-character budget with whole-line inclusion only. The prefix line is counted against the budget, and a section heading is emitted only when at least one entry from that section fits; header-only sections are suppressed.
|
|
||||||
|
|
||||||
`accountHotSessionStateRender()` returns prompt and omission accounting for future diagnostics. Evidence event integration for hot-state render omissions is planned for v2.
|
|
||||||
|
|
||||||
## Error Categories
|
## Error Categories
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"name": "opencode-working-memory",
|
||||||
"version": "1.5.5",
|
"version": "1.5.4",
|
||||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
|
|||||||
+34
-135
@@ -189,150 +189,49 @@ export function clearErrorsForSuccessfulCommand(state: SessionState, command: st
|
|||||||
state.updatedAt = new Date().toISOString();
|
state.updatedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HotStateSection = "active_files" | "open_errors" | "recent_decisions" | "pending_memories";
|
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
||||||
|
const activeFiles = rankActiveFiles(state.activeFiles).slice(0, HOT_STATE_LIMITS.maxActiveFilesRendered);
|
||||||
|
const openErrors = [...state.openErrors]
|
||||||
|
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||||
|
.slice(0, HOT_STATE_LIMITS.maxOpenErrorsRendered);
|
||||||
|
const decisions = state.recentDecisions.slice(-HOT_STATE_LIMITS.maxRecentDecisionsStored);
|
||||||
|
const pendingMemories = dedupePendingMemories(state.pendingMemories)
|
||||||
|
.slice(-HOT_STATE_LIMITS.maxPendingMemoriesRendered);
|
||||||
|
|
||||||
export type HotStateOmissionReason = "section_cap" | "char_budget";
|
if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0 && pendingMemories.length === 0) return "";
|
||||||
|
|
||||||
export type HotStateOmittedItem = {
|
const lines: string[] = ["Hot session state (current session):"];
|
||||||
section: HotStateSection;
|
|
||||||
reason: HotStateOmissionReason;
|
|
||||||
text: string;
|
|
||||||
memoryId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HotSessionStateRenderAccounting = {
|
if (activeFiles.length > 0) {
|
||||||
prompt: string;
|
lines.push("active_files:");
|
||||||
omitted: HotStateOmittedItem[];
|
for (const item of activeFiles) {
|
||||||
maxRenderedChars: number;
|
const viewPath = displayPath(workspaceRoot, item.path);
|
||||||
};
|
lines.push(`- ${viewPath} (${item.action}, ${item.count}x)`);
|
||||||
|
|
||||||
type HotStateRenderItem = {
|
|
||||||
section: HotStateSection;
|
|
||||||
line: string;
|
|
||||||
memoryId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HotStateRenderSection = {
|
|
||||||
heading: `${HotStateSection}:`;
|
|
||||||
items: HotStateRenderItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const HOT_STATE_PREFIX = "Hot session state (current session):";
|
|
||||||
|
|
||||||
export function accountHotSessionStateRender(state: SessionState, workspaceRoot: string): HotSessionStateRenderAccounting {
|
|
||||||
const maxRenderedChars = HOT_STATE_LIMITS.maxRenderedChars;
|
|
||||||
const omitted: HotStateOmittedItem[] = [];
|
|
||||||
const sections = buildHotStateRenderSections(state, workspaceRoot, omitted);
|
|
||||||
|
|
||||||
if (sections.every(section => section.items.length === 0)) {
|
|
||||||
return { prompt: "", omitted, maxRenderedChars };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [HOT_STATE_PREFIX];
|
|
||||||
let renderedEntryCount = 0;
|
|
||||||
|
|
||||||
for (const section of sections) {
|
|
||||||
let headingRendered = false;
|
|
||||||
|
|
||||||
for (const item of section.items) {
|
|
||||||
if (headingRendered) {
|
|
||||||
if (wouldFit(lines, item.line, maxRenderedChars)) {
|
|
||||||
lines.push(item.line);
|
|
||||||
renderedEntryCount += 1;
|
|
||||||
} else {
|
|
||||||
omitted.push(omitHotStateItem(item, "char_budget"));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wouldFit(lines, section.heading, maxRenderedChars)
|
|
||||||
&& wouldFit([...lines, section.heading], item.line, maxRenderedChars)) {
|
|
||||||
lines.push(section.heading, item.line);
|
|
||||||
headingRendered = true;
|
|
||||||
renderedEntryCount += 1;
|
|
||||||
} else {
|
|
||||||
omitted.push(omitHotStateItem(item, "char_budget"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (renderedEntryCount === 0) return { prompt: "", omitted, maxRenderedChars };
|
if (openErrors.length > 0) {
|
||||||
|
lines.push("open_errors:");
|
||||||
|
for (const err of openErrors) {
|
||||||
|
lines.push(`- [${err.category}] ${err.summary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { prompt: lines.join("\n"), omitted, maxRenderedChars };
|
if (decisions.length > 0) {
|
||||||
}
|
lines.push("recent_decisions:");
|
||||||
|
for (const decision of decisions) {
|
||||||
|
lines.push(`- ${decision.text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function renderHotSessionState(state: SessionState, workspaceRoot: string): string {
|
if (pendingMemories.length > 0) {
|
||||||
return accountHotSessionStateRender(state, workspaceRoot).prompt;
|
lines.push("pending_memories:");
|
||||||
}
|
for (const memory of pendingMemories) {
|
||||||
|
lines.push(`- [${memory.type}] ${memory.text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildHotStateRenderSections(
|
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
|
||||||
state: SessionState,
|
|
||||||
workspaceRoot: string,
|
|
||||||
omitted: HotStateOmittedItem[],
|
|
||||||
): HotStateRenderSection[] {
|
|
||||||
const activeFiles = rankActiveFiles(state.activeFiles).map(item => ({
|
|
||||||
section: "active_files" as const,
|
|
||||||
line: `- ${displayPath(workspaceRoot, item.path)} (${item.action}, ${item.count}x)`,
|
|
||||||
}));
|
|
||||||
const openErrors = [...state.openErrors]
|
|
||||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
||||||
.map(err => ({
|
|
||||||
section: "open_errors" as const,
|
|
||||||
line: `- [${err.category}] ${err.summary}`,
|
|
||||||
}));
|
|
||||||
const decisions = state.recentDecisions.map(item => ({
|
|
||||||
section: "recent_decisions" as const,
|
|
||||||
line: `- ${item.text}`,
|
|
||||||
}));
|
|
||||||
const pendingMemories = dedupePendingMemories(state.pendingMemories).map(item => ({
|
|
||||||
section: "pending_memories" as const,
|
|
||||||
line: `- [${item.type}] ${item.text}`,
|
|
||||||
memoryId: item.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderedActiveFiles = capHotStateItems(activeFiles, HOT_STATE_LIMITS.maxActiveFilesRendered, "start", omitted);
|
|
||||||
const renderedOpenErrors = capHotStateItems(openErrors, HOT_STATE_LIMITS.maxOpenErrorsRendered, "start", omitted);
|
|
||||||
const renderedDecisions = capHotStateItems(decisions, HOT_STATE_LIMITS.maxRecentDecisionsStored, "end", omitted);
|
|
||||||
const renderedPendingMemories = capHotStateItems(
|
|
||||||
pendingMemories,
|
|
||||||
HOT_STATE_LIMITS.maxPendingMemoriesRendered,
|
|
||||||
"end",
|
|
||||||
omitted,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ heading: "active_files:", items: renderedActiveFiles },
|
|
||||||
{ heading: "open_errors:", items: renderedOpenErrors },
|
|
||||||
{ heading: "recent_decisions:", items: renderedDecisions },
|
|
||||||
{ heading: "pending_memories:", items: renderedPendingMemories },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function capHotStateItems(
|
|
||||||
items: HotStateRenderItem[],
|
|
||||||
cap: number,
|
|
||||||
keep: "start" | "end",
|
|
||||||
omitted: HotStateOmittedItem[],
|
|
||||||
): HotStateRenderItem[] {
|
|
||||||
if (items.length <= cap) return items;
|
|
||||||
|
|
||||||
const renderedItems = keep === "start" ? items.slice(0, cap) : items.slice(-cap);
|
|
||||||
const omittedItems = keep === "start" ? items.slice(cap) : items.slice(0, items.length - cap);
|
|
||||||
omitted.push(...omittedItems.map(item => omitHotStateItem(item, "section_cap")));
|
|
||||||
return renderedItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
function omitHotStateItem(item: HotStateRenderItem, reason: HotStateOmissionReason): HotStateOmittedItem {
|
|
||||||
return {
|
|
||||||
section: item.section,
|
|
||||||
reason,
|
|
||||||
text: item.line,
|
|
||||||
...(item.memoryId ? { memoryId: item.memoryId } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wouldFit(lines: string[], nextLine: string, maxRenderedChars: number): boolean {
|
|
||||||
return [...lines, nextLine].join("\n").length <= maxRenderedChars;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
|
function rankActiveFiles(activeFiles: ActiveFile[]): ActiveFile[] {
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import * as sessionStateModule from "../src/session-state.ts";
|
|
||||||
import type { HotSessionStateRenderAccounting } from "../src/session-state.ts";
|
|
||||||
import type { ActiveFile, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts";
|
|
||||||
import { HOT_STATE_LIMITS } from "../src/types.ts";
|
|
||||||
|
|
||||||
type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting;
|
|
||||||
|
|
||||||
const accountHotSessionStateRender = (
|
|
||||||
sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender }
|
|
||||||
).accountHotSessionStateRender;
|
|
||||||
|
|
||||||
const { createEmptySessionState, renderHotSessionState } = sessionStateModule;
|
|
||||||
|
|
||||||
const root = "/repo";
|
|
||||||
|
|
||||||
function state(overrides: Partial<SessionState> = {}): SessionState {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
sessionID: "session-state-test",
|
|
||||||
turn: 0,
|
|
||||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
activeFiles: [],
|
|
||||||
openErrors: [],
|
|
||||||
recentDecisions: [],
|
|
||||||
pendingMemories: [],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile {
|
|
||||||
return { path, action, count, lastSeen };
|
|
||||||
}
|
|
||||||
|
|
||||||
function openError(id: string, summary: string, lastSeen: number): OpenError {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
category: "test",
|
|
||||||
summary,
|
|
||||||
fingerprint: `fingerprint-${id}`,
|
|
||||||
status: "open",
|
|
||||||
firstSeen: lastSeen - 1,
|
|
||||||
lastSeen,
|
|
||||||
seenCount: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function decision(id: string, text: string, createdAt: number): SessionDecision {
|
|
||||||
return { id, text, source: "assistant", createdAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function memory(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
text,
|
|
||||||
source: "compaction",
|
|
||||||
confidence: 0.75,
|
|
||||||
status: "active",
|
|
||||||
createdAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender returns empty prompt and no omissions for empty state", () => {
|
|
||||||
const accounting = accountHotSessionStateRender(createEmptySessionState("empty-session"), root);
|
|
||||||
|
|
||||||
assert.equal(accounting.prompt, "");
|
|
||||||
assert.deepEqual(accounting.omitted, []);
|
|
||||||
assert.equal(accounting.maxRenderedChars, HOT_STATE_LIMITS.maxRenderedChars);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender renders hot-state sections in stable order", () => {
|
|
||||||
const accounting = accountHotSessionStateRender(state({
|
|
||||||
activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)],
|
|
||||||
openErrors: [openError("err-1", "one failing test", 2)],
|
|
||||||
recentDecisions: [decision("dec-1", "Keep renderer simple", 3)],
|
|
||||||
pendingMemories: [memory("mem-1", "Promote useful fact")],
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
assert.ok(accounting.prompt.startsWith("Hot session state (current session):"));
|
|
||||||
assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:"));
|
|
||||||
assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:"));
|
|
||||||
assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender ranks active files by score then lastSeen descending", () => {
|
|
||||||
const accounting = accountHotSessionStateRender(state({
|
|
||||||
activeFiles: [
|
|
||||||
activeFile("/repo/src/edit.ts", "edit", 1, 400),
|
|
||||||
activeFile("/repo/src/write.ts", "write", 5, 200),
|
|
||||||
activeFile("/repo/src/grep.ts", "grep", 10, 300),
|
|
||||||
activeFile("/repo/src/read.ts", "read", 12, 100),
|
|
||||||
],
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
const lines = accounting.prompt.split("\n");
|
|
||||||
const activeLines = lines.filter(line => line.startsWith("- src/"));
|
|
||||||
assert.deepEqual(activeLines, [
|
|
||||||
"- src/grep.ts (grep, 10x)",
|
|
||||||
"- src/write.ts (write, 5x)",
|
|
||||||
"- src/read.ts (read, 12x)",
|
|
||||||
"- src/edit.ts (edit, 1x)",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender reports section-cap omissions for every capped section", () => {
|
|
||||||
const accounting = accountHotSessionStateRender(state({
|
|
||||||
activeFiles: Array.from({ length: 9 }, (_, index) => activeFile(`/repo/a${index}.ts`, "read", 1, 100 - index)),
|
|
||||||
openErrors: Array.from({ length: 4 }, (_, index) => openError(`err-${index}`, `e${index}`, 100 - index)),
|
|
||||||
recentDecisions: Array.from({ length: 9 }, (_, index) => decision(`dec-${index}`, `d${index}`, index)),
|
|
||||||
pendingMemories: Array.from({ length: 7 }, (_, index) => memory(`mem-${index}`, `m${index}`)),
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
const sectionCapOmissions = accounting.omitted.filter(item => item.reason === "section_cap");
|
|
||||||
assert.deepEqual(
|
|
||||||
sectionCapOmissions.map(item => item.section).sort(),
|
|
||||||
["active_files", "open_errors", "pending_memories", "recent_decisions"].sort(),
|
|
||||||
);
|
|
||||||
assert.equal(sectionCapOmissions.find(item => item.section === "pending_memories")?.memoryId, "mem-0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender omits over-budget entries without cutting rendered lines", () => {
|
|
||||||
const longPath = `/repo/${"x".repeat(650)}.ts`;
|
|
||||||
const accounting = accountHotSessionStateRender(state({
|
|
||||||
activeFiles: [
|
|
||||||
activeFile("/repo/src/short.ts", "read", 1, 20),
|
|
||||||
activeFile(longPath, "read", 1, 10),
|
|
||||||
],
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
assert.equal(accounting.prompt, [
|
|
||||||
"Hot session state (current session):",
|
|
||||||
"active_files:",
|
|
||||||
"- src/short.ts (read, 1x)",
|
|
||||||
].join("\n"));
|
|
||||||
assert.equal(accounting.omitted.length, 1);
|
|
||||||
assert.equal(accounting.omitted[0]?.reason, "char_budget");
|
|
||||||
assert.equal(accounting.omitted[0]?.section, "active_files");
|
|
||||||
assert.ok(!accounting.prompt.includes("x".repeat(20)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => {
|
|
||||||
const fixedPrompt = [
|
|
||||||
"Hot session state (current session):",
|
|
||||||
"pending_memories:",
|
|
||||||
"- [decision] ",
|
|
||||||
].join("\n");
|
|
||||||
const exactText = "x".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
|
|
||||||
const exactAccounting = accountHotSessionStateRender(state({
|
|
||||||
pendingMemories: [memory("mem-exact", exactText)],
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
|
|
||||||
assert.equal(exactAccounting.omitted.length, 0);
|
|
||||||
|
|
||||||
const overAccounting = accountHotSessionStateRender(state({
|
|
||||||
pendingMemories: [memory("mem-over", `${exactText}y`)],
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
assert.equal(overAccounting.prompt, "");
|
|
||||||
assert.equal(overAccounting.omitted.length, 1);
|
|
||||||
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
|
|
||||||
assert.equal(overAccounting.omitted[0]?.memoryId, "mem-over");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender suppresses header-only sections when no entries fit", () => {
|
|
||||||
const accounting = accountHotSessionStateRender(state({
|
|
||||||
activeFiles: [activeFile(`/repo/${"z".repeat(720)}.ts`, "read", 1, 1)],
|
|
||||||
}), root);
|
|
||||||
|
|
||||||
assert.equal(accounting.prompt, "");
|
|
||||||
assert.equal(accounting.omitted.length, 1);
|
|
||||||
assert.equal(accounting.omitted[0]?.reason, "char_budget");
|
|
||||||
assert.ok(!accounting.prompt.includes("active_files:"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renderHotSessionState delegates to accounted renderer prompt for empty and seeded states", () => {
|
|
||||||
const empty = createEmptySessionState("compat-empty");
|
|
||||||
const seeded = state({
|
|
||||||
activeFiles: [activeFile("/repo/src/a.ts", "edit", 2, 1)],
|
|
||||||
pendingMemories: [memory("mem-compat", "Compatibility prompt")],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(renderHotSessionState(empty, root), accountHotSessionStateRender(empty, root).prompt);
|
|
||||||
assert.equal(renderHotSessionState(seeded, root), accountHotSessionStateRender(seeded, root).prompt);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => {
|
|
||||||
const fixedPrompt = [
|
|
||||||
"Hot session state (current session):",
|
|
||||||
"recent_decisions:",
|
|
||||||
"- ",
|
|
||||||
].join("\n");
|
|
||||||
const exactText = "n".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length);
|
|
||||||
|
|
||||||
const exactAccounting = accountHotSessionStateRender(state({
|
|
||||||
recentDecisions: [decision("dec-exact", exactText, 1)],
|
|
||||||
}), root);
|
|
||||||
assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars);
|
|
||||||
assert.equal(exactAccounting.omitted.length, 0);
|
|
||||||
|
|
||||||
const overAccounting = accountHotSessionStateRender(state({
|
|
||||||
recentDecisions: [decision("dec-over", `${exactText}!`, 1)],
|
|
||||||
}), root);
|
|
||||||
assert.equal(overAccounting.prompt, "");
|
|
||||||
assert.equal(overAccounting.omitted.length, 1);
|
|
||||||
assert.equal(overAccounting.omitted[0]?.reason, "char_budget");
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user