neoctl 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,151 +1,151 @@
1
- # Agent Scaffold Source
2
-
3
- This directory is a TypeScript implementation scaffold for the parent README. Chapters 01, 02, 03, 04, 05-core, and 07 now have runnable paths; later chapters still expose stable module boundaries and placeholders.
4
-
5
- ## Shape
6
-
7
- - `src/repl`: the UI layer. It owns terminal input, slash commands, system init events, and rendering streamed events.
8
- - `src/core`: the multi-turn query loop, loop state, message pipeline, `QueryEngine`, and child-agent runner entry points.
9
- - `src/model`: provider-neutral model gateway/config/factory, provider adapters, OpenAI Responses/Chat mappers, HTTP transport, SSE decoder, retry, and normalized errors.
10
- - `src/tools`: lifecycle tool contracts, registry, schema validation, execution pipeline, batch orchestration, streaming executor, and built-in tools.
11
- - `src/context`: system prompt sections, runtime user/system context, deterministic compaction helpers, and model-driven compaction.
12
- - `src/session`: JSONL session transcripts, tool-result persistence, latest/specific session resume, and session listing.
13
- - `src/agents`: agent definitions, `AgentTool`, prompt rules, fork constraints, local task lifecycle, and agent smoke coverage.
14
- - `src/tasks`: background task store, task-control tools, named-agent message routing, and persisted task output files.
15
- - `src/skills`: inline workflow-as-tool injection and fork-skill boundary.
16
- - `src/safety`: optional permission, sandbox, and audit ports.
17
- - `src/app`: app-state ports used by tools and runtime code.
18
- - `vendor/ripgrep`: per-platform bundled `rg` binaries installed by `npm run vendor:rg` or optional `postinstall`.
19
-
20
- ## Commands
21
-
22
- ```bash
23
- npm install
24
- npm run vendor:rg
25
- npm run typecheck
26
- npm run build
27
- npm run smoke:core
28
- npm run smoke:tools
29
- npm run smoke:context
30
- npm run smoke:agents
31
- npm run smoke:skills
32
- npm run smoke:openai -- "Say pong"
33
- npm run dev
34
- ```
35
-
36
- ## Core Loop
37
-
38
- `src/core/query.ts` implements the Chapter 01 main path as a streaming state machine:
39
-
40
- - prepares each turn from a single `QueryState`
41
- - applies compact-boundary filtering and tool-result budgeting before model calls
42
- - builds system/user context into the model request
43
- - streams assistant deltas and messages to the UI
44
- - collects `tool_use` events, executes tools, and feeds `tool_result` messages into the next model turn
45
- - tracks `previousResponseId` for Responses API tool-result continuation
46
- - emits terminal reasons such as `completed`, `max_turns`, `model_error`, and abort states
47
- - keeps max-output-token recovery and reactive compact continuation points explicit
48
-
49
- `npm run smoke:core` verifies the tool-call follow-up loop with a fake model and the built-in `echo` tool.
50
-
51
- ## Tool System
52
-
53
- `src/tools` implements the Chapter 02 tool system contract:
54
-
55
- - tools are lifecycle objects with identity, aliases, schemas, metadata, validators, execution, result mapping, progress rendering, and optional context modifiers
56
- - `ToolRegistry` keeps built-in tools as a stable prompt-cache prefix, supports aliases, filters deferred tools, and merges external tools deterministically
57
- - `runToolUse()` performs schema validation, custom validation, permission decision, progress emission, abort handling, result mapping, max-result truncation, new messages, and context modifier propagation
58
- - `runTools()` partitions tool calls into concurrency-safe batches and serial batches, applying context modifiers in tool-use order
59
- - `StreamingToolExecutor` can start tools as tool calls arrive and can synthesize discarded results on fallback/abort
60
- - `grepTool` calls the bundled ripgrep binary through `ripgrep-binary.ts`, supports smart/sensitive/insensitive case modes, glob filters, hidden-file opt-in, bounded context lines, and bounded total results
61
- - `searchTool` performs web search through a pluggable `SearchProvider`; the initial backend is Exa MCP, with provider factory seams for future Bing/Tavily/custom implementations
62
- - `scripts/install-ripgrep.cjs` resolves the current OS/CPU, downloads the matching official ripgrep release asset, extracts `rg`, and writes a manifest beside the binary; runtime lookup does not depend on PATH
63
-
64
- `npm run smoke:tools` verifies aliases, schema/custom validation, unknown-tool errors, max result truncation, bundled-rg grep, pluggable web search, and concurrent batch execution.
65
-
66
- ## Context And Prompts
67
-
68
- `src/context` implements the Chapter 03 and Chapter 05 prompt/context path:
69
-
70
- - `prompts.ts` builds system prompt sections with `__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__`, replacement priority, proactive agent append mode, and prefix splitting for cache-aware providers
71
- - `DefaultContextManager` memoizes user context (`currentDate`, project memory files) and system context (`cwd`, platform, git branch/status/recent commit)
72
- - `message-pipeline.ts` prepends user context as a user message, appends system context to the system prompt, respects compact boundaries, and budgets oversized tool results
73
- - `ModelDrivenCompactor` uses the configured model gateway for autocompact and reactive compact summaries
74
- - deterministic snip, microcompact, and summary fallback remain available when model summarization fails or when tests need predictable output
75
- - `query.ts` persists compact-boundary messages into the event stream and retries once after provider `context_length` errors
76
-
77
- `npm run smoke:context` verifies prompt boundary splitting, context injection, tool result budgeting, deterministic compaction, model-driven autocompact, and prompt-too-long recovery.
78
-
79
- ## Session Resume
80
-
81
- The REPL writes JSONL transcripts under `.agent/sessions` by default. After each user message, a background title subagent check is scheduled after 5s: it creates one initial title, then performs one later refinement with the conversation and previous title if the prior title agent has finished. `/sessions` opens an interactive browser (default 10 per page): use ↑/↓ to select a session, ←/→ to switch pages when more than one page exists, Enter to resume the selected session, and Esc to close.
82
-
83
- Startup resume is available with environment variables:
84
-
85
- ```bash
86
- set AGENT_SESSION_RESUME=1
87
- npm run dev
88
-
89
- set AGENT_SESSION_ID=<session_id>
90
- set AGENT_SESSION_RESUME=1
91
- npm run dev
92
- ```
93
-
94
- Set `AGENT_SESSION_TRANSCRIPT=0` to disable transcript persistence, or `AGENT_SESSION_DIR=<absolute-or-relative-dir>` to store transcripts elsewhere. `/reset` clears the active history and records a reset marker so future resumes start after the reset.
95
-
96
- `npm run smoke:session` verifies transcript recording, latest-session lookup, specific-session resume, tool-result output persistence, and reset markers.
97
-
98
- ## Subagents And Tasks
99
-
100
- `src/agents` and `src/tasks` implement the Chapter 04 core path:
101
-
102
- - `AgentTool` is a normal tool with `prompt`, `description`, `subagent_type`, `model`, `run_in_background`, `name`, `team_name`, `mode`, `isolation`, and `cwd` inputs
103
- - `AgentTool` now exports prompt rules covering fresh/fork/background/parallel/prompt-quality behavior
104
- - `runAgent()` creates isolated child messages/context/tool pools and reuses the same `query()` loop
105
- - `AgentDefinition` supports tool allow/deny lists, model, effort, permission mode, background, max turns, memory, isolation, and custom system prompts
106
- - sync agents return a completed structured result; background/fork agents register `LocalAgentTask` and return `async_launched`
107
- - `TaskOutput`, `TaskList`, `TaskGet`, `TaskStop`, and `SendMessage` provide the minimum control surface for background agents
108
- - completed background tasks write `.agent-tasks/<task_id>.txt`; the directory is gitignored
109
- - fork children get explicit anti-recursion and scope boilerplate; teammate/team inputs are represented as named background agents for now
110
-
111
- `npm run smoke:agents` verifies sync delegation, async launch, task output, output-file persistence, task listing, and named-agent message routing.
112
-
113
- ## Skills
114
-
115
- `src/skills` implements the low-risk part of Chapter 05 SkillTool:
116
-
117
- - `SkillTool` validates skill existence and model invocation eligibility
118
- - inline skills inject a meta user message into the next model turn through `newMessages`
119
- - inline skills can update main loop model and effort through `contextModifier`
120
- - fork skills are recognized and return a structured `fork_required` result instead of silently pretending to run
121
-
122
- `npm run smoke:skills` verifies inline injection, context modification, fork-skill rejection, and unknown-skill validation.
123
-
124
- ## Model Providers
125
-
126
- The REPL loads environment files, reads `MODEL_*` settings into a small discriminated provider config, and constructs a provider through `provider-factory.ts`. It loads the current directory `.env` first, then overrides it with the user-level config at `%APPDATA%\\neo\\.env` on Windows or `~/.config/neo/.env` on other platforms. If the user-level config file is missing, `neo` creates a commented template there and prints a startup notice telling the user to fill `MODEL_API_KEY`. Set `NEO_ENV_FILE` to point at a custom env file; that file has the highest priority. Provider-specific switches stay inside provider-owned config (`OpenAIProviderConfig.openai.endpoint` today), while `OPENAI_*` variables remain supported as compatibility aliases.
127
-
128
- ```bash
129
- mkdir "%APPDATA%\\neo"
130
- notepad "%APPDATA%\\neo\\.env"
131
-
132
- # In the env file:
133
- MODEL_PROVIDER=openai
134
- MODEL_API_KEY=your-api-key
135
- MODEL_BASE_URL=https://api.openai.com
136
- MODEL_ID=gpt-5.5
137
- MODEL_REASONING_EFFORT=high
138
- MODEL_ENDPOINT=auto
139
-
140
- npm run smoke:openai -- "Say pong"
141
- npm run dev
142
- ```
143
-
144
- The OpenAI adapter is split into a small provider facade plus mappers:
145
-
146
- - `openai-adapter.ts`: endpoint selection, auth, transport, retry, and Responses-to-Chat fallback
147
- - `openai-responses-mapper.ts`: `/v1/responses` request and event mapping
148
- - `openai-chat-mapper.ts`: OpenAI-compatible `/v1/chat/completions` fallback mapping
149
- - `openai-mappers.ts`: shared tool/message/usage helpers
150
-
151
- `MODEL_ENDPOINT=auto` tries `/v1/responses` first and falls back to `/v1/chat/completions` for OpenAI-compatible gateways that do not expose Responses API.
1
+ # Agent Scaffold Source
2
+
3
+ This directory is a TypeScript implementation scaffold for the parent README. Chapters 01, 02, 03, 04, 05-core, and 07 now have runnable paths; later chapters still expose stable module boundaries and placeholders.
4
+
5
+ ## Shape
6
+
7
+ - `src/repl`: the UI layer. It owns terminal input, slash commands, system init events, and rendering streamed events.
8
+ - `src/core`: the multi-turn query loop, loop state, message pipeline, `QueryEngine`, and child-agent runner entry points.
9
+ - `src/model`: provider-neutral model gateway/config/factory, provider adapters, OpenAI Responses/Chat mappers, HTTP transport, SSE decoder, retry, and normalized errors.
10
+ - `src/tools`: lifecycle tool contracts, registry, schema validation, execution pipeline, batch orchestration, streaming executor, and built-in tools.
11
+ - `src/context`: system prompt sections, runtime user/system context, deterministic compaction helpers, and model-driven compaction.
12
+ - `src/session`: JSONL session transcripts, tool-result persistence, latest/specific session resume, and session listing.
13
+ - `src/agents`: agent definitions, `AgentTool`, prompt rules, fork constraints, local task lifecycle, and agent smoke coverage.
14
+ - `src/tasks`: background task store, task-control tools, named-agent message routing, and persisted task output files.
15
+ - `src/skills`: inline workflow-as-tool injection and fork-skill boundary.
16
+ - `src/safety`: optional permission, sandbox, and audit ports.
17
+ - `src/app`: app-state ports used by tools and runtime code.
18
+ - `vendor/ripgrep`: per-platform bundled `rg` binaries installed by `npm run vendor:rg` or optional `postinstall`.
19
+
20
+ ## Commands
21
+
22
+ ```bash
23
+ npm install
24
+ npm run vendor:rg
25
+ npm run typecheck
26
+ npm run build
27
+ npm run smoke:core
28
+ npm run smoke:tools
29
+ npm run smoke:context
30
+ npm run smoke:agents
31
+ npm run smoke:skills
32
+ npm run smoke:openai -- "Say pong"
33
+ npm run dev
34
+ ```
35
+
36
+ ## Core Loop
37
+
38
+ `src/core/query.ts` implements the Chapter 01 main path as a streaming state machine:
39
+
40
+ - prepares each turn from a single `QueryState`
41
+ - applies compact-boundary filtering and tool-result budgeting before model calls
42
+ - builds system/user context into the model request
43
+ - streams assistant deltas and messages to the UI
44
+ - collects `tool_use` events, executes tools, and feeds `tool_result` messages into the next model turn
45
+ - tracks `previousResponseId` for Responses API tool-result continuation
46
+ - emits terminal reasons such as `completed`, `max_turns`, `model_error`, and abort states
47
+ - keeps max-output-token recovery and reactive compact continuation points explicit
48
+
49
+ `npm run smoke:core` verifies the tool-call follow-up loop with a fake model and the built-in `echo` tool.
50
+
51
+ ## Tool System
52
+
53
+ `src/tools` implements the Chapter 02 tool system contract:
54
+
55
+ - tools are lifecycle objects with identity, aliases, schemas, metadata, validators, execution, result mapping, progress rendering, and optional context modifiers
56
+ - `ToolRegistry` keeps built-in tools as a stable prompt-cache prefix, supports aliases, filters deferred tools, and merges external tools deterministically
57
+ - `runToolUse()` performs schema validation, custom validation, permission decision, progress emission, abort handling, result mapping, max-result truncation, new messages, and context modifier propagation
58
+ - `runTools()` partitions tool calls into concurrency-safe batches and serial batches, applying context modifiers in tool-use order
59
+ - `StreamingToolExecutor` can start tools as tool calls arrive and can synthesize discarded results on fallback/abort
60
+ - `grepTool` calls the bundled ripgrep binary through `ripgrep-binary.ts`, supports smart/sensitive/insensitive case modes, glob filters, hidden-file opt-in, bounded context lines, and bounded total results
61
+ - `searchTool` performs web search through a pluggable `SearchProvider`; the initial backend is Exa MCP, with provider factory seams for future Bing/Tavily/custom implementations
62
+ - `scripts/install-ripgrep.cjs` resolves the current OS/CPU, downloads the matching official ripgrep release asset, extracts `rg`, and writes a manifest beside the binary; runtime lookup does not depend on PATH
63
+
64
+ `npm run smoke:tools` verifies aliases, schema/custom validation, unknown-tool errors, max result truncation, bundled-rg grep, pluggable web search, and concurrent batch execution.
65
+
66
+ ## Context And Prompts
67
+
68
+ `src/context` implements the Chapter 03 and Chapter 05 prompt/context path:
69
+
70
+ - `prompts.ts` builds system prompt sections with `__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__`, replacement priority, proactive agent append mode, and prefix splitting for cache-aware providers
71
+ - `DefaultContextManager` memoizes user context (`currentDate`, project memory files) and system context (`cwd`, platform, git branch/status/recent commit)
72
+ - `message-pipeline.ts` prepends user context as a user message, appends system context to the system prompt, respects compact boundaries, and budgets oversized tool results
73
+ - `ModelDrivenCompactor` uses the configured model gateway for autocompact and reactive compact summaries
74
+ - deterministic snip, microcompact, and summary fallback remain available when model summarization fails or when tests need predictable output
75
+ - `query.ts` persists compact-boundary messages into the event stream and retries once after provider `context_length` errors
76
+
77
+ `npm run smoke:context` verifies prompt boundary splitting, context injection, tool result budgeting, deterministic compaction, model-driven autocompact, and prompt-too-long recovery.
78
+
79
+ ## Session Resume
80
+
81
+ The REPL writes JSONL transcripts under `.agent/sessions` by default. After each user message, a background title subagent check is scheduled after 5s: it creates one initial title, then performs one later refinement with the conversation and previous title if the prior title agent has finished. `/sessions` opens an interactive browser (default 10 per page): use ↑/↓ to select a session, ←/→ to switch pages when more than one page exists, Enter to resume the selected session, and Esc to close.
82
+
83
+ Startup resume is available with environment variables:
84
+
85
+ ```bash
86
+ set AGENT_SESSION_RESUME=1
87
+ npm run dev
88
+
89
+ set AGENT_SESSION_ID=<session_id>
90
+ set AGENT_SESSION_RESUME=1
91
+ npm run dev
92
+ ```
93
+
94
+ Set `AGENT_SESSION_TRANSCRIPT=0` to disable transcript persistence, or `AGENT_SESSION_DIR=<absolute-or-relative-dir>` to store transcripts elsewhere. `/reset` clears the active history and records a reset marker so future resumes start after the reset.
95
+
96
+ `npm run smoke:session` verifies transcript recording, latest-session lookup, specific-session resume, tool-result output persistence, and reset markers.
97
+
98
+ ## Subagents And Tasks
99
+
100
+ `src/agents` and `src/tasks` implement the Chapter 04 core path:
101
+
102
+ - `AgentTool` is a normal tool with `prompt`, `description`, `subagent_type`, `model`, `run_in_background`, `name`, `team_name`, `mode`, `isolation`, and `cwd` inputs
103
+ - `AgentTool` now exports prompt rules covering fresh/fork/background/parallel/prompt-quality behavior
104
+ - `runAgent()` creates isolated child messages/context/tool pools and reuses the same `query()` loop
105
+ - `AgentDefinition` supports tool allow/deny lists, model, effort, permission mode, background, max turns, memory, isolation, and custom system prompts
106
+ - sync agents return a completed structured result; background/fork agents register `LocalAgentTask` and return `async_launched`
107
+ - `TaskOutput`, `TaskList`, `TaskGet`, `TaskStop`, and `SendMessage` provide the minimum control surface for background agents
108
+ - completed background tasks write `.agent-tasks/<task_id>.txt`; the directory is gitignored
109
+ - fork children get explicit anti-recursion and scope boilerplate; teammate/team inputs are represented as named background agents for now
110
+
111
+ `npm run smoke:agents` verifies sync delegation, async launch, task output, output-file persistence, task listing, and named-agent message routing.
112
+
113
+ ## Skills
114
+
115
+ `src/skills` implements the low-risk part of Chapter 05 SkillTool:
116
+
117
+ - `SkillTool` validates skill existence and model invocation eligibility
118
+ - inline skills inject a meta user message into the next model turn through `newMessages`
119
+ - inline skills can update main loop model and effort through `contextModifier`
120
+ - fork skills are recognized and return a structured `fork_required` result instead of silently pretending to run
121
+
122
+ `npm run smoke:skills` verifies inline injection, context modification, fork-skill rejection, and unknown-skill validation.
123
+
124
+ ## Model Providers
125
+
126
+ The REPL loads environment files, reads `MODEL_*` settings into a small discriminated provider config, and constructs a provider through `provider-factory.ts`. It loads the current directory `.env` first, then overrides it with the user-level config at `%APPDATA%\\neo\\.env` on Windows or `~/.config/neo/.env` on other platforms. If the user-level config file is missing, `neo` creates a commented template there and prints a startup notice telling the user to fill `MODEL_API_KEY`. Set `NEO_ENV_FILE` to point at a custom env file; that file has the highest priority. Provider-specific switches stay inside provider-owned config (`OpenAIProviderConfig.openai.endpoint` today), while `OPENAI_*` variables remain supported as compatibility aliases.
127
+
128
+ ```bash
129
+ mkdir "%APPDATA%\\neo"
130
+ notepad "%APPDATA%\\neo\\.env"
131
+
132
+ # In the env file:
133
+ MODEL_PROVIDER=openai
134
+ MODEL_API_KEY=your-api-key
135
+ MODEL_BASE_URL=https://api.openai.com
136
+ MODEL_ID=gpt-5.5
137
+ MODEL_REASONING_EFFORT=high
138
+ MODEL_ENDPOINT=auto
139
+
140
+ npm run smoke:openai -- "Say pong"
141
+ npm run dev
142
+ ```
143
+
144
+ The OpenAI adapter is split into a small provider facade plus mappers:
145
+
146
+ - `openai-adapter.ts`: endpoint selection, auth, transport, retry, and Responses-to-Chat fallback
147
+ - `openai-responses-mapper.ts`: `/v1/responses` request and event mapping
148
+ - `openai-chat-mapper.ts`: OpenAI-compatible `/v1/chat/completions` fallback mapping
149
+ - `openai-mappers.ts`: shared tool/message/usage helpers
150
+
151
+ `MODEL_ENDPOINT=auto` tries `/v1/responses` first and falls back to `/v1/chat/completions` for OpenAI-compatible gateways that do not expose Responses API.
@@ -15,20 +15,25 @@ export interface ContextBudgetOptions {
15
15
  compactModel?: string;
16
16
  compactMaxOutputTokens?: number;
17
17
  }
18
+ export type CompactionReason = "none" | "snip" | "microcompact" | "autocompact" | "reactive_compact" | "manualcompact" | "purecompact";
18
19
  export interface CompactionResult {
19
20
  messages: Message[];
20
21
  summary?: string;
21
22
  changed: boolean;
22
- reason?: "none" | "snip" | "microcompact" | "autocompact" | "reactive_compact";
23
+ reason?: CompactionReason;
23
24
  tokensFreed?: number;
24
25
  }
25
26
  export interface Compactor {
26
27
  compact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
28
+ manualCompact?(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
29
+ pureCompact?(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
27
30
  reactiveCompact?(messages: readonly Message[], error: Error, options?: ContextBudgetOptions): Promise<CompactionResult>;
28
31
  }
29
32
  export declare const CLEARED_TOOL_RESULT_CONTENT = "[Old tool result content cleared]";
30
33
  export declare class DeterministicCompactor implements Compactor {
31
34
  compact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
35
+ manualCompact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
36
+ pureCompact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
32
37
  reactiveCompact(messages: readonly Message[], error: Error, options?: ContextBudgetOptions): Promise<CompactionResult>;
33
38
  }
34
39
  export declare class ModelDrivenCompactor implements Compactor {
@@ -36,12 +41,16 @@ export declare class ModelDrivenCompactor implements Compactor {
36
41
  private readonly fallback;
37
42
  constructor(modelGateway: ModelGateway);
38
43
  compact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
44
+ manualCompact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
45
+ pureCompact(messages: readonly Message[], options?: ContextBudgetOptions): Promise<CompactionResult>;
39
46
  reactiveCompact(messages: readonly Message[], error: Error, options?: ContextBudgetOptions): Promise<CompactionResult>;
40
47
  private modelAutoCompactIfNeeded;
41
48
  private summarizeWithModel;
42
49
  }
43
50
  export declare class NoopCompactor implements Compactor {
44
51
  compact(messages: readonly Message[]): Promise<CompactionResult>;
52
+ manualCompact(messages: readonly Message[]): Promise<CompactionResult>;
53
+ pureCompact(messages: readonly Message[]): Promise<CompactionResult>;
45
54
  }
46
55
  export declare function estimateMessagesChars(messages: readonly Message[]): number;
47
56
  export declare function snipCompactIfNeeded(messages: readonly Message[], options?: ContextBudgetOptions): CompactionResult;
@@ -13,6 +13,12 @@ export class DeterministicCompactor {
13
13
  return micro;
14
14
  return { messages: [...messages], changed: false, reason: "none" };
15
15
  }
16
+ async manualCompact(messages, options = {}) {
17
+ return manualCompactWithSummary(messages, options);
18
+ }
19
+ async pureCompact(messages, options = {}) {
20
+ return pureCompactWithSanitizedSummary(messages, options);
21
+ }
16
22
  async reactiveCompact(messages, error, options = {}) {
17
23
  const micro = microCompactIfNeeded(messages, options);
18
24
  const reactive = reactiveCompactWithSummary(micro.messages, `Reactive compact after model context error: ${error.message}`, options);
@@ -37,6 +43,23 @@ export class ModelDrivenCompactor {
37
43
  return micro;
38
44
  return { messages: [...messages], changed: false, reason: "none" };
39
45
  }
46
+ async manualCompact(messages, options = {}) {
47
+ const keepRecentMessages = options.keepRecentMessages ?? 8;
48
+ const recent = messages.slice(-keepRecentMessages);
49
+ const older = messages.slice(0, Math.max(0, messages.length - keepRecentMessages));
50
+ if (older.length === 0)
51
+ return { messages: [...messages], changed: false, reason: "none" };
52
+ try {
53
+ const summary = await this.summarizeWithModel(older, MANUAL_COMPACT_INSTRUCTIONS, options);
54
+ return buildCompactionResult(messages, recent, summary, "manualcompact", true);
55
+ }
56
+ catch {
57
+ return this.fallback.manualCompact?.(messages, options) ?? manualCompactWithSummary(messages, options);
58
+ }
59
+ }
60
+ async pureCompact(messages, options = {}) {
61
+ return pureCompactWithSanitizedSummary(messages, options);
62
+ }
40
63
  async reactiveCompact(messages, error, options = {}) {
41
64
  const micro = microCompactIfNeeded(messages, options);
42
65
  const keepRecentMessages = options.keepRecentMessages ?? 8;
@@ -104,6 +127,12 @@ export class NoopCompactor {
104
127
  async compact(messages) {
105
128
  return { messages: [...messages], changed: false, reason: "none" };
106
129
  }
130
+ async manualCompact(messages) {
131
+ return { messages: [...messages], changed: false, reason: "none" };
132
+ }
133
+ async pureCompact(messages) {
134
+ return { messages: [...messages], changed: false, reason: "none" };
135
+ }
107
136
  }
108
137
  export function estimateMessagesChars(messages) {
109
138
  return messages.reduce((total, message) => total + serializeMessage(message).length, 0);
@@ -192,6 +221,15 @@ function shouldCompactForBudget(messages, options, fallbackMaxChars, triggerRati
192
221
  }
193
222
  return estimateMessagesChars(messages) > fallbackMaxChars;
194
223
  }
224
+ function manualCompactWithSummary(messages, options) {
225
+ const keepRecentMessages = options.keepRecentMessages ?? 8;
226
+ const recent = messages.slice(-keepRecentMessages);
227
+ const older = messages.slice(0, Math.max(0, messages.length - keepRecentMessages));
228
+ if (older.length === 0)
229
+ return { messages: [...messages], changed: false, reason: "none" };
230
+ const summary = buildHistorySummary(older, options.summaryMaxChars ?? 6000);
231
+ return buildCompactionResult(messages, recent, summary || "No older messages were available to summarize.", "manualcompact", false);
232
+ }
195
233
  function reactiveCompactWithSummary(messages, heading, options) {
196
234
  const keepRecentMessages = options.keepRecentMessages ?? 8;
197
235
  const recent = messages.slice(-keepRecentMessages);
@@ -199,8 +237,25 @@ function reactiveCompactWithSummary(messages, heading, options) {
199
237
  const summary = buildHistorySummary(older, options.summaryMaxChars ?? 6000);
200
238
  return buildCompactionResult(messages, recent, `${heading}\n\n${summary || "No older messages were available to summarize."}`, "reactive_compact", false);
201
239
  }
240
+ function pureCompactWithSanitizedSummary(messages, options) {
241
+ if (messages.length === 0)
242
+ return { messages: [], changed: false, reason: "none" };
243
+ const summary = buildPureSummary(messages, options.summaryMaxChars ?? 4000);
244
+ const boundary = createCompactionBoundaryMessage(`Pure compacted conversation after a transport/WAF risk block.\n\n${summary}`, "purecompact", false);
245
+ return {
246
+ messages: [boundary],
247
+ summary,
248
+ changed: true,
249
+ reason: "purecompact",
250
+ tokensFreed: Math.max(0, estimateMessagesChars(messages) - estimateMessagesChars([boundary])),
251
+ };
252
+ }
202
253
  function buildCompactionResult(originalMessages, recentMessages, summary, reason, modelDriven) {
203
- const label = reason === "autocompact" ? "Auto compacted earlier conversation." : "Reactive compacted earlier conversation.";
254
+ const label = reason === "autocompact"
255
+ ? "Auto compacted earlier conversation."
256
+ : reason === "manualcompact"
257
+ ? "Manually compacted earlier conversation."
258
+ : "Reactive compacted earlier conversation.";
204
259
  const boundary = createCompactionBoundaryMessage(`${label}\n\n${summary}`, reason, modelDriven);
205
260
  const compacted = [boundary, ...recentMessages];
206
261
  return {
@@ -255,6 +310,104 @@ function buildHistorySummary(messages, maxChars) {
255
310
  const joined = lines.join("\n");
256
311
  return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n- ...summary truncated...` : joined;
257
312
  }
313
+ function buildPureSummary(messages, maxChars) {
314
+ const userLines = [];
315
+ const assistantLines = [];
316
+ const toolLines = [];
317
+ const stateLines = [];
318
+ for (const message of messages) {
319
+ const text = sanitizeForPureState(serializeMessageForPure(message));
320
+ if (!text)
321
+ continue;
322
+ const line = `- ${text}`;
323
+ if (message.metadata?.compactBoundary === true)
324
+ stateLines.push(line);
325
+ else if (message.role === "user")
326
+ userLines.push(line);
327
+ else if (message.role === "assistant")
328
+ assistantLines.push(line);
329
+ else if (message.role === "tool_result")
330
+ toolLines.push(line);
331
+ else if (message.role !== "system" && message.role !== "progress" && message.role !== "tombstone")
332
+ stateLines.push(`- ${message.role}: ${text}`);
333
+ }
334
+ const sections = [
335
+ "Purpose: sanitized continuation state produced by /pure after a risk/WAF block; raw commands, logs, code snippets, and bulky tool output were intentionally omitted.",
336
+ formatPureSection("Recent user goals/constraints", lastItems(userLines, 10)),
337
+ formatPureSection("Recent assistant progress/decisions", lastItems(assistantLines, 8)),
338
+ formatPureSection("Tool activity summary", lastItems(toolLines, 8)),
339
+ formatPureSection("Prior compact/state facts", lastItems(stateLines, 8)),
340
+ "Pending work: continue from the latest user request using the sanitized facts above; ask for clarification only if a required detail was removed by sanitization.",
341
+ ].filter(Boolean).join("\n");
342
+ return sections.length > maxChars ? `${sections.slice(0, maxChars)}\n- ...pure summary truncated...` : sections;
343
+ }
344
+ function formatPureSection(title, lines) {
345
+ return lines.length > 0 ? `${title}:\n${dedupeLines(lines).join("\n")}` : `${title}: none retained.`;
346
+ }
347
+ function lastItems(items, limit) {
348
+ return items.slice(Math.max(0, items.length - limit));
349
+ }
350
+ function dedupeLines(lines) {
351
+ const seen = new Set();
352
+ const deduped = [];
353
+ for (const line of lines) {
354
+ if (seen.has(line))
355
+ continue;
356
+ seen.add(line);
357
+ deduped.push(line);
358
+ }
359
+ return deduped;
360
+ }
361
+ function serializeMessageForPure(message) {
362
+ return message.blocks
363
+ .map((block) => {
364
+ if (block.type === "text")
365
+ return block.text;
366
+ if (block.type === "image")
367
+ return block.label ?? `[image ${block.mimeType}]`;
368
+ if (block.type === "thinking")
369
+ return "[assistant thinking omitted]";
370
+ if (block.type === "tool_use")
371
+ return `tool_use ${block.name}: ${sanitizeToolPayload(block.input)}`;
372
+ if (block.type === "tool_result")
373
+ return `tool_result ${block.name}: ${block.ok ? "ok" : "error"}`;
374
+ return "";
375
+ })
376
+ .filter(Boolean)
377
+ .join("\n");
378
+ }
379
+ function sanitizeToolPayload(input) {
380
+ const serialized = typeof input === "string" ? input : JSON.stringify(input);
381
+ if (!serialized)
382
+ return "no input";
383
+ return sanitizeForPureState(serialized).slice(0, 180) || "details omitted";
384
+ }
385
+ function sanitizeForPureState(text) {
386
+ const normalized = text.replace(/```[\s\S]*?```/g, "[code block omitted]");
387
+ const lines = normalized
388
+ .split(/\r?\n/)
389
+ .map((line) => sanitizePureLine(line))
390
+ .filter(Boolean);
391
+ return dedupeLines(lines).join("; ").replace(/\s+/g, " ").trim().slice(0, 700);
392
+ }
393
+ function sanitizePureLine(line) {
394
+ let safe = line.trim();
395
+ if (!safe)
396
+ return "";
397
+ if (/\b(python\s+-c|node\s+-e|bash\s+-c|sh\s+-c|powershell|cmd\.exe|set-location|copy-item|invoke-webrequest|curl|read_text|write_text)\b/i.test(safe)) {
398
+ return "[omitted raw command/log detail]";
399
+ }
400
+ safe = safe.replace(/[A-Za-z]:[\\/][^\s"'`<>]+/g, (match) => summarizePathForPureState(match));
401
+ safe = safe.replace(/[\\]+/g, "/");
402
+ safe = safe.replace(/[{}<>`]/g, " ");
403
+ safe = safe.replace(/\s+/g, " ").trim();
404
+ return safe.length > 240 ? `${safe.slice(0, 240)}...` : safe;
405
+ }
406
+ function summarizePathForPureState(value) {
407
+ const parts = value.split(/[\\/]+/).filter(Boolean);
408
+ const tail = parts.slice(Math.max(0, parts.length - 3)).join("/");
409
+ return tail ? `[path:${tail}]` : "[path]";
410
+ }
258
411
  function serializeTranscriptForSummary(messages, maxChars) {
259
412
  const lines = messages.map((message) => {
260
413
  const metadata = [
@@ -324,6 +477,13 @@ function extractText(message) {
324
477
  function buildReactiveCompactInstruction(error) {
325
478
  return `${AUTO_COMPACT_INSTRUCTIONS}\n\nThe previous model request failed because the prompt was too long. Preserve enough task state to continue after this error. Error: ${error.message}`;
326
479
  }
480
+ const MANUAL_COMPACT_INSTRUCTIONS = [
481
+ "Summarize the earlier agent conversation for continuation after a user-requested context compaction.",
482
+ "Preserve: user goals and constraints, decisions made, files or commands mentioned, completed work, pending work, task ids, important tool results, and any errors or blockers.",
483
+ "Drop: repetitive logs, transient progress chatter, and irrelevant wording.",
484
+ "Return concise plain text labels, not Markdown headings. Use labels like Goal:, Constraints:, Work completed:, Important facts:, Pending work:, Open risks:.",
485
+ "Do not include final-answer prose; this summary is an internal continuation state only.",
486
+ ].join("\n");
327
487
  const AUTO_COMPACT_INSTRUCTIONS = [
328
488
  "Summarize the earlier agent conversation for continuation after context compaction.",
329
489
  "Preserve: user goals and constraints, decisions made, files or commands mentioned, completed work, pending work, task ids, important tool results, and any errors or blockers.",