@inerrata/channel 0.3.6 → 0.3.7

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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * API fetch helper — wraps fetch with auth headers for the inErrata API.
3
+ * GET requests are cached with an LRU strategy + per-prefix TTLs.
4
+ */
5
+ declare const MCP_BASE: string;
6
+ declare const API_KEY: string;
7
+ export { MCP_BASE, API_KEY };
8
+ /** Invalidate cache entries matching a base resource path. */
9
+ export declare function invalidateCache(path?: string): void;
10
+ /** Cache stats for diagnostics. */
11
+ export declare function cacheStats(): {
12
+ size: number;
13
+ };
14
+ export declare function apiFetch(path: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * API fetch helper — wraps fetch with auth headers for the inErrata API.
3
+ * GET requests are cached with an LRU strategy + per-prefix TTLs.
4
+ */
5
+ const MCP_BASE = (process.env.ERRATA_API_URL ?? 'https://inerrata-production.up.railway.app').replace('/api/v1', '');
6
+ const API_BASE = MCP_BASE + '/api/v1';
7
+ const API_KEY = process.env.ERRATA_API_KEY ?? '';
8
+ export { MCP_BASE, API_KEY };
9
+ const MAX_CACHE_ENTRIES = 100;
10
+ const cache = new Map();
11
+ /** TTL in ms by path prefix (longest prefix wins). Fallback: 30 min */
12
+ const TTL_BY_PREFIX = [
13
+ ['/messages', 60_000], // 1 min — messages change fast
14
+ ['/search', 15 * 60_000], // 15 min
15
+ ['/graph', 10 * 60_000], // 10 min
16
+ ['/questions', 60 * 60_000], // 60 min — questions are stable
17
+ ['/me', 5 * 60_000], // 5 min
18
+ ['/messages/connections', 60_000], // 1 min — presence changes
19
+ ];
20
+ const DEFAULT_TTL = 30 * 60_000;
21
+ function ttlFor(path) {
22
+ for (const [prefix, ttl] of TTL_BY_PREFIX) {
23
+ if (path.startsWith(prefix))
24
+ return ttl;
25
+ }
26
+ return DEFAULT_TTL;
27
+ }
28
+ function evictOldest() {
29
+ if (cache.size < MAX_CACHE_ENTRIES)
30
+ return;
31
+ const oldest = cache.keys().next().value;
32
+ if (oldest !== undefined)
33
+ cache.delete(oldest);
34
+ }
35
+ /** Invalidate cache entries matching a base resource path. */
36
+ export function invalidateCache(path) {
37
+ if (!path) {
38
+ cache.clear();
39
+ return;
40
+ }
41
+ const base = '/' + path.split('/').filter(Boolean).slice(0, 1).join('/');
42
+ for (const key of cache.keys()) {
43
+ if (key.startsWith(base))
44
+ cache.delete(key);
45
+ }
46
+ }
47
+ /** Cache stats for diagnostics. */
48
+ export function cacheStats() {
49
+ return { size: cache.size };
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Core fetch
53
+ // ---------------------------------------------------------------------------
54
+ export async function apiFetch(path, init) {
55
+ const method = (init?.method ?? 'GET').toUpperCase();
56
+ const isGet = method === 'GET';
57
+ // Check cache for GETs
58
+ if (isGet) {
59
+ const entry = cache.get(path);
60
+ if (entry && Date.now() < entry.expiresAt) {
61
+ return new Response(entry.body, {
62
+ status: entry.status,
63
+ headers: entry.headers,
64
+ });
65
+ }
66
+ if (entry)
67
+ cache.delete(path); // expired
68
+ }
69
+ const res = await fetch(`${API_BASE}${path}`, {
70
+ ...init,
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ Authorization: `Bearer ${API_KEY}`,
74
+ ...(init?.headers ?? {}),
75
+ },
76
+ });
77
+ // Cache successful GET responses
78
+ if (isGet && res.ok) {
79
+ const body = await res.text();
80
+ const headers = [...res.headers.entries()];
81
+ evictOldest();
82
+ cache.set(path, {
83
+ body,
84
+ status: res.status,
85
+ headers,
86
+ expiresAt: Date.now() + ttlFor(path),
87
+ });
88
+ // Return a new Response from the cached body (original stream consumed)
89
+ return new Response(body, { status: res.status, headers });
90
+ }
91
+ // Invalidate related cache on writes
92
+ if (!isGet && res.ok) {
93
+ invalidateCache(path);
94
+ }
95
+ return res;
96
+ }
@@ -0,0 +1,64 @@
1
+ export interface CacheEntry<T = unknown> {
2
+ key: string;
3
+ value: T;
4
+ expiresAt: number;
5
+ createdAt: number;
6
+ }
7
+ export interface LocalCacheOptions {
8
+ maxEntries?: number;
9
+ defaultTtlMs?: number;
10
+ persistPath?: string;
11
+ }
12
+ export declare class LocalCache {
13
+ private entries;
14
+ private maxEntries;
15
+ private defaultTtlMs;
16
+ private persistPath;
17
+ constructor(opts?: LocalCacheOptions);
18
+ /**
19
+ * Get a cached value. Returns undefined on miss or expiry.
20
+ */
21
+ get<T = unknown>(key: string): T | undefined;
22
+ /**
23
+ * Store a value with optional custom TTL.
24
+ */
25
+ set<T = unknown>(key: string, value: T, ttlMs?: number): void;
26
+ /**
27
+ * Check if a key exists and is not expired.
28
+ */
29
+ has(key: string): boolean;
30
+ /**
31
+ * Remove a specific key.
32
+ */
33
+ delete(key: string): boolean;
34
+ /**
35
+ * Clear all entries.
36
+ */
37
+ clear(): void;
38
+ /**
39
+ * Get cache stats.
40
+ */
41
+ stats(): {
42
+ size: number;
43
+ maxEntries: number;
44
+ hitRate?: number;
45
+ };
46
+ /**
47
+ * Remove all expired entries.
48
+ */
49
+ prune(): number;
50
+ /**
51
+ * Persist cache to disk (if persistPath configured).
52
+ */
53
+ saveToDisk(): void;
54
+ /**
55
+ * Load cache from disk (if persistPath configured).
56
+ */
57
+ private loadFromDisk;
58
+ }
59
+ /** Search result cache — 30 min TTL, 500 entries */
60
+ export declare function createSearchCache(persistPath?: string): LocalCache;
61
+ /** Question/answer cache — 60 min TTL, 200 entries */
62
+ export declare function createQuestionCache(persistPath?: string): LocalCache;
63
+ /** Graph traversal cache — 15 min TTL, 100 entries (graph changes more often) */
64
+ export declare function createGraphCache(persistPath?: string): LocalCache;
package/dist/cache.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Local result cache for the MCP channel client.
3
+ *
4
+ * Caches search results and question lookups in memory with optional
5
+ * filesystem persistence. Agents get instant responses for repeated
6
+ * queries without network round-trips.
7
+ *
8
+ * Design:
9
+ * - LRU eviction (default 500 entries)
10
+ * - TTL per entry (default 30 min for search, 60 min for questions)
11
+ * - Optional disk persistence via JSON file (survives agent restarts)
12
+ * - Cache-first: hit → return cached. Miss → fetch → cache → return.
13
+ */
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+ export class LocalCache {
17
+ entries = new Map();
18
+ maxEntries;
19
+ defaultTtlMs;
20
+ persistPath;
21
+ constructor(opts = {}) {
22
+ this.maxEntries = opts.maxEntries ?? 500;
23
+ this.defaultTtlMs = opts.defaultTtlMs ?? 30 * 60 * 1000; // 30 min
24
+ this.persistPath = opts.persistPath ?? null;
25
+ if (this.persistPath) {
26
+ this.loadFromDisk();
27
+ }
28
+ }
29
+ /**
30
+ * Get a cached value. Returns undefined on miss or expiry.
31
+ */
32
+ get(key) {
33
+ const entry = this.entries.get(key);
34
+ if (!entry)
35
+ return undefined;
36
+ if (Date.now() > entry.expiresAt) {
37
+ this.entries.delete(key);
38
+ return undefined;
39
+ }
40
+ // Move to end (LRU refresh)
41
+ this.entries.delete(key);
42
+ this.entries.set(key, entry);
43
+ return entry.value;
44
+ }
45
+ /**
46
+ * Store a value with optional custom TTL.
47
+ */
48
+ set(key, value, ttlMs) {
49
+ // Evict oldest if at capacity
50
+ if (this.entries.size >= this.maxEntries) {
51
+ const oldest = this.entries.keys().next().value;
52
+ if (oldest !== undefined)
53
+ this.entries.delete(oldest);
54
+ }
55
+ this.entries.set(key, {
56
+ key,
57
+ value,
58
+ createdAt: Date.now(),
59
+ expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
60
+ });
61
+ }
62
+ /**
63
+ * Check if a key exists and is not expired.
64
+ */
65
+ has(key) {
66
+ return this.get(key) !== undefined;
67
+ }
68
+ /**
69
+ * Remove a specific key.
70
+ */
71
+ delete(key) {
72
+ return this.entries.delete(key);
73
+ }
74
+ /**
75
+ * Clear all entries.
76
+ */
77
+ clear() {
78
+ this.entries.clear();
79
+ }
80
+ /**
81
+ * Get cache stats.
82
+ */
83
+ stats() {
84
+ return {
85
+ size: this.entries.size,
86
+ maxEntries: this.maxEntries,
87
+ };
88
+ }
89
+ /**
90
+ * Remove all expired entries.
91
+ */
92
+ prune() {
93
+ const now = Date.now();
94
+ let pruned = 0;
95
+ for (const [key, entry] of this.entries) {
96
+ if (now > entry.expiresAt) {
97
+ this.entries.delete(key);
98
+ pruned++;
99
+ }
100
+ }
101
+ return pruned;
102
+ }
103
+ /**
104
+ * Persist cache to disk (if persistPath configured).
105
+ */
106
+ saveToDisk() {
107
+ if (!this.persistPath)
108
+ return;
109
+ try {
110
+ const dir = dirname(this.persistPath);
111
+ if (!existsSync(dir))
112
+ mkdirSync(dir, { recursive: true });
113
+ // Only persist non-expired entries
114
+ const now = Date.now();
115
+ const data = Array.from(this.entries.values()).filter(e => e.expiresAt > now);
116
+ writeFileSync(this.persistPath, JSON.stringify(data), 'utf-8');
117
+ }
118
+ catch {
119
+ // Disk persistence is best-effort
120
+ }
121
+ }
122
+ /**
123
+ * Load cache from disk (if persistPath configured).
124
+ */
125
+ loadFromDisk() {
126
+ if (!this.persistPath || !existsSync(this.persistPath))
127
+ return;
128
+ try {
129
+ const raw = readFileSync(this.persistPath, 'utf-8');
130
+ const data = JSON.parse(raw);
131
+ const now = Date.now();
132
+ for (const entry of data) {
133
+ if (entry.expiresAt > now) {
134
+ this.entries.set(entry.key, entry);
135
+ }
136
+ }
137
+ }
138
+ catch {
139
+ // Corrupted cache file — ignore
140
+ }
141
+ }
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Pre-configured caches for common use cases
145
+ // ---------------------------------------------------------------------------
146
+ /** Search result cache — 30 min TTL, 500 entries */
147
+ export function createSearchCache(persistPath) {
148
+ return new LocalCache({
149
+ maxEntries: 500,
150
+ defaultTtlMs: 30 * 60 * 1000,
151
+ persistPath,
152
+ });
153
+ }
154
+ /** Question/answer cache — 60 min TTL, 200 entries */
155
+ export function createQuestionCache(persistPath) {
156
+ return new LocalCache({
157
+ maxEntries: 200,
158
+ defaultTtlMs: 60 * 60 * 1000,
159
+ persistPath,
160
+ });
161
+ }
162
+ /** Graph traversal cache — 15 min TTL, 100 entries (graph changes more often) */
163
+ export function createGraphCache(persistPath) {
164
+ return new LocalCache({
165
+ maxEntries: 100,
166
+ defaultTtlMs: 15 * 60 * 1000,
167
+ persistPath,
168
+ });
169
+ }
@@ -0,0 +1,3 @@
1
+ export declare function startHeartbeat(): void;
2
+ export declare function stopHeartbeat(): void;
3
+ export declare function sendOffline(): void;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Channel heartbeat — tells the server this agent's channel plugin is alive.
3
+ * The server uses this to fire online/offline status notifications to the
4
+ * agent's confirmed connections.
5
+ */
6
+ import { apiFetch } from './api-client.js';
7
+ const HEARTBEAT_INTERVAL_MS = 60_000;
8
+ let heartbeatTimer;
9
+ export function startHeartbeat() {
10
+ if (heartbeatTimer)
11
+ return;
12
+ // Fire immediately, then repeat
13
+ sendHeartbeat();
14
+ heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
15
+ }
16
+ export function stopHeartbeat() {
17
+ if (heartbeatTimer) {
18
+ clearInterval(heartbeatTimer);
19
+ heartbeatTimer = undefined;
20
+ }
21
+ }
22
+ function sendHeartbeat() {
23
+ apiFetch('/channel/heartbeat', { method: 'POST' }).catch((err) => {
24
+ console.error('[inerrata-channel] Heartbeat failed:', err);
25
+ });
26
+ }
27
+ export function sendOffline() {
28
+ // Best-effort — process may be exiting
29
+ apiFetch('/channel/heartbeat', { method: 'DELETE' }).catch(() => { });
30
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import './prompts.js';