@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,43 @@
1
+ /**
2
+ * providers/ollama.ts — Ollama embedding provider (EmbeddingProvider interface).
3
+ */
4
+ import type { EmbeddingProvider, EmbeddingProviderName, EmbeddingConfig } from "./embeddingProvider.js";
5
+
6
+ export class OllamaProvider implements EmbeddingProvider {
7
+ readonly name: EmbeddingProviderName = "ollama";
8
+ readonly model: string;
9
+ readonly dim = 768;
10
+
11
+ private baseUrl: string;
12
+
13
+ constructor(config: Partial<EmbeddingConfig>) {
14
+ this.model = config.model ?? "nomic-embed-text";
15
+ this.baseUrl = config.baseUrl ?? process.env["OLLAMA_BASE_URL"] ?? "http://localhost:11434";
16
+ }
17
+
18
+ async available(): Promise<boolean> {
19
+ try {
20
+ const res = await fetch(`${this.baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) });
21
+ return res.ok;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ async generate(text: string): Promise<Float32Array | null> {
28
+ try {
29
+ const res = await fetch(`${this.baseUrl}/api/embed`, {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ model: this.model, input: text }),
33
+ });
34
+ if (!res.ok) return null;
35
+ const json = await res.json() as { embeddings?: number[][] };
36
+ const values = json?.embeddings?.[0];
37
+ if (!values) return null;
38
+ return new Float32Array(values);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * providers/openai.ts — OpenAI embedding provider (EmbeddingProvider interface).
3
+ */
4
+ import type { EmbeddingProvider, EmbeddingProviderName, EmbeddingConfig } from "./embeddingProvider.js";
5
+
6
+ export class OpenAIProvider implements EmbeddingProvider {
7
+ readonly name: EmbeddingProviderName = "openai";
8
+ readonly model: string;
9
+ readonly dim = 1536;
10
+
11
+ private apiKey: string;
12
+
13
+ constructor(config: Partial<EmbeddingConfig>) {
14
+ this.model = config.model ?? "text-embedding-3-small";
15
+ this.apiKey = config.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
16
+ }
17
+
18
+ async available(): Promise<boolean> {
19
+ return this.apiKey.length > 0;
20
+ }
21
+
22
+ async generate(text: string): Promise<Float32Array | null> {
23
+ if (!this.apiKey) return null;
24
+ const res = await fetch("https://api.openai.com/v1/embeddings", {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
27
+ body: JSON.stringify({ model: this.model, input: text }),
28
+ });
29
+ if (!res.ok) return null;
30
+ const json = await res.json() as { data?: { embedding?: number[] }[] };
31
+ const values = json?.data?.[0]?.embedding;
32
+ if (!values) return null;
33
+ return new Float32Array(values);
34
+ }
35
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * queue/index.ts — Honker durable queue for embedding generation.
3
+ *
4
+ * Replaces the fire-and-forget embedMemory() call with a durable job: learn()
5
+ * enqueues { memoryId }, a long-lived worker (queue/worker.ts) claims, embeds,
6
+ * and acks with retry/backoff + dead-letter. Every export degrades to a no-op
7
+ * (null / false) when Honker is unavailable; callers keep the inline path.
8
+ */
9
+ import type { HonkerEnqueueOptions, HonkerQueue, HonkerTransaction } from "../lib/honkerTypes.js";
10
+ import { getHonker } from "../lib/honker.js";
11
+
12
+ /** Durable queue name for embedding jobs. */
13
+ export const EMBED_QUEUE = "ltm-embeddings";
14
+
15
+ /** Retries before a job is dead-lettered (honker_retry moves to dead at maxAttempts). */
16
+ export const EMBED_MAX_ATTEMPTS = 5;
17
+
18
+ /** Payload shape for an embedding job. */
19
+ export interface EmbeddingJob {
20
+ memoryId: number;
21
+ }
22
+
23
+ /** The embedding Queue handle, or null when Honker is unavailable. */
24
+ export function getEmbeddingQueue(): HonkerQueue | null {
25
+ const h = getHonker();
26
+ return h ? h.queue(EMBED_QUEUE, { maxAttempts: EMBED_MAX_ATTEMPTS }) : null;
27
+ }
28
+
29
+ /**
30
+ * Enqueue an embedding job for a memory. Returns the job id, or null when the
31
+ * queue is unavailable (caller should embed inline instead). Pass `tx` to make
32
+ * the enqueue atomic with the memory INSERT — the job only becomes visible on
33
+ * commit and is dropped on rollback.
34
+ */
35
+ export function enqueueEmbedding(memoryId: number, opts?: { tx?: HonkerTransaction }): number | null {
36
+ const q = getEmbeddingQueue();
37
+ if (!q) return null;
38
+ try {
39
+ const enqOpts: HonkerEnqueueOptions = opts?.tx ? { tx: opts.tx } : {};
40
+ return q.enqueue({ memoryId } satisfies EmbeddingJob, enqOpts);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Narrow an unknown job payload to an EmbeddingJob, or null if malformed. */
47
+ export function parseEmbeddingJob(payload: unknown): EmbeddingJob | null {
48
+ if (payload && typeof payload === "object" && "memoryId" in payload) {
49
+ const id = (payload as { memoryId: unknown }).memoryId;
50
+ if (typeof id === "number" && Number.isInteger(id) && id > 0) return { memoryId: id };
51
+ }
52
+ return null;
53
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * queue/worker.ts — long-lived embedding worker.
3
+ *
4
+ * Claims { memoryId } jobs from the Honker embedding queue, generates the
5
+ * embedding via embedMemory() (which writes memory_embeddings + the vec0 index),
6
+ * and acks. Provider/transient errors retry with exponential backoff; Honker
7
+ * dead-letters once attempts exceed the queue's maxAttempts. Malformed payloads
8
+ * are failed immediately (no retry).
9
+ *
10
+ * Lifecycle: start ONCE in the long-lived process (graph-server). Short-lived
11
+ * hook processes only enqueue — they must never start a worker. Returns an inert
12
+ * handle when Honker is unavailable.
13
+ */
14
+ import { getEmbeddingQueue, parseEmbeddingJob } from "./index.js";
15
+
16
+ export interface EmbeddingWorkerHandle {
17
+ /** Whether a live worker loop is running (false when Honker is unavailable). */
18
+ readonly running: boolean;
19
+ /** Stop the loop and wait for the current iteration to settle. */
20
+ stop(): Promise<void>;
21
+ }
22
+
23
+ const INERT: EmbeddingWorkerHandle = { running: false, stop: async () => {} };
24
+
25
+ const BASE_BACKOFF_S = 5;
26
+ function backoffSeconds(attempts: number): number {
27
+ return Math.ceil(BASE_BACKOFF_S * 2 ** Math.max(0, attempts - 1));
28
+ }
29
+
30
+ /**
31
+ * Start the embedding worker. No-op (inert handle) when Honker is unavailable.
32
+ */
33
+ export function startEmbeddingWorker(opts?: { workerId?: string }): EmbeddingWorkerHandle {
34
+ const queue = getEmbeddingQueue();
35
+ if (!queue) return INERT;
36
+
37
+ const workerId = opts?.workerId ?? `ltm-embed-${process.pid}`;
38
+ const controller = new AbortController();
39
+ const waker = queue.claimWaker();
40
+
41
+ const loop = (async () => {
42
+ const { embedMemory } = await import("../embeddings.js");
43
+ const { getDb } = await import("../shared-db.js");
44
+ const db = getDb();
45
+ try {
46
+ while (!controller.signal.aborted) {
47
+ const job = await waker.next(workerId, { signal: controller.signal });
48
+ if (!job) return; // aborted
49
+ const parsed = parseEmbeddingJob(job.payload);
50
+ if (!parsed) {
51
+ job.fail("invalid embedding job payload");
52
+ continue;
53
+ }
54
+ try {
55
+ await embedMemory(db, parsed.memoryId);
56
+ job.ack();
57
+ } catch (err) {
58
+ if (controller.signal.aborted) return;
59
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
60
+ job.retry(backoffSeconds(job.attempts), msg);
61
+ }
62
+ }
63
+ } finally {
64
+ waker.close();
65
+ }
66
+ })();
67
+ // Prevent unhandled rejection if the loop throws on teardown.
68
+ loop.catch(() => {});
69
+
70
+ return {
71
+ running: true,
72
+ stop: async () => {
73
+ controller.abort();
74
+ try { await loop; } catch { /* settled */ }
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * recall/categorise.ts — Heuristic + LLM-fallback category classifier.
3
+ *
4
+ * Strategy:
5
+ * 1. Score each MemoryCategory by keyword density in the content.
6
+ * 2. If the top score meets or exceeds confidenceThreshold → return it.
7
+ * 3. Otherwise call Anthropic Messages API (claude-haiku) to classify.
8
+ * 4. If LLM call fails, fall back to the best heuristic guess.
9
+ */
10
+
11
+ import type { MemoryCategory } from "../dao/types.js";
12
+
13
+ export interface CategoriseResult {
14
+ category: MemoryCategory;
15
+ confidence: number;
16
+ source: "heuristic" | "llm";
17
+ }
18
+
19
+ // ── Keyword tables ─────────────────────────────────────────────────────────────
20
+
21
+ const KEYWORDS: Record<MemoryCategory, string[]> = {
22
+ preference: [
23
+ "prefer", "always use", "convention", "style", "format", "naming",
24
+ "use .* not", "default", "standard", "i like", "we use",
25
+ ],
26
+ architecture: [
27
+ "architecture", "design", "structure", "system", "component", "module",
28
+ "layer", "schema", "database", "api", "interface", "integration",
29
+ "dependency", "abstraction", "service",
30
+ ],
31
+ gotcha: [
32
+ "warning", "pitfall", "bug", "avoid", "never", "don't", "dont",
33
+ "broken", "fail", "trap", "careful", "watch out", "⚠", "issue",
34
+ "error", "problem", "beware",
35
+ ],
36
+ pattern: [
37
+ "pattern", "approach", "recipe", "template", "boilerplate",
38
+ "solution", "technique", "method", "strategy", "implement",
39
+ "how to", "example", "reusable",
40
+ ],
41
+ workflow: [
42
+ "process", "step", "procedure", "workflow", "pipeline",
43
+ "sequence", "flow", "first", "then", "finally", "checklist",
44
+ "before", "after", "when to",
45
+ ],
46
+ constraint: [
47
+ "must", "required", "mandatory", "cannot", "should not",
48
+ "limit", "restriction", "enforce", "rule", "policy", "compliance",
49
+ "always", "never allowed", "forbidden",
50
+ ],
51
+ };
52
+
53
+ // ── Heuristic scorer ───────────────────────────────────────────────────────────
54
+
55
+ function scoreHeuristic(content: string): { category: MemoryCategory; confidence: number } {
56
+ const lower = content.toLowerCase();
57
+
58
+ const scores = Object.entries(KEYWORDS).map(([cat, keywords]) => {
59
+ let hits = 0;
60
+ for (const kw of keywords) {
61
+ const re = new RegExp(kw, "gi");
62
+ const matches = lower.match(re);
63
+ if (matches) hits += matches.length;
64
+ }
65
+ return { category: cat as MemoryCategory, hits };
66
+ });
67
+
68
+ const total = scores.reduce((s, x) => s + x.hits, 0);
69
+ const best = scores.sort((a, b) => b.hits - a.hits)[0];
70
+
71
+ if (!best || best.hits === 0) {
72
+ return { category: "pattern", confidence: 0 };
73
+ }
74
+
75
+ // Confidence = share of total hits, clamped to [0, 1].
76
+ // A dominant category scores high; if two categories are equal, confidence halves.
77
+ const confidence = Math.min(best.hits / Math.max(total, 1), 1);
78
+ return { category: best.category, confidence };
79
+ }
80
+
81
+ // ── LLM fallback ───────────────────────────────────────────────────────────────
82
+
83
+ const CATEGORIES_LIST = Object.keys(KEYWORDS).join(", ");
84
+ const VALID_CATEGORIES = new Set<string>(Object.keys(KEYWORDS));
85
+
86
+ async function classifyWithLlm(content: string): Promise<MemoryCategory | null> {
87
+ const apiKey = process.env["ANTHROPIC_API_KEY"];
88
+ if (!apiKey) return null;
89
+
90
+ try {
91
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
92
+ method: "POST",
93
+ headers: {
94
+ "x-api-key": apiKey,
95
+ "anthropic-version": "2023-06-01",
96
+ "content-type": "application/json",
97
+ },
98
+ body: JSON.stringify({
99
+ model: "claude-haiku-4-5-20251001",
100
+ max_tokens: 20,
101
+ messages: [{
102
+ role: "user",
103
+ content: `Classify this memory into exactly one category. Reply with only the category name, nothing else.\n\nCategories: ${CATEGORIES_LIST}\n\nMemory: ${content.slice(0, 500)}`,
104
+ }],
105
+ }),
106
+ signal: AbortSignal.timeout(8_000),
107
+ });
108
+
109
+ if (!res.ok) return null;
110
+
111
+ const json = await res.json() as { content?: Array<{ type: string; text?: string }> };
112
+ const text = json.content?.[0]?.text?.trim().toLowerCase() ?? "";
113
+
114
+ return VALID_CATEGORIES.has(text) ? (text as MemoryCategory) : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // ── Public API ─────────────────────────────────────────────────────────────────
121
+
122
+ export async function categorise(
123
+ content: string,
124
+ confidenceThreshold = 0.6,
125
+ ): Promise<CategoriseResult> {
126
+ const heuristic = scoreHeuristic(content);
127
+
128
+ if (heuristic.confidence >= confidenceThreshold) {
129
+ return { ...heuristic, source: "heuristic" };
130
+ }
131
+
132
+ const llmCategory = await classifyWithLlm(content);
133
+ if (llmCategory) {
134
+ return { category: llmCategory, confidence: 1.0, source: "llm" };
135
+ }
136
+
137
+ // LLM unavailable or failed — return best heuristic guess regardless of confidence
138
+ return { ...heuristic, source: "heuristic" };
139
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * recall/explainer.ts — Pure function that builds a score breakdown for each recall result.
3
+ * No DB access. No side effects. Deterministic.
4
+ *
5
+ * Score formula:
6
+ * totalScore = (fts * 0.40) + (semantic * 0.35) + (importance * 0.15) + (recency * 0.10)
7
+ * When provider is disabled, FTS weight absorbs the semantic slot:
8
+ * totalScore = (fts * 0.75) + (importance * 0.15) + (recency * 0.10)
9
+ */
10
+
11
+ export type MemoryTemperature = "hot" | "warm" | "cool" | "cold";
12
+
13
+ export interface RecallExplainer {
14
+ ftsRank: number | null;
15
+ semanticScore: number | null;
16
+ importanceBoost: number;
17
+ recencyBoost: number;
18
+ totalScore: number;
19
+ temperature: MemoryTemperature;
20
+ }
21
+
22
+ export interface ExplainerInput {
23
+ importance: number;
24
+ recall_count: number;
25
+ last_recalled_at: string | null | undefined;
26
+ ftsRank?: number | null;
27
+ semanticScore?: number | null;
28
+ }
29
+
30
+ /** Compute memory temperature from access pattern. */
31
+ export function computeTemperature(
32
+ recallCount: number,
33
+ lastRecalledAt: string | null | undefined,
34
+ ): MemoryTemperature {
35
+ const daysSince = lastRecalledAt
36
+ ? (Date.now() - new Date(lastRecalledAt).getTime()) / 86_400_000
37
+ : Infinity;
38
+
39
+ if (recallCount >= 10 || daysSince <= 7) return "hot";
40
+ if (recallCount >= 3 || daysSince <= 30) return "warm";
41
+ if (recallCount >= 1 || daysSince <= 90) return "cool";
42
+ return "cold";
43
+ }
44
+
45
+ /** Build a RecallExplainer for a single memory result. Pure function — no I/O. */
46
+ export function buildExplainer(input: ExplainerInput): RecallExplainer {
47
+ const importanceBoost = Math.min(input.importance, 5) / 5;
48
+
49
+ const daysSince = input.last_recalled_at
50
+ ? (Date.now() - new Date(input.last_recalled_at).getTime()) / 86_400_000
51
+ : 90;
52
+ const recencyBoost = Math.max(0, 1 - daysSince / 90);
53
+
54
+ const ftsRank = input.ftsRank ?? null;
55
+ const semanticScore = input.semanticScore ?? null;
56
+
57
+ let totalScore: number;
58
+ if (semanticScore !== null && ftsRank !== null) {
59
+ totalScore = (ftsRank * 0.40) + (semanticScore * 0.35) + (importanceBoost * 0.15) + (recencyBoost * 0.10);
60
+ } else if (ftsRank !== null) {
61
+ totalScore = (ftsRank * 0.75) + (importanceBoost * 0.15) + (recencyBoost * 0.10);
62
+ } else if (semanticScore !== null) {
63
+ totalScore = (semanticScore * 0.75) + (importanceBoost * 0.15) + (recencyBoost * 0.10);
64
+ } else {
65
+ totalScore = (importanceBoost * 0.15) + (recencyBoost * 0.10);
66
+ }
67
+
68
+ return {
69
+ ftsRank,
70
+ semanticScore,
71
+ importanceBoost,
72
+ recencyBoost,
73
+ totalScore,
74
+ temperature: computeTemperature(input.recall_count, input.last_recalled_at),
75
+ };
76
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * scheduler/index.ts — Honker leader-elected cron for the janitor.
3
+ *
4
+ * Registers the janitor as a recurring Honker schedule and runs two loops in
5
+ * the long-lived process: (1) the built-in leader-elected scheduler loop (only
6
+ * ONE of the agent processes ticks, via Honker's advisory lock), which enqueues
7
+ * a `janitor-run` job on each boundary; (2) a worker that claims those jobs,
8
+ * runs the janitor, and `notify()`s the result on the "janitor" channel.
9
+ *
10
+ * Dormant + inert when Honker is unavailable — the caller keeps the existing
11
+ * `startAutoRun()` interval + `POST /api/janitor/run` fallback.
12
+ */
13
+ import { getHonker } from "../lib/honker.js";
14
+
15
+ export const JANITOR_SCHEDULE_NAME = "ltm-janitor";
16
+ export const JANITOR_QUEUE = "ltm-janitor-queue";
17
+ export const JANITOR_CHANNEL = "janitor";
18
+ export const DEFAULT_JANITOR_CRON = "@every 6h";
19
+
20
+ export interface JanitorSchedulerHandle {
21
+ /** Whether the leader loop + worker are running (false without Honker). */
22
+ readonly running: boolean;
23
+ /** Stop both loops and wait for them to settle. */
24
+ stop(): Promise<void>;
25
+ }
26
+
27
+ const INERT: JanitorSchedulerHandle = { running: false, stop: async () => {} };
28
+
29
+ /**
30
+ * Register the janitor schedule (idempotent by name). Returns false when Honker
31
+ * is unavailable.
32
+ */
33
+ export function registerJanitorSchedule(cron: string = DEFAULT_JANITOR_CRON): boolean {
34
+ const h = getHonker();
35
+ if (!h) return false;
36
+ try {
37
+ h.scheduler().add({
38
+ name: JANITOR_SCHEDULE_NAME,
39
+ queue: JANITOR_QUEUE,
40
+ schedule: cron,
41
+ payload: { kind: "janitor-run" },
42
+ });
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Start the leader-elected janitor scheduler + its run worker. No-op (inert
51
+ * handle) when Honker is unavailable.
52
+ */
53
+ export function startJanitorScheduler(opts?: { cron?: string; owner?: string }): JanitorSchedulerHandle {
54
+ const h = getHonker();
55
+ if (!h) return INERT;
56
+ if (!registerJanitorSchedule(opts?.cron)) return INERT;
57
+
58
+ const owner = opts?.owner ?? `ltm-sched-${process.pid}`;
59
+ const controller = new AbortController();
60
+ const scheduler = h.scheduler();
61
+ const queue = h.queue(JANITOR_QUEUE, { maxAttempts: 3 });
62
+ const waker = queue.claimWaker();
63
+
64
+ // Loop 1 — leader-elected ticking (enqueues janitor-run jobs on boundaries).
65
+ const leader = scheduler.run({ owner, signal: controller.signal }).catch(() => {});
66
+
67
+ // Loop 2 — claim janitor-run jobs, run the janitor, notify the result.
68
+ const worker = (async () => {
69
+ const { runJanitor } = await import("../janitor/index.js");
70
+ try {
71
+ while (!controller.signal.aborted) {
72
+ const job = await waker.next(owner, { signal: controller.signal });
73
+ if (!job) return;
74
+ try {
75
+ const status = await runJanitor();
76
+ job.ack();
77
+ try { h.notify(JANITOR_CHANNEL, { type: "janitor-complete", status }); } catch { /* notify best-effort */ }
78
+ } catch (err) {
79
+ if (controller.signal.aborted) return;
80
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
81
+ job.retry(30, msg);
82
+ }
83
+ }
84
+ } finally {
85
+ waker.close();
86
+ }
87
+ })();
88
+ worker.catch(() => {});
89
+
90
+ return {
91
+ running: true,
92
+ stop: async () => {
93
+ controller.abort();
94
+ await Promise.allSettled([leader, worker]);
95
+ },
96
+ };
97
+ }