@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,415 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * migrations.ts — Versioned schema migration runner for openltm.db
4
+ * Uses bun:sqlite and Bun file APIs exclusively.
5
+ *
6
+ * CLI: bun migrations.ts [--status | --up | --down | --reset]
7
+ */
8
+ import { Database } from "bun:sqlite";
9
+ import { readdirSync, existsSync, statSync, unlinkSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { createHash } from "crypto";
12
+ import { getDbPath, getMigrationsDir } from "./paths.js";
13
+
14
+ const DB_PATH = getDbPath();
15
+ const MIGRATIONS_DIR = getMigrationsDir();
16
+
17
+ function openDb(): Database {
18
+ const db = new Database(DB_PATH, { create: true });
19
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
20
+ return db;
21
+ }
22
+
23
+ // ── Helpers ────────────────────────────────────────────────────────────────────
24
+
25
+ export function ensureMigrationsTable(db: Database): void {
26
+ db.exec(`
27
+ CREATE TABLE IF NOT EXISTS _schema_version (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ version INTEGER NOT NULL UNIQUE,
30
+ name TEXT NOT NULL,
31
+ applied_at TEXT NOT NULL DEFAULT (datetime('now')),
32
+ checksum TEXT NOT NULL
33
+ )
34
+ `);
35
+ }
36
+
37
+ export interface MigrationFile {
38
+ version: number;
39
+ name: string;
40
+ path: string;
41
+ content: string;
42
+ }
43
+
44
+ export async function getMigrationFiles(): Promise<MigrationFile[]> {
45
+ if (!existsSync(MIGRATIONS_DIR)) return [];
46
+
47
+ const files = readdirSync(MIGRATIONS_DIR)
48
+ .filter((f) => f.endsWith(".sql"))
49
+ .sort();
50
+
51
+ const results: MigrationFile[] = [];
52
+ for (const file of files) {
53
+ const match = file.match(/^(\d+)_(.+)\.sql$/);
54
+ if (!match) throw new Error(`Migration filename must match NNN_name.sql: ${file}`);
55
+ const version = parseInt(match[1]!, 10);
56
+ const name = match[2]!;
57
+ const filePath = join(MIGRATIONS_DIR, file);
58
+ const content = await Bun.file(filePath).text();
59
+ results.push({ version, name, path: filePath, content });
60
+ }
61
+ return results;
62
+ }
63
+
64
+ export function getAppliedVersions(db: Database): Set<number> {
65
+ ensureMigrationsTable(db);
66
+ const rows = db
67
+ .query<{ version: number }, []>("SELECT version FROM _schema_version ORDER BY version")
68
+ .all();
69
+ return new Set(rows.map((r) => r.version));
70
+ }
71
+
72
+ export function computeChecksum(content: string): string {
73
+ return createHash("sha256").update(content).digest("hex");
74
+ }
75
+
76
+ export async function backupDb(): Promise<string> {
77
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
78
+ const backupPath = `${DB_PATH}.bak-${timestamp}`;
79
+ // Skip backup when the source DB doesn't exist (e.g. in-process test DBs
80
+ // that were created on a different path than the module-level DB_PATH).
81
+ if (!existsSync(DB_PATH)) return backupPath;
82
+ const bytes = await Bun.file(DB_PATH).arrayBuffer();
83
+ await Bun.write(backupPath, bytes);
84
+ return backupPath;
85
+ }
86
+
87
+ /**
88
+ * Read the default backup retention count from `LTM_BACKUP_RETENTION` env var.
89
+ * Falls back to 1. Negative or non-numeric values also default to 1.
90
+ */
91
+ export function getRetentionDefault(): number {
92
+ const env = process.env["LTM_BACKUP_RETENTION"];
93
+ if (!env) return 1;
94
+ const n = parseInt(env, 10);
95
+ if (Number.isNaN(n) || n < 0) return 1;
96
+ return n;
97
+ }
98
+
99
+ export interface EnforceRetentionOptions {
100
+ /** Override the db path (defaults to getDbPath() result). */
101
+ dbPath?: string;
102
+ /** Path of a .bak file just created by backupDb() — never delete it. */
103
+ currentBackupPath?: string;
104
+ /** Skip files whose mtime is newer than this many ms (default 60_000 = 60s). */
105
+ gracePeriodMs?: number;
106
+ }
107
+
108
+ export interface EnforceRetentionResult {
109
+ /** Absolute paths of files successfully deleted. */
110
+ deleted: string[];
111
+ /** Absolute paths of files that remain (within the retention window). */
112
+ kept: string[];
113
+ /** Non-fatal issues encountered (e.g. stat/unlink permission errors). */
114
+ warnings: string[];
115
+ }
116
+
117
+ /**
118
+ * Enforce a maximum number of .bak files alongside the database.
119
+ *
120
+ * Filenames are `openltm.db.bak-<ISO-timestamp>`. ISO timestamps sort lexically,
121
+ * so sorting by filename = sorting by creation time. We keep the newest N
122
+ * and delete the rest. Files newer than `gracePeriodMs` (default 60s) are
123
+ * skipped to avoid racing with a concurrent writer.
124
+ *
125
+ * The function is idempotent and never throws — every error becomes a
126
+ * warning in the result.
127
+ */
128
+ export async function enforceRetention(
129
+ maxBackups: number,
130
+ options: EnforceRetentionOptions = {},
131
+ ): Promise<EnforceRetentionResult> {
132
+ const dbPath = options.dbPath ?? getDbPath();
133
+ const gracePeriodMs = options.gracePeriodMs ?? 60_000;
134
+ const result: EnforceRetentionResult = { deleted: [], kept: [], warnings: [] };
135
+
136
+ const dir = dirname(dbPath);
137
+ if (!existsSync(dir)) {
138
+ result.warnings.push(`Directory does not exist: ${dir}`);
139
+ return result;
140
+ }
141
+
142
+ const files = readdirSync(dir).filter((f) => f.startsWith("openltm.db.bak-"));
143
+ if (files.length === 0) return result;
144
+
145
+ const now = Date.now();
146
+ const eligible: string[] = [];
147
+ const excluded: string[] = [];
148
+ for (const f of files) {
149
+ const fullPath = join(dir, f);
150
+ try {
151
+ const mtime = statSync(fullPath).mtimeMs;
152
+ if (now - mtime < gracePeriodMs) {
153
+ excluded.push(fullPath);
154
+ continue;
155
+ }
156
+ } catch (err: unknown) {
157
+ const code = (err as NodeJS.ErrnoException).code;
158
+ if (code === "ENOENT") continue;
159
+ result.warnings.push(`stat failed for ${fullPath}: ${(err as Error).message}`);
160
+ continue;
161
+ }
162
+ if (options.currentBackupPath && fullPath === options.currentBackupPath) {
163
+ excluded.push(fullPath);
164
+ continue;
165
+ }
166
+ eligible.push(fullPath);
167
+ }
168
+
169
+ if (eligible.length === 0) {
170
+ result.kept = excluded;
171
+ return result;
172
+ }
173
+
174
+ eligible.sort();
175
+
176
+ const toDelete = eligible.slice(0, Math.max(0, eligible.length - maxBackups));
177
+ const toKeep = eligible.slice(toDelete.length);
178
+
179
+ for (const file of toDelete) {
180
+ try {
181
+ unlinkSync(file);
182
+ result.deleted.push(file);
183
+ } catch (err: unknown) {
184
+ const code = (err as NodeJS.ErrnoException).code;
185
+ if (code === "ENOENT") {
186
+ result.deleted.push(file);
187
+ } else {
188
+ result.warnings.push(`unlink failed for ${file}: ${(err as Error).message}`);
189
+ }
190
+ }
191
+ }
192
+
193
+ result.kept = [...excluded, ...toKeep];
194
+ return result;
195
+ }
196
+
197
+ export interface ParsedMigration {
198
+ up: string;
199
+ down: string;
200
+ }
201
+
202
+ export function parseMigration(content: string): ParsedMigration {
203
+ const parts = content.split(/^--\s*DOWN\s*$/m);
204
+ const up = (parts[0] ?? "")
205
+ .replace(/^--.*$/gm, "")
206
+ .trim();
207
+ const down = (parts[1] ?? "")
208
+ .replace(/^--.*$/gm, "")
209
+ .trim();
210
+ return { up, down };
211
+ }
212
+
213
+ // ── Core migration actions ─────────────────────────────────────────────────────
214
+
215
+ export interface MigrationResult {
216
+ version: number;
217
+ name: string;
218
+ action: "applied" | "skipped" | "rolled_back";
219
+ }
220
+
221
+ export async function runPendingMigrations(db?: Database): Promise<MigrationResult[]> {
222
+ const _db = db ?? openDb();
223
+ ensureMigrationsTable(_db);
224
+
225
+ const files = await getMigrationFiles();
226
+ const applied = getAppliedVersions(_db);
227
+ const pending = files.filter((f) => !applied.has(f.version));
228
+
229
+ if (pending.length === 0) return [];
230
+
231
+ const newBackupPath = await backupDb();
232
+
233
+ const results: MigrationResult[] = [];
234
+ for (const file of pending) {
235
+ const { up } = parseMigration(file.content);
236
+ const checksum = computeChecksum(file.content);
237
+
238
+ try {
239
+ _db.transaction(() => {
240
+ // Skip exec for marker migrations that have no DDL (e.g. 001_baseline).
241
+ if (up) _db.exec(up);
242
+ _db.run(
243
+ `INSERT INTO _schema_version (version, name, checksum) VALUES (?, ?, ?)`,
244
+ [file.version, file.name, checksum],
245
+ );
246
+ })();
247
+ } catch (err: unknown) {
248
+ const msg = (err as { message?: string }).message ?? "";
249
+ // Column was already added by shared-db.ts runMigrations() — self-heal by recording as applied
250
+ if (msg.includes("duplicate column name") || msg.includes("already exists")) {
251
+ _db.run(
252
+ `INSERT OR IGNORE INTO _schema_version (version, name, checksum) VALUES (?, ?, ?)`,
253
+ [file.version, file.name, checksum],
254
+ );
255
+ } else {
256
+ throw err;
257
+ }
258
+ }
259
+
260
+ results.push({ version: file.version, name: file.name, action: "applied" });
261
+ }
262
+
263
+ try {
264
+ await enforceRetention(getRetentionDefault(), { currentBackupPath: newBackupPath });
265
+ } catch (err: unknown) {
266
+ console.warn(`[migrations] retention enforcement failed: ${(err as Error).message}`);
267
+ }
268
+
269
+ return results;
270
+ }
271
+
272
+ export interface MigrationStatus {
273
+ version: number;
274
+ name: string;
275
+ status: "applied" | "pending";
276
+ applied_at?: string;
277
+ }
278
+
279
+ export async function getMigrationStatus(db?: Database): Promise<MigrationStatus[]> {
280
+ const _db = db ?? openDb();
281
+ ensureMigrationsTable(_db);
282
+
283
+ const files = await getMigrationFiles();
284
+ const rows = _db
285
+ .query<{ version: number; applied_at: string }, []>(
286
+ "SELECT version, applied_at FROM _schema_version ORDER BY version",
287
+ )
288
+ .all();
289
+ const appliedMap = new Map(rows.map((r) => [r.version, r.applied_at]));
290
+
291
+ return files.map((f) => {
292
+ const applied_at = appliedMap.get(f.version);
293
+ return {
294
+ version: f.version,
295
+ name: f.name,
296
+ status: (applied_at ? "applied" : "pending") as "applied" | "pending",
297
+ ...(applied_at ? { applied_at } : {}),
298
+ };
299
+ });
300
+ }
301
+
302
+ export async function rollbackLast(db?: Database): Promise<MigrationResult | null> {
303
+ const _db = db ?? openDb();
304
+ ensureMigrationsTable(_db);
305
+
306
+ const last = _db
307
+ .query<{ version: number; name: string }, []>(
308
+ "SELECT version, name FROM _schema_version ORDER BY version DESC LIMIT 1",
309
+ )
310
+ .get();
311
+
312
+ if (!last) return null;
313
+
314
+ const files = await getMigrationFiles();
315
+ const file = files.find((f) => f.version === last.version);
316
+ if (!file) throw new Error(`Migration file for version ${last.version} not found`);
317
+
318
+ const { down } = parseMigration(file.content);
319
+ if (!down) throw new Error(`Migration ${last.version} has no DOWN section`);
320
+
321
+ _db.transaction(() => {
322
+ _db.exec(down);
323
+ _db.run("DELETE FROM _schema_version WHERE version = ?", [last.version]);
324
+ })();
325
+
326
+ return { version: last.version, name: last.name, action: "rolled_back" };
327
+ }
328
+
329
+ export async function resetAll(db?: Database): Promise<MigrationResult[]> {
330
+ const _db = db ?? openDb();
331
+ ensureMigrationsTable(_db);
332
+
333
+ // Read files once, then roll back in reverse order without repeated disk reads
334
+ const files = await getMigrationFiles();
335
+ const fileMap = new Map(files.map((f) => [f.version, f]));
336
+
337
+ const applied = _db
338
+ .query<{ version: number; name: string }, []>(
339
+ "SELECT version, name FROM _schema_version ORDER BY version DESC",
340
+ )
341
+ .all();
342
+
343
+ const results: MigrationResult[] = [];
344
+ for (const row of applied) {
345
+ const file = fileMap.get(row.version);
346
+ if (!file) throw new Error(`Migration file for version ${row.version} not found`);
347
+ const { down } = parseMigration(file.content);
348
+ if (!down) throw new Error(`Migration ${row.version} has no DOWN section`);
349
+ _db.transaction(() => {
350
+ _db.exec(down);
351
+ _db.run("DELETE FROM _schema_version WHERE version = ?", [row.version]);
352
+ })();
353
+ results.push({ version: row.version, name: row.name, action: "rolled_back" });
354
+ }
355
+ return results;
356
+ }
357
+
358
+ // ── CLI entry point ────────────────────────────────────────────────────────────
359
+
360
+ if (import.meta.main) {
361
+ void (async () => {
362
+ const arg = process.argv[2] ?? "--status";
363
+ const db = openDb();
364
+
365
+ try {
366
+ if (arg === "--status") {
367
+ const statuses = await getMigrationStatus(db);
368
+ if (statuses.length === 0) {
369
+ console.error("No migration files found in migrations/");
370
+ } else {
371
+ console.error("\nMigration Status:");
372
+ console.error("─".repeat(60));
373
+ for (const s of statuses) {
374
+ const tag = s.status === "applied" ? "[applied]" : "[pending]";
375
+ const date = s.applied_at ? ` (${s.applied_at})` : "";
376
+ console.error(` ${tag.padEnd(10)} v${String(s.version).padStart(3, "0")} ${s.name}${date}`);
377
+ }
378
+ console.error("");
379
+ }
380
+ } else if (arg === "--up") {
381
+ const results = await runPendingMigrations(db);
382
+ if (results.length === 0) {
383
+ console.error("No pending migrations.");
384
+ } else {
385
+ for (const r of results) {
386
+ console.error(`Applied: v${String(r.version).padStart(3, "0")} ${r.name}`);
387
+ }
388
+ }
389
+ } else if (arg === "--down") {
390
+ const result = await rollbackLast(db);
391
+ if (!result) {
392
+ console.error("No applied migrations to roll back.");
393
+ } else {
394
+ console.error(`Rolled back: v${String(result.version).padStart(3, "0")} ${result.name}`);
395
+ }
396
+ } else if (arg === "--reset") {
397
+ const results = await resetAll(db);
398
+ if (results.length === 0) {
399
+ console.error("Nothing to reset.");
400
+ } else {
401
+ for (const r of results) {
402
+ console.error(`Rolled back: v${String(r.version).padStart(3, "0")} ${r.name}`);
403
+ }
404
+ }
405
+ } else {
406
+ console.error(`Unknown argument: ${arg}`);
407
+ console.error("Usage: bun migrations.ts [--status | --up | --down | --reset]");
408
+ process.exit(1);
409
+ }
410
+ } catch (err) {
411
+ console.error("Migration error:", err);
412
+ process.exit(1);
413
+ }
414
+ })();
415
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * paths.ts (openltm-core) — Path resolution without any host-specific defaults.
3
+ * Priority: LTM_DB_PATH env var > CLAUDE_PLUGIN_DATA env var > dev fallback.
4
+ * The CLAUDE_DIR constant is intentionally absent — adapters inject paths via LtmCoreConfig.
5
+ */
6
+ import { join } from "path";
7
+
8
+ export function getDbPath(): string {
9
+ if (process.env["LTM_DB_PATH"]) return process.env["LTM_DB_PATH"];
10
+ if (process.env["CLAUDE_PLUGIN_DATA"]) return join(process.env["CLAUDE_PLUGIN_DATA"], "openltm.db");
11
+ return join(import.meta.dir, "..", "..", "..", "data", "openltm.db");
12
+ }
13
+
14
+ export function getSchemaPath(): string {
15
+ return join(import.meta.dir, "schema.sql");
16
+ }
17
+
18
+ export function getMigrationsDir(): string {
19
+ // Resolves to project-root migrations/ during development inside the monorepo.
20
+ // Adapters should set LtmCoreConfig.migrationsDir when deploying standalone.
21
+ return join(import.meta.dir, "..", "..", "..", "migrations");
22
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * proposals.ts — Review interface for memory proposals written by EvaluateSession.
3
+ *
4
+ * Proposals live as JSON files in ${CLAUDE_PLUGIN_DATA}/proposals/<session-id>.json.
5
+ * Each file: { proposals: MemoryProposal[], generatedAt: number }
6
+ */
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ import { learn } from "./db.js";
11
+ import type { MemoryCategory } from "./db.js";
12
+
13
+ interface MemoryProposal {
14
+ content: string;
15
+ category: string;
16
+ importance: number;
17
+ source: string;
18
+ }
19
+
20
+ interface ProposalFile {
21
+ proposals: MemoryProposal[];
22
+ generatedAt: number;
23
+ }
24
+
25
+ export interface PendingProposal {
26
+ sessionId: string;
27
+ index: number;
28
+ content: string;
29
+ category: string;
30
+ importance: number;
31
+ source: string;
32
+ generatedAt: number;
33
+ }
34
+
35
+ function getProposalsDir(): string {
36
+ const base = process.env.CLAUDE_PLUGIN_DATA
37
+ ?? join(homedir(), ".claude", "plugins", "data", "OpenLtm-openltm");
38
+ return join(base, "proposals");
39
+ }
40
+
41
+ function readProposalFile(path: string): ProposalFile | null {
42
+ try {
43
+ const raw = readFileSync(path, "utf-8");
44
+ const parsed = JSON.parse(raw) as ProposalFile;
45
+ if (!Array.isArray(parsed.proposals)) return null;
46
+ return parsed;
47
+ } catch {
48
+ return null; // silent: malformed or missing file
49
+ }
50
+ }
51
+
52
+ export function listPendingProposals(): PendingProposal[] {
53
+ const dir = getProposalsDir();
54
+ if (!existsSync(dir)) return [];
55
+
56
+ const results: PendingProposal[] = [];
57
+ for (const file of readdirSync(dir)) {
58
+ if (!file.endsWith(".json")) continue;
59
+ const sessionId = file.replace(/\.json$/, "");
60
+ const data = readProposalFile(join(dir, file));
61
+ if (!data) continue;
62
+ for (let i = 0; i < data.proposals.length; i++) {
63
+ const p = data.proposals[i]!;
64
+ results.push({
65
+ sessionId,
66
+ index: i,
67
+ content: p.content,
68
+ category: p.category,
69
+ importance: p.importance,
70
+ source: p.source,
71
+ generatedAt: data.generatedAt,
72
+ });
73
+ }
74
+ }
75
+ return results.sort((a, b) => b.importance - a.importance || b.generatedAt - a.generatedAt);
76
+ }
77
+
78
+ export function acceptProposal(sessionId: string, index: number): boolean {
79
+ const dir = getProposalsDir();
80
+ const filePath = join(dir, `${sessionId}.json`);
81
+ const data = readProposalFile(filePath);
82
+ if (!data) return false;
83
+
84
+ const proposal = data.proposals[index];
85
+ if (!proposal) return false;
86
+
87
+ learn({
88
+ content: proposal.content,
89
+ category: proposal.category as MemoryCategory,
90
+ importance: proposal.importance,
91
+ source: proposal.source,
92
+ skipExport: true,
93
+ });
94
+
95
+ // Remove the accepted proposal from the file; delete file if empty
96
+ const remaining = data.proposals.filter((_, i) => i !== index);
97
+ if (remaining.length === 0) {
98
+ try { unlinkSync(filePath); } catch {}
99
+ } else {
100
+ writeFileSync(filePath, JSON.stringify({ ...data, proposals: remaining }, null, 2), "utf-8");
101
+ }
102
+ return true;
103
+ }
104
+
105
+ export function rejectProposal(sessionId: string, index: number): boolean {
106
+ const dir = getProposalsDir();
107
+ const filePath = join(dir, `${sessionId}.json`);
108
+ const data = readProposalFile(filePath);
109
+ if (!data) return false;
110
+
111
+ if (index < 0 || index >= data.proposals.length) return false;
112
+
113
+ const remaining = data.proposals.filter((_, i) => i !== index);
114
+ if (remaining.length === 0) {
115
+ try { unlinkSync(filePath); } catch {}
116
+ } else {
117
+ writeFileSync(filePath, JSON.stringify({ ...data, proposals: remaining }, null, 2), "utf-8");
118
+ }
119
+ return true;
120
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * providers/disabled.ts — No-op embedding provider.
3
+ * Default when no provider is configured. Zero I/O, zero API key required.
4
+ */
5
+ import type { EmbeddingProvider } from "./embeddingProvider.js";
6
+
7
+ export class DisabledProvider implements EmbeddingProvider {
8
+ readonly name = "disabled" as const;
9
+ readonly model = "none";
10
+ readonly dim = 0;
11
+
12
+ available(): Promise<boolean> {
13
+ return Promise.resolve(false);
14
+ }
15
+
16
+ generate(_text: string): Promise<Float32Array | null> {
17
+ return Promise.resolve(null);
18
+ }
19
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * providers/embeddingProvider.ts — Pluggable embedding provider interface.
3
+ * loadProvider() is the single factory used by learn() and recall().
4
+ * The "disabled" provider is the default — no API key required.
5
+ */
6
+
7
+ export type EmbeddingProviderName = "gemini" | "openai" | "ollama" | "disabled";
8
+
9
+ export interface EmbeddingConfig {
10
+ provider: EmbeddingProviderName;
11
+ apiKey?: string;
12
+ model?: string;
13
+ baseUrl?: string;
14
+ confidenceThreshold: number;
15
+ }
16
+
17
+ export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = {
18
+ provider: "disabled",
19
+ confidenceThreshold: 0.6,
20
+ };
21
+
22
+ export interface EmbeddingProvider {
23
+ readonly name: EmbeddingProviderName;
24
+ readonly model: string;
25
+ readonly dim: number;
26
+ available(): Promise<boolean>;
27
+ generate(text: string): Promise<Float32Array | null>;
28
+ }
29
+
30
+ /** Factory — returns the configured provider. Defaults to "disabled" if unrecognised. */
31
+ export async function loadProvider(config?: Partial<EmbeddingConfig>): Promise<EmbeddingProvider> {
32
+ const providerName = config?.provider ?? "disabled";
33
+
34
+ if (providerName === "gemini") {
35
+ const { GeminiProvider } = await import("./gemini.js");
36
+ return new GeminiProvider(config ?? {});
37
+ }
38
+ if (providerName === "openai") {
39
+ const { OpenAIProvider } = await import("./openai.js");
40
+ return new OpenAIProvider(config ?? {});
41
+ }
42
+ if (providerName === "ollama") {
43
+ const { OllamaProvider } = await import("./ollama.js");
44
+ return new OllamaProvider(config ?? {});
45
+ }
46
+
47
+ const { DisabledProvider } = await import("./disabled.js");
48
+ return new DisabledProvider();
49
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * providers/gemini.ts — Gemini embedding provider (EmbeddingProvider interface).
3
+ * Filled in by T5. Stub here to satisfy tsc for T3.
4
+ */
5
+ import type { EmbeddingProvider, EmbeddingProviderName, EmbeddingConfig } from "./embeddingProvider.js";
6
+
7
+ export class GeminiProvider implements EmbeddingProvider {
8
+ readonly name: EmbeddingProviderName = "gemini";
9
+ readonly model: string;
10
+ readonly dim = 768;
11
+
12
+ private apiKey: string;
13
+
14
+ constructor(config: Partial<EmbeddingConfig>) {
15
+ this.model = config.model ?? "text-embedding-004";
16
+ this.apiKey = config.apiKey ?? process.env["GEMINI_API_KEY"] ?? "";
17
+ }
18
+
19
+ async available(): Promise<boolean> {
20
+ return this.apiKey.length > 0;
21
+ }
22
+
23
+ async generate(text: string): Promise<Float32Array | null> {
24
+ if (!this.apiKey) return null;
25
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:embedContent?key=${this.apiKey}`;
26
+ const res = await fetch(url, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({ model: `models/${this.model}`, content: { parts: [{ text }] } }),
30
+ });
31
+ if (!res.ok) return null;
32
+ const json = await res.json() as { embedding?: { values?: number[] } };
33
+ const values = json?.embedding?.values;
34
+ if (!values) return null;
35
+ return new Float32Array(values);
36
+ }
37
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./embeddingProvider.js";
2
+ export * from "./disabled.js";