@cairnvibe/sdk 0.2.13 → 0.3.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/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +71 -0
- package/dist/element-ladder.js +168 -0
- package/dist/index.d.ts +79 -1
- package/dist/index.js +864 -89
- package/dist/key-rotator.d.ts +28 -0
- package/dist/key-rotator.js +57 -3
- package/dist/memory-sqlite.d.ts +86 -0
- package/dist/memory-sqlite.js +230 -0
- package/dist/realtime-cli.js +22 -1
- package/dist/realtime-server.d.ts +83 -2
- package/dist/realtime-server.js +549 -120
- package/dist/server.d.ts +249 -5
- package/dist/server.js +984 -77
- package/dist/skill-store.d.ts +17 -0
- package/dist/skill-store.js +78 -0
- package/dist/tts-stream.d.ts +25 -0
- package/dist/tts-stream.js +32 -0
- package/dist/vad.d.ts +27 -0
- package/dist/vad.js +128 -0
- package/dist/verb-executor.d.ts +32 -11
- package/dist/verb-executor.js +224 -16
- package/dist/webmcp-client.d.ts +14 -1
- package/dist/webmcp-client.js +22 -1
- package/package.json +3 -1
- package/src/agent-loop.ts +222 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +914 -93
- package/src/key-rotator.ts +57 -2
- package/src/memory-sqlite.ts +283 -0
- package/src/realtime-cli.ts +24 -1
- package/src/realtime-server.ts +655 -122
- package/src/server.ts +1077 -77
- package/src/skill-store.ts +88 -0
- package/src/tts-stream.ts +30 -0
- package/src/vad.ts +153 -0
- package/src/verb-executor.ts +243 -22
- package/src/web-component.ts +82 -17
- package/src/webmcp-client.ts +30 -2
package/dist/key-rotator.d.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
export declare class KeyRotator {
|
|
2
2
|
private keys;
|
|
3
3
|
private next;
|
|
4
|
+
private deadKeys;
|
|
4
5
|
constructor(keys: string[]);
|
|
5
6
|
static fromEnvList(value: string | undefined): KeyRotator | null;
|
|
7
|
+
/**
|
|
8
|
+
* Round-robins across whichever keys haven't been confirmed dead yet.
|
|
9
|
+
* If EVERY configured key has been marked dead (a real, if unlikely,
|
|
10
|
+
* total outage or a fully-expired key set), falls back to rotating
|
|
11
|
+
* across the full original list anyway — a bounded retry loop still
|
|
12
|
+
* needs something real to try, and refusing to ever retry again would
|
|
13
|
+
* turn "every key happens to be dead right now" into "permanently
|
|
14
|
+
* broken for the rest of this process," which is strictly worse.
|
|
15
|
+
*/
|
|
6
16
|
take(): string;
|
|
17
|
+
/**
|
|
18
|
+
* Marks a key as confirmed invalid (a real 401 from the provider, not a
|
|
19
|
+
* rate limit) — excluded from `take()`'s rotation for the rest of this
|
|
20
|
+
* process's life. Logs once per key the first time it's marked, naming
|
|
21
|
+
* only its last 4 characters (never the real secret) and how many
|
|
22
|
+
* configured keys still remain live, so a real deployment's own logs
|
|
23
|
+
* show exactly what happened instead of a silent, confusing drop in
|
|
24
|
+
* capacity.
|
|
25
|
+
*/
|
|
26
|
+
markDead(key: string): void;
|
|
27
|
+
/** How many distinct keys are configured — callers use this to bound a
|
|
28
|
+
* rate-limit retry loop (no point trying more times than there are
|
|
29
|
+
* actual keys to fall back to). */
|
|
30
|
+
get size(): number;
|
|
31
|
+
/** How many configured keys have NOT been marked dead — narrower than
|
|
32
|
+
* `size` once markDead has actually excluded something; used to bound a
|
|
33
|
+
* retry loop against only the keys genuinely worth trying right now. */
|
|
34
|
+
get liveSize(): number;
|
|
7
35
|
}
|
package/dist/key-rotator.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KeyRotator = void 0;
|
|
2
4
|
// Round-robins across a comma-separated list of API keys (e.g. `GROQ_API_KEYS`)
|
|
3
5
|
// so runtime verb calls spread across several free-tier rate limits instead
|
|
4
6
|
// of hammering a single key. Mirrors packages/indexer/src/key-rotator.ts —
|
|
5
7
|
// small enough that duplicating it beats adding a shared package for it.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
//
|
|
9
|
+
// Real, live-found gap this closes: a key that's genuinely invalid/expired
|
|
10
|
+
// (a real 401, confirmed directly against Groq's own API — not a transient
|
|
11
|
+
// rate limit) used to just keep getting handed out by `take()` on every
|
|
12
|
+
// pass through the rotation, forever, for the life of the process — a
|
|
13
|
+
// configured-but-dead key wasn't just wasted capacity, it actively
|
|
14
|
+
// sabotaged roughly (dead keys / total keys) of every real request, since
|
|
15
|
+
// GroqVerbLLM's own retry-on-a-different-key logic only ever triggered on
|
|
16
|
+
// a 429 (rate limit), never a 401 (dead key) — see server.ts's own
|
|
17
|
+
// isInvalidKeyError. `markDead` is the fix: once a caller confirms a key
|
|
18
|
+
// is genuinely invalid (not just rate-limited), it's excluded from
|
|
19
|
+
// rotation for the rest of THIS process's life — a session-scoped
|
|
20
|
+
// blocklist, not a persisted one, since a key's validity is checked fresh
|
|
21
|
+
// every time this module is loaded (no stale cross-process assumptions).
|
|
8
22
|
class KeyRotator {
|
|
9
23
|
keys;
|
|
10
24
|
next = 0;
|
|
25
|
+
deadKeys = new Set();
|
|
11
26
|
constructor(keys) {
|
|
12
27
|
if (keys.length === 0)
|
|
13
28
|
throw new Error("KeyRotator: at least one key is required");
|
|
@@ -22,10 +37,49 @@ class KeyRotator {
|
|
|
22
37
|
.filter(Boolean);
|
|
23
38
|
return keys.length > 0 ? new KeyRotator(keys) : null;
|
|
24
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Round-robins across whichever keys haven't been confirmed dead yet.
|
|
42
|
+
* If EVERY configured key has been marked dead (a real, if unlikely,
|
|
43
|
+
* total outage or a fully-expired key set), falls back to rotating
|
|
44
|
+
* across the full original list anyway — a bounded retry loop still
|
|
45
|
+
* needs something real to try, and refusing to ever retry again would
|
|
46
|
+
* turn "every key happens to be dead right now" into "permanently
|
|
47
|
+
* broken for the rest of this process," which is strictly worse.
|
|
48
|
+
*/
|
|
25
49
|
take() {
|
|
26
|
-
const
|
|
50
|
+
const liveKeys = this.keys.filter((k) => !this.deadKeys.has(k));
|
|
51
|
+
const pool = liveKeys.length > 0 ? liveKeys : this.keys;
|
|
52
|
+
const key = pool[this.next % pool.length];
|
|
27
53
|
this.next += 1;
|
|
28
54
|
return key;
|
|
29
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Marks a key as confirmed invalid (a real 401 from the provider, not a
|
|
58
|
+
* rate limit) — excluded from `take()`'s rotation for the rest of this
|
|
59
|
+
* process's life. Logs once per key the first time it's marked, naming
|
|
60
|
+
* only its last 4 characters (never the real secret) and how many
|
|
61
|
+
* configured keys still remain live, so a real deployment's own logs
|
|
62
|
+
* show exactly what happened instead of a silent, confusing drop in
|
|
63
|
+
* capacity.
|
|
64
|
+
*/
|
|
65
|
+
markDead(key) {
|
|
66
|
+
if (this.deadKeys.has(key))
|
|
67
|
+
return;
|
|
68
|
+
this.deadKeys.add(key);
|
|
69
|
+
const remaining = this.keys.length - this.deadKeys.size;
|
|
70
|
+
console.warn(`[cairn] API key ending in "${key.slice(-4)}" is invalid (confirmed via a real 401) — excluded from rotation for the rest of this session. ${remaining} of ${this.keys.length} configured key(s) remain.`);
|
|
71
|
+
}
|
|
72
|
+
/** How many distinct keys are configured — callers use this to bound a
|
|
73
|
+
* rate-limit retry loop (no point trying more times than there are
|
|
74
|
+
* actual keys to fall back to). */
|
|
75
|
+
get size() {
|
|
76
|
+
return this.keys.length;
|
|
77
|
+
}
|
|
78
|
+
/** How many configured keys have NOT been marked dead — narrower than
|
|
79
|
+
* `size` once markDead has actually excluded something; used to bound a
|
|
80
|
+
* retry loop against only the keys genuinely worth trying right now. */
|
|
81
|
+
get liveSize() {
|
|
82
|
+
return this.keys.length - this.deadKeys.size;
|
|
83
|
+
}
|
|
30
84
|
}
|
|
31
85
|
exports.KeyRotator = KeyRotator;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { HistoryTurn } from "@cairnvibe/core";
|
|
3
|
+
export interface MemoryTurnRecord {
|
|
4
|
+
role: "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
}
|
|
8
|
+
export interface MemoryStore {
|
|
9
|
+
/** Core tier — explicit remember, upsert by (scopeId, key), same as
|
|
10
|
+
* Track B's own "remember" being an explicit act, never automatic.
|
|
11
|
+
* Kept small on purpose (MAX_CORE_FACTS_PER_SCOPE): once a scope's Core
|
|
12
|
+
* facts exceed the cap, the least-recently-updated ones are moved to
|
|
13
|
+
* the Archive tier below (see this file's own doc comment) rather than
|
|
14
|
+
* deleted outright — "the piece that lets memory scale" the plan calls
|
|
15
|
+
* for, not a data-loss cliff. */
|
|
16
|
+
rememberFact(scopeId: string, key: string, value: string): void;
|
|
17
|
+
recallFact(scopeId: string, key: string): string | null;
|
|
18
|
+
/** Every CORE fact for this scope, key -> value — never includes Archive tier facts (see recallArchivedFacts for those). */
|
|
19
|
+
recallFacts(scopeId: string): Record<string, string>;
|
|
20
|
+
/** Recall tier — append-only, one row per turn, both roles. */
|
|
21
|
+
recordTurn(scopeId: string, role: "user" | "assistant", content: string): void;
|
|
22
|
+
/** Recall tier — recency-ordered, oldest-first (ready to feed straight into a HistoryTurn[] array). */
|
|
23
|
+
recentTurns(scopeId: string, limit?: number): MemoryTurnRecord[];
|
|
24
|
+
/**
|
|
25
|
+
* Architecture Pillar 5 — the Recall tier's own real search: a keyword
|
|
26
|
+
* match against past turn content, not just a recency LIMIT. Lets a
|
|
27
|
+
* later question reach back further than `recentTurns`' own window
|
|
28
|
+
* without loading the ENTIRE history every time. Recency-ordered
|
|
29
|
+
* (newest match first) — no relevance ranking beyond "matched at all,"
|
|
30
|
+
* a deliberately simple v1.
|
|
31
|
+
*/
|
|
32
|
+
searchTurns(scopeId: string, query: string, limit?: number): MemoryTurnRecord[];
|
|
33
|
+
/**
|
|
34
|
+
* Architecture Pillar 5 — the Archive tier. Long-term facts pulled in
|
|
35
|
+
* only when a real query actually relates to them, never always-
|
|
36
|
+
* injected the way Core facts are (that's the whole point — this is
|
|
37
|
+
* what lets memory scale past Core's small cap without either losing
|
|
38
|
+
* old facts or paying to inject all of them on every single turn).
|
|
39
|
+
* Populated automatically when `rememberFact` evicts an over-cap Core
|
|
40
|
+
* fact; a caller may also archive something directly if it never
|
|
41
|
+
* belonged in the small, always-injected Core set to begin with.
|
|
42
|
+
*/
|
|
43
|
+
archiveFact(scopeId: string, key: string, value: string): void;
|
|
44
|
+
/** Keyword match against BOTH key and value, recency-ordered. Empty object for no match — never guesses at relevance. */
|
|
45
|
+
recallArchivedFacts(scopeId: string, query: string, limit?: number): Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* @param target Either a file path (opened/created, parent dir made if
|
|
49
|
+
* needed) or an already-open better-sqlite3 `Database` — pass an open
|
|
50
|
+
* connection to share it with your own tables instead of opening a second file.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createSqliteMemoryStore(target: string | Database.Database): MemoryStore;
|
|
53
|
+
/**
|
|
54
|
+
* Phase 5 step 4 — pure, standalone, directly testable (no closures, no
|
|
55
|
+
* DB, no WebSocket), storage-agnostic (works from a `MemoryStore`
|
|
56
|
+
* however it's actually backed) — shared by BOTH transports (the
|
|
57
|
+
* realtime relay, which seeds this once per connection, and the typed/
|
|
58
|
+
* HTTP handler, which seeds it once per genuinely fresh session — see
|
|
59
|
+
* server.ts's own use). Prior turns go FIRST (oldest overall), any
|
|
60
|
+
* already-accumulated history stays after them, then the whole thing is
|
|
61
|
+
* capped to `maxTurns` — same cap `history` itself already uses
|
|
62
|
+
* everywhere else, just applied once more here so a scope with a long
|
|
63
|
+
* real memory can't blow past it the moment a session starts.
|
|
64
|
+
*/
|
|
65
|
+
export declare function seedHistoryFromMemory(existingHistory: readonly HistoryTurn[], priorTurns: readonly MemoryTurnRecord[], maxTurns: number): HistoryTurn[];
|
|
66
|
+
/**
|
|
67
|
+
* Phase 5 step 3 — closes the loop step 2 opened: a fact the model
|
|
68
|
+
* explicitly remembered was being written but never read back into a
|
|
69
|
+
* LATER turn's context, only `recentTurns`' raw conversation text
|
|
70
|
+
* (unstructured, unreliable) had any chance of mentioning it. Pure and
|
|
71
|
+
* standalone for the same reason as `seedHistoryFromMemory` — directly
|
|
72
|
+
* testable with a plain object, no store, no connection. Returns null
|
|
73
|
+
* for an empty fact set (nothing to say) rather than an empty string,
|
|
74
|
+
* so a caller can cleanly skip adding a turn at all.
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatRememberedFacts(facts: Readonly<Record<string, string>>): string | null;
|
|
77
|
+
/**
|
|
78
|
+
* Architecture Pillar 5 — the Archive tier's own version of
|
|
79
|
+
* formatRememberedFacts, worded to make the tier distinction legible to
|
|
80
|
+
* the model instead of silently blending long-since-archived facts in
|
|
81
|
+
* with the small, always-injected Core set (the two ARE genuinely
|
|
82
|
+
* different: Core is curated and small; this only ever shows up because
|
|
83
|
+
* this exact turn's own question happened to relate to it). Same null-
|
|
84
|
+
* for-empty discipline as its Core counterpart.
|
|
85
|
+
*/
|
|
86
|
+
export declare function formatArchivedFacts(facts: Readonly<Record<string, string>>): string | null;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Phase 5 — real cross-session memory, backed by SQLite. Shape-inspired
|
|
3
|
+
// by (not copied from) Track B's services/graph/src/cairn_graph/memory.py
|
|
4
|
+
// (facts: an explicit upsert keyed by (scope, key); turns: append-only,
|
|
5
|
+
// recency-ordered recall, no search) — reimplemented here because Track
|
|
6
|
+
// B's version has no real tenant/scope isolation and wasn't built for
|
|
7
|
+
// Track A's actual runtime. Mirrors dashboard-sqlite.ts's own shape
|
|
8
|
+
// (file-or-shared-Database, a namespaced table, plain better-sqlite3) —
|
|
9
|
+
// the same real, already-shipped pattern, not a new one.
|
|
10
|
+
//
|
|
11
|
+
// `scopeId` is deliberately opaque and caller-supplied, never invented
|
|
12
|
+
// here: Track A has no existing identity concept anywhere (no userId/
|
|
13
|
+
// sessionId/tenantId in this SDK today — confirmed before writing this).
|
|
14
|
+
// A caller passes whatever it already has — a Cairn customer's own
|
|
15
|
+
// end-user id if they have login, or any other stable string they
|
|
16
|
+
// choose — and gets exactly the isolation that string implies. This
|
|
17
|
+
// store makes no claim about WHO a scope actually is.
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.createSqliteMemoryStore = createSqliteMemoryStore;
|
|
23
|
+
exports.seedHistoryFromMemory = seedHistoryFromMemory;
|
|
24
|
+
exports.formatRememberedFacts = formatRememberedFacts;
|
|
25
|
+
exports.formatArchivedFacts = formatArchivedFacts;
|
|
26
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
27
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
28
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
29
|
+
const FACTS_TABLE = "cairn_memory_facts";
|
|
30
|
+
const TURNS_TABLE = "cairn_memory_turns";
|
|
31
|
+
const ARCHIVE_TABLE = "cairn_memory_archive";
|
|
32
|
+
const DEFAULT_RECENT_TURNS = 20;
|
|
33
|
+
const DEFAULT_SEARCH_LIMIT = 5;
|
|
34
|
+
// Architecture Pillar 5 — MemGPT-shaped tiered memory. A REAL, measured
|
|
35
|
+
// finding motivates this, not a hunch: swapping a tiered-memory agent for
|
|
36
|
+
// a flat/long-context-only one dropped multi-session task completion from
|
|
37
|
+
// ~80% to ~45% (MemoryArena, arxiv 2603.07670). Core is deliberately kept
|
|
38
|
+
// SMALL — "durable, curated facts, always injected" only means something
|
|
39
|
+
// if the set actually stays small; a scope that just accumulates facts
|
|
40
|
+
// forever isn't curated, it's a second, worse-organized turn log. Chosen
|
|
41
|
+
// as a real, testable number (not tuned against production data, which
|
|
42
|
+
// doesn't exist yet for this) — see this file's own tests for the exact
|
|
43
|
+
// eviction behavior at the boundary.
|
|
44
|
+
const MAX_CORE_FACTS_PER_SCOPE = 20;
|
|
45
|
+
/** Real, significant (4+ letter) words, lowercased — the same crude but
|
|
46
|
+
* dependency-free keyword-matching approach server.ts's Skill retrieval
|
|
47
|
+
* (matchSkillByGoal) already established, reused here for the same
|
|
48
|
+
* "cheap, deterministic, no new dependency" reasoning: no FTS5 extension
|
|
49
|
+
* required, no semantic embedding call, just real substring matching
|
|
50
|
+
* against words a human would actually recognize as meaningful. */
|
|
51
|
+
function significantWords(text) {
|
|
52
|
+
return text
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.split(/[^a-z0-9]+/)
|
|
55
|
+
.filter((w) => w.length >= 4);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @param target Either a file path (opened/created, parent dir made if
|
|
59
|
+
* needed) or an already-open better-sqlite3 `Database` — pass an open
|
|
60
|
+
* connection to share it with your own tables instead of opening a second file.
|
|
61
|
+
*/
|
|
62
|
+
function createSqliteMemoryStore(target) {
|
|
63
|
+
const db = typeof target === "string" ? openFile(target) : target;
|
|
64
|
+
db.exec(`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS ${FACTS_TABLE} (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
scope_id TEXT NOT NULL,
|
|
68
|
+
key TEXT NOT NULL,
|
|
69
|
+
value TEXT NOT NULL,
|
|
70
|
+
updated_at TEXT NOT NULL,
|
|
71
|
+
UNIQUE(scope_id, key)
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
db.exec(`
|
|
75
|
+
CREATE TABLE IF NOT EXISTS ${TURNS_TABLE} (
|
|
76
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
77
|
+
scope_id TEXT NOT NULL,
|
|
78
|
+
role TEXT NOT NULL,
|
|
79
|
+
content TEXT NOT NULL,
|
|
80
|
+
created_at TEXT NOT NULL
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_${TURNS_TABLE}_scope ON ${TURNS_TABLE}(scope_id, id)`);
|
|
84
|
+
db.exec(`
|
|
85
|
+
CREATE TABLE IF NOT EXISTS ${ARCHIVE_TABLE} (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
scope_id TEXT NOT NULL,
|
|
88
|
+
key TEXT NOT NULL,
|
|
89
|
+
value TEXT NOT NULL,
|
|
90
|
+
updated_at TEXT NOT NULL,
|
|
91
|
+
UNIQUE(scope_id, key)
|
|
92
|
+
)
|
|
93
|
+
`);
|
|
94
|
+
const upsertFact = db.prepare(`
|
|
95
|
+
INSERT INTO ${FACTS_TABLE} (scope_id, key, value, updated_at) VALUES (?, ?, ?, ?)
|
|
96
|
+
ON CONFLICT(scope_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
97
|
+
`);
|
|
98
|
+
const selectFact = db.prepare(`SELECT value FROM ${FACTS_TABLE} WHERE scope_id = ? AND key = ?`);
|
|
99
|
+
const selectAllFacts = db.prepare(`SELECT key, value FROM ${FACTS_TABLE} WHERE scope_id = ?`);
|
|
100
|
+
const countFacts = db.prepare(`SELECT COUNT(*) as count FROM ${FACTS_TABLE} WHERE scope_id = ?`);
|
|
101
|
+
// ORDER BY updated_at, then id — a real tiebreaker: several facts
|
|
102
|
+
// written in the same millisecond (easily happens in a tight loop, or
|
|
103
|
+
// any two facts genuinely remembered together) would otherwise leave
|
|
104
|
+
// "least-recently-updated" ambiguous, since SQLite makes no ordering
|
|
105
|
+
// guarantee among rows with an equal ORDER BY key. Falling back to
|
|
106
|
+
// insertion order (id) is the correct default when two facts are
|
|
107
|
+
// equally "old" by their real timestamp.
|
|
108
|
+
const selectOldestFacts = db.prepare(`SELECT key, value, updated_at FROM ${FACTS_TABLE} WHERE scope_id = ? ORDER BY updated_at ASC, id ASC LIMIT ?`);
|
|
109
|
+
const deleteFact = db.prepare(`DELETE FROM ${FACTS_TABLE} WHERE scope_id = ? AND key = ?`);
|
|
110
|
+
const insertTurn = db.prepare(`INSERT INTO ${TURNS_TABLE} (scope_id, role, content, created_at) VALUES (?, ?, ?, ?)`);
|
|
111
|
+
const selectRecentTurns = db.prepare(`SELECT role, content, created_at FROM ${TURNS_TABLE} WHERE scope_id = ? ORDER BY id DESC LIMIT ?`);
|
|
112
|
+
const upsertArchiveFact = db.prepare(`
|
|
113
|
+
INSERT INTO ${ARCHIVE_TABLE} (scope_id, key, value, updated_at) VALUES (?, ?, ?, ?)
|
|
114
|
+
ON CONFLICT(scope_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
115
|
+
`);
|
|
116
|
+
function archiveFactImpl(scopeId, key, value, updatedAt) {
|
|
117
|
+
upsertArchiveFact.run(scopeId, key, value, updatedAt);
|
|
118
|
+
}
|
|
119
|
+
/** Enforces MAX_CORE_FACTS_PER_SCOPE after a real write — moves the
|
|
120
|
+
* least-recently-updated Core facts (oldest `updated_at` first) to
|
|
121
|
+
* Archive instead of deleting them, until back at the cap. Never
|
|
122
|
+
* evicts the fact that was just written (it's always the most
|
|
123
|
+
* recently updated, so ORDER BY updated_at ASC naturally excludes it
|
|
124
|
+
* as long as the cap is >= 1). */
|
|
125
|
+
function enforceCoreCap(scopeId) {
|
|
126
|
+
const { count } = countFacts.get(scopeId);
|
|
127
|
+
const overflow = count - MAX_CORE_FACTS_PER_SCOPE;
|
|
128
|
+
if (overflow <= 0)
|
|
129
|
+
return;
|
|
130
|
+
const toEvict = selectOldestFacts.all(scopeId, overflow);
|
|
131
|
+
for (const row of toEvict) {
|
|
132
|
+
archiveFactImpl(scopeId, row.key, row.value, row.updated_at);
|
|
133
|
+
deleteFact.run(scopeId, row.key);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
rememberFact(scopeId, key, value) {
|
|
138
|
+
upsertFact.run(scopeId, key, value, new Date().toISOString());
|
|
139
|
+
enforceCoreCap(scopeId);
|
|
140
|
+
},
|
|
141
|
+
recallFact(scopeId, key) {
|
|
142
|
+
const row = selectFact.get(scopeId, key);
|
|
143
|
+
return row?.value ?? null;
|
|
144
|
+
},
|
|
145
|
+
recallFacts(scopeId) {
|
|
146
|
+
const rows = selectAllFacts.all(scopeId);
|
|
147
|
+
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
148
|
+
},
|
|
149
|
+
recordTurn(scopeId, role, content) {
|
|
150
|
+
insertTurn.run(scopeId, role, content, new Date().toISOString());
|
|
151
|
+
},
|
|
152
|
+
recentTurns(scopeId, limit = DEFAULT_RECENT_TURNS) {
|
|
153
|
+
const rows = selectRecentTurns.all(scopeId, limit);
|
|
154
|
+
// DESC + LIMIT gets the N most recent, then reversed to oldest-first — ready to feed straight into a HistoryTurn[]-shaped array, same convention that array already uses.
|
|
155
|
+
return rows.reverse().map((r) => ({ role: r.role, content: r.content, createdAt: r.created_at }));
|
|
156
|
+
},
|
|
157
|
+
searchTurns(scopeId, query, limit = DEFAULT_SEARCH_LIMIT) {
|
|
158
|
+
const words = significantWords(query);
|
|
159
|
+
if (words.length === 0)
|
|
160
|
+
return [];
|
|
161
|
+
const clause = words.map(() => "content LIKE ?").join(" OR ");
|
|
162
|
+
const params = words.map((w) => `%${w}%`);
|
|
163
|
+
const rows = db.prepare(`SELECT role, content, created_at FROM ${TURNS_TABLE} WHERE scope_id = ? AND (${clause}) ORDER BY id DESC LIMIT ?`).all(scopeId, ...params, limit);
|
|
164
|
+
return rows.reverse().map((r) => ({ role: r.role, content: r.content, createdAt: r.created_at }));
|
|
165
|
+
},
|
|
166
|
+
archiveFact(scopeId, key, value) {
|
|
167
|
+
archiveFactImpl(scopeId, key, value, new Date().toISOString());
|
|
168
|
+
},
|
|
169
|
+
recallArchivedFacts(scopeId, query, limit = DEFAULT_SEARCH_LIMIT) {
|
|
170
|
+
const words = significantWords(query);
|
|
171
|
+
if (words.length === 0)
|
|
172
|
+
return {};
|
|
173
|
+
const clause = words.map(() => "(key LIKE ? OR value LIKE ?)").join(" OR ");
|
|
174
|
+
const params = words.flatMap((w) => [`%${w}%`, `%${w}%`]);
|
|
175
|
+
const rows = db.prepare(`SELECT key, value FROM ${ARCHIVE_TABLE} WHERE scope_id = ? AND (${clause}) ORDER BY updated_at DESC LIMIT ?`).all(scopeId, ...params, limit);
|
|
176
|
+
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function openFile(filePath) {
|
|
181
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
|
|
182
|
+
return new better_sqlite3_1.default(filePath);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Phase 5 step 4 — pure, standalone, directly testable (no closures, no
|
|
186
|
+
* DB, no WebSocket), storage-agnostic (works from a `MemoryStore`
|
|
187
|
+
* however it's actually backed) — shared by BOTH transports (the
|
|
188
|
+
* realtime relay, which seeds this once per connection, and the typed/
|
|
189
|
+
* HTTP handler, which seeds it once per genuinely fresh session — see
|
|
190
|
+
* server.ts's own use). Prior turns go FIRST (oldest overall), any
|
|
191
|
+
* already-accumulated history stays after them, then the whole thing is
|
|
192
|
+
* capped to `maxTurns` — same cap `history` itself already uses
|
|
193
|
+
* everywhere else, just applied once more here so a scope with a long
|
|
194
|
+
* real memory can't blow past it the moment a session starts.
|
|
195
|
+
*/
|
|
196
|
+
function seedHistoryFromMemory(existingHistory, priorTurns, maxTurns) {
|
|
197
|
+
const combined = [...priorTurns.map((t) => ({ role: t.role, text: t.content })), ...existingHistory];
|
|
198
|
+
return combined.slice(Math.max(0, combined.length - maxTurns));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Phase 5 step 3 — closes the loop step 2 opened: a fact the model
|
|
202
|
+
* explicitly remembered was being written but never read back into a
|
|
203
|
+
* LATER turn's context, only `recentTurns`' raw conversation text
|
|
204
|
+
* (unstructured, unreliable) had any chance of mentioning it. Pure and
|
|
205
|
+
* standalone for the same reason as `seedHistoryFromMemory` — directly
|
|
206
|
+
* testable with a plain object, no store, no connection. Returns null
|
|
207
|
+
* for an empty fact set (nothing to say) rather than an empty string,
|
|
208
|
+
* so a caller can cleanly skip adding a turn at all.
|
|
209
|
+
*/
|
|
210
|
+
function formatRememberedFacts(facts) {
|
|
211
|
+
const entries = Object.entries(facts);
|
|
212
|
+
if (entries.length === 0)
|
|
213
|
+
return null;
|
|
214
|
+
return `Remembered from a previous conversation with this user: ${entries.map(([key, value]) => `${key} — ${value}`).join("; ")}.`;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Architecture Pillar 5 — the Archive tier's own version of
|
|
218
|
+
* formatRememberedFacts, worded to make the tier distinction legible to
|
|
219
|
+
* the model instead of silently blending long-since-archived facts in
|
|
220
|
+
* with the small, always-injected Core set (the two ARE genuinely
|
|
221
|
+
* different: Core is curated and small; this only ever shows up because
|
|
222
|
+
* this exact turn's own question happened to relate to it). Same null-
|
|
223
|
+
* for-empty discipline as its Core counterpart.
|
|
224
|
+
*/
|
|
225
|
+
function formatArchivedFacts(facts) {
|
|
226
|
+
const entries = Object.entries(facts);
|
|
227
|
+
if (entries.length === 0)
|
|
228
|
+
return null;
|
|
229
|
+
return `Also found in older, archived memory (relevant to this question): ${entries.map(([key, value]) => `${key} — ${value}`).join("; ")}.`;
|
|
230
|
+
}
|
package/dist/realtime-cli.js
CHANGED
|
@@ -22,6 +22,8 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
22
22
|
const node_child_process_1 = require("node:child_process");
|
|
23
23
|
const core_1 = require("@cairnvibe/core");
|
|
24
24
|
const realtime_server_1 = require("./realtime-server");
|
|
25
|
+
const memory_sqlite_1 = require("./memory-sqlite");
|
|
26
|
+
const skill_store_1 = require("./skill-store");
|
|
25
27
|
function parseCapability(raw) {
|
|
26
28
|
if (raw === "explain" || raw === "guide" || raw === "act")
|
|
27
29
|
return raw;
|
|
@@ -124,7 +126,26 @@ function main() {
|
|
|
124
126
|
const port = parsePortFlag(process.argv.slice(2)) ?? Number(process.env.CAIRN_REALTIME_PORT ?? 3010);
|
|
125
127
|
const capability = parseCapability(process.env.CAIRN_CAPABILITY);
|
|
126
128
|
const persona = process.env.CAIRN_PERSONA || undefined;
|
|
127
|
-
|
|
129
|
+
// Phase 5 / Architecture Pillar 5 — real cross-session memory, opt-in
|
|
130
|
+
// via a real file path. Closes the gap DEVELOPMENT.md's own Pillar 5
|
|
131
|
+
// entry flagged: MemoryStore was wired into createCopilotHandler and
|
|
132
|
+
// ConnectionDeps from the start, but never actually reachable from
|
|
133
|
+
// this CLI — a real deployment had no zero-code way to turn it on for
|
|
134
|
+
// the realtime relay. Absent env var means exactly today's behavior:
|
|
135
|
+
// no memory, zero overhead.
|
|
136
|
+
const memoryDbPath = process.env.CAIRN_MEMORY_DB_PATH;
|
|
137
|
+
const memory = memoryDbPath ? (0, memory_sqlite_1.createSqliteMemoryStore)(node_path_1.default.resolve(process.cwd(), memoryDbPath)) : undefined;
|
|
138
|
+
// Architecture Pillar 3 (Skill half) — same real, previously-missing
|
|
139
|
+
// wiring for self-authored Skills. Deliberately a SEPARATE file/scope
|
|
140
|
+
// from memory (see skill-store.ts's own doc comment: Skills are
|
|
141
|
+
// per-deployment, memory is per-user) — sharing the same underlying
|
|
142
|
+
// sqlite file is still fine if a deployment points both env vars at
|
|
143
|
+
// the same path, since each store creates its own distinctly-named
|
|
144
|
+
// tables.
|
|
145
|
+
const skillsDbPath = process.env.CAIRN_SKILLS_DB_PATH;
|
|
146
|
+
const skills = skillsDbPath ? (0, skill_store_1.createSqliteSkillStore)(node_path_1.default.resolve(process.cwd(), skillsDbPath)) : undefined;
|
|
147
|
+
const skillsScopeId = process.env.CAIRN_SKILLS_SCOPE_ID || undefined;
|
|
148
|
+
const server = (0, realtime_server_1.createRealtimeServer)({ manifest, provider, deepgramApiKey, registeredActions, capability, persona, memory, skills, skillsScopeId });
|
|
128
149
|
server.listen(port, () => {
|
|
129
150
|
console.error(`cairn-realtime: listening on ws://localhost:${port} (provider: ${provider})`);
|
|
130
151
|
if (withCommand)
|
|
@@ -1,23 +1,74 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { WebSocket } from "ws";
|
|
3
3
|
import { type HistoryTurn, type LiveElement, type Manifest, type WebMcpTool } from "@cairnvibe/core";
|
|
4
|
-
import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
|
|
4
|
+
import { createCriticLLM, createPlanLLM, createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
|
|
5
|
+
import { formatRememberedFacts, seedHistoryFromMemory, type MemoryStore } from "./memory-sqlite";
|
|
6
|
+
import type { SkillStore } from "./skill-store";
|
|
5
7
|
export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
|
|
6
8
|
manifest: Manifest;
|
|
7
9
|
deepgramApiKey: string;
|
|
8
10
|
sttModel?: string;
|
|
9
11
|
ttsVoice?: string;
|
|
12
|
+
/** Phase 5 — real cross-session memory (packages/sdk/src/memory-sqlite.ts,
|
|
13
|
+
* or any store implementing the same interface). Optional — omitting it
|
|
14
|
+
* keeps every connection exactly as memory-less as before this existed.
|
|
15
|
+
* Scoped by whatever `scopeId` string a connection's own client sends in
|
|
16
|
+
* its "context" message (see ConnectionDeps' own doc comment) — this SDK
|
|
17
|
+
* invents no identity of its own. */
|
|
18
|
+
memory?: MemoryStore;
|
|
19
|
+
/** Architecture Pillar 3 (Skill half) — see ConnectionDeps' own doc
|
|
20
|
+
* comment. Optional; omitting it keeps every connection exactly as it
|
|
21
|
+
* was before this existed. */
|
|
22
|
+
skills?: SkillStore;
|
|
23
|
+
/** See ConnectionDeps' own doc comment. Defaults to "default" when `skills` is set but this is omitted. */
|
|
24
|
+
skillsScopeId?: string;
|
|
10
25
|
}
|
|
26
|
+
export { seedHistoryFromMemory, formatRememberedFacts };
|
|
11
27
|
export declare function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server;
|
|
12
28
|
export interface ConnectionDeps {
|
|
13
29
|
deepgramApiKey: string;
|
|
14
30
|
sttModel: string;
|
|
15
31
|
ttsVoice: string;
|
|
16
32
|
llm: ReturnType<typeof createVerbLLM>;
|
|
33
|
+
/** Phase 3 step 2 — a separately-configured Planner LLM, called on the
|
|
34
|
+
* first continuing step of a turn (see finalizeTurn). Optional so
|
|
35
|
+
* existing ConnectionDeps construction (and every existing test) keeps
|
|
36
|
+
* working unchanged; absent means no Planner call happens at all. */
|
|
37
|
+
planLLM?: ReturnType<typeof createPlanLLM>;
|
|
38
|
+
/** Phase 3 step 3 — a separately-configured Critic LLM. Only engages
|
|
39
|
+
* (task-advancement/replan/give-up actually driving the loop, not just
|
|
40
|
+
* logging) when BOTH this and planLLM are present — the Critic needs a
|
|
41
|
+
* real Plan's current task to check against. Optional for the same
|
|
42
|
+
* backward-compatibility reason as planLLM. */
|
|
43
|
+
criticLLM?: ReturnType<typeof createCriticLLM>;
|
|
17
44
|
systemPrompt: string;
|
|
18
45
|
manifest: Manifest;
|
|
19
46
|
registeredActions: string[];
|
|
47
|
+
/** Phase 4 step 4 — real descriptions for registeredActions ids, same
|
|
48
|
+
* shape/purpose as CreateCopilotHandlerOptions.actionDescriptions.
|
|
49
|
+
* Optional, defaults to {} — an existing ConnectionDeps construction
|
|
50
|
+
* (own or a test's) keeps working with every action rendered bare. */
|
|
51
|
+
actionDescriptions?: Record<string, string>;
|
|
20
52
|
capability: CapabilityTier;
|
|
53
|
+
/** Phase 5 — see CreateRealtimeServerOptions' own doc comment. Optional,
|
|
54
|
+
* same backward-compatibility reason as every other addition here:
|
|
55
|
+
* absent means no memory read/write happens for any connection, ever
|
|
56
|
+
* — today's exact behavior. */
|
|
57
|
+
memory?: MemoryStore;
|
|
58
|
+
/**
|
|
59
|
+
* Architecture Pillar 3 (Skill half) — real, persistent storage for
|
|
60
|
+
* self-authored Skills (skill-store.ts). A DIFFERENT axis of scope than
|
|
61
|
+
* `memory` above: Skills are meant to be shared across every user who
|
|
62
|
+
* talks to this deployment (the same scope `ui-manifest.json` itself
|
|
63
|
+
* already has), never per-user — see skill-store.ts's own doc comment.
|
|
64
|
+
* Optional; absent means no Skill retrieval/saving happens at all, zero
|
|
65
|
+
* overhead, today's exact behavior.
|
|
66
|
+
*/
|
|
67
|
+
skills?: SkillStore;
|
|
68
|
+
/** The deployment-wide scope Skills are stored/looked up under when
|
|
69
|
+
* `skills` is configured. Defaults to "default" — a single-deployment
|
|
70
|
+
* setup, today's only real usage — when omitted. */
|
|
71
|
+
skillsScopeId?: string;
|
|
21
72
|
}
|
|
22
73
|
export declare function handleDeepgramMessage(raw: string, client: WebSocket, deps: ConnectionDeps, getContext: () => {
|
|
23
74
|
route: string;
|
|
@@ -26,4 +77,34 @@ export declare function handleDeepgramMessage(raw: string, client: WebSocket, de
|
|
|
26
77
|
webMcpTools: WebMcpTool[];
|
|
27
78
|
}, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
|
|
28
79
|
buffer: string;
|
|
29
|
-
}, getGeneration: () => number, waitForToolResult: () => Promise<string
|
|
80
|
+
}, getGeneration: () => number, waitForToolResult: () => Promise<string>,
|
|
81
|
+
/** Phase 5 — called with each real (role, text) turn as it's finalized,
|
|
82
|
+
* right alongside the same-shaped `history.push`. Optional and a no-op
|
|
83
|
+
* by default so every existing call site keeps working unchanged. The
|
|
84
|
+
* realtime connection's own recordMemoryTurn writes it to durable
|
|
85
|
+
* storage when memory + a scopeId are both configured for this
|
|
86
|
+
* connection — see ConnectionDeps.memory's own doc comment. */
|
|
87
|
+
recordMemoryTurn?: (role: "user" | "assistant", text: string) => void,
|
|
88
|
+
/** Phase 5 step 2 — see finalizeTurn's own doc comment. Threaded
|
|
89
|
+
* through here purely to reach finalizeTurn's two call sites below. */
|
|
90
|
+
getScopeId?: () => string | null,
|
|
91
|
+
/** Real, live-found gap this closes: `generation` (getGeneration/
|
|
92
|
+
* triggerServerBargeIn) previously only ever bumped on an EXPLICIT
|
|
93
|
+
* barge-in — two ordinary, sequential turns with no interruption
|
|
94
|
+
* between them shared the exact same generation number. That was
|
|
95
|
+
* fine for what `generation` was originally built for (dropping
|
|
96
|
+
* audio/verbs abandoned mid-turn by a real interruption), but it
|
|
97
|
+
* left the CLIENT's own generation-based staleness check (added for
|
|
98
|
+
* that same reason, in index.tsx) with no way to tell a merely SLOW
|
|
99
|
+
* turn's late-arriving reply apart from the current one — nothing
|
|
100
|
+
* had bumped, so the late reply's generation still matched. Found
|
|
101
|
+
* live: a "hello" reply that took long enough to arrive AFTER the
|
|
102
|
+
* next question's own "final" had already fired, landing on the
|
|
103
|
+
* wrong caption because both were tagged the same generation.
|
|
104
|
+
* Called once per genuinely NEW turn (both call sites below), so
|
|
105
|
+
* every real "final" gets its own fresh generation — a turn is now
|
|
106
|
+
* "superseded" the instant a newer one starts, not only when an
|
|
107
|
+
* explicit interruption says so. Optional and a no-op by default so
|
|
108
|
+
* every existing call site (own or a test's) that doesn't pass this
|
|
109
|
+
* keeps behaving exactly as before. */
|
|
110
|
+
bumpGeneration?: () => void): Promise<void>;
|