@rohirik/openltm-core 2.8.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.
Files changed (97) hide show
  1. package/README.md +67 -0
  2. package/assets/opencode/agents/aegis.md +211 -0
  3. package/assets/opencode/plugins/aegis.ts +3 -0
  4. package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
  5. package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
  6. package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
  7. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
  8. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
  9. package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
  10. package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
  11. package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
  12. package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
  13. package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
  14. package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
  15. package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
  16. package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
  17. package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
  18. package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
  19. package/package.json +41 -0
  20. package/src/__tests__/cli/claude.test.ts +122 -0
  21. package/src/__tests__/cli/detect.test.ts +91 -0
  22. package/src/__tests__/cli/install.test.ts +161 -0
  23. package/src/__tests__/cli/opencode.test.ts +169 -0
  24. package/src/__tests__/cli/pi.test.ts +113 -0
  25. package/src/__tests__/cli.test.ts +70 -0
  26. package/src/__tests__/events/crossProcess.test.ts +82 -0
  27. package/src/__tests__/events/index.test.ts +32 -0
  28. package/src/__tests__/extensions.test.ts +81 -0
  29. package/src/__tests__/migrations/retention.test.ts +118 -0
  30. package/src/__tests__/queue/index.test.ts +61 -0
  31. package/src/__tests__/scheduler/index.test.ts +39 -0
  32. package/src/__tests__/vec/index.test.ts +130 -0
  33. package/src/__tests__/vec/parity.test.ts +70 -0
  34. package/src/adapterTypes.ts +23 -0
  35. package/src/cli/_shared.ts +120 -0
  36. package/src/cli/bin.ts +97 -0
  37. package/src/cli/claude.ts +124 -0
  38. package/src/cli/detect.ts +55 -0
  39. package/src/cli/hook.ts +25 -0
  40. package/src/cli/index.ts +22 -0
  41. package/src/cli/install.ts +185 -0
  42. package/src/cli/opencode.ts +193 -0
  43. package/src/cli/pi.ts +74 -0
  44. package/src/cli/types.ts +78 -0
  45. package/src/config.ts +163 -0
  46. package/src/context.ts +172 -0
  47. package/src/dao/conflicts.ts +26 -0
  48. package/src/dao/contextItems.ts +70 -0
  49. package/src/dao/embeddings.ts +78 -0
  50. package/src/dao/index.ts +9 -0
  51. package/src/dao/provenanceAudit.ts +108 -0
  52. package/src/dao/types.ts +142 -0
  53. package/src/db.ts +780 -0
  54. package/src/dedup.ts +12 -0
  55. package/src/embeddings.ts +386 -0
  56. package/src/events/index.ts +130 -0
  57. package/src/extensions.ts +140 -0
  58. package/src/graph.ts +268 -0
  59. package/src/index.ts +95 -0
  60. package/src/janitor/archive.ts +66 -0
  61. package/src/janitor/decay.ts +60 -0
  62. package/src/janitor/dedup.ts +333 -0
  63. package/src/janitor/embeddings.ts +209 -0
  64. package/src/janitor/index.ts +215 -0
  65. package/src/janitor/promote.ts +188 -0
  66. package/src/janitor/providers/anthropic.ts +91 -0
  67. package/src/janitor/providers/cohere.ts +135 -0
  68. package/src/janitor/providers/gemini.ts +156 -0
  69. package/src/janitor/providers/ollama.ts +177 -0
  70. package/src/janitor/providers/openai.ts +121 -0
  71. package/src/janitor/providers/openrouter.ts +182 -0
  72. package/src/janitor/providers/types.ts +154 -0
  73. package/src/janitor/providers/utils.ts +35 -0
  74. package/src/janitor/supersedes.ts +199 -0
  75. package/src/lib/honker.ts +54 -0
  76. package/src/lib/honkerTypes.ts +109 -0
  77. package/src/lib/jsonlLogger.ts +92 -0
  78. package/src/lib/writeQueue.ts +28 -0
  79. package/src/migrations.ts +415 -0
  80. package/src/paths.ts +22 -0
  81. package/src/proposals.ts +120 -0
  82. package/src/providers/disabled.ts +19 -0
  83. package/src/providers/embeddingProvider.ts +49 -0
  84. package/src/providers/gemini.ts +37 -0
  85. package/src/providers/index.ts +2 -0
  86. package/src/providers/ollama.ts +43 -0
  87. package/src/providers/openai.ts +35 -0
  88. package/src/queue/index.ts +53 -0
  89. package/src/queue/worker.ts +77 -0
  90. package/src/recall/categorise.ts +139 -0
  91. package/src/recall/explainer.ts +76 -0
  92. package/src/scheduler/index.ts +97 -0
  93. package/src/schema.sql +191 -0
  94. package/src/secretsScrubber.ts +105 -0
  95. package/src/shared-db.ts +158 -0
  96. package/src/vec/index.ts +161 -0
  97. package/tsconfig.json +9 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * janitor/index.ts — Janitor orchestrator.
3
+ * Runs inside the server.ts process, sharing the DB instance.
4
+ * Coordinates decay, promote, dedup, and embedding generation.
5
+ */
6
+ import { getSetting, setSetting, getDb } from "../shared-db.js";
7
+ import { runDecay, type DecayResult } from "./decay.js";
8
+ import { findDuplicates, saveDedupCandidates, type DedupResult } from "./dedup.js";
9
+ import { embedMissingMemories } from "./embeddings.js";
10
+ import { runPromote, type PromoteResult } from "./promote.js";
11
+ import { runArchive, type ArchiveResult } from "./archive.js";
12
+ import { SETTING_KEYS, getDefault } from "./providers/types.js";
13
+
14
+ export interface JanitorStatus {
15
+ /** Whether the janitor is currently running. */
16
+ running: boolean;
17
+ /** Last run timestamp (ISO string) or null if never run. */
18
+ lastRun: string | null;
19
+ /** Result of the last run. */
20
+ lastResult: JanitorRunResult | null;
21
+ /** Auto-run interval in minutes (0 = disabled). */
22
+ intervalMinutes: number;
23
+ /** Next scheduled run (ISO string) or null if auto-run disabled. */
24
+ nextRun: string | null;
25
+ }
26
+
27
+ export interface JanitorRunResult {
28
+ timestamp: string;
29
+ /** Duration in milliseconds. */
30
+ durationMs: number;
31
+ embed: { embedded: number };
32
+ decay: DecayResult;
33
+ archive: ArchiveResult;
34
+ promote: PromoteResult;
35
+ dedup: { pairsCompared: number; candidatesFound: number };
36
+ errors: string[];
37
+ }
38
+
39
+ let _running = false;
40
+ let _lastRun: string | null = null;
41
+ let _lastResult: JanitorRunResult | null = null;
42
+ let _interval: ReturnType<typeof setInterval> | null = null;
43
+
44
+ /**
45
+ * Run all janitor tasks in sequence:
46
+ * 1. Embed missing memories (required for dedup)
47
+ * 2. Decay stale memories
48
+ * 3. Promote eligible context_items
49
+ * 4. Find duplicates (no auto-merge without LLM verification)
50
+ */
51
+ export async function runJanitor(): Promise<JanitorRunResult> {
52
+ if (_running) {
53
+ throw new Error("Janitor is already running");
54
+ }
55
+
56
+ _running = true;
57
+ const startTime = Date.now();
58
+ const errors: string[] = [];
59
+
60
+ let embedCount = 0;
61
+ let decayResult: DecayResult = { refreshed: 0, deprecated: 0 };
62
+ let archiveResult: ArchiveResult = { archived: 0 };
63
+ let promoteResult: PromoteResult = {
64
+ promoted: 0,
65
+ skipped: 0,
66
+ scanned: 0,
67
+ };
68
+ let dedupResult: DedupResult = {
69
+ pairsCompared: 0,
70
+ candidates: [],
71
+ autoMerged: 0,
72
+ };
73
+
74
+ try {
75
+ // 1. Embed missing memories
76
+ try {
77
+ embedCount = await embedMissingMemories();
78
+ } catch (e) {
79
+ errors.push(`embed: ${String(e)}`);
80
+ }
81
+
82
+ // 2. Decay stale memories (batch SQL refresh + deprecation)
83
+ try {
84
+ decayResult = runDecay();
85
+ } catch (e) {
86
+ errors.push(`decay: ${String(e)}`);
87
+ }
88
+
89
+ // 3. Archive evicted deprecated memories
90
+ try {
91
+ archiveResult = runArchive();
92
+ } catch (e) {
93
+ errors.push(`archive: ${String(e)}`);
94
+ }
95
+
96
+ // 4. Promote context_items
97
+ try {
98
+ promoteResult = runPromote();
99
+ } catch (e) {
100
+ errors.push(`promote: ${String(e)}`);
101
+ }
102
+
103
+ // 5. Find duplicates and save as pending for review
104
+ try {
105
+ dedupResult = await findDuplicates(0.85, true);
106
+ if (dedupResult.candidates.length > 0) {
107
+ saveDedupCandidates(dedupResult.candidates);
108
+ }
109
+ } catch (e) {
110
+ errors.push(`dedup: ${String(e)}`);
111
+ }
112
+ } finally {
113
+ _running = false;
114
+ }
115
+
116
+ const result: JanitorRunResult = {
117
+ timestamp: new Date().toISOString(),
118
+ durationMs: Date.now() - startTime,
119
+ embed: { embedded: embedCount },
120
+ decay: decayResult,
121
+ archive: archiveResult,
122
+ promote: promoteResult,
123
+ dedup: {
124
+ pairsCompared: dedupResult.pairsCompared,
125
+ candidatesFound: dedupResult.candidates.length,
126
+ },
127
+ errors,
128
+ };
129
+
130
+ // Persist run stats to settings for /openltm:health
131
+ await Promise.all([
132
+ setSetting(SETTING_KEYS.JANITOR_LAST_RUN_AT, result.timestamp),
133
+ setSetting(SETTING_KEYS.JANITOR_LAST_DECAY_REFRESHED, String(decayResult.refreshed)),
134
+ setSetting(SETTING_KEYS.JANITOR_LAST_DEPRECATED, String(decayResult.deprecated)),
135
+ setSetting(SETTING_KEYS.JANITOR_LAST_ARCHIVED, String(archiveResult.archived)),
136
+ ]);
137
+
138
+ // WAL hygiene + query planner refresh
139
+ try {
140
+ const db = getDb();
141
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
142
+ db.exec("PRAGMA analysis_limit=400");
143
+ db.exec("ANALYZE");
144
+ } catch { /* non-fatal — next run will retry */ }
145
+
146
+ _lastRun = result.timestamp;
147
+ _lastResult = result;
148
+
149
+ return result;
150
+ }
151
+
152
+ /** Get current janitor status. */
153
+ export function getJanitorStatus(): JanitorStatus {
154
+ const intervalMinutes = Number.parseInt(
155
+ getSetting(SETTING_KEYS.JANITOR_INTERVAL_MINUTES) ||
156
+ getDefault(SETTING_KEYS.JANITOR_INTERVAL_MINUTES),
157
+ 10,
158
+ );
159
+
160
+ let nextRun: string | null = null;
161
+ if (intervalMinutes > 0 && _lastRun) {
162
+ const lastRunTime = new Date(_lastRun).getTime();
163
+ const nextRunTime = lastRunTime + intervalMinutes * 60 * 1000;
164
+ nextRun = new Date(nextRunTime).toISOString();
165
+ }
166
+
167
+ return {
168
+ running: _running,
169
+ lastRun: _lastRun,
170
+ lastResult: _lastResult,
171
+ intervalMinutes,
172
+ nextRun,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Start auto-run interval. Called by server.ts on startup if interval > 0.
178
+ * Safe to call multiple times — clears any existing interval first.
179
+ */
180
+ export function startAutoRun(): void {
181
+ stopAutoRun();
182
+
183
+ const intervalMinutes = Number.parseInt(
184
+ getSetting(SETTING_KEYS.JANITOR_INTERVAL_MINUTES) ||
185
+ getDefault(SETTING_KEYS.JANITOR_INTERVAL_MINUTES),
186
+ 10,
187
+ );
188
+
189
+ if (intervalMinutes <= 0) return;
190
+
191
+ const intervalMs = intervalMinutes * 60 * 1000;
192
+ _interval = setInterval(async () => {
193
+ try {
194
+ await runJanitor();
195
+ } catch {
196
+ // Logged in result.errors — don't crash the interval
197
+ }
198
+ }, intervalMs);
199
+ }
200
+
201
+ /** Stop auto-run interval. */
202
+ export function stopAutoRun(): void {
203
+ if (_interval) {
204
+ clearInterval(_interval);
205
+ _interval = null;
206
+ }
207
+ }
208
+
209
+ // Re-export sub-modules for direct access from server routes
210
+ export { approveMemory, getPendingMemories, rejectMemory } from "./promote.js";
211
+ export { mergeMemories, parseDedupSource } from "./dedup.js";
212
+ export { supersede } from "./supersedes.js";
213
+ export { touchMemory } from "./decay.js";
214
+ export { getEmbeddingProvider, semanticSearch, findSimilarMemories } from "./embeddings.js";
215
+ export { runArchive } from "./archive.js";
@@ -0,0 +1,188 @@
1
+ /**
2
+ * promote.ts — Auto-promote context_items (decisions/gotchas) to pending memories.
3
+ * Unlike the existing context.ts promote() which creates active memories immediately,
4
+ * this creates them as 'pending' for review in the approval UI.
5
+ */
6
+ import { getDb, getSetting } from "../shared-db.js";
7
+ import { normalizeKey } from "../dedup.js";
8
+ import { SETTING_KEYS, getDefault } from "./providers/types.js";
9
+
10
+ export interface PromoteResult {
11
+ /** Number of context_items promoted to pending memories. */
12
+ promoted: number;
13
+ /** Number of context_items skipped (already promoted or duplicate). */
14
+ skipped: number;
15
+ /** Total eligible items scanned. */
16
+ scanned: number;
17
+ }
18
+
19
+ /**
20
+ * Scan context_items for decisions/gotchas that should be promoted to memories.
21
+ *
22
+ * Criteria:
23
+ * 1. Type is 'decision' or 'gotcha' (not goals or progress)
24
+ * 2. Status is 'active' (not already pending_promotion or promoted)
25
+ * 3. Not already linked to a memory (memory_id IS NULL)
26
+ * 4. Content is substantial enough (> 20 chars)
27
+ *
28
+ * Creates memories with status='pending' for review/approval.
29
+ */
30
+ export function runPromote(): PromoteResult {
31
+ const db = getDb();
32
+ const result: PromoteResult = { promoted: 0, skipped: 0, scanned: 0 };
33
+
34
+ const minImportance = Number.parseInt(
35
+ getSetting(SETTING_KEYS.PROMOTE_MIN_IMPORTANCE) ||
36
+ getDefault(SETTING_KEYS.PROMOTE_MIN_IMPORTANCE),
37
+ 10,
38
+ );
39
+
40
+ // Find eligible context items
41
+ const items = db
42
+ .query<
43
+ {
44
+ id: number;
45
+ project_name: string;
46
+ type: string;
47
+ content: string;
48
+ },
49
+ []
50
+ >(
51
+ `SELECT id, project_name, type, content
52
+ FROM context_items
53
+ WHERE type IN ('decision', 'gotcha')
54
+ AND status = 'active'
55
+ AND memory_id IS NULL
56
+ AND LENGTH(content) > 20
57
+ ORDER BY id ASC`,
58
+ )
59
+ .all();
60
+
61
+ result.scanned = items.length;
62
+
63
+ for (const item of items) {
64
+ const dedupKey = normalizeKey(item.content);
65
+
66
+ // Check if a memory with this dedup_key already exists
67
+ const existing = db
68
+ .query<{ id: number }, [string]>(
69
+ "SELECT id FROM memories WHERE dedup_key = ?",
70
+ )
71
+ .get(dedupKey);
72
+
73
+ if (existing) {
74
+ // Link the context_item to the existing memory
75
+ db.run(
76
+ "UPDATE context_items SET memory_id = ?, status = 'promoted' WHERE id = ?",
77
+ [existing.id, item.id],
78
+ );
79
+ result.skipped++;
80
+ continue;
81
+ }
82
+
83
+ // Map context type to memory category
84
+ const category = item.type === "decision" ? "architecture" : "gotcha";
85
+ const importance = item.type === "gotcha" ? 4 : minImportance;
86
+
87
+ // Create a pending memory
88
+ const insertResult = db.run(
89
+ `INSERT INTO memories (content, category, importance, confidence, source, project_scope, dedup_key, status)
90
+ VALUES (?, ?, ?, 0.8, 'auto-promote', ?, ?, 'pending')`,
91
+ [item.content, category, importance, item.project_name, dedupKey],
92
+ );
93
+
94
+ const memoryId = Number(insertResult.lastInsertRowid);
95
+
96
+ // Update the context_item to link it and mark as pending_promotion
97
+ db.run(
98
+ "UPDATE context_items SET memory_id = ?, status = 'pending_promotion' WHERE id = ?",
99
+ [memoryId, item.id],
100
+ );
101
+
102
+ result.promoted++;
103
+ }
104
+
105
+ return result;
106
+ }
107
+
108
+ /**
109
+ * Approve a pending memory — set status to 'active'.
110
+ * Also updates the linked context_item status to 'promoted'.
111
+ */
112
+ export function approveMemory(memoryId: number): boolean {
113
+ const db = getDb();
114
+ const mem = db
115
+ .query<{ id: number; status: string }, [number]>(
116
+ "SELECT id, status FROM memories WHERE id = ?",
117
+ )
118
+ .get(memoryId);
119
+
120
+ if (!mem || mem.status !== "pending") return false;
121
+
122
+ db.run("UPDATE memories SET status = 'active' WHERE id = ?", [memoryId]);
123
+ db.run(
124
+ "UPDATE context_items SET status = 'promoted' WHERE memory_id = ?",
125
+ [memoryId],
126
+ );
127
+
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Reject a pending memory — delete it and reset the context_item.
133
+ */
134
+ export function rejectMemory(memoryId: number): boolean {
135
+ const db = getDb();
136
+ const mem = db
137
+ .query<{ id: number; status: string }, [number]>(
138
+ "SELECT id, status FROM memories WHERE id = ?",
139
+ )
140
+ .get(memoryId);
141
+
142
+ if (!mem || mem.status !== "pending") return false;
143
+
144
+ // Reset linked context_items back to active
145
+ db.run(
146
+ "UPDATE context_items SET memory_id = NULL, status = 'active' WHERE memory_id = ?",
147
+ [memoryId],
148
+ );
149
+
150
+ // Delete the pending memory
151
+ db.run("DELETE FROM memories WHERE id = ?", [memoryId]);
152
+
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Get all pending memories for the review UI.
158
+ */
159
+ export function getPendingMemories(): Array<{
160
+ id: number;
161
+ content: string;
162
+ category: string;
163
+ importance: number;
164
+ confidence: number;
165
+ project_scope: string | null;
166
+ source: string | null;
167
+ created_at: string;
168
+ }> {
169
+ const db = getDb();
170
+ return db
171
+ .query<
172
+ {
173
+ id: number;
174
+ content: string;
175
+ category: string;
176
+ importance: number;
177
+ confidence: number;
178
+ project_scope: string | null;
179
+ source: string | null;
180
+ created_at: string;
181
+ },
182
+ []
183
+ >(
184
+ `SELECT id, content, category, importance, confidence, project_scope, source, created_at
185
+ FROM memories WHERE status = 'pending' ORDER BY created_at DESC`,
186
+ )
187
+ .all();
188
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * anthropic.ts — Anthropic Claude provider for LLM chat (no embedding API).
3
+ */
4
+ import {
5
+ SETTING_KEYS,
6
+ type ChatInput,
7
+ type ChatResult,
8
+ type LLMProvider,
9
+ } from "./types.js";
10
+ import { httpErrorResult, makeApiKeyGetter, makeModelGetter } from "./utils.js";
11
+
12
+ const ANTHROPIC_API_BASE = "https://api.anthropic.com/v1";
13
+ const ANTHROPIC_VERSION = "2023-06-01";
14
+
15
+ const getApiKey = makeApiKeyGetter(SETTING_KEYS.ANTHROPIC_API_KEY, "ANTHROPIC_API_KEY", "Anthropic");
16
+ const getLlmModel = makeModelGetter(SETTING_KEYS.ANTHROPIC_LLM_MODEL);
17
+
18
+ function authHeaders(apiKey: string) {
19
+ return {
20
+ "Content-Type": "application/json",
21
+ "x-api-key": apiKey,
22
+ "anthropic-version": ANTHROPIC_VERSION,
23
+ };
24
+ }
25
+
26
+ export const anthropicLLM: LLMProvider = {
27
+ name: "anthropic",
28
+
29
+ async chat(input: ChatInput): Promise<ChatResult> {
30
+ const apiKey = getApiKey();
31
+ const model = getLlmModel();
32
+
33
+ // Anthropic uses a single `system` string; only the first system message is used.
34
+ const systemMsg = input.messages.find((m) => m.role === "system");
35
+ const messages = input.messages
36
+ .filter((m) => m.role !== "system")
37
+ .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
38
+
39
+ const body: Record<string, unknown> = {
40
+ model,
41
+ messages,
42
+ max_tokens: input.maxTokens ?? 1024,
43
+ temperature: input.temperature ?? 0.1,
44
+ };
45
+ if (systemMsg) body.system = systemMsg.content;
46
+
47
+ const res = await fetch(`${ANTHROPIC_API_BASE}/messages`, {
48
+ method: "POST",
49
+ headers: authHeaders(apiKey),
50
+ body: JSON.stringify(body),
51
+ });
52
+
53
+ if (!res.ok) {
54
+ const { error } = await httpErrorResult(res);
55
+ throw new Error(`Anthropic chat failed: ${error}`);
56
+ }
57
+
58
+ const data = (await res.json()) as {
59
+ content: Array<{ type: string; text: string }>;
60
+ model: string;
61
+ usage: { input_tokens: number; output_tokens: number };
62
+ };
63
+
64
+ return {
65
+ content: data.content.find((b) => b.type === "text")?.text ?? "",
66
+ model: data.model,
67
+ promptTokens: data.usage.input_tokens,
68
+ completionTokens: data.usage.output_tokens,
69
+ };
70
+ },
71
+
72
+ async verify(): Promise<{ ok: boolean; error?: string }> {
73
+ try {
74
+ const apiKey = getApiKey();
75
+ const model = getLlmModel();
76
+ const res = await fetch(`${ANTHROPIC_API_BASE}/messages`, {
77
+ method: "POST",
78
+ headers: authHeaders(apiKey),
79
+ body: JSON.stringify({
80
+ model,
81
+ messages: [{ role: "user", content: "Reply with OK" }],
82
+ max_tokens: 5,
83
+ }),
84
+ });
85
+ if (!res.ok) return httpErrorResult(res);
86
+ return { ok: true };
87
+ } catch (e) {
88
+ return { ok: false, error: String(e) };
89
+ }
90
+ },
91
+ };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * cohere.ts — Cohere provider for embeddings and LLM chat (v2 API).
3
+ */
4
+ import {
5
+ SETTING_KEYS,
6
+ type ChatInput,
7
+ type ChatResult,
8
+ type EmbedInput,
9
+ type EmbedResult,
10
+ type EmbeddingProvider,
11
+ type EmbeddingVector,
12
+ type LLMProvider,
13
+ } from "./types.js";
14
+ import { httpErrorResult, makeApiKeyGetter, makeModelGetter } from "./utils.js";
15
+
16
+ const COHERE_API_BASE = "https://api.cohere.com/v2";
17
+
18
+ const getApiKey = makeApiKeyGetter(SETTING_KEYS.COHERE_API_KEY, "COHERE_API_KEY", "Cohere");
19
+ const getEmbedModel = makeModelGetter(SETTING_KEYS.COHERE_EMBED_MODEL);
20
+ const getLlmModel = makeModelGetter(SETTING_KEYS.COHERE_LLM_MODEL);
21
+
22
+ function authHeaders(apiKey: string) {
23
+ return { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
24
+ }
25
+
26
+ export const cohereEmbedding: EmbeddingProvider = {
27
+ name: "cohere",
28
+
29
+ async embed(input: EmbedInput): Promise<EmbedResult> {
30
+ const apiKey = getApiKey();
31
+ const model = getEmbedModel();
32
+
33
+ const res = await fetch(`${COHERE_API_BASE}/embed`, {
34
+ method: "POST",
35
+ headers: authHeaders(apiKey),
36
+ body: JSON.stringify({
37
+ model,
38
+ texts: input.texts,
39
+ input_type: "search_document",
40
+ embedding_types: ["float"],
41
+ }),
42
+ });
43
+
44
+ if (!res.ok) {
45
+ const { error } = await httpErrorResult(res);
46
+ throw new Error(`Cohere embed failed: ${error}`);
47
+ }
48
+
49
+ const data = (await res.json()) as {
50
+ embeddings: { float: number[][] };
51
+ meta?: { billed_units?: { input_tokens?: number } };
52
+ };
53
+
54
+ const vectors: EmbeddingVector[] = data.embeddings.float.map(
55
+ (e) => new Float32Array(e),
56
+ );
57
+
58
+ return {
59
+ vectors,
60
+ model,
61
+ dimensions: vectors[0]?.length ?? 0,
62
+ totalTokens: data.meta?.billed_units?.input_tokens ?? 0,
63
+ };
64
+ },
65
+
66
+ async verify(): Promise<{ ok: boolean; error?: string }> {
67
+ try {
68
+ await this.embed({ texts: ["test"] });
69
+ return { ok: true };
70
+ } catch (e) {
71
+ return { ok: false, error: String(e) };
72
+ }
73
+ },
74
+ };
75
+
76
+ export const cohereLLM: LLMProvider = {
77
+ name: "cohere",
78
+
79
+ async chat(input: ChatInput): Promise<ChatResult> {
80
+ const apiKey = getApiKey();
81
+ const model = getLlmModel();
82
+
83
+ const res = await fetch(`${COHERE_API_BASE}/chat`, {
84
+ method: "POST",
85
+ headers: authHeaders(apiKey),
86
+ body: JSON.stringify({
87
+ model,
88
+ messages: input.messages.map((m) => ({
89
+ role: m.role === "assistant" ? "assistant" : m.role === "system" ? "system" : "user",
90
+ content: m.content,
91
+ })),
92
+ max_tokens: input.maxTokens ?? 1024,
93
+ temperature: input.temperature ?? 0.1,
94
+ ...(input.jsonMode ? { response_format: { type: "json_object" } } : {}),
95
+ }),
96
+ });
97
+
98
+ if (!res.ok) {
99
+ const { error } = await httpErrorResult(res);
100
+ throw new Error(`Cohere chat failed: ${error}`);
101
+ }
102
+
103
+ const data = (await res.json()) as {
104
+ message: { content: Array<{ type: string; text: string }> };
105
+ usage?: { billed_units?: { input_tokens?: number; output_tokens?: number } };
106
+ };
107
+
108
+ return {
109
+ content: data.message.content.find((b) => b.type === "text")?.text ?? "",
110
+ model,
111
+ promptTokens: data.usage?.billed_units?.input_tokens ?? 0,
112
+ completionTokens: data.usage?.billed_units?.output_tokens ?? 0,
113
+ };
114
+ },
115
+
116
+ async verify(): Promise<{ ok: boolean; error?: string }> {
117
+ try {
118
+ const apiKey = getApiKey();
119
+ const model = getLlmModel();
120
+ const res = await fetch(`${COHERE_API_BASE}/chat`, {
121
+ method: "POST",
122
+ headers: authHeaders(apiKey),
123
+ body: JSON.stringify({
124
+ model,
125
+ messages: [{ role: "user", content: "Reply with OK" }],
126
+ max_tokens: 5,
127
+ }),
128
+ });
129
+ if (!res.ok) return httpErrorResult(res);
130
+ return { ok: true };
131
+ } catch (e) {
132
+ return { ok: false, error: String(e) };
133
+ }
134
+ },
135
+ };