loopengine 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Yan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # loopengine
2
+
3
+ A minimal framework for defining and running tool-using AI agents. Write an
4
+ agent as a plain config object — persona, tools, permission rules — and run
5
+ it through one generic ReAct loop over the CLI, an HTTP API, or both. No
6
+ chain DSL, no hidden control flow: `run-agent.ts` is a single readable
7
+ function you can step through top to bottom.
8
+
9
+ It's built on six small, independently-published packages that each solve
10
+ one piece of the agent-loop problem:
11
+
12
+ | Package | Responsibility |
13
+ |---|---|
14
+ | [`actauth`](https://www.npmjs.com/package/actauth) | Rule-based permission gating (allow/ask/deny) with human-approval hooks |
15
+ | [`contextclip`](https://www.npmjs.com/package/contextclip) | Context-window budget tracking with staged recovery |
16
+ | [`reflowkit`](https://www.npmjs.com/package/reflowkit) | Retries prompt-too-long / truncated-output failures |
17
+ | [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable, DAG-shaped session log with crash-interruption detection |
18
+ | [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md`-based skill discovery and lazy loading |
19
+ | [`toollane`](https://www.npmjs.com/package/toollane) | Schedules approved tool calls into parallel/solo execution lanes |
20
+
21
+ `run-agent.ts` wires the first five together — `sessionknit` sits one layer
22
+ out, in `session-store.ts`, since it persists what `run-agent.ts` produces
23
+ rather than participating in the loop itself. Everything else in this repo
24
+ — agent definitions, channel adapters — is built on top of those.
25
+
26
+ ## Using loopengine as a library
27
+
28
+ This repo is two things in one: a small library (the loop itself) and a
29
+ reference app built on top of it (demo agents, a CLI, an HTTP server). Only
30
+ the library half ships to npm:
31
+
32
+ ```bash
33
+ npm install loopengine actauth contextclip reflowkit toollane skillgarden
34
+ ```
35
+
36
+ ```ts
37
+ import { runAgent, type AgentConfig, type ToolDefinition } from 'loopengine'
38
+
39
+ const getWeather: ToolDefinition = {
40
+ name: 'get_weather',
41
+ description: 'Look up the weather for a city',
42
+ input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
43
+ execute: async (input) => `Sunny in ${input.city}`,
44
+ }
45
+
46
+ const config: AgentConfig = {
47
+ name: 'weather-agent',
48
+ systemPrompt: 'You answer questions about the weather.',
49
+ tools: [getWeather],
50
+ rules: [{ scopePattern: 'default/production/weather-agent', tool: 'get_weather', decision: 'allow' }],
51
+ defaultDecision: 'ask',
52
+ }
53
+
54
+ // bring your own ModelCall, or use the one below
55
+ const result = await runAgent(config, myModelCall, "What's the weather in Boston?", [])
56
+ console.log(result.text)
57
+ ```
58
+
59
+ The full exported surface: `runAgent`, `AgentConfig`/`ToolSchema`/`ToolDefinition`,
60
+ `loadAgent` (for MCP-backed tools), `connectMcpTools`, `FileSessionStore`/
61
+ `RedisSessionStore`/`createSessionStore`, `VectorIndex`/`embed`/
62
+ `cosineSimilarity` (for RAG — see below), and `createAnthropicModelCall` (a
63
+ real, ready-to-use `ModelCall`). See `index.ts` for the exact list.
64
+
65
+ **Not** part of the package: `agents/*.ts` (this repo's own demo agents),
66
+ `agent-registry.ts` (this repo's own name → config lookup table — build your
67
+ own, same one-line-per-agent shape), `adapters/cli.ts`/`adapters/http.ts`
68
+ (one specific CLI/HTTP wiring choice, not imposed on every consumer), and
69
+ the `Dockerfile`. Everything below this point describes that reference app —
70
+ read it as a worked example of what to build with the library, not as
71
+ library API itself.
72
+
73
+ ## Quick start
74
+
75
+ ```bash
76
+ npm install
77
+ npx tsx agents/file-agent.ts # run the file-summarizer demo agent
78
+ npx tsx agents/customer-service-agent.ts # run the customer-service demo agent
79
+ npx tsx agents/rag-agent.ts # run the retrieval-augmented demo agent
80
+ ```
81
+
82
+ All demo agents use a **simulated** model call (no `ANTHROPIC_API_KEY` is
83
+ wired up) — see "Wiring a real model" below.
84
+
85
+ ## Defining your own agent
86
+
87
+ An agent is an `AgentConfig` (`agent-config.ts`): a name, a system prompt, a
88
+ list of tools, and ActAuth permission rules.
89
+
90
+ ```ts
91
+ import type { AgentConfig } from './agent-config.js'
92
+
93
+ export const config: AgentConfig = {
94
+ name: 'my-agent',
95
+ systemPrompt: 'You are ...',
96
+ tools: [
97
+ {
98
+ name: 'do_thing',
99
+ description: 'What the model sees when deciding to call this',
100
+ input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
101
+ execute: async (input) => {
102
+ // real implementation — DB call, HTTP request, whatever
103
+ return { ok: true }
104
+ },
105
+ },
106
+ ],
107
+ rules: [
108
+ { scopePattern: 'default/production/my-agent', tool: 'do_thing', decision: 'allow' },
109
+ ],
110
+ defaultDecision: 'ask', // anything not covered by a rule requires approval
111
+ }
112
+ ```
113
+
114
+ `agents/file-agent.ts` and `agents/customer-service-agent.ts` are two complete, working
115
+ examples — same `runAgent` loop, entirely different personas and tools.
116
+
117
+ To make a new agent runnable through the adapters, add one line to
118
+ `agent-registry.ts` mapping its name to its `config` and a `createModelCall`
119
+ factory.
120
+
121
+ ## Connecting MCP tools
122
+
123
+ A tool doesn't have to be hand-written — `AgentConfig.mcpServers` connects to
124
+ a real MCP server and turns everything it exposes into ordinary tools, with
125
+ the same ActAuth gating and ToolLane scheduling as `do_thing` above:
126
+
127
+ ```ts
128
+ export const config: AgentConfig = {
129
+ name: 'my-mcp-agent',
130
+ systemPrompt: 'You are ...',
131
+ mcpServers: [
132
+ { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/some/dir'] },
133
+ ],
134
+ rules: [
135
+ { scopePattern: 'default/production/my-mcp-agent', tool: 'read_text_file', decision: 'allow' },
136
+ ],
137
+ defaultDecision: 'ask', // any tool the server exposes that you didn't write a rule for
138
+ }
139
+ ```
140
+
141
+ `agents/mcp-filesystem-agent.ts` is a complete example — it has zero hand-written
142
+ tools; every one comes from the official
143
+ `@modelcontextprotocol/server-filesystem`, spawned as a real subprocess.
144
+ `load-agent.ts` is what resolves `mcpServers` into real tools: it runs for
145
+ every agent (a no-op if `mcpServers` is absent), which is what makes this a
146
+ config change rather than a bespoke loader function per server — write rules
147
+ for the tool names you expect the server to expose (check its docs, or
148
+ connect once and call `listTools()` yourself), the same way you'd write
149
+ rules for a hand-written tool. Anything the server exposes that you didn't
150
+ write a rule for still safely falls through to `defaultDecision`.
151
+
152
+ ## Retrieval (RAG)
153
+
154
+ RAG is just another tool — the model decides when to call it, `run-agent.ts`
155
+ doesn't know or care that "executing" this particular tool means a vector
156
+ search instead of a filesystem read or an MCP round-trip:
157
+
158
+ ```ts
159
+ const searchDocs: ToolDefinition = {
160
+ name: 'search_docs',
161
+ description: 'Search the knowledge base for relevant passages',
162
+ input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
163
+ execute: async (input) => {
164
+ const hits = index.search(input.query as string, 3) // top-3 chunks
165
+ return hits.map((h) => h.text).join('\n\n')
166
+ },
167
+ }
168
+ ```
169
+
170
+ `agents/rag-agent.ts` is a complete example, backed by `vector-index.ts` — a
171
+ small, dependency-free in-memory vector index (feature-hashing embeddings,
172
+ cosine-similarity search) good enough to prove the retrieval mechanics
173
+ end-to-end without wiring up a real embedding API. Swap `embed()` for a real
174
+ embedding model, or swap `VectorIndex` for a real vector DB client, and the
175
+ `search_docs` tool works unchanged — same "host provides the real
176
+ implementation, the package proves the pipeline" pattern as ContextClip's
177
+ `Summarizer` or ActAuth's `Approver`. The retrieved text comes back as an
178
+ ordinary tool result, subject to the same ActAuth gating, ToolLane
179
+ scheduling, and ContextClip budget tracking as any other tool call.
180
+
181
+ ## Running an agent
182
+
183
+ **CLI:**
184
+
185
+ ```bash
186
+ npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arrived broken"
187
+ ```
188
+
189
+ **HTTP:**
190
+
191
+ ```bash
192
+ npx tsx adapters/http.ts
193
+ curl -X POST localhost:8787/agents/customer-service/messages \
194
+ -H 'content-type: application/json' \
195
+ -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
196
+ ```
197
+
198
+ **HTTP, streamed:** same request, `/stream` suffix, one Server-Sent Event per
199
+ loop step instead of a single response after the whole thing finishes —
200
+ `contextclip:check`, `actauth:decision`, `toollane:result`, ..., a final
201
+ `done` with the answer:
202
+
203
+ ```bash
204
+ curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
205
+ -H 'content-type: application/json' \
206
+ -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
207
+ ```
208
+
209
+ Both adapters are thin: they own request parsing and session persistence,
210
+ and call the same `runAgent(config, modelCall, message, history)` underneath.
211
+ `runAgent` itself does no I/O — callers own conversation history, which is
212
+ what lets it serve a one-shot CLI call and a long-lived chat session with no
213
+ code difference. The streaming route isn't a separate code path through
214
+ `runAgent` either — it forwards the same `onEvent` hook the non-streaming
215
+ route just discards, over SSE instead of into a log. What this streams is
216
+ loop *steps* (tool calls, permission decisions, budget checks), not model
217
+ output tokens — no `ModelCall` implementation here (fake or
218
+ `createAnthropicModelCall`) streams partial text from the model itself, so
219
+ there's nothing at the token level to forward yet.
220
+
221
+ ## Sessions and multi-tenancy
222
+
223
+ `session-store.ts` exposes one method, `withSession(sessionId, fn)`, that
224
+ resumes history, runs `fn` **exclusively** for that session, and appends
225
+ whatever new messages `fn` produced — this is what stops two concurrent
226
+ requests for the same conversation from racing on read-modify-write.
227
+
228
+ History itself is a [`sessionknit`](https://www.npmjs.com/package/sessionknit)
229
+ log, not a flat file rewritten whole on every turn: each message is one
230
+ entry in an append-only, parent-linked chain, so a turn only ever appends,
231
+ never rewrites what's already durable. That buys two things a flat
232
+ rewrite can't:
233
+
234
+ - **Non-blocking, debounced writes** instead of a full read-modify-write of
235
+ the entire session every turn.
236
+ - **Interruption detection.** `run-agent.ts` pushes one
237
+ `[requested: tool_a, tool_b]` message before any tool runs, then one
238
+ `[... result]` message per completed tool as they finish. If the process
239
+ dies in that window, the next `resume()` sees a request with no result
240
+ behind it, flags the session as resumed-after-interruption, and injects a
241
+ note into context saying so — instead of silently resending an
242
+ incomplete turn as if it were clean.
243
+
244
+ Two implementations, same `SessionStore` interface:
245
+
246
+ - `FileSessionStore` — one JSONL entry log per session under `.sessions/`,
247
+ locked in-process. Fine for local dev and the CLI.
248
+ - `RedisSessionStore` — same log shape (a Redis list of entries, one
249
+ `RPUSH` per append), with a real distributed lock, safe across multiple
250
+ server instances.
251
+
252
+ `createSessionStore()` picks between them based on `REDIS_URL` — set it and
253
+ you get Redis, otherwise it falls back to the file store. Either way,
254
+ turn-level exclusivity (no two concurrent turns for the same session
255
+ interleaving) is still this module's job, not SessionKnit's — SessionKnit's
256
+ own topology repair handles branching *within* one resumed chain (parallel
257
+ tool calls, crash recovery), not races between two full concurrent turns.
258
+
259
+ The HTTP adapter derives the session key from `customerEmail` (hashed, so
260
+ raw emails never end up in storage keys), so different customers can never
261
+ read or write each other's history, and concurrent messages from the same
262
+ customer are serialized rather than dropped.
263
+
264
+ ## Deployment
265
+
266
+ ```bash
267
+ docker compose up --build
268
+ ```
269
+
270
+ builds the HTTP adapter into a container (`Dockerfile`, multi-stage:
271
+ `npm ci` + `tsc`, then a slim runtime image) and starts it alongside a Redis
272
+ container (`docker-compose.yml`). For a real deployment, push the same
273
+ image to any container platform (Cloud Run, Fargate, Fly.io) with `REDIS_URL`
274
+ and any tool-specific secrets set as environment variables.
275
+
276
+ ## Wiring a real model
277
+
278
+ Every example agent (`agents/file-agent.ts`, `agents/customer-service-agent.ts`,
279
+ `agents/mcp-filesystem-agent.ts`, `agents/rag-agent.ts`) still uses a canned,
280
+ turn-counting `ModelCall` so the whole loop is runnable and testable with no
281
+ API key. To go live, swap it
282
+ for `createAnthropicModelCall` (`anthropic-model-call.ts`), the one real
283
+ `ModelCall` implementation this repo ships:
284
+
285
+ ```ts
286
+ import { createAnthropicModelCall } from './anthropic-model-call.js'
287
+
288
+ const modelCall = createAnthropicModelCall({ model: 'claude-sonnet-5' }) // reads ANTHROPIC_API_KEY from the env
289
+
290
+ const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
291
+ ```
292
+
293
+ Nothing else in `run-agent.ts`, the adapters, or any `AgentConfig` needs to
294
+ change — `ModelCall` is the only seam a real API call needs.
295
+
296
+ One tradeoff worth knowing: loopengine's `Message` type (from `contextclip`)
297
+ is deliberately generic — `{role, content: string}` — so conversation
298
+ history round-trips to the API as plain user/assistant text turns, not
299
+ Anthropic's native `tool_use`/`tool_result` content blocks. Claude reads
300
+ `"[lookup_order result] {...}"` as plain text just fine and the conversation
301
+ still works correctly, but it's not the structured, block-native history the
302
+ API is built around. This was verified against the real SDK (with a stubbed
303
+ `fetch`, not a live call) — request shape, tool schemas, and response
304
+ mapping all round-trip correctly end to end.
305
+
306
+ ## Skills
307
+
308
+ Agents can discover and invoke `SKILL.md` files (see `skills/`), the same
309
+ convention production coding agents use: a short frontmatter index (`name`,
310
+ `description`) stays in context at all times, and the full body is loaded
311
+ only when the model actually invokes that skill. Set `skillsDirs` on an
312
+ `AgentConfig` to enable it; omit it for agents that don't need skills
313
+ (`agents/customer-service-agent.ts` doesn't).
314
+
315
+ ## Project layout
316
+
317
+ ```
318
+ index.ts Public API surface — what actually ships to npm
319
+ agent-config.ts AgentConfig type — the thing you fill in to define an agent
320
+ run-agent.ts The generic ReAct loop every agent and adapter runs through
321
+ load-agent.ts Resolves AgentConfig.mcpServers into real tools (no-op without it)
322
+ mcp-tools.ts Wraps one MCP server's tools as ToolDefinition[]
323
+ vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
324
+ agent-registry.ts Maps agent name -> {config, createModelCall}, via load-agent.ts
325
+ session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
326
+ anthropic-model-call.ts createAnthropicModelCall — the one real ModelCall this repo ships
327
+ agents/file-agent.ts Example agent: summarizes text files
328
+ agents/customer-service-agent.ts Example agent: order lookup / refund / email
329
+ agents/mcp-filesystem-agent.ts Example agent: every tool comes from a real MCP server, zero hand-written
330
+ agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
331
+ adapters/cli.ts Channel adapter: command line
332
+ adapters/http.ts Channel adapter: HTTP API
333
+ skills/ SKILL.md files discoverable by agents
334
+ examples/file-agent/ Sample input files + generated output for the file-agent demo
335
+ Dockerfile, docker-compose.yml Container build + local Redis for testing
336
+ ```
@@ -0,0 +1,40 @@
1
+ import type { Rule, Decision, Scope, Approver } from 'actauth';
2
+ import type { SafetyClassifier } from 'toollane';
3
+ import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';
4
+ export interface ToolSchema {
5
+ name: string;
6
+ description: string;
7
+ /** JSON schema sent to the model so it knows how to call the tool. */
8
+ input_schema: Record<string, unknown>;
9
+ }
10
+ export interface ToolDefinition extends ToolSchema {
11
+ execute: (input: Record<string, unknown>) => Promise<unknown>;
12
+ }
13
+ export interface AgentConfig {
14
+ /** Also doubles as the ActAuth scope.agent segment. */
15
+ name: string;
16
+ systemPrompt: string;
17
+ /** Hand-written tools. Default [] — an agent can run on mcpServers alone. */
18
+ tools?: ToolDefinition[];
19
+ /** MCP servers to connect at load time (see load-agent.ts); their
20
+ * tools are discovered via listTools() and appended to `tools`. This
21
+ * is what makes adding an MCP-backed agent a config change — write
22
+ * rules for the tool names you expect it to expose (check its docs, or
23
+ * probe it once), same as for a hand-written tool. Anything it exposes
24
+ * that you didn't write a rule for still safely falls through to
25
+ * defaultDecision. */
26
+ mcpServers?: StdioServerParameters[];
27
+ /** ActAuth rules, e.g. { scopePattern: 'default/production/customer-service', tool: 'issue_refund', decision: 'ask' } */
28
+ rules: Rule[];
29
+ /** Decision when no rule matches. Default 'ask' — new tools are opt-in, not silently allowed. */
30
+ defaultDecision?: Decision;
31
+ /** Default ConsoleApprover (blocks on stdin) — pass e.g. a Slack-backed Approver for unattended agents. */
32
+ approver?: Approver;
33
+ scope?: Partial<Omit<Scope, 'agent'>>;
34
+ /** SKILL.md directories this agent can discover and invoke. Omit if the agent has no skills. */
35
+ skillsDirs?: string[];
36
+ skillIndexBudgetTokens?: number;
37
+ contextBudgetTokens?: number;
38
+ /** Which tools ToolLane may run in a parallel lane. Default: none (every tool runs solo). */
39
+ isSafeTool?: SafetyClassifier;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import type { ModelCall } from './run-agent.js';
3
+ export interface AnthropicModelCallOptions {
4
+ /** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
5
+ apiKey?: string;
6
+ model?: string;
7
+ maxTokens?: number;
8
+ /** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
9
+ client?: Anthropic;
10
+ }
11
+ export declare function createAnthropicModelCall(options?: AnthropicModelCallOptions): ModelCall;
@@ -0,0 +1,49 @@
1
+ // The one real ModelCall implementation loopengine ships. Every example
2
+ // agent (file-agent.ts, customer-service-agent.ts, mcp-filesystem-agent.ts)
3
+ // still uses a canned, turn-counting fake so the whole loop is runnable
4
+ // with no API key — swap that fake for createAnthropicModelCall(...) and
5
+ // nothing in run-agent.ts, the adapters, or any AgentConfig changes.
6
+ // ModelCall is the only seam this needed.
7
+ //
8
+ // loopengine's Message type (contextclip) is deliberately generic —
9
+ // {role, content: string} — so conversation history round-trips to the
10
+ // API as plain user/assistant text turns, not Anthropic's native
11
+ // tool_use/tool_result content blocks. Claude reads
12
+ // "[lookup_order result] {...}" as plain text just fine and the
13
+ // conversation still works correctly, but it's not the structured,
14
+ // block-native history the API is built around. If you need that
15
+ // fidelity later, it means changing what run-agent.ts stores in
16
+ // `messages`, not this file.
17
+ import Anthropic from '@anthropic-ai/sdk';
18
+ export function createAnthropicModelCall(options = {}) {
19
+ const client = options.client ?? new Anthropic({ apiKey: options.apiKey });
20
+ const model = options.model ?? 'claude-sonnet-5';
21
+ const maxTokens = options.maxTokens ?? 4096;
22
+ return async (messages, system, tools) => {
23
+ const response = await client.messages.create({
24
+ model,
25
+ max_tokens: maxTokens,
26
+ system,
27
+ messages: messages.map((m) => ({
28
+ role: m.role === 'assistant' ? 'assistant' : 'user',
29
+ content: m.content,
30
+ })),
31
+ tools: tools.length
32
+ ? tools.map((t) => ({
33
+ name: t.name,
34
+ description: t.description,
35
+ input_schema: t.input_schema,
36
+ }))
37
+ : undefined,
38
+ });
39
+ const content = response.content.map((block) => {
40
+ if (block.type === 'text')
41
+ return { type: 'text', text: block.text };
42
+ if (block.type === 'tool_use') {
43
+ return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
44
+ }
45
+ return { type: block.type };
46
+ });
47
+ return { stop_reason: response.stop_reason ?? 'end_turn', content };
48
+ };
49
+ }
@@ -0,0 +1,13 @@
1
+ export { runAgent } from './run-agent.js';
2
+ export type { ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
3
+ export type { AgentConfig, ToolSchema, ToolDefinition } from './agent-config.js';
4
+ export { loadAgent } from './load-agent.js';
5
+ export type { LoadedAgent } from './load-agent.js';
6
+ export { connectMcpTools } from './mcp-tools.js';
7
+ export type { McpConnection } from './mcp-tools.js';
8
+ export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
9
+ export type { SessionStore, SessionResult } from './session-store.js';
10
+ export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
11
+ export type { Document, ScoredDocument } from './vector-index.js';
12
+ export { createAnthropicModelCall } from './anthropic-model-call.js';
13
+ export type { AnthropicModelCallOptions } from './anthropic-model-call.js';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // The public API — everything a consumer building on loopengine needs to
2
+ // define and run their own agent. `agents/*.ts`, `agent-registry.ts`,
3
+ // `adapters/*.ts`, and the Dockerfile are this repo's own reference app
4
+ // built on top of this surface, not part of it — see the README section
5
+ // "Using loopengine as a library" for the boundary and why.
6
+ export { runAgent } from './run-agent.js';
7
+ export { loadAgent } from './load-agent.js';
8
+ export { connectMcpTools } from './mcp-tools.js';
9
+ export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
10
+ export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
11
+ export { createAnthropicModelCall } from './anthropic-model-call.js';
@@ -0,0 +1,8 @@
1
+ import type { AgentConfig } from './agent-config.js';
2
+ export interface LoadedAgent {
3
+ /** Same config, with tools = hand-written tools + everything discovered from mcpServers. */
4
+ config: AgentConfig;
5
+ /** Closes every MCP connection this load opened. No-op if the config had no mcpServers. */
6
+ close: () => Promise<void>;
7
+ }
8
+ export declare function loadAgent(config: AgentConfig): Promise<LoadedAgent>;
@@ -0,0 +1,27 @@
1
+ // Resolves an AgentConfig's mcpServers (if any) into real tools. This is
2
+ // what makes MCP support config-driven instead of one bespoke async
3
+ // builder function per server: an agent that declares mcpServers and one
4
+ // that doesn't both go through loadAgent() the same way — it's a no-op
5
+ // passthrough for the latter. agent-registry.ts calls this for every
6
+ // agent, so adding a new MCP-backed agent means writing an AgentConfig,
7
+ // not a new .ts file with its own connection-handling logic.
8
+ import { connectMcpTools } from './mcp-tools.js';
9
+ export async function loadAgent(config) {
10
+ if (!config.mcpServers?.length) {
11
+ return { config: { ...config, tools: config.tools ?? [] }, close: async () => { } };
12
+ }
13
+ const connections = await Promise.all(config.mcpServers.map(connectMcpTools));
14
+ const mcpTools = connections.flatMap((c) => c.tools);
15
+ // Last-one-wins on name collisions (hand-written tool vs. MCP tool, or
16
+ // two MCP servers exposing the same name) — same resolution run-agent.ts
17
+ // itself uses when it builds its own tool map. Sending the model a
18
+ // duplicate tool name in its schema list would be a live API error, so
19
+ // this dedupes before it gets that far.
20
+ const byName = new Map([...(config.tools ?? []), ...mcpTools].map((t) => [t.name, t]));
21
+ return {
22
+ config: { ...config, tools: [...byName.values()] },
23
+ close: async () => {
24
+ await Promise.all(connections.map((c) => c.close()));
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,11 @@
1
+ import { type StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';
2
+ import type { ToolDefinition } from './agent-config.js';
3
+ export interface McpConnection {
4
+ tools: ToolDefinition[];
5
+ close: () => Promise<void>;
6
+ }
7
+ /** Spawns an MCP server over stdio, discovers its tools via listTools(),
8
+ * and wraps each one so execute() forwards to callTool() on the real
9
+ * server. Call close() when the agent is done with it (or on process
10
+ * shutdown) to kill the child process. */
11
+ export declare function connectMcpTools(serverParams: StdioServerParameters): Promise<McpConnection>;
@@ -0,0 +1,31 @@
1
+ // Wraps a real MCP server's tools as ToolDefinition[] — the same shape as
2
+ // any hand-written tool (agent-config.ts), so they drop straight into
3
+ // AgentConfig.tools. Neither run-agent.ts nor ActAuth nor ToolLane know or
4
+ // care that these particular tools proxy to a subprocess speaking MCP
5
+ // instead of running in-process; permission gating and execution still go
6
+ // through the exact same path as lookup_order or read_file.
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
9
+ /** Spawns an MCP server over stdio, discovers its tools via listTools(),
10
+ * and wraps each one so execute() forwards to callTool() on the real
11
+ * server. Call close() when the agent is done with it (or on process
12
+ * shutdown) to kill the child process. */
13
+ export async function connectMcpTools(serverParams) {
14
+ const client = new Client({ name: 'loopengine', version: '0.0.1' });
15
+ await client.connect(new StdioClientTransport(serverParams));
16
+ const { tools } = await client.listTools();
17
+ const toolDefs = tools.map((tool) => ({
18
+ name: tool.name,
19
+ description: tool.description ?? tool.name,
20
+ input_schema: tool.inputSchema,
21
+ execute: async (input) => {
22
+ const result = await client.callTool({ name: tool.name, arguments: input });
23
+ const text = result.content
24
+ .filter((block) => block.type === 'text')
25
+ .map((block) => block.text)
26
+ .join('\n');
27
+ return result.isError ? { error: text } : text;
28
+ },
29
+ }));
30
+ return { tools: toolDefs, close: () => client.close() };
31
+ }
@@ -0,0 +1,28 @@
1
+ import { type Message } from 'contextclip';
2
+ import type { AgentConfig, ToolSchema } from './agent-config.js';
3
+ export interface ModelContentBlock {
4
+ type: string;
5
+ text?: string;
6
+ id?: string;
7
+ name?: string;
8
+ input?: Record<string, unknown>;
9
+ }
10
+ export interface ModelResponse {
11
+ stop_reason: string;
12
+ content: ModelContentBlock[];
13
+ }
14
+ /** Swap the Anthropic SDK in here — this is the only seam a real API call needs. */
15
+ export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[]) => Promise<ModelResponse>;
16
+ export interface RunAgentOptions {
17
+ /** Emitted at each loop step for callers that want visibility (logging, UI, tests). Default: no-op. */
18
+ onEvent?: (event: string, detail: unknown) => void;
19
+ }
20
+ export interface RunAgentResult {
21
+ /** Final assistant text for this turn. */
22
+ text: string;
23
+ /** Full conversation so far, including this turn — pass back in as
24
+ * `history` on the next call to continue the conversation. Opaque to
25
+ * the caller: store/transmit it, don't inspect or mutate it. */
26
+ history: Message[];
27
+ }
28
+ export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
@@ -0,0 +1,100 @@
1
+ // The one ReAct loop every agent runs through, and every channel adapter
2
+ // (CLI, HTTP, ...) calls unchanged — only their AgentConfig and modelCall
3
+ // differ. runAgent itself does no I/O and holds no state between calls:
4
+ // callers own conversation history, which is what lets the same function
5
+ // serve a one-shot CLI invocation and a long-lived chat session.
6
+ import { Gate, RuleSet, ConsoleApprover } from 'actauth';
7
+ import { SkillGarden } from 'skillgarden';
8
+ import { ContextClipper } from 'contextclip';
9
+ import { ToolLane } from 'toollane';
10
+ import { Reflow } from 'reflowkit';
11
+ export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
12
+ const log = options.onEvent ?? (() => { });
13
+ const scope = { tenant: 'default', environment: 'production', ...config.scope, agent: config.name };
14
+ const skillGarden = config.skillsDirs?.length
15
+ ? new SkillGarden({ dirs: config.skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
16
+ : null;
17
+ const skillIndex = skillGarden?.buildIndex().included ?? [];
18
+ const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages: 4 });
19
+ const rules = new RuleSet(config.rules, config.defaultDecision ?? 'ask');
20
+ const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
21
+ const toolLane = new ToolLane({ isSafe: config.isSafeTool ?? (() => false) });
22
+ const reflow = new Reflow({
23
+ onPromptTooLong: async (messages) => {
24
+ const result = await contextClip.recover(messages);
25
+ log('reflow:recover', { from: messages.length, to: result.messages.length });
26
+ return result.messages;
27
+ },
28
+ });
29
+ // Optional on AgentConfig — an MCP-only agent's tools are populated by
30
+ // load-agent.ts before this ever runs, but default defensively here too
31
+ // in case a config reaches runAgent without going through it.
32
+ const tools = config.tools ?? [];
33
+ const toolsByName = new Map(tools.map((t) => [t.name, t]));
34
+ const toolSchemas = tools.map(({ name, description, input_schema }) => ({
35
+ name,
36
+ description,
37
+ input_schema,
38
+ }));
39
+ const systemPrompt = skillIndex.length
40
+ ? `${config.systemPrompt}\n\nAvailable skills:\n${skillIndex.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
41
+ : config.systemPrompt;
42
+ let messages = [...history, { role: 'user', content: userMessage }];
43
+ for (;;) {
44
+ const budget = contextClip.check(messages);
45
+ log('contextclip:check', budget);
46
+ if (budget.action === 'nudge' && budget.nudge)
47
+ messages.push(budget.nudge);
48
+ const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
49
+ if (recoveries.length > 0)
50
+ log('reflow:recoveries', recoveries);
51
+ const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
52
+ if (toolUseBlocks.length === 0) {
53
+ const text = response.content.find((b) => b.type === 'text')?.text ?? '';
54
+ log('loop:done', text);
55
+ messages.push({ role: 'assistant', content: text });
56
+ return { text, history: messages };
57
+ }
58
+ // Skill invocation injects instructions into context; it isn't a tool
59
+ // that needs ActAuth gating or ToolLane scheduling.
60
+ const skillCall = toolUseBlocks.find((b) => b.name === 'Skill');
61
+ if (skillCall && skillGarden) {
62
+ const body = skillGarden.invoke(skillCall.input.skill);
63
+ log('skillgarden:invoke', skillCall.input.skill);
64
+ messages.push({ role: 'assistant', content: `[called Skill: ${skillCall.input.skill}]` });
65
+ messages.push({ role: 'user', content: body });
66
+ continue;
67
+ }
68
+ const approved = [];
69
+ const denied = [];
70
+ const reasons = new Map();
71
+ for (const block of toolUseBlocks) {
72
+ const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
73
+ log('actauth:decision', { tool: block.name, decision: decision.decision, reason: decision.reason });
74
+ if (decision.decision === 'allow') {
75
+ approved.push(block);
76
+ }
77
+ else {
78
+ denied.push(block);
79
+ reasons.set(block.id, decision.reason);
80
+ }
81
+ }
82
+ messages.push({ role: 'assistant', content: `[requested: ${toolUseBlocks.map((b) => b.name).join(', ')}]` });
83
+ // Every requested tool gets exactly one result message back — a denied
84
+ // call is not simply dropped, or the model has no way to tell "denied"
85
+ // apart from "hasn't run yet" and may just re-request it forever.
86
+ for (const block of denied) {
87
+ messages.push({ role: 'user', content: `[${block.name} result] denied: ${reasons.get(block.id)}` });
88
+ }
89
+ const calls = approved.map((b) => ({
90
+ id: b.id,
91
+ name: b.name,
92
+ execute: () => toolsByName.get(b.name).execute(b.input ?? {}),
93
+ }));
94
+ for await (const result of toolLane.run(calls)) {
95
+ const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
96
+ log('toollane:result', { name: result.name, summary });
97
+ messages.push({ role: 'user', content: `[${result.name} result] ${summary}` });
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,52 @@
1
+ import { type Storage } from 'sessionknit';
2
+ import type { Message } from 'contextclip';
3
+ export interface SessionResult<T> {
4
+ history: Message[];
5
+ result: T;
6
+ }
7
+ export interface SessionStore {
8
+ /** Loads history for sessionId, runs fn exclusively — no other
9
+ * withSession call for the same sessionId runs concurrently, in this
10
+ * process or (for RedisSessionStore) any other — appends the new
11
+ * messages fn's result adds, and resolves with fn's result. */
12
+ withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
13
+ close(): Promise<void>;
14
+ }
15
+ /** Wraps a SessionKnit log behind the flat load/run/append contract
16
+ * runAgent's callers use. Shared by both the file- and Redis-backed
17
+ * stores below — only the Storage and the lock differ. */
18
+ declare class SessionKnitStore implements SessionStore {
19
+ private readonly lock;
20
+ private readonly onClose;
21
+ private readonly knit;
22
+ constructor(storage: Storage<Message>, lock: <T>(sessionId: string, fn: () => Promise<T>) => Promise<T>, onClose?: () => Promise<void>);
23
+ withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
24
+ close(): Promise<void>;
25
+ }
26
+ /** One JSONL entry log per session under `dir`. Fine for a single local
27
+ * process (CLI, dev server); not safe across multiple processes/instances
28
+ * — those need RedisSessionStore. */
29
+ export declare class FileSessionStore extends SessionKnitStore {
30
+ constructor(dir: string);
31
+ }
32
+ /** Redis-backed store for multi-instance deployments. Locking is a
33
+ * single-instance-Redis lock (SET NX PX + a compare-and-delete unlock
34
+ * script) — good enough as long as session state lives in one Redis; true
35
+ * multi-node Redlock is overkill until it doesn't. This lock is what keeps
36
+ * two concurrent turns for the same session from interleaving appends
37
+ * across instances — SessionKnit's own topology repair handles branching
38
+ * *within* one resumed chain, not that. */
39
+ export declare class RedisSessionStore extends SessionKnitStore {
40
+ private readonly lockTtlMs;
41
+ private readonly lockRetryDelayMs;
42
+ private readonly lockMaxWaitMs;
43
+ private readonly redis;
44
+ constructor(redisUrl: string, lockTtlMs?: number, lockRetryDelayMs?: number, lockMaxWaitMs?: number);
45
+ private lockKeyFor;
46
+ private withLock;
47
+ }
48
+ /** Picks the store from environment so adapters don't hardcode a backend:
49
+ * REDIS_URL set -> RedisSessionStore (required once you run >1 instance),
50
+ * otherwise FileSessionStore under .sessions (fine for local dev). */
51
+ export declare function createSessionStore(): SessionStore;
52
+ export {};
@@ -0,0 +1,170 @@
1
+ // Where conversation history lives between calls to runAgent, and how
2
+ // concurrent requests for the *same* session are kept from racing on
3
+ // read-modify-write. Adapters never call load/save directly — they go
4
+ // through withSession, so locking isn't something each adapter has to
5
+ // remember to do correctly.
6
+ //
7
+ // Storage itself is SessionKnit: a durable, append-only, parent-linked log
8
+ // per session, rather than a flat blob rewritten whole on every turn. That
9
+ // buys two things runAgent's history array can't give itself:
10
+ // - non-blocking, debounced writes instead of a full read-modify-write
11
+ // of the entire session on every turn
12
+ // - interruption detection — if the process dies between requesting
13
+ // tools and recording their results (run-agent.ts pushes an
14
+ // "[requested: ...]" message before any tool runs, then one
15
+ // "[... result]" message per completed tool), the next resume() sees
16
+ // an unresolved request and injects a note into context saying so,
17
+ // instead of silently resending an incomplete turn as if it were
18
+ // clean.
19
+ // SessionKnit's own topology repair is for branches *within* one resumed
20
+ // chain (parallel tool calls, crash recovery) — it isn't a substitute for
21
+ // turn-level exclusivity, so this module still serializes concurrent
22
+ // withSession calls for the same sessionId itself (KeyedMutex / a Redis
23
+ // lock), same as before.
24
+ import { randomUUID } from 'node:crypto';
25
+ import path from 'node:path';
26
+ import { Redis } from 'ioredis';
27
+ import { SessionKnit, FileStorage } from 'sessionknit';
28
+ /** Serializes async work per key within this process. Good enough for
29
+ * FileSessionStore (single process); RedisSessionStore needs a real
30
+ * distributed lock instead since multiple server instances share it. */
31
+ class KeyedMutex {
32
+ tails = new Map();
33
+ run(key, fn) {
34
+ const prevTail = this.tails.get(key) ?? Promise.resolve();
35
+ const result = prevTail.then(fn, fn);
36
+ this.tails.set(key, result.catch(() => { }));
37
+ return result;
38
+ }
39
+ }
40
+ // run-agent.ts pushes exactly one "[requested: tool_a, tool_b]" assistant
41
+ // message before any tool executes, then one "[tool result]" user message
42
+ // per completed tool as they finish (adapters/http.ts, adapters/cli.ts
43
+ // never see this — it lives entirely inside the messages array runAgent
44
+ // returns). If the process dies in that window, the request message is the
45
+ // last thing on disk with no result behind it.
46
+ function hasUnresolvedToolCall(message) {
47
+ return message.role === 'assistant' && message.content.startsWith('[requested:');
48
+ }
49
+ function buildContinuation() {
50
+ return {
51
+ role: 'user',
52
+ content: '[session resumed after an interruption — the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]',
53
+ };
54
+ }
55
+ /** Wraps a SessionKnit log behind the flat load/run/append contract
56
+ * runAgent's callers use. Shared by both the file- and Redis-backed
57
+ * stores below — only the Storage and the lock differ. */
58
+ class SessionKnitStore {
59
+ lock;
60
+ onClose;
61
+ knit;
62
+ constructor(storage, lock, onClose = async () => { }) {
63
+ this.lock = lock;
64
+ this.onClose = onClose;
65
+ this.knit = new SessionKnit(storage, { hasUnresolvedToolCall, buildContinuation });
66
+ }
67
+ async withSession(sessionId, fn) {
68
+ return this.lock(sessionId, async () => {
69
+ const { messages, leafId } = await this.knit.resume(sessionId);
70
+ const { history: nextHistory, result } = await fn(messages);
71
+ // Everything fn's history grew by beyond what resume() handed it —
72
+ // this naturally excludes the synthetic continuation message too
73
+ // (it's already counted in messages.length), which is exactly right:
74
+ // it's a resend-only hint, never meant to be durably stored.
75
+ let parentId = leafId;
76
+ for (const message of nextHistory.slice(messages.length)) {
77
+ const id = randomUUID();
78
+ await this.knit.append(sessionId, { id, parentId, message });
79
+ parentId = id;
80
+ }
81
+ await this.knit.flush(sessionId);
82
+ return result;
83
+ });
84
+ }
85
+ async close() {
86
+ await this.onClose();
87
+ }
88
+ }
89
+ const fileMutex = new KeyedMutex();
90
+ /** One JSONL entry log per session under `dir`. Fine for a single local
91
+ * process (CLI, dev server); not safe across multiple processes/instances
92
+ * — those need RedisSessionStore. */
93
+ export class FileSessionStore extends SessionKnitStore {
94
+ constructor(dir) {
95
+ super(new FileStorage(dir), (sessionId, fn) => fileMutex.run(path.resolve(dir, sessionId), fn));
96
+ }
97
+ }
98
+ /** SessionKnit Storage backed by a Redis list — one RPUSH per appended
99
+ * entry. Redis is already durable per write, so unlike FileStorage there's
100
+ * nothing to debounce: flush() is a no-op. */
101
+ class RedisEntryStorage {
102
+ redis;
103
+ constructor(redis) {
104
+ this.redis = redis;
105
+ }
106
+ keyFor(sessionId) {
107
+ return `session-log:${sessionId}`;
108
+ }
109
+ async append(sessionId, entry) {
110
+ await this.redis.rpush(this.keyFor(sessionId), JSON.stringify(entry));
111
+ }
112
+ async flush() { }
113
+ async readAll(sessionId) {
114
+ const raw = await this.redis.lrange(this.keyFor(sessionId), 0, -1);
115
+ return raw.map((line) => JSON.parse(line));
116
+ }
117
+ }
118
+ /** Redis-backed store for multi-instance deployments. Locking is a
119
+ * single-instance-Redis lock (SET NX PX + a compare-and-delete unlock
120
+ * script) — good enough as long as session state lives in one Redis; true
121
+ * multi-node Redlock is overkill until it doesn't. This lock is what keeps
122
+ * two concurrent turns for the same session from interleaving appends
123
+ * across instances — SessionKnit's own topology repair handles branching
124
+ * *within* one resumed chain, not that. */
125
+ export class RedisSessionStore extends SessionKnitStore {
126
+ lockTtlMs;
127
+ lockRetryDelayMs;
128
+ lockMaxWaitMs;
129
+ redis;
130
+ constructor(redisUrl, lockTtlMs = 30_000, lockRetryDelayMs = 50, lockMaxWaitMs = 15_000) {
131
+ const redis = new Redis(redisUrl);
132
+ super(new RedisEntryStorage(redis), (sessionId, fn) => this.withLock(sessionId, fn), () => redis.quit().then(() => { }));
133
+ this.lockTtlMs = lockTtlMs;
134
+ this.lockRetryDelayMs = lockRetryDelayMs;
135
+ this.lockMaxWaitMs = lockMaxWaitMs;
136
+ this.redis = redis;
137
+ }
138
+ lockKeyFor(sessionId) {
139
+ return `session-lock:${sessionId}`;
140
+ }
141
+ async withLock(sessionId, fn) {
142
+ const lockKey = this.lockKeyFor(sessionId);
143
+ const token = randomUUID();
144
+ const deadline = Date.now() + this.lockMaxWaitMs;
145
+ for (;;) {
146
+ const ok = await this.redis.set(lockKey, token, 'PX', this.lockTtlMs, 'NX');
147
+ if (ok)
148
+ break;
149
+ if (Date.now() > deadline)
150
+ throw new Error(`timed out waiting for lock on session '${sessionId}'`);
151
+ await new Promise((resolve) => setTimeout(resolve, this.lockRetryDelayMs));
152
+ }
153
+ try {
154
+ return await fn();
155
+ }
156
+ finally {
157
+ // Only delete if we still hold it — otherwise we'd release a lock our
158
+ // TTL already expired and a different request has since acquired.
159
+ const script = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
160
+ await this.redis.eval(script, 1, lockKey, token);
161
+ }
162
+ }
163
+ }
164
+ /** Picks the store from environment so adapters don't hardcode a backend:
165
+ * REDIS_URL set -> RedisSessionStore (required once you run >1 instance),
166
+ * otherwise FileSessionStore under .sessions (fine for local dev). */
167
+ export function createSessionStore() {
168
+ const redisUrl = process.env.REDIS_URL;
169
+ return redisUrl ? new RedisSessionStore(redisUrl) : new FileSessionStore('.sessions');
170
+ }
@@ -0,0 +1,25 @@
1
+ /** Hashes each token into one of EMBEDDING_DIMENSIONS buckets (FNV-1a),
2
+ * with the hash's sign deciding +1/-1 to reduce collision bias, then
3
+ * L2-normalizes. Works on tokens it's never seen and needs no training
4
+ * step, at the cost of real embedding quality — a stand-in, not a
5
+ * production embedding model. */
6
+ export declare function embed(text: string): Float64Array;
7
+ /** Both inputs are unit vectors (embed() normalizes), so the dot product
8
+ * already is the cosine similarity. */
9
+ export declare function cosineSimilarity(a: Float64Array, b: Float64Array): number;
10
+ export interface Document {
11
+ id: string;
12
+ text: string;
13
+ }
14
+ export interface ScoredDocument extends Document {
15
+ score: number;
16
+ }
17
+ /** In-memory vector store: add() embeds and keeps the document, search()
18
+ * returns the top-k most similar by cosine similarity. Swap for a real
19
+ * vector DB (pgvector, Pinecone, ...) once the corpus outgrows memory —
20
+ * same shape, so callers (like a search_docs tool) don't need to change. */
21
+ export declare class VectorIndex {
22
+ private readonly documents;
23
+ add(id: string, text: string): void;
24
+ search(query: string, topK?: number): ScoredDocument[];
25
+ }
@@ -0,0 +1,72 @@
1
+ // A small, dependency-free vector index for RAG demos — feature-hashing
2
+ // embeddings (no external embedding API call, no vocabulary to train)
3
+ // plus cosine-similarity search. Real technique (the "hashing trick"),
4
+ // just not a learned one — good enough to prove the retrieval mechanics
5
+ // end-to-end without wiring up a live embedding model. Swap embed() for a
6
+ // real one and VectorIndex works unchanged: same seam as ContextClip's
7
+ // Summarizer or ActAuth's Approver — a working default the rest of the
8
+ // pipeline can be built and tested against.
9
+ // Higher dimensions mean fewer hash collisions between unrelated tokens,
10
+ // which matters more here than with a learned embedding since there's no
11
+ // training to smooth collisions out — 1024 keeps ranking stable for the
12
+ // short, few-dozen-document corpora this is meant for.
13
+ const EMBEDDING_DIMENSIONS = 1024;
14
+ function tokenize(text) {
15
+ return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
16
+ }
17
+ function normalize(vector) {
18
+ let magnitude = 0;
19
+ for (const value of vector)
20
+ magnitude += value * value;
21
+ magnitude = Math.sqrt(magnitude);
22
+ if (magnitude === 0)
23
+ return vector;
24
+ const result = new Float64Array(vector.length);
25
+ for (let i = 0; i < vector.length; i++)
26
+ result[i] = vector[i] / magnitude;
27
+ return result;
28
+ }
29
+ /** Hashes each token into one of EMBEDDING_DIMENSIONS buckets (FNV-1a),
30
+ * with the hash's sign deciding +1/-1 to reduce collision bias, then
31
+ * L2-normalizes. Works on tokens it's never seen and needs no training
32
+ * step, at the cost of real embedding quality — a stand-in, not a
33
+ * production embedding model. */
34
+ export function embed(text) {
35
+ const vector = new Float64Array(EMBEDDING_DIMENSIONS);
36
+ for (const token of tokenize(text)) {
37
+ let hash = 2166136261; // FNV offset basis
38
+ for (let i = 0; i < token.length; i++) {
39
+ hash ^= token.charCodeAt(i);
40
+ hash = Math.imul(hash, 16777619); // FNV prime
41
+ }
42
+ const bucket = Math.abs(hash) % EMBEDDING_DIMENSIONS;
43
+ const sign = hash & 1 ? 1 : -1;
44
+ vector[bucket] += sign;
45
+ }
46
+ return normalize(vector);
47
+ }
48
+ /** Both inputs are unit vectors (embed() normalizes), so the dot product
49
+ * already is the cosine similarity. */
50
+ export function cosineSimilarity(a, b) {
51
+ let dot = 0;
52
+ for (let i = 0; i < a.length; i++)
53
+ dot += a[i] * b[i];
54
+ return dot;
55
+ }
56
+ /** In-memory vector store: add() embeds and keeps the document, search()
57
+ * returns the top-k most similar by cosine similarity. Swap for a real
58
+ * vector DB (pgvector, Pinecone, ...) once the corpus outgrows memory —
59
+ * same shape, so callers (like a search_docs tool) don't need to change. */
60
+ export class VectorIndex {
61
+ documents = [];
62
+ add(id, text) {
63
+ this.documents.push({ id, text, vector: embed(text) });
64
+ }
65
+ search(query, topK = 3) {
66
+ const queryVector = embed(query);
67
+ return this.documents
68
+ .map(({ id, text, vector }) => ({ id, text, score: cosineSimilarity(queryVector, vector) }))
69
+ .sort((a, b) => b.score - a.score)
70
+ .slice(0, topK);
71
+ }
72
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "loopengine",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "A minimal framework for defining and running tool-using AI agents over CLI, HTTP, or both.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/evanyan22/loopengine.git"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "files": [
14
+ "dist/index.js",
15
+ "dist/index.d.ts",
16
+ "dist/run-agent.js",
17
+ "dist/run-agent.d.ts",
18
+ "dist/agent-config.js",
19
+ "dist/agent-config.d.ts",
20
+ "dist/load-agent.js",
21
+ "dist/load-agent.d.ts",
22
+ "dist/mcp-tools.js",
23
+ "dist/mcp-tools.d.ts",
24
+ "dist/session-store.js",
25
+ "dist/session-store.d.ts",
26
+ "dist/vector-index.js",
27
+ "dist/vector-index.d.ts",
28
+ "dist/anthropic-model-call.js",
29
+ "dist/anthropic-model-call.d.ts",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "prepublishOnly": "npm run build",
35
+ "start": "node dist/adapters/http.js",
36
+ "test": "vitest run"
37
+ },
38
+ "dependencies": {
39
+ "@anthropic-ai/sdk": "^0.116.0",
40
+ "@modelcontextprotocol/sdk": "^1.30.0",
41
+ "actauth": "^0.0.2",
42
+ "contextclip": "^0.0.1",
43
+ "ioredis": "^6.0.0",
44
+ "reflowkit": "^0.0.1",
45
+ "sessionknit": "^0.0.2",
46
+ "skillgarden": "^0.0.1",
47
+ "toollane": "^0.0.1"
48
+ },
49
+ "devDependencies": {
50
+ "@modelcontextprotocol/server-filesystem": "^2026.7.10",
51
+ "@types/node": "^26.2.0",
52
+ "tsx": "^4.19.0",
53
+ "typescript": "^5.7.0",
54
+ "vitest": "^2.1.9"
55
+ }
56
+ }