@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 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
@@ -0,0 +1,3 @@
1
+ export { AIContextManager } from './AIContextManager.js'
2
+ export { openaiPreset } from './presets/openai.js'
3
+ export { anthropicPreset } from './presets/anthropic.js'
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { AIContextManager } from './AIContextManager.js'
2
+ export { openaiPreset } from './presets/openai.js'
3
+ export { anthropicPreset } from './presets/anthropic.js'
@@ -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
+ }