@inixiative/foundry-core 0.1.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/LICENSE +21 -0
- package/package.json +39 -0
- package/src/action-prompt.ts +248 -0
- package/src/adapters/file-memory.ts +718 -0
- package/src/adapters/http-memory.ts +141 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/markdown-docs.ts +213 -0
- package/src/adapters/sqlite-memory.ts +188 -0
- package/src/base-agent.ts +137 -0
- package/src/bounded-set.ts +42 -0
- package/src/cache-lifecycle.ts +187 -0
- package/src/capability.ts +252 -0
- package/src/classifier.ts +43 -0
- package/src/context-layer.ts +706 -0
- package/src/context-stack.ts +223 -0
- package/src/credentials.ts +14 -0
- package/src/decider.ts +62 -0
- package/src/delivery-evidence.ts +85 -0
- package/src/event-stream.ts +105 -0
- package/src/executor.ts +185 -0
- package/src/harness.ts +749 -0
- package/src/hooks.ts +133 -0
- package/src/hydrator.ts +148 -0
- package/src/id.ts +39 -0
- package/src/index.ts +273 -0
- package/src/intervention.ts +134 -0
- package/src/messages.ts +315 -0
- package/src/middleware.ts +118 -0
- package/src/native-evidence.ts +140 -0
- package/src/permission-middleware.ts +92 -0
- package/src/retry.ts +91 -0
- package/src/router.ts +41 -0
- package/src/scope.ts +20 -0
- package/src/signal.ts +117 -0
- package/src/thread.ts +528 -0
- package/src/token-counts.ts +30 -0
- package/src/token-tracker.ts +433 -0
- package/src/tools.ts +714 -0
- package/src/trace.ts +261 -0
- package/src/types.ts +167 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ContextSource } from "../context-layer";
|
|
2
|
+
import type { HydrationAdapter, ContextRef } from "../hydrator";
|
|
3
|
+
import type { Signal } from "../signal";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTTP/REST adapter.
|
|
7
|
+
*
|
|
8
|
+
* Talks to any API that returns text or JSON. Zero deps — uses fetch.
|
|
9
|
+
* Use this for:
|
|
10
|
+
* - Notion, Confluence, or any knowledge base API
|
|
11
|
+
* - Custom internal tools
|
|
12
|
+
* - MCP servers
|
|
13
|
+
* - Vector DB APIs (Qdrant, Pinecone, Weaviate)
|
|
14
|
+
* - Any external system with an HTTP interface
|
|
15
|
+
*/
|
|
16
|
+
export class HttpMemory {
|
|
17
|
+
readonly baseUrl: string;
|
|
18
|
+
private _headers: Record<string, string>;
|
|
19
|
+
private _timeout: number;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
baseUrl: string,
|
|
23
|
+
opts?: {
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
}
|
|
27
|
+
) {
|
|
28
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
29
|
+
this._headers = opts?.headers ?? {};
|
|
30
|
+
this._timeout = opts?.timeout ?? 10_000;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** GET a path and return the response body as text. */
|
|
34
|
+
async get(path: string, params?: Record<string, string>): Promise<string> {
|
|
35
|
+
const url = new URL(path, this.baseUrl);
|
|
36
|
+
if (params) {
|
|
37
|
+
for (const [k, v] of Object.entries(params)) {
|
|
38
|
+
url.searchParams.set(k, v);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const res = await fetch(url.toString(), {
|
|
43
|
+
headers: this._headers,
|
|
44
|
+
signal: AbortSignal.timeout(this._timeout),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText} (${url.pathname})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
52
|
+
if (ct.includes("application/json")) {
|
|
53
|
+
const json = await res.json();
|
|
54
|
+
return typeof json === "string" ? json : JSON.stringify(json, null, 2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return res.text();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** POST to a path with a JSON body. */
|
|
61
|
+
async post(path: string, body: unknown): Promise<string> {
|
|
62
|
+
const url = new URL(path, this.baseUrl);
|
|
63
|
+
|
|
64
|
+
const res = await fetch(url.toString(), {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { ...this._headers, "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(this._timeout),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText} (${url.pathname})`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
76
|
+
if (ct.includes("application/json")) {
|
|
77
|
+
const json = await res.json();
|
|
78
|
+
return typeof json === "string" ? json : JSON.stringify(json, null, 2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return res.text();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a ContextSource that fetches from a GET endpoint.
|
|
86
|
+
* The response body becomes the context content.
|
|
87
|
+
*/
|
|
88
|
+
asSource(
|
|
89
|
+
id: string,
|
|
90
|
+
path: string,
|
|
91
|
+
params?: Record<string, string>
|
|
92
|
+
): ContextSource {
|
|
93
|
+
const http = this;
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
async load() {
|
|
97
|
+
try {
|
|
98
|
+
return await http.get(path, params);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn(`[HttpMemory] source load failed for "${id}":`, (err as Error).message);
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a HydrationAdapter.
|
|
109
|
+
* Refs use paths as locators, fetched via GET.
|
|
110
|
+
*/
|
|
111
|
+
asAdapter(system: string = "http"): HydrationAdapter {
|
|
112
|
+
const http = this;
|
|
113
|
+
return {
|
|
114
|
+
system,
|
|
115
|
+
async hydrate(ref: ContextRef): Promise<string> {
|
|
116
|
+
try {
|
|
117
|
+
const params = ref.meta as Record<string, string> | undefined;
|
|
118
|
+
return await http.get(ref.locator, params);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn(`[HttpMemory] hydrate failed for "${ref.locator}":`, (err as Error).message);
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a signal writer that POSTs signals to an endpoint.
|
|
129
|
+
* Use this to forward signals to an external system.
|
|
130
|
+
*/
|
|
131
|
+
signalWriter(path: string = "/signals") {
|
|
132
|
+
const http = this;
|
|
133
|
+
return async (signal: Signal): Promise<void> => {
|
|
134
|
+
try {
|
|
135
|
+
await http.post(path, signal);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
console.warn(`[HttpMemory] signal forwarding failed:`, (err as Error).message);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { join, relative, basename, extname } from "node:path";
|
|
2
|
+
import type { ContextSource } from "../context-layer";
|
|
3
|
+
import type { HydrationAdapter, ContextRef } from "../hydrator";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Markdown directory adapter.
|
|
7
|
+
*
|
|
8
|
+
* Reads a directory of .md files as context. Perfect for:
|
|
9
|
+
* - CLAUDE.md and project docs
|
|
10
|
+
* - Architecture decision records
|
|
11
|
+
* - Convention files
|
|
12
|
+
* - Any docs/ directory
|
|
13
|
+
*
|
|
14
|
+
* Zero external deps — just filesystem.
|
|
15
|
+
*/
|
|
16
|
+
export class MarkdownDocs {
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
readonly glob: string;
|
|
19
|
+
private _cache: Map<string, { content: string; mtime: number }> = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param dir — root directory to scan
|
|
23
|
+
* @param glob — file pattern (default: all .md files recursively)
|
|
24
|
+
*/
|
|
25
|
+
constructor(dir: string, glob: string = "**/*.md") {
|
|
26
|
+
this.dir = dir;
|
|
27
|
+
this.glob = glob;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Scan and load all matching files. */
|
|
31
|
+
async load(): Promise<Map<string, string>> {
|
|
32
|
+
const results = new Map<string, string>();
|
|
33
|
+
const scanner = new Bun.Glob(this.glob).scan({ cwd: this.dir, absolute: false });
|
|
34
|
+
|
|
35
|
+
for await (const relPath of scanner) {
|
|
36
|
+
const absPath = join(this.dir, relPath);
|
|
37
|
+
const file = Bun.file(absPath);
|
|
38
|
+
const stat = await file.stat();
|
|
39
|
+
const cached = this._cache.get(relPath);
|
|
40
|
+
|
|
41
|
+
// Use cache if file hasn't changed
|
|
42
|
+
if (cached && cached.mtime === stat.mtimeMs) {
|
|
43
|
+
results.set(relPath, cached.content);
|
|
44
|
+
} else {
|
|
45
|
+
const content = await file.text();
|
|
46
|
+
this._cache.set(relPath, { content, mtime: stat.mtimeMs });
|
|
47
|
+
results.set(relPath, content);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read a single file by relative path. */
|
|
55
|
+
async readFile(relPath: string): Promise<string> {
|
|
56
|
+
const absPath = join(this.dir, relPath);
|
|
57
|
+
const file = Bun.file(absPath);
|
|
58
|
+
if (!(await file.exists())) return "";
|
|
59
|
+
return file.text();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** List all matching files. */
|
|
63
|
+
async list(): Promise<string[]> {
|
|
64
|
+
const paths: string[] = [];
|
|
65
|
+
const scanner = new Bun.Glob(this.glob).scan({ cwd: this.dir, absolute: false });
|
|
66
|
+
for await (const relPath of scanner) {
|
|
67
|
+
paths.push(relPath);
|
|
68
|
+
}
|
|
69
|
+
return paths;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a ContextSource that loads all docs as one string.
|
|
74
|
+
* Each file is prefixed with its path as a header.
|
|
75
|
+
*/
|
|
76
|
+
asSource(id: string): ContextSource {
|
|
77
|
+
const docs = this;
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
async load() {
|
|
81
|
+
const files = await docs.load();
|
|
82
|
+
if (files.size === 0) return "";
|
|
83
|
+
|
|
84
|
+
const parts: string[] = [];
|
|
85
|
+
for (const [path, content] of files) {
|
|
86
|
+
parts.push(`## ${path}\n\n${content}`);
|
|
87
|
+
}
|
|
88
|
+
return parts.join("\n\n---\n\n");
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a ContextSource for a single file.
|
|
95
|
+
*/
|
|
96
|
+
fileSource(id: string, relPath: string): ContextSource {
|
|
97
|
+
const docs = this;
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
async load() {
|
|
101
|
+
return docs.readFile(relPath);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a ContextSource that emits a compact topology of the corpus —
|
|
108
|
+
* one line per file with its H1 title and H2 headings. No full content.
|
|
109
|
+
*
|
|
110
|
+
* This is the docs-warden warm-cache substrate for corpora too large to
|
|
111
|
+
* hold inline (400KB+). The warden's advise LLM sees this ~50-tokens-per-file
|
|
112
|
+
* index and picks file paths; the orchestrator hydrates selected files
|
|
113
|
+
* on demand via HydrationAdapter.
|
|
114
|
+
*
|
|
115
|
+
* Output format (stable, deterministic — safe to diff across warm cycles):
|
|
116
|
+
*
|
|
117
|
+
* docs/claude/API_ROUTES.md — API Routes
|
|
118
|
+
* H2: Overview, Naming, Validation, Error shapes
|
|
119
|
+
*
|
|
120
|
+
* docs/claude/AUTH.md — Authentication
|
|
121
|
+
* H2: Session model, Token storage, Refresh flow
|
|
122
|
+
*
|
|
123
|
+
* Files with no H1 fall back to the file basename. Files with no H2s omit
|
|
124
|
+
* the headings line entirely. Empty files are skipped.
|
|
125
|
+
*/
|
|
126
|
+
topologySource(id: string, opts: { maxHeadings?: number } = {}): ContextSource {
|
|
127
|
+
const docs = this;
|
|
128
|
+
const maxHeadings = opts.maxHeadings ?? 12;
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
async load() {
|
|
132
|
+
const files = await docs.load();
|
|
133
|
+
if (files.size === 0) return "";
|
|
134
|
+
|
|
135
|
+
const lines: string[] = [];
|
|
136
|
+
const paths = [...files.keys()].sort();
|
|
137
|
+
|
|
138
|
+
for (const path of paths) {
|
|
139
|
+
const content = files.get(path) ?? "";
|
|
140
|
+
if (!content.trim()) continue;
|
|
141
|
+
|
|
142
|
+
const { title, headings } = extractHeadings(content, maxHeadings);
|
|
143
|
+
const displayTitle = title ?? basename(path, extname(path));
|
|
144
|
+
lines.push(`${path} — ${displayTitle}`);
|
|
145
|
+
if (headings.length > 0) {
|
|
146
|
+
lines.push(` H2: ${headings.join(", ")}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push("");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lines.join("\n").trimEnd();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Create a HydrationAdapter. Refs use relative paths as locators. */
|
|
157
|
+
asAdapter(): HydrationAdapter {
|
|
158
|
+
const docs = this;
|
|
159
|
+
return {
|
|
160
|
+
system: "markdown",
|
|
161
|
+
async hydrate(ref: ContextRef): Promise<string> {
|
|
162
|
+
return docs.readFile(ref.locator);
|
|
163
|
+
},
|
|
164
|
+
async hydrateBatch(refs: ContextRef[]): Promise<string[]> {
|
|
165
|
+
return Promise.all(refs.map((r) => docs.readFile(r.locator)));
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract the first H1 and all H2 headings from markdown content.
|
|
173
|
+
* Returns `{ title: null, headings: [] }` if no structure is found.
|
|
174
|
+
*/
|
|
175
|
+
function extractHeadings(
|
|
176
|
+
content: string,
|
|
177
|
+
maxHeadings: number,
|
|
178
|
+
): { title: string | null; headings: string[] } {
|
|
179
|
+
let title: string | null = null;
|
|
180
|
+
const headings: string[] = [];
|
|
181
|
+
const lines = content.split("\n");
|
|
182
|
+
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
if (title === null) {
|
|
185
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line);
|
|
186
|
+
if (h1) {
|
|
187
|
+
title = h1[1];
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
192
|
+
if (h2) {
|
|
193
|
+
headings.push(h2[1]);
|
|
194
|
+
if (headings.length >= maxHeadings) break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { title, headings };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Convenience: create a ContextSource from a single CLAUDE.md or similar file.
|
|
203
|
+
*/
|
|
204
|
+
export function claudemdSource(id: string, path: string): ContextSource {
|
|
205
|
+
return {
|
|
206
|
+
id,
|
|
207
|
+
async load() {
|
|
208
|
+
const file = Bun.file(path);
|
|
209
|
+
if (!(await file.exists())) return "";
|
|
210
|
+
return file.text();
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { ContextSource } from "../context-layer";
|
|
3
|
+
import type { HydrationAdapter, ContextRef } from "../hydrator";
|
|
4
|
+
import type { Signal } from "../signal";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SQLite-backed memory system.
|
|
8
|
+
*
|
|
9
|
+
* Zero external deps — uses bun:sqlite. Supports full-text search
|
|
10
|
+
* via SQLite FTS5. Good for local dev and single-machine deployments.
|
|
11
|
+
*/
|
|
12
|
+
export class SqliteMemory {
|
|
13
|
+
readonly db: Database;
|
|
14
|
+
|
|
15
|
+
constructor(path: string = ":memory:") {
|
|
16
|
+
this.db = new Database(path, { create: true });
|
|
17
|
+
this._init();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private _init() {
|
|
21
|
+
this.db.run(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
content TEXT NOT NULL,
|
|
26
|
+
source TEXT,
|
|
27
|
+
timestamp INTEGER NOT NULL,
|
|
28
|
+
meta TEXT
|
|
29
|
+
)
|
|
30
|
+
`);
|
|
31
|
+
|
|
32
|
+
// FTS5 for full-text search
|
|
33
|
+
this.db.run(`
|
|
34
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts
|
|
35
|
+
USING fts5(id, kind, content, content=entries, content_rowid=rowid)
|
|
36
|
+
`);
|
|
37
|
+
|
|
38
|
+
// Triggers to keep FTS in sync
|
|
39
|
+
this.db.run(`
|
|
40
|
+
CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
|
|
41
|
+
INSERT INTO entries_fts(rowid, id, kind, content) VALUES (NEW.rowid, NEW.id, NEW.kind, NEW.content);
|
|
42
|
+
END
|
|
43
|
+
`);
|
|
44
|
+
this.db.run(`
|
|
45
|
+
CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
|
|
46
|
+
INSERT INTO entries_fts(entries_fts, rowid, id, kind, content) VALUES ('delete', OLD.rowid, OLD.id, OLD.kind, OLD.content);
|
|
47
|
+
END
|
|
48
|
+
`);
|
|
49
|
+
this.db.run(`
|
|
50
|
+
CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
|
|
51
|
+
INSERT INTO entries_fts(entries_fts, rowid, id, kind, content) VALUES ('delete', OLD.rowid, OLD.id, OLD.kind, OLD.content);
|
|
52
|
+
INSERT INTO entries_fts(rowid, id, kind, content) VALUES (NEW.rowid, NEW.id, NEW.kind, NEW.content);
|
|
53
|
+
END
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Write an entry. Upserts. */
|
|
58
|
+
write(entry: SqliteEntry): void {
|
|
59
|
+
this.db.run(
|
|
60
|
+
`INSERT OR REPLACE INTO entries (id, kind, content, source, timestamp, meta) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
61
|
+
[entry.id, entry.kind, entry.content, entry.source ?? null, entry.timestamp, entry.meta ? JSON.stringify(entry.meta) : null]
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read by id. */
|
|
66
|
+
get(id: string): SqliteEntry | undefined {
|
|
67
|
+
const row = this.db.query("SELECT * FROM entries WHERE id = ?").get(id) as any;
|
|
68
|
+
return row ? this._rowToEntry(row) : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Get all entries, optionally filtered by kind. */
|
|
72
|
+
all(kind?: string): SqliteEntry[] {
|
|
73
|
+
const query = kind
|
|
74
|
+
? this.db.query("SELECT * FROM entries WHERE kind = ? ORDER BY timestamp DESC")
|
|
75
|
+
: this.db.query("SELECT * FROM entries ORDER BY timestamp DESC");
|
|
76
|
+
const rows = kind ? query.all(kind) : query.all();
|
|
77
|
+
return (rows as any[]).map(this._rowToEntry);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Full-text search. */
|
|
81
|
+
search(query: string, limit: number = 20): SqliteEntry[] {
|
|
82
|
+
const rows = this.db
|
|
83
|
+
.query(
|
|
84
|
+
`SELECT entries.* FROM entries_fts JOIN entries ON entries_fts.id = entries.id WHERE entries_fts MATCH ? ORDER BY rank LIMIT ?`
|
|
85
|
+
)
|
|
86
|
+
.all(query, limit);
|
|
87
|
+
return (rows as any[]).map(this._rowToEntry);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Delete by id. */
|
|
91
|
+
delete(id: string): boolean {
|
|
92
|
+
const result = this.db.run("DELETE FROM entries WHERE id = ?", [id]);
|
|
93
|
+
return result.changes > 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Count entries, optionally by kind. */
|
|
97
|
+
count(kind?: string): number {
|
|
98
|
+
const query = kind
|
|
99
|
+
? this.db.query("SELECT COUNT(*) as c FROM entries WHERE kind = ?")
|
|
100
|
+
: this.db.query("SELECT COUNT(*) as c FROM entries");
|
|
101
|
+
const row = (kind ? query.get(kind) : query.get()) as any;
|
|
102
|
+
return row.c;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Recent entries. */
|
|
106
|
+
recent(limit: number = 50, kind?: string): SqliteEntry[] {
|
|
107
|
+
const query = kind
|
|
108
|
+
? this.db.query("SELECT * FROM entries WHERE kind = ? ORDER BY timestamp DESC LIMIT ?")
|
|
109
|
+
: this.db.query("SELECT * FROM entries ORDER BY timestamp DESC LIMIT ?");
|
|
110
|
+
const rows = kind ? query.all(kind, limit) : query.all(limit);
|
|
111
|
+
return (rows as any[]).map(this._rowToEntry);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Create a ContextSource that loads entries as context. */
|
|
115
|
+
asSource(id: string, kind?: string, limit: number = 100): ContextSource {
|
|
116
|
+
const mem = this;
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
async load() {
|
|
120
|
+
const entries = mem.recent(limit, kind);
|
|
121
|
+
if (entries.length === 0) return "";
|
|
122
|
+
return entries
|
|
123
|
+
.map((e) => `[${e.kind}] ${e.id}: ${e.content}`)
|
|
124
|
+
.join("\n");
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Create a HydrationAdapter. */
|
|
130
|
+
asAdapter(): HydrationAdapter {
|
|
131
|
+
const mem = this;
|
|
132
|
+
return {
|
|
133
|
+
system: "sqlite",
|
|
134
|
+
async hydrate(ref: ContextRef): Promise<string> {
|
|
135
|
+
const entry = mem.get(ref.locator);
|
|
136
|
+
return entry ? entry.content : "";
|
|
137
|
+
},
|
|
138
|
+
async hydrateBatch(refs: ContextRef[]): Promise<string[]> {
|
|
139
|
+
return refs.map((r) => {
|
|
140
|
+
const entry = mem.get(r.locator);
|
|
141
|
+
return entry ? entry.content : "";
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Signal handler that writes signals to SQLite. */
|
|
148
|
+
signalWriter() {
|
|
149
|
+
const mem = this;
|
|
150
|
+
return async (signal: Signal): Promise<void> => {
|
|
151
|
+
mem.write({
|
|
152
|
+
id: signal.id,
|
|
153
|
+
kind: signal.kind,
|
|
154
|
+
content:
|
|
155
|
+
typeof signal.content === "string"
|
|
156
|
+
? signal.content
|
|
157
|
+
: JSON.stringify(signal.content),
|
|
158
|
+
source: signal.source,
|
|
159
|
+
timestamp: signal.timestamp,
|
|
160
|
+
meta: { confidence: signal.confidence, refs: signal.refs },
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
close(): void {
|
|
166
|
+
this.db.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private _rowToEntry(row: any): SqliteEntry {
|
|
170
|
+
return {
|
|
171
|
+
id: row.id,
|
|
172
|
+
kind: row.kind,
|
|
173
|
+
content: row.content,
|
|
174
|
+
source: row.source ?? undefined,
|
|
175
|
+
timestamp: row.timestamp,
|
|
176
|
+
meta: row.meta ? JSON.parse(row.meta) : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface SqliteEntry {
|
|
182
|
+
readonly id: string;
|
|
183
|
+
readonly kind: string;
|
|
184
|
+
readonly content: string;
|
|
185
|
+
readonly source?: string;
|
|
186
|
+
readonly timestamp: number;
|
|
187
|
+
readonly meta?: Record<string, unknown>;
|
|
188
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { TokenCounts } from "./token-counts";
|
|
2
|
+
import { computeHash } from "./context-layer";
|
|
3
|
+
import { ContextStack, type LayerFilter, type AssembledContext } from "./context-stack";
|
|
4
|
+
|
|
5
|
+
export interface ExecutionResult<T = unknown> {
|
|
6
|
+
readonly output: T;
|
|
7
|
+
readonly tokens?: TokenCounts;
|
|
8
|
+
readonly contextHash: string;
|
|
9
|
+
readonly meta?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-agent LLM configuration.
|
|
14
|
+
*
|
|
15
|
+
* Each agent can specify its own provider, model, and inference settings.
|
|
16
|
+
* This allows tuning each part of the composition independently:
|
|
17
|
+
* - Fast/cheap models for middleware (classify, route)
|
|
18
|
+
* - Powerful models for executors (code generation, planning)
|
|
19
|
+
*/
|
|
20
|
+
export interface AgentLLMConfig {
|
|
21
|
+
/** Provider ID — which LLM provider to use (e.g. "anthropic", "openai", "gemini"). */
|
|
22
|
+
readonly provider?: string;
|
|
23
|
+
/** Model override (e.g. "claude-haiku-4-5-20251001", "gpt-4o-mini"). */
|
|
24
|
+
readonly model?: string;
|
|
25
|
+
/** Temperature (0 = deterministic, 1 = creative). */
|
|
26
|
+
readonly temperature?: number;
|
|
27
|
+
/** Max tokens for response. */
|
|
28
|
+
readonly maxTokens?: number;
|
|
29
|
+
/** Data source IDs this agent can access (layer filter by source). */
|
|
30
|
+
readonly sources?: string[];
|
|
31
|
+
/** Maximum call-chain depth to prevent infinite agent delegation. */
|
|
32
|
+
readonly maxDepth?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AgentConfig {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
stack: ContextStack;
|
|
38
|
+
layerFilter?: LayerFilter;
|
|
39
|
+
/** System prompt defining this agent's role. */
|
|
40
|
+
prompt?: string;
|
|
41
|
+
/** LLM configuration for this agent. */
|
|
42
|
+
llm?: AgentLLMConfig;
|
|
43
|
+
/** IDs of peer agents this agent knows about (for delegation). */
|
|
44
|
+
peers?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Base agent primitive.
|
|
49
|
+
*
|
|
50
|
+
* An agent has a context stack and receives payloads.
|
|
51
|
+
* Subclasses define what happens — Executors go do work,
|
|
52
|
+
* Deciders make decisions and return slim results.
|
|
53
|
+
*
|
|
54
|
+
* The agent doesn't own the stack — the Thread does.
|
|
55
|
+
* The agent gets a reference and a filter that scopes what it sees.
|
|
56
|
+
*/
|
|
57
|
+
export abstract class BaseAgent<TPayload = unknown, TResult = unknown> {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly prompt: string | undefined;
|
|
60
|
+
readonly llm: AgentLLMConfig | undefined;
|
|
61
|
+
readonly peers: string[];
|
|
62
|
+
protected _stack: ContextStack;
|
|
63
|
+
protected _layerFilter: LayerFilter | undefined;
|
|
64
|
+
|
|
65
|
+
constructor(config: AgentConfig) {
|
|
66
|
+
this.id = config.id;
|
|
67
|
+
this.prompt = config.prompt;
|
|
68
|
+
this.llm = config.llm;
|
|
69
|
+
this.peers = config.peers ?? [];
|
|
70
|
+
this._stack = config.stack;
|
|
71
|
+
this._layerFilter = config.layerFilter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getContext(): string {
|
|
75
|
+
return this._layerFilter
|
|
76
|
+
? this._stack.slice(this._layerFilter)
|
|
77
|
+
: this._stack.merge();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Assemble structured prompt blocks from agent prompt + layer prompts + content.
|
|
82
|
+
* Use this instead of getContext() when you want the full prompt-layer pairing.
|
|
83
|
+
*/
|
|
84
|
+
assembleContext(filterOverride?: LayerFilter): AssembledContext {
|
|
85
|
+
const effective = filterOverride ?? this._layerFilter;
|
|
86
|
+
return this._stack.assemble(this.prompt, effective);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getContextHash(): string {
|
|
90
|
+
return computeHash(this.getContext());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run this agent with a payload and optional per-dispatch layer filter override.
|
|
95
|
+
* If filterOverride is provided, it scopes context for this run only
|
|
96
|
+
* without mutating the agent's permanent filter.
|
|
97
|
+
* Meta carries runtime context (cwd, threadId) from the dispatching thread.
|
|
98
|
+
*/
|
|
99
|
+
abstract run(
|
|
100
|
+
payload: TPayload,
|
|
101
|
+
filterOverride?: LayerFilter,
|
|
102
|
+
meta?: Record<string, unknown>
|
|
103
|
+
): Promise<ExecutionResult<TResult>>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create an independent instance of this agent bound to another stack.
|
|
107
|
+
* Handler, prompt, LLM config, peers, and layer filter carry over; the
|
|
108
|
+
* original keeps its own stack. Used to give each thread its own agents.
|
|
109
|
+
*/
|
|
110
|
+
abstract withStack(stack: ContextStack): BaseAgent<TPayload, TResult>;
|
|
111
|
+
|
|
112
|
+
/** Config needed to re-instantiate this agent on a different stack. */
|
|
113
|
+
protected agentConfig(stack: ContextStack): AgentConfig {
|
|
114
|
+
return {
|
|
115
|
+
id: this.id,
|
|
116
|
+
stack,
|
|
117
|
+
layerFilter: this._layerFilter,
|
|
118
|
+
prompt: this.prompt,
|
|
119
|
+
llm: this.llm,
|
|
120
|
+
peers: [...this.peers],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setLayerFilter(filter: LayerFilter): void {
|
|
125
|
+
this._layerFilter = filter;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setStack(stack: ContextStack): void {
|
|
129
|
+
this._stack = stack;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get context with a temporary filter (doesn't mutate agent state). */
|
|
133
|
+
protected getContextWith(filter?: LayerFilter): string {
|
|
134
|
+
const effective = filter ?? this._layerFilter;
|
|
135
|
+
return effective ? this._stack.slice(effective) : this._stack.merge();
|
|
136
|
+
}
|
|
137
|
+
}
|