@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.
- package/README.md +67 -0
- package/assets/opencode/agents/aegis.md +211 -0
- package/assets/opencode/plugins/aegis.ts +3 -0
- package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
- package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
- package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
- package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
- package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
- package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
- package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
- package/package.json +41 -0
- package/src/__tests__/cli/claude.test.ts +122 -0
- package/src/__tests__/cli/detect.test.ts +91 -0
- package/src/__tests__/cli/install.test.ts +161 -0
- package/src/__tests__/cli/opencode.test.ts +169 -0
- package/src/__tests__/cli/pi.test.ts +113 -0
- package/src/__tests__/cli.test.ts +70 -0
- package/src/__tests__/events/crossProcess.test.ts +82 -0
- package/src/__tests__/events/index.test.ts +32 -0
- package/src/__tests__/extensions.test.ts +81 -0
- package/src/__tests__/migrations/retention.test.ts +118 -0
- package/src/__tests__/queue/index.test.ts +61 -0
- package/src/__tests__/scheduler/index.test.ts +39 -0
- package/src/__tests__/vec/index.test.ts +130 -0
- package/src/__tests__/vec/parity.test.ts +70 -0
- package/src/adapterTypes.ts +23 -0
- package/src/cli/_shared.ts +120 -0
- package/src/cli/bin.ts +97 -0
- package/src/cli/claude.ts +124 -0
- package/src/cli/detect.ts +55 -0
- package/src/cli/hook.ts +25 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/install.ts +185 -0
- package/src/cli/opencode.ts +193 -0
- package/src/cli/pi.ts +74 -0
- package/src/cli/types.ts +78 -0
- package/src/config.ts +163 -0
- package/src/context.ts +172 -0
- package/src/dao/conflicts.ts +26 -0
- package/src/dao/contextItems.ts +70 -0
- package/src/dao/embeddings.ts +78 -0
- package/src/dao/index.ts +9 -0
- package/src/dao/provenanceAudit.ts +108 -0
- package/src/dao/types.ts +142 -0
- package/src/db.ts +780 -0
- package/src/dedup.ts +12 -0
- package/src/embeddings.ts +386 -0
- package/src/events/index.ts +130 -0
- package/src/extensions.ts +140 -0
- package/src/graph.ts +268 -0
- package/src/index.ts +95 -0
- package/src/janitor/archive.ts +66 -0
- package/src/janitor/decay.ts +60 -0
- package/src/janitor/dedup.ts +333 -0
- package/src/janitor/embeddings.ts +209 -0
- package/src/janitor/index.ts +215 -0
- package/src/janitor/promote.ts +188 -0
- package/src/janitor/providers/anthropic.ts +91 -0
- package/src/janitor/providers/cohere.ts +135 -0
- package/src/janitor/providers/gemini.ts +156 -0
- package/src/janitor/providers/ollama.ts +177 -0
- package/src/janitor/providers/openai.ts +121 -0
- package/src/janitor/providers/openrouter.ts +182 -0
- package/src/janitor/providers/types.ts +154 -0
- package/src/janitor/providers/utils.ts +35 -0
- package/src/janitor/supersedes.ts +199 -0
- package/src/lib/honker.ts +54 -0
- package/src/lib/honkerTypes.ts +109 -0
- package/src/lib/jsonlLogger.ts +92 -0
- package/src/lib/writeQueue.ts +28 -0
- package/src/migrations.ts +415 -0
- package/src/paths.ts +22 -0
- package/src/proposals.ts +120 -0
- package/src/providers/disabled.ts +19 -0
- package/src/providers/embeddingProvider.ts +49 -0
- package/src/providers/gemini.ts +37 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/ollama.ts +43 -0
- package/src/providers/openai.ts +35 -0
- package/src/queue/index.ts +53 -0
- package/src/queue/worker.ts +77 -0
- package/src/recall/categorise.ts +139 -0
- package/src/recall/explainer.ts +76 -0
- package/src/scheduler/index.ts +97 -0
- package/src/schema.sql +191 -0
- package/src/secretsScrubber.ts +105 -0
- package/src/shared-db.ts +158 -0
- package/src/vec/index.ts +161 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — Provider interfaces for the janitor system.
|
|
3
|
+
* Supports embedding generation (for semantic search/dedup)
|
|
4
|
+
* and LLM calls (for smart promote/dedup decisions).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A single embedding vector — float32 array stored as BLOB in SQLite. */
|
|
8
|
+
export type EmbeddingVector = Float32Array;
|
|
9
|
+
|
|
10
|
+
/** Input for batch embedding generation. */
|
|
11
|
+
export interface EmbedInput {
|
|
12
|
+
/** Text strings to embed. */
|
|
13
|
+
texts: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Result from embedding generation. */
|
|
17
|
+
export interface EmbedResult {
|
|
18
|
+
/** Embedding vectors, one per input text. Same order as input. */
|
|
19
|
+
vectors: EmbeddingVector[];
|
|
20
|
+
/** Model used for generation. */
|
|
21
|
+
model: string;
|
|
22
|
+
/** Dimensionality of each vector. */
|
|
23
|
+
dimensions: number;
|
|
24
|
+
/** Total tokens consumed (for cost tracking). */
|
|
25
|
+
totalTokens: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Embedding provider — generates vector representations of text. */
|
|
29
|
+
export interface EmbeddingProvider {
|
|
30
|
+
/** Provider name for display/logging. */
|
|
31
|
+
readonly name: string;
|
|
32
|
+
/** Generate embeddings for a batch of texts. */
|
|
33
|
+
embed(input: EmbedInput): Promise<EmbedResult>;
|
|
34
|
+
/** Verify the provider is configured and reachable. */
|
|
35
|
+
verify(): Promise<{ ok: boolean; error?: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A single chat message for LLM calls. */
|
|
39
|
+
export interface ChatMessage {
|
|
40
|
+
role: "system" | "user" | "assistant";
|
|
41
|
+
content: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Input for LLM chat completion. */
|
|
45
|
+
export interface ChatInput {
|
|
46
|
+
messages: ChatMessage[];
|
|
47
|
+
/** Max tokens to generate. */
|
|
48
|
+
maxTokens?: number;
|
|
49
|
+
/** Temperature (0.0 = deterministic, 1.0 = creative). */
|
|
50
|
+
temperature?: number;
|
|
51
|
+
/** If true, expect JSON output. */
|
|
52
|
+
jsonMode?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Result from LLM chat completion. */
|
|
56
|
+
export interface ChatResult {
|
|
57
|
+
content: string;
|
|
58
|
+
model: string;
|
|
59
|
+
promptTokens: number;
|
|
60
|
+
completionTokens: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** LLM provider — used for smart dedup/promote decisions. */
|
|
64
|
+
export interface LLMProvider {
|
|
65
|
+
readonly name: string;
|
|
66
|
+
chat(input: ChatInput): Promise<ChatResult>;
|
|
67
|
+
verify(): Promise<{ ok: boolean; error?: string }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Settings keys used by the janitor for provider configuration. */
|
|
71
|
+
export const SETTING_KEYS = {
|
|
72
|
+
/** Embedding provider */
|
|
73
|
+
EMBED_PROVIDER: "ltm.embed.provider",
|
|
74
|
+
/** LLM provider for smart decisions */
|
|
75
|
+
LLM_PROVIDER: "ltm.llm.provider",
|
|
76
|
+
// Gemini
|
|
77
|
+
GEMINI_API_KEY: "ltm.gemini.apiKey",
|
|
78
|
+
GEMINI_EMBED_MODEL: "ltm.gemini.embedModel",
|
|
79
|
+
GEMINI_LLM_MODEL: "ltm.gemini.llmModel",
|
|
80
|
+
// OpenRouter
|
|
81
|
+
OPENROUTER_API_KEY: "ltm.openrouter.apiKey",
|
|
82
|
+
OPENROUTER_EMBED_MODEL: "ltm.openrouter.embedModel",
|
|
83
|
+
OPENROUTER_LLM_MODEL: "ltm.openrouter.llmModel",
|
|
84
|
+
// Ollama
|
|
85
|
+
OLLAMA_BASE_URL: "ltm.ollama.baseUrl",
|
|
86
|
+
OLLAMA_EMBED_MODEL: "ltm.ollama.embedModel",
|
|
87
|
+
OLLAMA_LLM_MODEL: "ltm.ollama.llmModel",
|
|
88
|
+
// OpenAI
|
|
89
|
+
OPENAI_API_KEY: "ltm.openai.apiKey",
|
|
90
|
+
OPENAI_EMBED_MODEL: "ltm.openai.embedModel",
|
|
91
|
+
OPENAI_LLM_MODEL: "ltm.openai.llmModel",
|
|
92
|
+
// Anthropic (LLM only)
|
|
93
|
+
ANTHROPIC_API_KEY: "ltm.anthropic.apiKey",
|
|
94
|
+
ANTHROPIC_LLM_MODEL: "ltm.anthropic.llmModel",
|
|
95
|
+
// Cohere
|
|
96
|
+
COHERE_API_KEY: "ltm.cohere.apiKey",
|
|
97
|
+
COHERE_EMBED_MODEL: "ltm.cohere.embedModel",
|
|
98
|
+
COHERE_LLM_MODEL: "ltm.cohere.llmModel",
|
|
99
|
+
// Decay
|
|
100
|
+
DECAY_GRACE_DAYS: "ltm.decay.graceDays",
|
|
101
|
+
DECAY_RATE: "ltm.decay.rate",
|
|
102
|
+
DECAY_MIN_CONFIDENCE: "ltm.decay.minConfidence",
|
|
103
|
+
// Promote & Janitor
|
|
104
|
+
PROMOTE_MIN_IMPORTANCE: "ltm.promote.minImportance",
|
|
105
|
+
JANITOR_INTERVAL_MINUTES: "ltm.janitor.intervalMinutes",
|
|
106
|
+
// Janitor run tracking (Phase 4)
|
|
107
|
+
JANITOR_LAST_RUN_AT: "ltm.janitor.lastRunAt",
|
|
108
|
+
JANITOR_LAST_DECAY_REFRESHED: "ltm.janitor.lastDecayRefreshed",
|
|
109
|
+
JANITOR_LAST_DEPRECATED: "ltm.janitor.lastDeprecated",
|
|
110
|
+
JANITOR_LAST_ARCHIVED: "ltm.janitor.lastArchived",
|
|
111
|
+
// Cross-process sync (Phase 8 X1) — opt-in, requires Honker pub/sub
|
|
112
|
+
CROSS_PROCESS_SYNC: "ltm.crossProcessSync",
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
/** Default values for all settings. */
|
|
116
|
+
export const SETTING_DEFAULTS: Record<string, string> = {
|
|
117
|
+
[SETTING_KEYS.EMBED_PROVIDER]: "gemini",
|
|
118
|
+
[SETTING_KEYS.LLM_PROVIDER]: "gemini",
|
|
119
|
+
[SETTING_KEYS.GEMINI_API_KEY]: "",
|
|
120
|
+
[SETTING_KEYS.GEMINI_EMBED_MODEL]: "text-embedding-004",
|
|
121
|
+
[SETTING_KEYS.GEMINI_LLM_MODEL]: "gemini-2.0-flash",
|
|
122
|
+
[SETTING_KEYS.OPENROUTER_API_KEY]: "",
|
|
123
|
+
[SETTING_KEYS.OPENROUTER_EMBED_MODEL]: "openai/text-embedding-3-small",
|
|
124
|
+
[SETTING_KEYS.OPENROUTER_LLM_MODEL]: "google/gemini-2.0-flash-001",
|
|
125
|
+
[SETTING_KEYS.OLLAMA_BASE_URL]: "http://localhost:11434",
|
|
126
|
+
[SETTING_KEYS.OLLAMA_EMBED_MODEL]: "nomic-embed-text",
|
|
127
|
+
[SETTING_KEYS.OLLAMA_LLM_MODEL]: "llama3.2",
|
|
128
|
+
[SETTING_KEYS.OPENAI_API_KEY]: "",
|
|
129
|
+
[SETTING_KEYS.OPENAI_EMBED_MODEL]: "text-embedding-3-small",
|
|
130
|
+
[SETTING_KEYS.OPENAI_LLM_MODEL]: "gpt-4o-mini",
|
|
131
|
+
[SETTING_KEYS.ANTHROPIC_API_KEY]: "",
|
|
132
|
+
[SETTING_KEYS.ANTHROPIC_LLM_MODEL]: "claude-haiku-4-5-20251001",
|
|
133
|
+
[SETTING_KEYS.COHERE_API_KEY]: "",
|
|
134
|
+
[SETTING_KEYS.COHERE_EMBED_MODEL]: "embed-v4.0",
|
|
135
|
+
[SETTING_KEYS.COHERE_LLM_MODEL]: "command-r-plus",
|
|
136
|
+
[SETTING_KEYS.DECAY_GRACE_DAYS]: "30",
|
|
137
|
+
[SETTING_KEYS.DECAY_RATE]: "0.02",
|
|
138
|
+
[SETTING_KEYS.DECAY_MIN_CONFIDENCE]: "0.2",
|
|
139
|
+
[SETTING_KEYS.PROMOTE_MIN_IMPORTANCE]: "3",
|
|
140
|
+
[SETTING_KEYS.JANITOR_INTERVAL_MINUTES]: "0",
|
|
141
|
+
[SETTING_KEYS.JANITOR_LAST_RUN_AT]: "",
|
|
142
|
+
[SETTING_KEYS.JANITOR_LAST_DECAY_REFRESHED]: "0",
|
|
143
|
+
[SETTING_KEYS.JANITOR_LAST_DEPRECATED]: "0",
|
|
144
|
+
[SETTING_KEYS.JANITOR_LAST_ARCHIVED]: "0",
|
|
145
|
+
[SETTING_KEYS.CROSS_PROCESS_SYNC]: "off",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Provider type identifiers. */
|
|
149
|
+
export type ProviderType = "gemini" | "openrouter" | "ollama" | "openai" | "anthropic" | "cohere";
|
|
150
|
+
|
|
151
|
+
/** Get a default setting value, guaranteed non-undefined. */
|
|
152
|
+
export function getDefault(key: string): string {
|
|
153
|
+
return SETTING_DEFAULTS[key] ?? "";
|
|
154
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils.ts — Shared helpers for all janitor providers.
|
|
3
|
+
* Eliminates copy-paste of getApiKey / getModel patterns.
|
|
4
|
+
*/
|
|
5
|
+
import { getSetting } from "../../shared-db.js";
|
|
6
|
+
import { getDefault } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Create a getter for an API key setting, with env-var fallback and missing-key error. */
|
|
9
|
+
export function makeApiKeyGetter(
|
|
10
|
+
settingKey: string,
|
|
11
|
+
envVar: string,
|
|
12
|
+
providerName: string,
|
|
13
|
+
): () => string {
|
|
14
|
+
return () => {
|
|
15
|
+
const key = getSetting(settingKey) || process.env[envVar] || "";
|
|
16
|
+
if (!key)
|
|
17
|
+
throw new Error(
|
|
18
|
+
`${providerName} API key not configured. Set it in Settings or ${envVar} env var.`,
|
|
19
|
+
);
|
|
20
|
+
return key;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create a getter for a model setting, with defaults fallback. */
|
|
25
|
+
export function makeModelGetter(settingKey: string): () => string {
|
|
26
|
+
return () => getSetting(settingKey) || getDefault(settingKey);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the standard `{ ok: false, error }` result for a failed HTTP response. */
|
|
30
|
+
export async function httpErrorResult(
|
|
31
|
+
res: Response,
|
|
32
|
+
): Promise<{ ok: false; error: string }> {
|
|
33
|
+
const body = await res.text();
|
|
34
|
+
return { ok: false, error: `${res.status}: ${body}` };
|
|
35
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* supersedes.ts — Manage supersedes relations between memories.
|
|
3
|
+
* When a new memory supersedes an old one, the old memory is marked
|
|
4
|
+
* as 'superseded' and a directed relation is created.
|
|
5
|
+
*/
|
|
6
|
+
import { getDb } from "../shared-db.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mark a memory as superseding another.
|
|
10
|
+
* - Creates a 'supersedes' relation from newId -> oldId
|
|
11
|
+
* - Sets the old memory's status to 'superseded'
|
|
12
|
+
* - Optionally transfers tags from old to new
|
|
13
|
+
*/
|
|
14
|
+
export function supersede(
|
|
15
|
+
newId: number,
|
|
16
|
+
oldId: number,
|
|
17
|
+
transferTags = true,
|
|
18
|
+
): void {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
|
|
21
|
+
const newMem = db.query("SELECT id, status FROM memories WHERE id = ?").get(newId) as { id: number; status: string } | undefined;
|
|
22
|
+
const oldMem = db.query("SELECT id, status FROM memories WHERE id = ?").get(oldId) as { id: number; status: string } | undefined;
|
|
23
|
+
|
|
24
|
+
if (!newMem) throw new Error(`New memory ${newId} not found`);
|
|
25
|
+
if (!oldMem) throw new Error(`Old memory ${oldId} not found`);
|
|
26
|
+
if (newId === oldId) throw new Error("Cannot supersede self");
|
|
27
|
+
|
|
28
|
+
db.transaction(() => {
|
|
29
|
+
db.run(
|
|
30
|
+
`INSERT OR IGNORE INTO memory_relations (source_memory_id, target_memory_id, relationship_type)
|
|
31
|
+
VALUES (?, ?, 'supersedes')`,
|
|
32
|
+
[newId, oldId],
|
|
33
|
+
);
|
|
34
|
+
db.run("UPDATE memories SET status = 'superseded' WHERE id = ?", [oldId]);
|
|
35
|
+
if (transferTags) {
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT OR IGNORE INTO memory_tags (memory_id, tag_id)
|
|
38
|
+
SELECT ?, tag_id FROM memory_tags WHERE memory_id = ?`,
|
|
39
|
+
[newId, oldId],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
db.run("UPDATE context_items SET memory_id = ? WHERE memory_id = ?", [newId, oldId]);
|
|
43
|
+
})();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getSupersededChain(
|
|
47
|
+
memoryId: number,
|
|
48
|
+
): Array<{ id: number; content: string; created_at: string }> {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
const chain: Array<{ id: number; content: string; created_at: string }> = [];
|
|
51
|
+
const visited = new Set<number>();
|
|
52
|
+
const queue = [memoryId];
|
|
53
|
+
|
|
54
|
+
while (queue.length > 0) {
|
|
55
|
+
const currentId = queue.shift()!;
|
|
56
|
+
if (visited.has(currentId)) continue;
|
|
57
|
+
visited.add(currentId);
|
|
58
|
+
|
|
59
|
+
const superseded = db.query(
|
|
60
|
+
`SELECT m.id, m.content, m.created_at
|
|
61
|
+
FROM memories m
|
|
62
|
+
JOIN memory_relations r ON r.target_memory_id = m.id
|
|
63
|
+
WHERE r.source_memory_id = ? AND r.relationship_type = 'supersedes'`
|
|
64
|
+
).all(currentId) as typeof chain;
|
|
65
|
+
|
|
66
|
+
for (const s of superseded) {
|
|
67
|
+
chain.push(s);
|
|
68
|
+
queue.push(s.id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return chain;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getSupersededBy(
|
|
76
|
+
memoryId: number,
|
|
77
|
+
): { id: number; content: string } | null {
|
|
78
|
+
const db = getDb();
|
|
79
|
+
return db.query(
|
|
80
|
+
`SELECT m.id, m.content
|
|
81
|
+
FROM memories m
|
|
82
|
+
JOIN memory_relations r ON r.source_memory_id = m.id
|
|
83
|
+
WHERE r.target_memory_id = ? AND r.relationship_type = 'supersedes'
|
|
84
|
+
LIMIT 1`
|
|
85
|
+
).get(memoryId) as { id: number; content: string } | null ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function unsupersede(newId: number, oldId: number): void {
|
|
89
|
+
const db = getDb();
|
|
90
|
+
|
|
91
|
+
db.transaction(() => {
|
|
92
|
+
db.run(
|
|
93
|
+
`DELETE FROM memory_relations
|
|
94
|
+
WHERE source_memory_id = ? AND target_memory_id = ? AND relationship_type = 'supersedes'`,
|
|
95
|
+
[newId, oldId],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const stillSuperseded = db.query(
|
|
99
|
+
`SELECT COUNT(*) as cnt FROM memory_relations
|
|
100
|
+
WHERE target_memory_id = ? AND relationship_type = 'supersedes'`
|
|
101
|
+
).get(oldId) as { cnt: number } | undefined;
|
|
102
|
+
|
|
103
|
+
if ((stillSuperseded?.cnt ?? 0) === 0) {
|
|
104
|
+
db.run("UPDATE memories SET status = 'active' WHERE id = ? AND status = 'superseded'", [oldId]);
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ============================================================
|
|
110
|
+
// Contradiction detection
|
|
111
|
+
// ============================================================
|
|
112
|
+
|
|
113
|
+
export interface Contradiction {
|
|
114
|
+
olderId: number;
|
|
115
|
+
olderContent: string;
|
|
116
|
+
newerId: number;
|
|
117
|
+
newerContent: string;
|
|
118
|
+
term: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const CONTRADICTION_PAIRS = [
|
|
122
|
+
["npm", "bun"],
|
|
123
|
+
["yarn", "bun"],
|
|
124
|
+
["npm", "pnpm"],
|
|
125
|
+
["rest", "graphql"],
|
|
126
|
+
["sql", "nosql"],
|
|
127
|
+
["mysql", "postgres"],
|
|
128
|
+
["javascript", "typescript"],
|
|
129
|
+
["js", "ts"],
|
|
130
|
+
["class", "functional"],
|
|
131
|
+
["oop", "functional"],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
export function detectContradictions(
|
|
135
|
+
projectScope: string | null,
|
|
136
|
+
): Contradiction[] {
|
|
137
|
+
const db = getDb();
|
|
138
|
+
const contradictions: Contradiction[] = [];
|
|
139
|
+
|
|
140
|
+
let memories: Array<{ id: number; content: string; category: string; created_at: string }>;
|
|
141
|
+
if (projectScope) {
|
|
142
|
+
memories = db.query(
|
|
143
|
+
`SELECT id, content, category, created_at FROM memories WHERE project_scope = ? AND status = 'active'`
|
|
144
|
+
).all(projectScope) as typeof memories;
|
|
145
|
+
} else {
|
|
146
|
+
memories = db.query(
|
|
147
|
+
`SELECT id, content, category, created_at FROM memories WHERE project_scope IS NULL AND status = 'active'`
|
|
148
|
+
).all() as typeof memories;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const mem of memories) {
|
|
152
|
+
const content = mem.content.toLowerCase();
|
|
153
|
+
|
|
154
|
+
for (const [term1, term2] of CONTRADICTION_PAIRS) {
|
|
155
|
+
if (content.includes(term1)) {
|
|
156
|
+
const opposite = memories.filter(
|
|
157
|
+
m => m.id !== mem.id &&
|
|
158
|
+
m.content.toLowerCase().includes(term2) &&
|
|
159
|
+
m.category === mem.category,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
for (const opp of opposite) {
|
|
163
|
+
if (new Date(opp.created_at) > new Date(mem.created_at)) {
|
|
164
|
+
contradictions.push({
|
|
165
|
+
olderId: mem.id,
|
|
166
|
+
olderContent: mem.content,
|
|
167
|
+
newerId: opp.id,
|
|
168
|
+
newerContent: opp.content,
|
|
169
|
+
term: `${term1} vs ${term2}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return contradictions;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function applyContradictions(contradictions: Contradiction[]): number {
|
|
181
|
+
const db = getDb();
|
|
182
|
+
let applied = 0;
|
|
183
|
+
|
|
184
|
+
for (const con of contradictions) {
|
|
185
|
+
const existing = db.query(
|
|
186
|
+
`SELECT id FROM memories WHERE id = ? AND superseded_by IS NOT NULL`
|
|
187
|
+
).get(con.olderId) as { id: number } | null;
|
|
188
|
+
|
|
189
|
+
if (existing) continue;
|
|
190
|
+
|
|
191
|
+
db.run(
|
|
192
|
+
`UPDATE memories SET superseded_by = ?, superseded_at = datetime('now') WHERE id = ?`,
|
|
193
|
+
[con.newerId, con.olderId],
|
|
194
|
+
);
|
|
195
|
+
applied++;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return applied;
|
|
199
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/honker.ts — process-wide lazy handle to the Honker runtime.
|
|
3
|
+
*
|
|
4
|
+
* Honker (durable queues, pub/sub, scheduler) is an OPTIONAL loadable SQLite
|
|
5
|
+
* extension. honker-bun's `open()` manages its OWN connection + update watcher
|
|
6
|
+
* over the same openltm.db file, so queue/events/scheduler share a single handle
|
|
7
|
+
* created here. Everything degrades to null when:
|
|
8
|
+
* - the capability probe reports honker unavailable (getCapabilities().honker)
|
|
9
|
+
* - LTM_HONKER_EXT is unset / the binary is missing
|
|
10
|
+
* - the honker-bun package isn't installed, or open() throws
|
|
11
|
+
*
|
|
12
|
+
* NOTHING here throws — callers branch on a null handle and fall back to the
|
|
13
|
+
* pre-Honker behaviour (inline embed, HTTP poll, file-watcher).
|
|
14
|
+
*/
|
|
15
|
+
import type { HonkerDb, HonkerModule } from "./honkerTypes.js";
|
|
16
|
+
import { getCapabilities, locateSystemSqlite } from "../extensions.js";
|
|
17
|
+
import { DB_PATH } from "../shared-db.js";
|
|
18
|
+
|
|
19
|
+
let _handle: HonkerDb | null | undefined = undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Return the shared Honker handle, opening it once on first use. Returns null
|
|
23
|
+
* (cached) whenever Honker is unavailable. Synchronous — honker-bun's open() is
|
|
24
|
+
* synchronous and we require the package lazily so an absent install is a no-op.
|
|
25
|
+
*/
|
|
26
|
+
export function getHonker(): HonkerDb | null {
|
|
27
|
+
if (_handle !== undefined) return _handle;
|
|
28
|
+
_handle = openHonker();
|
|
29
|
+
return _handle;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function openHonker(): HonkerDb | null {
|
|
33
|
+
if (!getCapabilities().honker) return null;
|
|
34
|
+
const ext = process.env["LTM_HONKER_EXT"];
|
|
35
|
+
if (!ext) return null;
|
|
36
|
+
try {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
38
|
+
const mod = require("@russellthehippo/honker-bun") as HonkerModule;
|
|
39
|
+
const lib = locateSystemSqlite();
|
|
40
|
+
return mod.open(DB_PATH, ext, lib ? { sqliteLibPath: lib } : {});
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when a live Honker handle is available. */
|
|
47
|
+
export function isHonkerAvailable(): boolean {
|
|
48
|
+
return getHonker() !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Test-only: drop the cached handle (does not close it). */
|
|
52
|
+
export function _resetHonkerForTesting(): void {
|
|
53
|
+
_handle = undefined;
|
|
54
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* honkerTypes.ts — minimal structural types for the Honker runtime surface we
|
|
3
|
+
* use. We deliberately do NOT `import` from @russellthehippo/honker-bun: it
|
|
4
|
+
* ships raw .ts with internal type errors that tsc would compile (skipLibCheck
|
|
5
|
+
* only skips .d.ts), and it is an OPTIONAL dependency that may be absent in
|
|
6
|
+
* consumer installs. These local types keep the build self-contained and let
|
|
7
|
+
* every Honker call site stay strongly typed against the verified API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface HonkerEnqueueOptions {
|
|
11
|
+
delay?: number;
|
|
12
|
+
runAt?: number;
|
|
13
|
+
priority?: number;
|
|
14
|
+
expires?: number;
|
|
15
|
+
tx?: HonkerTransaction;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HonkerQueueOptions {
|
|
19
|
+
visibilityTimeoutS?: number;
|
|
20
|
+
maxAttempts?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HonkerJob {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly queue: string;
|
|
26
|
+
readonly payload: unknown;
|
|
27
|
+
readonly workerId: string;
|
|
28
|
+
readonly attempts: number;
|
|
29
|
+
ack(): boolean;
|
|
30
|
+
retry(delaySec: number, errorMsg: string): boolean;
|
|
31
|
+
fail(errorMsg: string): boolean;
|
|
32
|
+
heartbeat(extendSec: number): boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HonkerClaimWaker {
|
|
36
|
+
tryNext(workerId: string): HonkerJob | null;
|
|
37
|
+
next(workerId: string, opts?: { signal?: AbortSignal }): Promise<HonkerJob | null>;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HonkerQueue {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
enqueue(payload: unknown, opts?: HonkerEnqueueOptions): number;
|
|
44
|
+
claimBatch(workerId: string, n: number): HonkerJob[];
|
|
45
|
+
claimOne(workerId: string): HonkerJob | null;
|
|
46
|
+
ackBatch(ids: number[], workerId: string): number;
|
|
47
|
+
sweepExpired(): number;
|
|
48
|
+
nextClaimAt(): number | null;
|
|
49
|
+
claimWaker(opts?: { idlePollS?: number | null; pollMs?: number | null }): HonkerClaimWaker;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface HonkerTransaction {
|
|
53
|
+
readonly raw: unknown;
|
|
54
|
+
commit(): void;
|
|
55
|
+
rollback(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HonkerScheduledTask {
|
|
59
|
+
name: string;
|
|
60
|
+
queue: string;
|
|
61
|
+
schedule?: string;
|
|
62
|
+
cron?: string;
|
|
63
|
+
payload: unknown;
|
|
64
|
+
priority?: number;
|
|
65
|
+
expiresS?: number | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface HonkerScheduledFire {
|
|
69
|
+
name: string;
|
|
70
|
+
queue: string;
|
|
71
|
+
fire_at: number;
|
|
72
|
+
job_id: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface HonkerScheduler {
|
|
76
|
+
add(task: HonkerScheduledTask): void;
|
|
77
|
+
remove(name: string): number;
|
|
78
|
+
tick(): HonkerScheduledFire[];
|
|
79
|
+
soonest(): number;
|
|
80
|
+
/** Leader-elected blocking loop — only the lock holder ticks. */
|
|
81
|
+
run(opts: { owner: string; signal: AbortSignal }): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface HonkerNotification {
|
|
85
|
+
id: number;
|
|
86
|
+
channel: string;
|
|
87
|
+
payload: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface HonkerDb {
|
|
91
|
+
readonly raw: unknown;
|
|
92
|
+
queue(name: string, opts?: HonkerQueueOptions): HonkerQueue;
|
|
93
|
+
scheduler(): HonkerScheduler;
|
|
94
|
+
notify(channel: string, payload: unknown, opts?: { tx?: HonkerTransaction }): number;
|
|
95
|
+
listen(
|
|
96
|
+
channel: string,
|
|
97
|
+
opts?: { signal?: AbortSignal; pollMs?: number },
|
|
98
|
+
): AsyncIterableIterator<HonkerNotification>;
|
|
99
|
+
transaction(): HonkerTransaction;
|
|
100
|
+
close(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface HonkerModule {
|
|
104
|
+
open(
|
|
105
|
+
path: string,
|
|
106
|
+
extensionPath: string,
|
|
107
|
+
opts?: { sqliteLibPath?: string; watcherBackend?: string | null },
|
|
108
|
+
): HonkerDb;
|
|
109
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonlLogger.ts — Structured JSONL event log for the LTM plugin.
|
|
3
|
+
*
|
|
4
|
+
* Write path: ${CLAUDE_PLUGIN_DATA}/logs/ltm.jsonl
|
|
5
|
+
* Rotation: 10MB → keep last 7MB (byte-slice, no rename needed).
|
|
6
|
+
*
|
|
7
|
+
* Supplements hookLogger.ts — does NOT replace it.
|
|
8
|
+
* Never throws — all errors are silently swallowed to protect hook execution.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
|
|
14
|
+
export interface LtmEvent {
|
|
15
|
+
ts: string;
|
|
16
|
+
hook: string;
|
|
17
|
+
event: string;
|
|
18
|
+
project?: string;
|
|
19
|
+
count?: number;
|
|
20
|
+
durationMs?: number;
|
|
21
|
+
detail?: string;
|
|
22
|
+
level?: "info" | "warn" | "error";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
26
|
+
const KEEP_BYTES = 7 * 1024 * 1024; // 7 MB kept after rotation
|
|
27
|
+
const ROTATE_INTERVAL_MS = 60_000; // at most one rotation check per minute
|
|
28
|
+
|
|
29
|
+
let _logPath: string | null = null;
|
|
30
|
+
let _dirEnsured = false;
|
|
31
|
+
let _lastRotateCheck = 0;
|
|
32
|
+
|
|
33
|
+
export function getLogPath(): string {
|
|
34
|
+
if (_logPath) return _logPath;
|
|
35
|
+
const base = process.env.CLAUDE_PLUGIN_DATA
|
|
36
|
+
?? join(homedir(), ".claude", "plugins", "data", "OpenLtm-openltm");
|
|
37
|
+
_logPath = join(base, "logs", "ltm.jsonl");
|
|
38
|
+
return _logPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensureDir(logPath: string): void {
|
|
42
|
+
if (_dirEnsured) return;
|
|
43
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
44
|
+
_dirEnsured = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function maybeRotate(logPath: string): void {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (now - _lastRotateCheck < ROTATE_INTERVAL_MS) return;
|
|
50
|
+
_lastRotateCheck = now;
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(logPath)) return;
|
|
53
|
+
const size = statSync(logPath).size;
|
|
54
|
+
if (size <= MAX_BYTES) return;
|
|
55
|
+
// Slice the tail — keep the most recent KEEP_BYTES
|
|
56
|
+
const content = readFileSync(logPath);
|
|
57
|
+
writeFileSync(logPath, content.slice(content.length - KEEP_BYTES));
|
|
58
|
+
} catch { /* rotation failure is non-fatal */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function emitEvent(event: LtmEvent): void {
|
|
62
|
+
try {
|
|
63
|
+
const logPath = getLogPath();
|
|
64
|
+
ensureDir(logPath);
|
|
65
|
+
maybeRotate(logPath);
|
|
66
|
+
const entry: LtmEvent = { ...event, ts: event.ts ?? new Date().toISOString() };
|
|
67
|
+
appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
68
|
+
} catch { /* never throw from logger */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readRecentEvents(limit: number): LtmEvent[] {
|
|
72
|
+
try {
|
|
73
|
+
const logPath = getLogPath();
|
|
74
|
+
if (!existsSync(logPath)) return [];
|
|
75
|
+
const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
|
|
76
|
+
const tail = lines.slice(-Math.max(limit, 0));
|
|
77
|
+
const events: LtmEvent[] = [];
|
|
78
|
+
for (const line of tail) {
|
|
79
|
+
try { events.push(JSON.parse(line) as LtmEvent); } catch { /* skip malformed lines */ }
|
|
80
|
+
}
|
|
81
|
+
return events;
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reset cached state — test-only. */
|
|
88
|
+
export function _resetForTesting(): void {
|
|
89
|
+
_logPath = null;
|
|
90
|
+
_dirEnsured = false;
|
|
91
|
+
_lastRotateCheck = 0;
|
|
92
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* writeQueue.ts — Serialises all DB writes through a promise-chain queue.
|
|
3
|
+
* Prevents concurrent write corruption (W12).
|
|
4
|
+
*/
|
|
5
|
+
import type { Database } from "bun:sqlite";
|
|
6
|
+
|
|
7
|
+
export class WriteQueue {
|
|
8
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
9
|
+
|
|
10
|
+
enqueue<T>(fn: () => T | Promise<T>, db?: Database): Promise<T> {
|
|
11
|
+
const next = this.tail.then(async () => {
|
|
12
|
+
if (!db) return fn();
|
|
13
|
+
db.exec("BEGIN IMMEDIATE");
|
|
14
|
+
try {
|
|
15
|
+
const result = await Promise.resolve(fn());
|
|
16
|
+
db.exec("COMMIT");
|
|
17
|
+
return result;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
try { db.exec("ROLLBACK"); } catch {}
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
this.tail = next.catch(() => {}); // prevent unhandled rejection on queue
|
|
24
|
+
return next as Promise<T>;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const writeQueue = new WriteQueue();
|