@benkhz/context-manager 1.0.0
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 +231 -0
- package/package.json +59 -0
- package/src/AIContextManager.js +284 -0
- package/src/EventEmitter.js +33 -0
- package/src/index.d.ts +3 -0
- package/src/index.js +3 -0
- package/src/presets/anthropic.js +58 -0
- package/src/presets/openai.js +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# @benkhz/context-manager
|
|
2
|
+
|
|
3
|
+
A vanilla JS class that manages LLM conversation context end-to-end. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @benkhz/context-manager
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { AIContextManager, openaiPreset } from '@benkhz/context-manager'
|
|
17
|
+
|
|
18
|
+
const mgr = new AIContextManager({
|
|
19
|
+
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
20
|
+
model: 'gpt-4o',
|
|
21
|
+
headers: { Authorization: `Bearer ${process.env.OPENAI_KEY}` },
|
|
22
|
+
hooks: {
|
|
23
|
+
...openaiPreset,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const reply = await mgr.send('What is the capital of France?')
|
|
28
|
+
console.log(reply.content) // "The capital of France is Paris."
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Design decisions
|
|
34
|
+
|
|
35
|
+
| Concern | Decision | Rationale |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Provider agnosticism | Caller supplies `formatRequest` + `parseResponse` hooks | No lock-in; presets ship for OpenAI and Anthropic as reference |
|
|
38
|
+
| Context compaction | Manager POSTs to the same endpoint with a summarise prompt | Automatic; `onCompact` hook overrides for custom logic |
|
|
39
|
+
| Reactive state | `setState / getState / subscribe` — callbacks fire synchronously | Familiar, zero magic, easy to test |
|
|
40
|
+
| Tool loop cap | 10 iterations max | Guards against infinite loops without blocking legitimate multi-step reasoning |
|
|
41
|
+
| Context sizing | Character count approximation | Token counting requires a tokenizer dep; chars are close enough for limit-triggering |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Constructor
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
new AIContextManager(config)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Option | Type | Default | Description |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `endpoint` | `string` | **required** | URL to POST every LLM request to |
|
|
54
|
+
| `hooks` | `object` | **required** | See [Hooks](#hooks) |
|
|
55
|
+
| `model` | `string` | — | Passed through to `formatRequest` |
|
|
56
|
+
| `maxTokens` | `number` | — | Passed through to `formatRequest` |
|
|
57
|
+
| `contextLimit` | `number` | `80_000` | Char count that triggers auto-compaction |
|
|
58
|
+
| `compactKeepLast` | `number` | `6` | Messages preserved verbatim after compaction |
|
|
59
|
+
| `headers` | `object` | `{}` | Extra HTTP headers on every `fetch` call |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Hooks
|
|
64
|
+
|
|
65
|
+
All hooks live in `config.hooks`. Only `formatRequest` and `parseResponse` are required.
|
|
66
|
+
|
|
67
|
+
### Required
|
|
68
|
+
|
|
69
|
+
#### `formatRequest(context, config) → RequestBody`
|
|
70
|
+
|
|
71
|
+
Converts the internal context snapshot into the HTTP request body.
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
// context shape
|
|
75
|
+
{
|
|
76
|
+
messages: [{ role, content, toolCalls?, toolCallId? }],
|
|
77
|
+
tools: [{ name, schema: { description, parameters } }],
|
|
78
|
+
summary: string | null,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// config shape (your constructor options + per-call system override)
|
|
82
|
+
{ endpoint, model, maxTokens, headers, system? }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
#### `parseResponse(rawJson) → ParsedResponse`
|
|
86
|
+
|
|
87
|
+
Converts the raw HTTP response JSON into the internal shape.
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// must return
|
|
91
|
+
{
|
|
92
|
+
content: string, // assistant text
|
|
93
|
+
stopReason: string, // e.g. 'stop', 'tool_use'
|
|
94
|
+
toolCalls?: [{ id, name, args }], // present when model calls tools
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Optional lifecycle hooks
|
|
99
|
+
|
|
100
|
+
| Hook | Signature | Description |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `beforeSend` | `(messages[]) → messages[]` | Transform or filter the message array before each POST. Return the array. |
|
|
103
|
+
| `afterReceive` | `(parsed) → parsed` | Transform the parsed response before tool/message processing. |
|
|
104
|
+
| `onCompact` | `(overflow[], currentSummary) → string \| void` | Return a summary string to override the LLM-generated one. |
|
|
105
|
+
| `onToolCall` | `(name, args) → args \| void` | Intercept before a tool runs. Return new args to override. |
|
|
106
|
+
| `onToolResult` | `(name, result) → result` | Transform a tool result before appending to context. |
|
|
107
|
+
| `onError` | `(error, phase) → void` | Called on any error. `phase` is `'send'`, `'compact'`, or `'tool'`. |
|
|
108
|
+
| `onStateChange` | `(key, oldVal, newVal) → void` | Observe every state mutation globally. |
|
|
109
|
+
| `onContextLimit` | `(charCount, limit) → 'compact' \| 'truncate' \| 'error'` | Choose what happens when the context limit is hit. Defaults to `'compact'`. |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Messaging API
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
const reply = await mgr.send('your message')
|
|
117
|
+
// → { role: 'assistant', content: string }
|
|
118
|
+
|
|
119
|
+
await mgr.send('follow-up', { system: 'You are a pirate.' })
|
|
120
|
+
|
|
121
|
+
await mgr.compact()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Tool API
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
mgr.addTool(
|
|
130
|
+
'getWeather',
|
|
131
|
+
{
|
|
132
|
+
description: 'Get current weather for a city',
|
|
133
|
+
parameters: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: { city: { type: 'string', description: 'City name' } },
|
|
136
|
+
required: ['city'],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
async ({ city }) => ({ temp: 72, unit: 'F', condition: 'sunny' })
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
mgr.removeTool('getWeather')
|
|
143
|
+
mgr.getTools() // → [{ name, schema }]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Event bus
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
mgr.on('message:sent', ({ message }) => ...)
|
|
152
|
+
mgr.on('message:received', ({ message }) => ...)
|
|
153
|
+
mgr.on('tool:call', ({ name, args }) => ...)
|
|
154
|
+
mgr.on('tool:result', ({ name, result }) => ...)
|
|
155
|
+
mgr.on('context:compact', ({ messageCount }) => ...)
|
|
156
|
+
mgr.on('context:compacted',({ summary }) => ...)
|
|
157
|
+
mgr.on('state:change', ({ key, oldValue, newValue }) => ...)
|
|
158
|
+
mgr.on('error', ({ error, phase }) => ...)
|
|
159
|
+
|
|
160
|
+
mgr.off('message:received', handler)
|
|
161
|
+
mgr.once('message:received', handler)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Reactive state
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
mgr.setState('userId', 'u_123')
|
|
170
|
+
mgr.getState('userId') // → 'u_123'
|
|
171
|
+
|
|
172
|
+
const unsub = mgr.subscribe('userId', (newVal, oldVal) => {
|
|
173
|
+
console.log(`userId changed: ${oldVal} → ${newVal}`)
|
|
174
|
+
})
|
|
175
|
+
unsub()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Introspection
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
mgr.getMessages() // → Message[] (shallow copy)
|
|
184
|
+
mgr.getSummary() // → string | null
|
|
185
|
+
mgr.getTools() // → [{ name, schema }]
|
|
186
|
+
mgr.getContext() // → { messages, summary, tools }
|
|
187
|
+
mgr.reset() // clear messages, summary, state — returns this
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Presets
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
import { openaiPreset, anthropicPreset } from '@benkhz/context-manager'
|
|
196
|
+
|
|
197
|
+
// OpenAI / Azure / Ollama / LM Studio
|
|
198
|
+
const mgr = new AIContextManager({
|
|
199
|
+
hooks: {
|
|
200
|
+
...openaiPreset,
|
|
201
|
+
beforeSend: msgs => msgs.filter(m => m.content),
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Anthropic Messages API
|
|
206
|
+
const mgr = new AIContextManager({
|
|
207
|
+
hooks: { ...anthropicPreset },
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Context compaction
|
|
214
|
+
|
|
215
|
+
When the total character count of `_messages` exceeds `contextLimit`:
|
|
216
|
+
|
|
217
|
+
1. `onContextLimit` hook is called — returns `'compact'` (default), `'truncate'`, or `'error'`
|
|
218
|
+
2. If `compact`: the overflow messages are sent to the LLM with a summarise prompt
|
|
219
|
+
3. The summary is stored in `_summary`; the last `compactKeepLast` messages are kept verbatim
|
|
220
|
+
4. `formatRequest` receives `context.summary` and can inline it however the API prefers
|
|
221
|
+
|
|
222
|
+
The `onCompact` hook can return a string to bypass the LLM call entirely.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Open decisions
|
|
227
|
+
|
|
228
|
+
- **Token counting** — currently approximated via character count. A future `tokenizer` option could accept a `(messages) => number` fn for more accurate limiting.
|
|
229
|
+
- **Streaming** — `send()` is request/response only.
|
|
230
|
+
- **Persistence** — `getContext()` returns a serialisable snapshot. A future `loadContext(snapshot)` method would complete the persistence story.
|
|
231
|
+
- **Multi-modal** — content is currently assumed to be `string`.
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@benkhz/context-manager",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Provider-agnostic LLM context manager with tool execution, auto-compaction, reactive state, and an event bus.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./presets/openai": "./src/presets/openai.js",
|
|
12
|
+
"./presets/anthropic": "./src/presets/anthropic.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"llm",
|
|
19
|
+
"ai",
|
|
20
|
+
"context-manager",
|
|
21
|
+
"openai",
|
|
22
|
+
"anthropic",
|
|
23
|
+
"tool-calling",
|
|
24
|
+
"agent"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/BenKhz/context-manager"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "echo 'No build step — pure ESM source'",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"test:coverage": "vitest run --coverage"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": ">=18",
|
|
41
|
+
"vue": ">=3"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"react": { "optional": true },
|
|
45
|
+
"vue": { "optional": true }
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@testing-library/dom": "^10",
|
|
49
|
+
"@testing-library/react": "^16.3.2",
|
|
50
|
+
"@testing-library/vue": "^8.1.0",
|
|
51
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
52
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
53
|
+
"jsdom": "^29.1.1",
|
|
54
|
+
"react": "^19.2.6",
|
|
55
|
+
"react-dom": "^19.2.6",
|
|
56
|
+
"vitest": "^3.2.4",
|
|
57
|
+
"vue": "^3.5.35"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { EventEmitter } from './EventEmitter.js'
|
|
2
|
+
|
|
3
|
+
const MAX_TOOL_ITERATIONS = 10
|
|
4
|
+
const DEFAULT_CONTEXT_LIMIT = 80_000
|
|
5
|
+
const DEFAULT_COMPACT_KEEP_LAST = 6
|
|
6
|
+
|
|
7
|
+
export class AIContextManager {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} config
|
|
10
|
+
* @param {string} config.endpoint — URL to POST every LLM request to
|
|
11
|
+
* @param {string} [config.model] — forwarded to formatRequest
|
|
12
|
+
* @param {number} [config.maxTokens] — forwarded to formatRequest
|
|
13
|
+
* @param {number} [config.contextLimit] — char count before auto-compact (default 80 000)
|
|
14
|
+
* @param {number} [config.compactKeepLast] — messages to keep verbatim after compact (default 6)
|
|
15
|
+
* @param {object} [config.headers] — extra HTTP headers on every fetch
|
|
16
|
+
* @param {object} config.hooks — see README for full hook reference
|
|
17
|
+
*/
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
const {
|
|
20
|
+
endpoint,
|
|
21
|
+
model,
|
|
22
|
+
maxTokens,
|
|
23
|
+
contextLimit = DEFAULT_CONTEXT_LIMIT,
|
|
24
|
+
compactKeepLast = DEFAULT_COMPACT_KEEP_LAST,
|
|
25
|
+
headers = {},
|
|
26
|
+
hooks = {},
|
|
27
|
+
} = config
|
|
28
|
+
|
|
29
|
+
if (!endpoint) throw new Error('AIContextManager: config.endpoint is required')
|
|
30
|
+
if (!hooks.formatRequest) throw new Error('AIContextManager: hooks.formatRequest is required')
|
|
31
|
+
if (!hooks.parseResponse) throw new Error('AIContextManager: hooks.parseResponse is required')
|
|
32
|
+
|
|
33
|
+
this._config = { endpoint, model, maxTokens, contextLimit, compactKeepLast, headers }
|
|
34
|
+
this._hooks = hooks
|
|
35
|
+
|
|
36
|
+
this._messages = []
|
|
37
|
+
this._summary = null
|
|
38
|
+
this._tools = new Map() // name → { schema, handler }
|
|
39
|
+
this._emitter = new EventEmitter()
|
|
40
|
+
this._state = new Map() // reactive state
|
|
41
|
+
this._subscribers = new Map() // key → Set<fn>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Messaging ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send a user message and receive the assistant reply.
|
|
48
|
+
* Handles tool loops, auto-compaction, and all lifecycle hooks.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} content
|
|
51
|
+
* @param {object} [opts]
|
|
52
|
+
* @param {string} [opts.system] — per-call system prompt override
|
|
53
|
+
* @returns {Promise<{ role: 'assistant', content: string }>}
|
|
54
|
+
*/
|
|
55
|
+
async send(content, opts = {}) {
|
|
56
|
+
const userMsg = { role: 'user', content }
|
|
57
|
+
this._messages.push(userMsg)
|
|
58
|
+
this._emitter.emit('message:sent', { message: userMsg })
|
|
59
|
+
|
|
60
|
+
// beforeSend hook — can return a transformed messages array (sync or async)
|
|
61
|
+
if (this._hooks.beforeSend) {
|
|
62
|
+
const next = await this._hooks.beforeSend([...this._messages])
|
|
63
|
+
if (Array.isArray(next)) this._messages = next
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Auto-compact when approaching the context limit
|
|
67
|
+
const chars = this._charCount()
|
|
68
|
+
if (chars > this._config.contextLimit) {
|
|
69
|
+
const policy = this._hooks.onContextLimit?.(chars, this._config.contextLimit) ?? 'compact'
|
|
70
|
+
if (policy === 'compact') await this.compact()
|
|
71
|
+
else if (policy === 'truncate') this._truncate()
|
|
72
|
+
else throw new Error(`AIContextManager: context limit exceeded (${chars} chars)`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const assistantMsg = await this._runLoop(opts.system)
|
|
77
|
+
this._messages.push(assistantMsg)
|
|
78
|
+
this._emitter.emit('message:received', { message: assistantMsg })
|
|
79
|
+
return assistantMsg
|
|
80
|
+
} catch (err) {
|
|
81
|
+
this._hooks.onError?.(err, 'send')
|
|
82
|
+
this._emitter.emit('error', { error: err, phase: 'send' })
|
|
83
|
+
throw err
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Summarise overflow messages and slim the context window.
|
|
89
|
+
* Called automatically by send() when contextLimit is reached,
|
|
90
|
+
* or can be called manually at any time.
|
|
91
|
+
*/
|
|
92
|
+
async compact() {
|
|
93
|
+
const keep = this._config.compactKeepLast
|
|
94
|
+
const overflow = this._messages.slice(0, -keep)
|
|
95
|
+
if (!overflow.length) return
|
|
96
|
+
|
|
97
|
+
this._emitter.emit('context:compact', { messageCount: overflow.length })
|
|
98
|
+
|
|
99
|
+
// Hook can override the summary (sync or async); fallback to LLM-generated summary
|
|
100
|
+
let summary = await this._hooks.onCompact?.(overflow, this._summary)
|
|
101
|
+
if (!summary) summary = await this._summarise(overflow)
|
|
102
|
+
|
|
103
|
+
this._summary = summary
|
|
104
|
+
this._messages = this._messages.slice(-keep)
|
|
105
|
+
this._emitter.emit('context:compacted', { summary })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Tools ────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Register a local tool the LLM can call.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} name
|
|
114
|
+
* @param {object} schema — { description, parameters } (JSON Schema for parameters)
|
|
115
|
+
* @param {Function} handler — async (args) => result
|
|
116
|
+
*/
|
|
117
|
+
addTool(name, schema, handler) {
|
|
118
|
+
this._tools.set(name, { schema, handler })
|
|
119
|
+
return this
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
removeTool(name) {
|
|
123
|
+
this._tools.delete(name)
|
|
124
|
+
return this
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Event bus ────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
on(event, fn) { this._emitter.on(event, fn); return this }
|
|
130
|
+
off(event, fn) { this._emitter.off(event, fn); return this }
|
|
131
|
+
once(event, fn) { this._emitter.once(event, fn); return this }
|
|
132
|
+
|
|
133
|
+
// ── Reactive state ───────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
setState(key, value) {
|
|
136
|
+
const oldValue = this._state.get(key)
|
|
137
|
+
this._state.set(key, value)
|
|
138
|
+
this._hooks.onStateChange?.(key, oldValue, value)
|
|
139
|
+
this._emitter.emit('state:change', { key, oldValue, newValue: value })
|
|
140
|
+
this._subscribers.get(key)?.forEach(cb => cb(value, oldValue))
|
|
141
|
+
return this
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getState(key) {
|
|
145
|
+
return this._state.get(key)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Subscribe to changes on a specific state key.
|
|
150
|
+
* @returns {Function} unsubscribe — call to stop listening
|
|
151
|
+
*/
|
|
152
|
+
subscribe(key, cb) {
|
|
153
|
+
if (!this._subscribers.has(key)) this._subscribers.set(key, new Set())
|
|
154
|
+
this._subscribers.get(key).add(cb)
|
|
155
|
+
return () => this._subscribers.get(key).delete(cb)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Introspection ─────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
getMessages() { return [...this._messages] }
|
|
161
|
+
getSummary() { return this._summary }
|
|
162
|
+
getTools() { return [...this._tools.entries()].map(([name, { schema }]) => ({ name, schema })) }
|
|
163
|
+
|
|
164
|
+
/** Full snapshot of context — useful for debugging or serialisation */
|
|
165
|
+
getContext() {
|
|
166
|
+
return { messages: this.getMessages(), summary: this._summary, tools: this.getTools() }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Reset all conversation state. Does not touch config or registered tools. */
|
|
170
|
+
reset() {
|
|
171
|
+
this._messages = []
|
|
172
|
+
this._summary = null
|
|
173
|
+
this._state = new Map()
|
|
174
|
+
this._subscribers = new Map()
|
|
175
|
+
return this
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Private ───────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Core LLM call + tool execution loop.
|
|
182
|
+
* Recurses until the model stops calling tools or MAX_TOOL_ITERATIONS is hit.
|
|
183
|
+
*/
|
|
184
|
+
async _runLoop(system, depth = 0) {
|
|
185
|
+
if (depth >= MAX_TOOL_ITERATIONS) {
|
|
186
|
+
throw new Error(`AIContextManager: tool loop exceeded ${MAX_TOOL_ITERATIONS} iterations`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const context = this.getContext()
|
|
190
|
+
const body = this._hooks.formatRequest(context, { ...this._config, system })
|
|
191
|
+
const raw = await this._fetch(body)
|
|
192
|
+
let parsed = this._hooks.parseResponse(raw)
|
|
193
|
+
|
|
194
|
+
if (this._hooks.afterReceive) {
|
|
195
|
+
parsed = (await this._hooks.afterReceive(parsed)) ?? parsed
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// No tool calls → return the final assistant message
|
|
199
|
+
if (!parsed.toolCalls?.length) {
|
|
200
|
+
return { role: 'assistant', content: parsed.content ?? '' }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Append the assistant turn that requested tools, then execute each
|
|
204
|
+
const assistantMsg = {
|
|
205
|
+
role: 'assistant',
|
|
206
|
+
content: parsed.content ?? '',
|
|
207
|
+
toolCalls: parsed.toolCalls,
|
|
208
|
+
}
|
|
209
|
+
this._messages.push(assistantMsg)
|
|
210
|
+
|
|
211
|
+
for (const tc of parsed.toolCalls) {
|
|
212
|
+
const toolDef = this._tools.get(tc.name)
|
|
213
|
+
if (!toolDef) throw new Error(`AIContextManager: unknown tool "${tc.name}"`)
|
|
214
|
+
|
|
215
|
+
let args = tc.args
|
|
216
|
+
this._emitter.emit('tool:call', { name: tc.name, args })
|
|
217
|
+
const intercepted = await this._hooks.onToolCall?.(tc.name, args)
|
|
218
|
+
if (intercepted !== undefined) args = intercepted
|
|
219
|
+
|
|
220
|
+
let result
|
|
221
|
+
try {
|
|
222
|
+
result = await toolDef.handler(args)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
this._hooks.onError?.(err, 'tool')
|
|
225
|
+
this._emitter.emit('error', { error: err, phase: 'tool' })
|
|
226
|
+
throw err
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const transformed = await this._hooks.onToolResult?.(tc.name, result)
|
|
230
|
+
if (transformed !== undefined) result = transformed
|
|
231
|
+
|
|
232
|
+
this._emitter.emit('tool:result', { name: tc.name, result })
|
|
233
|
+
this._messages.push({ role: 'tool', content: JSON.stringify(result), toolCallId: tc.id })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return this._runLoop(system, depth + 1)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** POST overflow messages to the LLM with a summarise prompt */
|
|
240
|
+
async _summarise(messages) {
|
|
241
|
+
const summaryMessages = [
|
|
242
|
+
...(this._summary
|
|
243
|
+
? [{ role: 'system', content: `Previous summary:\n${this._summary}` }]
|
|
244
|
+
: []),
|
|
245
|
+
{
|
|
246
|
+
role: 'user',
|
|
247
|
+
content: [
|
|
248
|
+
'Summarise the conversation below. Preserve all key facts, decisions, and context. Be concise.',
|
|
249
|
+
'',
|
|
250
|
+
...messages.map(m => `${m.role.toUpperCase()}: ${m.content}`),
|
|
251
|
+
].join('\n'),
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
const context = { messages: summaryMessages, tools: [], summary: null }
|
|
256
|
+
const body = this._hooks.formatRequest(context, this._config)
|
|
257
|
+
const raw = await this._fetch(body)
|
|
258
|
+
const parsed = this._hooks.parseResponse(raw)
|
|
259
|
+
return parsed.content
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async _fetch(body) {
|
|
263
|
+
const res = await fetch(this._config.endpoint, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'Content-Type': 'application/json', ...this._config.headers },
|
|
266
|
+
body: JSON.stringify(body),
|
|
267
|
+
})
|
|
268
|
+
if (!res.ok) {
|
|
269
|
+
const text = await res.text().catch(() => '')
|
|
270
|
+
throw new Error(`AIContextManager: HTTP ${res.status} — ${text}`)
|
|
271
|
+
}
|
|
272
|
+
return res.json()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Approximate context size in characters */
|
|
276
|
+
_charCount() {
|
|
277
|
+
return this._messages.reduce((n, m) => n + (m.content?.length ?? 0), 0)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Hard truncation — drop oldest messages down to compactKeepLast */
|
|
281
|
+
_truncate() {
|
|
282
|
+
this._messages = this._messages.slice(-this._config.compactKeepLast)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class EventEmitter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this._handlers = new Map()
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
on(event, fn) {
|
|
7
|
+
if (!this._handlers.has(event)) this._handlers.set(event, new Set())
|
|
8
|
+
this._handlers.get(event).add(fn)
|
|
9
|
+
return this
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
off(event, fn) {
|
|
13
|
+
this._handlers.get(event)?.delete(fn)
|
|
14
|
+
return this
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
once(event, fn) {
|
|
18
|
+
const wrapper = data => { fn(data); this.off(event, wrapper) }
|
|
19
|
+
return this.on(event, wrapper)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
emit(event, data) {
|
|
23
|
+
this._handlers.get(event)?.forEach(fn => fn(data))
|
|
24
|
+
return this
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Awaits all handlers for an event in parallel. Use sparingly — prefer hooks for flow control. */
|
|
28
|
+
async emitAsync(event, data) {
|
|
29
|
+
const handlers = this._handlers.get(event)
|
|
30
|
+
if (handlers) await Promise.all([...handlers].map(fn => fn(data)))
|
|
31
|
+
return this
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.d.ts
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API formatRequest / parseResponse preset.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { anthropicPreset } from 'libs'
|
|
6
|
+
* const mgr = new AIContextManager({ hooks: { ...anthropicPreset, beforeSend } })
|
|
7
|
+
*/
|
|
8
|
+
export const anthropicPreset = {
|
|
9
|
+
formatRequest(context, config) {
|
|
10
|
+
const systemParts = [
|
|
11
|
+
config.system,
|
|
12
|
+
context.summary ? `[Conversation history]\n${context.summary}` : null,
|
|
13
|
+
].filter(Boolean)
|
|
14
|
+
|
|
15
|
+
const messages = context.messages.map(m => {
|
|
16
|
+
if (m.role === 'tool') {
|
|
17
|
+
// Anthropic wraps tool results in a user turn
|
|
18
|
+
return {
|
|
19
|
+
role: 'user',
|
|
20
|
+
content: [{ type: 'tool_result', tool_use_id: m.toolCallId, content: m.content }],
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (m.toolCalls?.length) {
|
|
24
|
+
return {
|
|
25
|
+
role: 'assistant',
|
|
26
|
+
content: [
|
|
27
|
+
...(m.content ? [{ type: 'text', text: m.content }] : []),
|
|
28
|
+
...m.toolCalls.map(tc => ({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args })),
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { role: m.role, content: m.content }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const body = { model: config.model, messages }
|
|
36
|
+
if (systemParts.length) body.system = systemParts.join('\n\n')
|
|
37
|
+
if (config.maxTokens) body.max_tokens = config.maxTokens
|
|
38
|
+
if (context.tools.length) {
|
|
39
|
+
body.tools = context.tools.map(t => ({
|
|
40
|
+
name: t.name,
|
|
41
|
+
description: t.schema.description,
|
|
42
|
+
input_schema: t.schema.parameters,
|
|
43
|
+
}))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return body
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
parseResponse(raw) {
|
|
50
|
+
const text = raw.content?.find(b => b.type === 'text')
|
|
51
|
+
const toolUses = raw.content?.filter(b => b.type === 'tool_use') ?? []
|
|
52
|
+
return {
|
|
53
|
+
content: text?.text ?? '',
|
|
54
|
+
stopReason: raw.stop_reason,
|
|
55
|
+
toolCalls: toolUses.length ? toolUses.map(b => ({ id: b.id, name: b.name, args: b.input })) : undefined,
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible formatRequest / parseResponse preset.
|
|
3
|
+
* Works with OpenAI, Azure OpenAI, Ollama, LM Studio, and most proxies.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { openaiPreset } from 'libs'
|
|
7
|
+
* const mgr = new AIContextManager({ hooks: { ...openaiPreset, beforeSend } })
|
|
8
|
+
*/
|
|
9
|
+
export const openaiPreset = {
|
|
10
|
+
formatRequest(context, config) {
|
|
11
|
+
const messages = []
|
|
12
|
+
|
|
13
|
+
// Prepend system message with any summary inline
|
|
14
|
+
const systemParts = [
|
|
15
|
+
config.system,
|
|
16
|
+
context.summary ? `[Conversation history]\n${context.summary}` : null,
|
|
17
|
+
].filter(Boolean)
|
|
18
|
+
|
|
19
|
+
if (systemParts.length) {
|
|
20
|
+
messages.push({ role: 'system', content: systemParts.join('\n\n') })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const m of context.messages) {
|
|
24
|
+
if (m.role === 'tool') {
|
|
25
|
+
messages.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content })
|
|
26
|
+
} else if (m.toolCalls?.length) {
|
|
27
|
+
messages.push({
|
|
28
|
+
role: 'assistant',
|
|
29
|
+
content: m.content || null,
|
|
30
|
+
tool_calls: m.toolCalls.map(tc => ({
|
|
31
|
+
id: tc.id,
|
|
32
|
+
type: 'function',
|
|
33
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
|
34
|
+
})),
|
|
35
|
+
})
|
|
36
|
+
} else {
|
|
37
|
+
messages.push({ role: m.role, content: m.content })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const body = { model: config.model, messages }
|
|
42
|
+
if (config.maxTokens) body.max_tokens = config.maxTokens
|
|
43
|
+
if (context.tools.length) {
|
|
44
|
+
body.tools = context.tools.map(t => ({
|
|
45
|
+
type: 'function',
|
|
46
|
+
function: { name: t.name, description: t.schema.description, parameters: t.schema.parameters },
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return body
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
parseResponse(raw) {
|
|
54
|
+
const choice = raw.choices?.[0]
|
|
55
|
+
const message = choice?.message ?? {}
|
|
56
|
+
return {
|
|
57
|
+
content: message.content ?? '',
|
|
58
|
+
stopReason: choice?.finish_reason,
|
|
59
|
+
toolCalls: message.tool_calls?.map(tc => ({
|
|
60
|
+
id: tc.id,
|
|
61
|
+
name: tc.function.name,
|
|
62
|
+
args: JSON.parse(tc.function.arguments),
|
|
63
|
+
})),
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|