@tpsdev-ai/agent 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/bun.lock ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "@tpsdev-ai/agent",
7
+ "dependencies": {
8
+ "js-yaml": "^4.1.0",
9
+ "zod": "^3.24.0",
10
+ },
11
+ "devDependencies": {
12
+ "@biomejs/biome": "^2.4.4",
13
+ "@types/js-yaml": "^4.0.9",
14
+ "@types/node": "^22.0.0",
15
+ "typescript": "^5.7.0",
16
+ },
17
+ },
18
+ },
19
+ "packages": {
20
+ "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="],
21
+
22
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="],
23
+
24
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="],
25
+
26
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="],
27
+
28
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="],
29
+
30
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="],
31
+
32
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="],
33
+
34
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="],
35
+
36
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="],
37
+
38
+ "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
39
+
40
+ "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
41
+
42
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
43
+
44
+ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
45
+
46
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
47
+
48
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
49
+
50
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@tpsdev-ai/agent",
3
+ "version": "0.1.0",
4
+ "description": "Native TPS Agent Runtime — headless, mail-driven, nono-sandboxed",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "test": "bun test",
18
+ "lint": "biome lint ./src",
19
+ "lint:ci": "biome lint ./src --max-diagnostics=200"
20
+ },
21
+ "keywords": ["agents", "tps", "runtime", "mail-driven"],
22
+ "license": "Apache-2.0",
23
+ "dependencies": {
24
+ "js-yaml": "^4.1.0",
25
+ "zod": "^3.24.0"
26
+ },
27
+ "devDependencies": {
28
+ "@biomejs/biome": "^2.4.4",
29
+ "@types/js-yaml": "^4.0.9",
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5.7.0"
32
+ },
33
+ "author": "tpsdev-ai",
34
+ "publishConfig": { "access": "public" }
35
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * BoundaryManager discovers nono profile constraints at boot and communicates
3
+ * allowed capabilities to the ToolRegistry.
4
+ *
5
+ * NOTE: The system prompt is NOT a security boundary. Hard nono syscall/network
6
+ * filters and pre-execution checks in ToolRegistry are the actual enforcement.
7
+ */
8
+ export class BoundaryManager {
9
+ private allowedNetworkHosts: Set<string> = new Set();
10
+ private allowedPaths: Set<string> = new Set();
11
+
12
+ addNetworkHost(host: string): void {
13
+ this.allowedNetworkHosts.add(host);
14
+ }
15
+
16
+ addPath(path: string): void {
17
+ this.allowedPaths.add(path);
18
+ }
19
+
20
+ isNetworkAllowed(host: string): boolean {
21
+ return this.allowedNetworkHosts.has(host) || this.allowedNetworkHosts.has("*");
22
+ }
23
+
24
+ isPathAllowed(path: string): boolean {
25
+ for (const allowed of this.allowedPaths) {
26
+ if (path === allowed || path.startsWith(allowed + "/")) return true;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ /** Returns a human-readable capabilities summary for the LLM system prompt. */
32
+ describeCapabilities(): string {
33
+ const nets = [...this.allowedNetworkHosts].join(", ") || "none";
34
+ const paths = [...this.allowedPaths].join(", ") || "none";
35
+ return `Network access: ${nets}\nFilesystem access: ${paths}`;
36
+ }
37
+ }
@@ -0,0 +1,34 @@
1
+ import type { MailClient } from "../io/mail.js";
2
+
3
+ /** Actions that must pause for human-in-the-loop approval. */
4
+ const HIGH_RISK_ACTIONS = new Set([
5
+ "git_push",
6
+ "package_install",
7
+ "file_delete",
8
+ "exec_privileged",
9
+ ]);
10
+
11
+ export class ReviewGate {
12
+ constructor(
13
+ private readonly mail: MailClient,
14
+ private readonly approverAddress: string
15
+ ) {}
16
+
17
+ isHighRisk(toolName: string): boolean {
18
+ return HIGH_RISK_ACTIONS.has(toolName);
19
+ }
20
+
21
+ async requestApproval(toolName: string, args: Record<string, unknown>): Promise<void> {
22
+ const body = JSON.stringify(
23
+ {
24
+ type: "approval_request",
25
+ tool: toolName,
26
+ args,
27
+ requestedAt: new Date().toISOString(),
28
+ },
29
+ null,
30
+ 2
31
+ );
32
+ await this.mail.sendMail(this.approverAddress, body);
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Public API for @tpsdev-ai/agent
2
+
3
+ // Runtime
4
+ export { AgentRuntime } from "./runtime/agent.js";
5
+ export { EventLoop } from "./runtime/event-loop.js";
6
+ export type { AgentConfig, LLMConfig, AgentState } from "./runtime/types.js";
7
+
8
+ // I/O
9
+ export { MailClient } from "./io/mail.js";
10
+ export type { MailMessage } from "./io/mail.js";
11
+ export { MemoryStore } from "./io/memory.js";
12
+ export type { MemoryEvent } from "./io/memory.js";
13
+ export { ContextManager } from "./io/context.js";
14
+
15
+ // LLM
16
+ export { ProviderManager } from "./llm/provider.js";
17
+ export type { CompletionRequest, CompletionResponse } from "./llm/provider.js";
18
+
19
+ // Tools
20
+ export { ToolRegistry } from "./tools/registry.js";
21
+ export type { Tool } from "./tools/registry.js";
22
+
23
+ // Governance
24
+ export { BoundaryManager } from "./governance/boundary.js";
25
+ export { ReviewGate } from "./governance/review-gate.js";
@@ -0,0 +1,40 @@
1
+ import type { MemoryStore, MemoryEvent } from "./memory.js";
2
+
3
+ /**
4
+ * Sliding window context manager with compaction.
5
+ *
6
+ * When token usage approaches the configured window, the oldest 50%
7
+ * of events are summarised into a dense state blob to preserve headroom
8
+ * without losing history.
9
+ */
10
+ export class ContextManager {
11
+ constructor(
12
+ private readonly memory: MemoryStore,
13
+ private readonly windowTokens: number
14
+ ) {}
15
+
16
+ /** Return recent events that fit within the token window. */
17
+ getWindow(): MemoryEvent[] {
18
+ const all = this.memory.readAll();
19
+ if (all.length === 0) return [];
20
+
21
+ // Simple sliding-window: walk from newest, accumulate until budget
22
+ const budget = this.windowTokens * 4; // chars ≈ tokens * 4
23
+ let budget_remaining = budget;
24
+ const window: MemoryEvent[] = [];
25
+
26
+ for (let i = all.length - 1; i >= 0; i--) {
27
+ const serialized = JSON.stringify(all[i]);
28
+ if (serialized.length > budget_remaining) break;
29
+ window.unshift(all[i]!);
30
+ budget_remaining -= serialized.length;
31
+ }
32
+
33
+ return window;
34
+ }
35
+
36
+ /** Returns true if compaction is needed (>80% of window used). */
37
+ needsCompaction(): boolean {
38
+ return this.memory.estimatedTokenCount() > this.windowTokens * 0.8;
39
+ }
40
+ }
package/src/io/mail.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readdirSync, renameSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface MailMessage {
5
+ filename: string;
6
+ body: string;
7
+ receivedAt: Date;
8
+ }
9
+
10
+ /**
11
+ * Maildir-compatible mail client.
12
+ * Reads from mailDir/inbox/new and moves processed messages to mailDir/inbox/cur.
13
+ * Writes outgoing mail to mailDir/outbox/new.
14
+ */
15
+ export class MailClient {
16
+ private inboxNew: string;
17
+ private inboxCur: string;
18
+ private outboxNew: string;
19
+
20
+ constructor(public readonly mailDir: string) {
21
+ this.inboxNew = join(mailDir, "inbox", "new");
22
+ this.inboxCur = join(mailDir, "inbox", "cur");
23
+ this.outboxNew = join(mailDir, "outbox", "new");
24
+ // Ensure directories exist
25
+ for (const dir of [this.inboxNew, this.inboxCur, this.outboxNew]) {
26
+ mkdirSync(dir, { recursive: true });
27
+ }
28
+ }
29
+
30
+ /** Return all messages in inbox/new and move them to inbox/cur. */
31
+ async checkNewMail(): Promise<MailMessage[]> {
32
+ if (!existsSync(this.inboxNew)) return [];
33
+
34
+ const files = readdirSync(this.inboxNew).filter((f) => !f.startsWith(".") && !f.includes("/") && !f.includes("\\"));
35
+ const messages: MailMessage[] = [];
36
+
37
+ for (const file of files) {
38
+ const srcPath = join(this.inboxNew, file);
39
+ const dstPath = join(this.inboxCur, file);
40
+ const body = readFileSync(srcPath, "utf-8");
41
+ renameSync(srcPath, dstPath);
42
+ messages.push({ filename: file, body, receivedAt: new Date() });
43
+ }
44
+
45
+ return messages;
46
+ }
47
+
48
+ /** Write a message to outbox/new for relay delivery. */
49
+ async sendMail(to: string, body: string): Promise<void> {
50
+ const { writeFileSync } = await import("node:fs");
51
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
52
+ writeFileSync(
53
+ join(this.outboxNew, filename),
54
+ JSON.stringify({ to, body, sentAt: new Date().toISOString() }, null, 2),
55
+ "utf-8"
56
+ );
57
+ }
58
+ }
@@ -0,0 +1,59 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ export interface MemoryEvent {
5
+ type: string;
6
+ ts: string;
7
+ data: unknown;
8
+ }
9
+
10
+ /**
11
+ * Append-only JSONL memory store. Each event is a line of JSON.
12
+ * Provides full audit trail of agent actions and LLM interactions.
13
+ */
14
+ export class MemoryStore {
15
+ constructor(public readonly memoryPath: string) {
16
+ mkdirSync(dirname(memoryPath), { recursive: true });
17
+ }
18
+
19
+ append(event: MemoryEvent): void {
20
+ appendFileSync(this.memoryPath, JSON.stringify(event) + "\n", "utf-8");
21
+ }
22
+
23
+ readAll(maxBytes = 1024 * 1024): MemoryEvent[] {
24
+ if (!existsSync(this.memoryPath)) return [];
25
+
26
+ const stat = statSync(this.memoryPath);
27
+ if (stat.size === 0) return [];
28
+
29
+ const size = Math.min(stat.size, maxBytes);
30
+ const pos = Math.max(0, stat.size - size);
31
+
32
+ const fd = openSync(this.memoryPath, "r");
33
+ const buffer = Buffer.alloc(size);
34
+ readSync(fd, buffer, 0, size, pos);
35
+ closeSync(fd);
36
+
37
+ const content = buffer.toString("utf-8");
38
+ const lines = content.split("\n");
39
+ if (pos > 0) lines.shift(); // drop partial first line
40
+
41
+ return lines
42
+ .filter(Boolean)
43
+ .map((line) => {
44
+ try {
45
+ return JSON.parse(line) as MemoryEvent;
46
+ } catch {
47
+ return null;
48
+ }
49
+ })
50
+ .filter(Boolean) as MemoryEvent[];
51
+ }
52
+
53
+ /** Count approximate token length (4 chars ≈ 1 token) */
54
+ estimatedTokenCount(): number {
55
+ if (!existsSync(this.memoryPath)) return 0;
56
+ const stat = statSync(this.memoryPath);
57
+ return Math.ceil(stat.size / 4);
58
+ }
59
+ }
@@ -0,0 +1,126 @@
1
+ import type { LLMConfig } from "../runtime/types.js";
2
+
3
+ export interface CompletionRequest {
4
+ systemPrompt?: string;
5
+ messages: Array<{ role: "user" | "assistant"; content: string }>;
6
+ maxTokens?: number;
7
+ }
8
+
9
+ export interface CompletionResponse {
10
+ content: string;
11
+ inputTokens: number;
12
+ outputTokens: number;
13
+ }
14
+
15
+ /**
16
+ * Routes completion requests to the appropriate provider.
17
+ * Supports Anthropic, Google, and local Ollama.
18
+ */
19
+ export class ProviderManager {
20
+ constructor(private readonly config: LLMConfig) {}
21
+
22
+ async complete(request: CompletionRequest): Promise<CompletionResponse> {
23
+ switch (this.config.provider) {
24
+ case "anthropic":
25
+ return this.completeAnthropic(request);
26
+ case "google":
27
+ return this.completeGoogle(request);
28
+ case "ollama":
29
+ return this.completeOllama(request);
30
+ default:
31
+ throw new Error(`Unsupported provider: ${this.config.provider}`);
32
+ }
33
+ }
34
+
35
+ private async completeAnthropic(request: CompletionRequest): Promise<CompletionResponse> {
36
+ const apiKey = this.config.apiKey ?? process.env.ANTHROPIC_API_KEY;
37
+ if (!apiKey) throw new Error("ANTHROPIC_API_KEY not set");
38
+
39
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
40
+ method: "POST",
41
+ headers: {
42
+ "Content-Type": "application/json",
43
+ "x-api-key": apiKey,
44
+ "anthropic-version": "2023-06-01",
45
+ },
46
+ body: JSON.stringify({
47
+ model: this.config.model,
48
+ max_tokens: request.maxTokens ?? 2048,
49
+ system: request.systemPrompt,
50
+ messages: request.messages,
51
+ }),
52
+ });
53
+
54
+ if (!res.ok) {
55
+ throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
56
+ }
57
+
58
+ const data = (await res.json()) as any;
59
+ return {
60
+ content: data.content?.[0]?.text ?? "",
61
+ inputTokens: data.usage?.input_tokens ?? 0,
62
+ outputTokens: data.usage?.output_tokens ?? 0,
63
+ };
64
+ }
65
+
66
+ private async completeGoogle(request: CompletionRequest): Promise<CompletionResponse> {
67
+ const apiKey = this.config.apiKey ?? process.env.GOOGLE_API_KEY;
68
+ if (!apiKey) throw new Error("GOOGLE_API_KEY not set");
69
+
70
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.config.model}:generateContent?key=${apiKey}`;
71
+ const res = await fetch(url, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({
75
+ contents: request.messages.map((m) => ({
76
+ role: m.role === "assistant" ? "model" : "user",
77
+ parts: [{ text: m.content }],
78
+ })),
79
+ systemInstruction: request.systemPrompt
80
+ ? { parts: [{ text: request.systemPrompt }] }
81
+ : undefined,
82
+ }),
83
+ });
84
+
85
+ if (!res.ok) {
86
+ throw new Error(`Google API error: ${res.status} ${await res.text()}`);
87
+ }
88
+
89
+ const data = (await res.json()) as any;
90
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
91
+ const usage = data.usageMetadata ?? {};
92
+ return {
93
+ content: text,
94
+ inputTokens: usage.promptTokenCount ?? 0,
95
+ outputTokens: usage.candidatesTokenCount ?? 0,
96
+ };
97
+ }
98
+
99
+ private async completeOllama(request: CompletionRequest): Promise<CompletionResponse> {
100
+ const baseUrl = this.config.baseUrl ?? "http://localhost:11434";
101
+ const systemMsg = request.systemPrompt
102
+ ? [{ role: "system" as const, content: request.systemPrompt }]
103
+ : [];
104
+
105
+ const res = await fetch(`${baseUrl}/api/chat`, {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({
109
+ model: this.config.model,
110
+ messages: [...systemMsg, ...request.messages],
111
+ stream: false,
112
+ }),
113
+ });
114
+
115
+ if (!res.ok) {
116
+ throw new Error(`Ollama API error: ${res.status} ${await res.text()}`);
117
+ }
118
+
119
+ const data = (await res.json()) as any;
120
+ return {
121
+ content: data.message?.content ?? "",
122
+ inputTokens: data.prompt_eval_count ?? 0,
123
+ outputTokens: data.eval_count ?? 0,
124
+ };
125
+ }
126
+ }
@@ -0,0 +1,26 @@
1
+ import type { AgentConfig } from "./types.js";
2
+ import { EventLoop } from "./event-loop.js";
3
+ import { MailClient } from "../io/mail.js";
4
+ import { MemoryStore } from "../io/memory.js";
5
+ import { ContextManager } from "../io/context.js";
6
+ import { ProviderManager } from "../llm/provider.js";
7
+
8
+ export class AgentRuntime {
9
+ private loop: EventLoop;
10
+
11
+ constructor(public readonly config: AgentConfig) {
12
+ const mail = new MailClient(config.mailDir);
13
+ const memory = new MemoryStore(config.memoryPath);
14
+ const context = new ContextManager(memory, config.contextWindowTokens ?? 8_000);
15
+ const provider = new ProviderManager(config.llm);
16
+ this.loop = new EventLoop({ config, mail, memory, context, provider });
17
+ }
18
+
19
+ async start(): Promise<void> {
20
+ await this.loop.run();
21
+ }
22
+
23
+ async stop(): Promise<void> {
24
+ await this.loop.stop();
25
+ }
26
+ }
@@ -0,0 +1,76 @@
1
+ import type { AgentConfig, AgentState } from "./types.js";
2
+ import type { MailClient } from "../io/mail.js";
3
+ import type { MemoryStore } from "../io/memory.js";
4
+ import type { ContextManager } from "../io/context.js";
5
+ import type { ProviderManager } from "../llm/provider.js";
6
+
7
+ interface EventLoopDeps {
8
+ config: AgentConfig;
9
+ mail: MailClient;
10
+ memory: MemoryStore;
11
+ context: ContextManager;
12
+ provider: ProviderManager;
13
+ }
14
+
15
+ export class EventLoop {
16
+ private state: AgentState = "idle";
17
+ private running = false;
18
+
19
+ constructor(private readonly deps: EventLoopDeps) {}
20
+
21
+ async run(): Promise<void> {
22
+ this.running = true;
23
+ this.state = "idle";
24
+
25
+ while (this.running) {
26
+ const messages = await this.deps.mail.checkNewMail();
27
+
28
+ if (messages.length === 0) {
29
+ // No work — sleep briefly before next poll
30
+ await sleep(500);
31
+ continue;
32
+ }
33
+
34
+ for (const msg of messages) {
35
+ if (!this.running) break;
36
+
37
+ this.state = "processing";
38
+
39
+ try {
40
+ await this.processMessage(msg);
41
+ } catch (err) {
42
+ // Log errors to memory and continue rather than crashing the loop
43
+ await this.deps.memory.append({
44
+ type: "error",
45
+ ts: new Date().toISOString(),
46
+ data: String(err),
47
+ });
48
+ }
49
+
50
+ this.state = "idle";
51
+ }
52
+ }
53
+ }
54
+
55
+ async stop(): Promise<void> {
56
+ this.running = false;
57
+ this.state = "stopped";
58
+ }
59
+
60
+ getState(): AgentState {
61
+ return this.state;
62
+ }
63
+
64
+ private async processMessage(msg: unknown): Promise<void> {
65
+ // Stub: real implementation builds prompt, calls LLM, handles tool calls
66
+ await this.deps.memory.append({
67
+ type: "message",
68
+ ts: new Date().toISOString(),
69
+ data: msg,
70
+ });
71
+ }
72
+ }
73
+
74
+ function sleep(ms: number): Promise<void> {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
@@ -0,0 +1,30 @@
1
+ export interface LLMConfig {
2
+ provider: "anthropic" | "google" | "ollama";
3
+ model: string;
4
+ apiKey?: string;
5
+ baseUrl?: string;
6
+ }
7
+
8
+ export interface AgentConfig {
9
+ /** Agent identifier from tps.yaml */
10
+ agentId: string;
11
+ /** Human-readable name */
12
+ name: string;
13
+ /** Maildir root: ~/mail/inbox, ~/mail/outbox */
14
+ mailDir: string;
15
+ /** JSONL memory file path */
16
+ memoryPath: string;
17
+ /** Target token context window (for compaction) */
18
+ contextWindowTokens?: number;
19
+ /** LLM provider config */
20
+ llm: LLMConfig;
21
+ /** System prompt override */
22
+ systemPrompt?: string;
23
+ }
24
+
25
+ /** Runtime state machine states */
26
+ export type AgentState =
27
+ | "idle"
28
+ | "processing"
29
+ | "awaiting_approval"
30
+ | "stopped";
@@ -0,0 +1,33 @@
1
+ export interface Tool {
2
+ name: string;
3
+ description: string;
4
+ parameters: Record<string, { type: string; description: string; required?: boolean }>;
5
+ execute(args: Record<string, unknown>): Promise<string>;
6
+ }
7
+
8
+ /**
9
+ * Central registry for native TPS agent tools.
10
+ * Only tools explicitly registered (and allowed by the nono profile)
11
+ * are exposed to the LLM.
12
+ */
13
+ export class ToolRegistry {
14
+ private tools = new Map<string, Tool>();
15
+
16
+ register(tool: Tool): void {
17
+ this.tools.set(tool.name, tool);
18
+ }
19
+
20
+ get(name: string): Tool | undefined {
21
+ return this.tools.get(name);
22
+ }
23
+
24
+ list(): Tool[] {
25
+ return Array.from(this.tools.values());
26
+ }
27
+
28
+ async execute(name: string, args: Record<string, unknown>): Promise<string> {
29
+ const tool = this.tools.get(name);
30
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
31
+ return tool.execute(args);
32
+ }
33
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { BoundaryManager } from "../src/governance/boundary.js";
6
+ import { ReviewGate } from "../src/governance/review-gate.js";
7
+ import { MailClient } from "../src/io/mail.js";
8
+ import { ToolRegistry } from "../src/tools/registry.js";
9
+
10
+ describe("BoundaryManager", () => {
11
+ test("allows registered network host", () => {
12
+ const mgr = new BoundaryManager();
13
+ mgr.addNetworkHost("api.anthropic.com");
14
+ expect(mgr.isNetworkAllowed("api.anthropic.com")).toBe(true);
15
+ expect(mgr.isNetworkAllowed("evil.com")).toBe(false);
16
+ });
17
+
18
+ test("wildcard allows any host", () => {
19
+ const mgr = new BoundaryManager();
20
+ mgr.addNetworkHost("*");
21
+ expect(mgr.isNetworkAllowed("api.anthropic.com")).toBe(true);
22
+ });
23
+
24
+ test("allows registered path prefix", () => {
25
+ const mgr = new BoundaryManager();
26
+ mgr.addPath("/workspace");
27
+ expect(mgr.isPathAllowed("/workspace/src")).toBe(true);
28
+ expect(mgr.isPathAllowed("/etc/passwd")).toBe(false);
29
+ });
30
+
31
+ test("describeCapabilities returns readable string", () => {
32
+ const mgr = new BoundaryManager();
33
+ mgr.addNetworkHost("api.anthropic.com");
34
+ mgr.addPath("/workspace");
35
+ const desc = mgr.describeCapabilities();
36
+ expect(desc).toContain("api.anthropic.com");
37
+ expect(desc).toContain("/workspace");
38
+ });
39
+ });
40
+
41
+ describe("ReviewGate", () => {
42
+ let tmpDir: string;
43
+ let mail: MailClient;
44
+
45
+ beforeEach(() => {
46
+ tmpDir = mkdtempSync(join(tmpdir(), "tps-gate-test-"));
47
+ mail = new MailClient(tmpDir);
48
+ });
49
+
50
+ afterEach(() => {
51
+ rmSync(tmpDir, { recursive: true, force: true });
52
+ });
53
+
54
+ test("identifies high-risk tools", () => {
55
+ const gate = new ReviewGate(mail, "host@tps");
56
+ expect(gate.isHighRisk("git_push")).toBe(true);
57
+ expect(gate.isHighRisk("file_delete")).toBe(true);
58
+ expect(gate.isHighRisk("fs_read")).toBe(false);
59
+ });
60
+
61
+ test("requestApproval sends mail to approver", async () => {
62
+ const gate = new ReviewGate(mail, "host@tps");
63
+ await gate.requestApproval("git_push", { branch: "main" });
64
+ const { readdirSync } = await import("node:fs");
65
+ const outbox = join(tmpDir, "outbox", "new");
66
+ expect(readdirSync(outbox).length).toBe(1);
67
+ });
68
+ });
69
+
70
+ describe("ToolRegistry", () => {
71
+ test("registers and executes a tool", async () => {
72
+ const registry = new ToolRegistry();
73
+ registry.register({
74
+ name: "echo",
75
+ description: "Echo input",
76
+ parameters: { input: { type: "string", description: "text to echo" } },
77
+ async execute(args) {
78
+ return String(args.input);
79
+ },
80
+ });
81
+
82
+ const result = await registry.execute("echo", { input: "hello" });
83
+ expect(result).toBe("hello");
84
+ });
85
+
86
+ test("throws for unknown tool", async () => {
87
+ const registry = new ToolRegistry();
88
+ await expect(registry.execute("noop", {})).rejects.toThrow("Unknown tool: noop");
89
+ });
90
+
91
+ test("lists registered tools", () => {
92
+ const registry = new ToolRegistry();
93
+ registry.register({
94
+ name: "echo",
95
+ description: "Echo input",
96
+ parameters: {},
97
+ async execute() { return ""; },
98
+ });
99
+ expect(registry.list().length).toBe(1);
100
+ expect(registry.list()[0]!.name).toBe("echo");
101
+ });
102
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, join as pathJoin } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { MailClient } from "../src/io/mail.js";
6
+ import { MemoryStore } from "../src/io/memory.js";
7
+ import { ContextManager } from "../src/io/context.js";
8
+
9
+ describe("MailClient", () => {
10
+ let tmpDir: string;
11
+ let client: MailClient;
12
+
13
+ beforeEach(() => {
14
+ tmpDir = mkdtempSync(join(tmpdir(), "tps-mail-test-"));
15
+ client = new MailClient(tmpDir);
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(tmpDir, { recursive: true, force: true });
20
+ });
21
+
22
+ test("creates required maildir directories on construction", () => {
23
+ const { existsSync } = require("node:fs");
24
+ expect(existsSync(join(tmpDir, "inbox", "new"))).toBe(true);
25
+ expect(existsSync(join(tmpDir, "inbox", "cur"))).toBe(true);
26
+ expect(existsSync(join(tmpDir, "outbox", "new"))).toBe(true);
27
+ });
28
+
29
+ test("checkNewMail returns empty when inbox is empty", async () => {
30
+ const msgs = await client.checkNewMail();
31
+ expect(msgs).toEqual([]);
32
+ });
33
+
34
+ test("checkNewMail returns and moves messages from new to cur", async () => {
35
+ const { writeFileSync } = await import("node:fs");
36
+ writeFileSync(join(tmpDir, "inbox", "new", "test-1.json"), "hello world", "utf-8");
37
+
38
+ const msgs = await client.checkNewMail();
39
+ expect(msgs.length).toBe(1);
40
+ expect(msgs[0]!.body).toBe("hello world");
41
+
42
+ // File should now be in cur
43
+ const { existsSync } = await import("node:fs");
44
+ expect(existsSync(join(tmpDir, "inbox", "new", "test-1.json"))).toBe(false);
45
+ expect(existsSync(join(tmpDir, "inbox", "cur", "test-1.json"))).toBe(true);
46
+ });
47
+
48
+ test("sendMail writes a file to outbox/new", async () => {
49
+ await client.sendMail("host@tps", "hello from agent");
50
+
51
+ const { readdirSync } = await import("node:fs");
52
+ const files = readdirSync(join(tmpDir, "outbox", "new"));
53
+ expect(files.length).toBe(1);
54
+ });
55
+ });
56
+
57
+ describe("MemoryStore", () => {
58
+ let tmpDir: string;
59
+
60
+ beforeEach(() => {
61
+ tmpDir = mkdtempSync(join(tmpdir(), "tps-mem-test-"));
62
+ });
63
+
64
+ afterEach(() => {
65
+ rmSync(tmpDir, { recursive: true, force: true });
66
+ });
67
+
68
+ test("append and readAll round-trip", () => {
69
+ const store = new MemoryStore(join(tmpDir, "memory.jsonl"));
70
+ store.append({ type: "test", ts: "2025-01-01T00:00:00Z", data: "hello" });
71
+ const all = store.readAll();
72
+ expect(all.length).toBe(1);
73
+ expect(all[0]!.type).toBe("test");
74
+ expect(all[0]!.data).toBe("hello");
75
+ });
76
+
77
+ test("readAll returns empty array for missing file", () => {
78
+ const store = new MemoryStore(join(tmpDir, "missing.jsonl"));
79
+ expect(store.readAll()).toEqual([]);
80
+ });
81
+ });
82
+
83
+ describe("ContextManager", () => {
84
+ let tmpDir: string;
85
+
86
+ beforeEach(() => {
87
+ tmpDir = mkdtempSync(join(tmpdir(), "tps-ctx-test-"));
88
+ });
89
+
90
+ afterEach(() => {
91
+ rmSync(tmpDir, { recursive: true, force: true });
92
+ });
93
+
94
+ test("getWindow returns empty for empty memory", () => {
95
+ const store = new MemoryStore(join(tmpDir, "mem.jsonl"));
96
+ const ctx = new ContextManager(store, 1000);
97
+ expect(ctx.getWindow()).toEqual([]);
98
+ });
99
+
100
+ test("needsCompaction is false for empty memory", () => {
101
+ const store = new MemoryStore(join(tmpDir, "mem.jsonl"));
102
+ const ctx = new ContextManager(store, 1000);
103
+ expect(ctx.needsCompaction()).toBe(false);
104
+ });
105
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { AgentRuntime } from "../src/runtime/agent.js";
6
+ import type { AgentConfig } from "../src/runtime/types.js";
7
+
8
+ describe("AgentRuntime", () => {
9
+ let tmpDir: string;
10
+ let config: AgentConfig;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = mkdtempSync(join(tmpdir(), "tps-agent-test-"));
14
+ config = {
15
+ agentId: "test-agent",
16
+ name: "Test Agent",
17
+ mailDir: join(tmpDir, "mail"),
18
+ memoryPath: join(tmpDir, "memory.jsonl"),
19
+ contextWindowTokens: 1000,
20
+ llm: { provider: "anthropic", model: "claude-3-haiku-20240307" },
21
+ };
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ test("can be instantiated with a valid config", () => {
29
+ const runtime = new AgentRuntime(config);
30
+ expect(runtime).toBeDefined();
31
+ expect(runtime.config.agentId).toBe("test-agent");
32
+ expect(runtime.config.name).toBe("Test Agent");
33
+ });
34
+
35
+ test("start() returns when stop() is called", async () => {
36
+ const runtime = new AgentRuntime(config);
37
+
38
+ // Start and stop after 50ms to avoid infinite loop
39
+ const startPromise = runtime.start();
40
+ setTimeout(() => runtime.stop(), 50);
41
+ await startPromise;
42
+ // Just verify it completes without throwing
43
+ expect(true).toBe(true);
44
+ });
45
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist", "test"]
19
+ }