@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,193 @@
1
+ /**
2
+ * cli/opencode.ts — Installer for OpenCode.
3
+ *
4
+ * Installs the complete LTM OpenCode customization:
5
+ * - Plugin package (@rohirik/opencode-ltm)
6
+ * - Agents (aegis.md)
7
+ * - Skills (AgentTrustBoundaries, CommandPathSafety, SecretSafeHandling)
8
+ * - Plugins (aegis.ts)
9
+ * Resolves the config path using OpenCode's documented priority order.
10
+ * Idempotent — safe to run multiple times.
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from "fs";
13
+ import { join, dirname } from "path";
14
+ import os from "os";
15
+ import type { InstallResult } from "./types.js";
16
+
17
+ // ── Constants ─────────────────────────────────────────────────────────────────
18
+
19
+ const PLUGIN_PACKAGE = "@rohirik/opencode-ltm@latest";
20
+ const PLUGIN_MATCH = "@rohirik/opencode-ltm";
21
+ const SCHEMA = "https://opencode.ai/config.json";
22
+
23
+ // Files that must never be copied into the user's config when deploying assets.
24
+ const COPY_DENYLIST = new Set([
25
+ "node_modules",
26
+ "package.json",
27
+ "package-lock.json",
28
+ "bun.lock",
29
+ "bun.lockb",
30
+ ".gitignore",
31
+ ".DS_Store",
32
+ ]);
33
+
34
+ /**
35
+ * Resolve the bundled OpenCode customization assets.
36
+ *
37
+ * The canonical, shipped location is `assets/opencode/` inside this package —
38
+ * it is committed and included in the published npm tarball, so it resolves
39
+ * identically in dev, in a built package, and in a `bunx`/installed package.
40
+ * The repo-root `.opencode/` is kept as a dev-only fallback.
41
+ */
42
+ function getOpenCodeSourceDir(): string {
43
+ const candidates = [
44
+ join(import.meta.dir, "..", "..", "assets", "opencode"), // shipped: packages/openltm-core/assets/opencode
45
+ join(import.meta.dir, "..", "..", "..", ".opencode"), // dev fallback: monorepo root
46
+ ];
47
+ for (const c of candidates) {
48
+ if (existsSync(c)) return c;
49
+ }
50
+ return candidates[0];
51
+ }
52
+
53
+ // ── Path resolution ───────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Resolve the OpenCode config file path using the standard priority order:
57
+ * 1. $XDG_CONFIG_HOME/opencode/opencode.json
58
+ * 2. ~/.config/opencode/opencode.json
59
+ * 3. ~/Library/Application Support/opencode/opencode.json (darwin only)
60
+ *
61
+ * Returns the first path that exists. If none exist, returns the default
62
+ * creation path for the current platform.
63
+ */
64
+ function resolveConfigPath(homedir: string): { path: string; exists: boolean } {
65
+ const candidates: string[] = [];
66
+
67
+ const xdg = process.env["XDG_CONFIG_HOME"];
68
+ if (xdg) candidates.push(join(xdg, "opencode", "opencode.json"));
69
+
70
+ candidates.push(join(homedir, ".config", "opencode", "opencode.json"));
71
+
72
+ if (process.platform === "darwin") {
73
+ candidates.push(join(homedir, "Library", "Application Support", "opencode", "opencode.json"));
74
+ }
75
+
76
+ for (const p of candidates) {
77
+ if (existsSync(p)) return { path: p, exists: true };
78
+ }
79
+
80
+ // Default creation path
81
+ const defaultPath =
82
+ process.platform === "darwin"
83
+ ? join(homedir, "Library", "Application Support", "opencode", "opencode.json")
84
+ : join(homedir, ".config", "opencode", "opencode.json");
85
+
86
+ return { path: defaultPath, exists: false };
87
+ }
88
+
89
+ // ── Public API ────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * installOpenCode — install complete LTM OpenCode customization.
93
+ *
94
+ * @param opts.homedir - Override home directory (useful for tests).
95
+ * @param opts.dryRun - Compute result without writing any files.
96
+ * @returns InstallResult describing what happened.
97
+ */
98
+ export async function installOpenCode(opts: {
99
+ homedir?: string;
100
+ dryRun?: boolean;
101
+ }): Promise<InstallResult> {
102
+ const homedir = opts.homedir ?? os.homedir();
103
+ const dryRun = opts.dryRun ?? false;
104
+
105
+ const details: string[] = [];
106
+
107
+ // 1. Install plugin package in config
108
+ const { path: configPath, exists } = resolveConfigPath(homedir);
109
+ let config: Record<string, unknown> = {};
110
+
111
+ if (exists) {
112
+ try {
113
+ config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
114
+ } catch {
115
+ config = {};
116
+ }
117
+ } else {
118
+ config = { $schema: SCHEMA };
119
+ }
120
+
121
+ const plugins = Array.isArray(config["plugin"]) ? (config["plugin"] as unknown[]) : [];
122
+ const pluginAlreadyPresent = plugins.some(
123
+ (p) => typeof p === "string" && p.includes(PLUGIN_MATCH),
124
+ );
125
+
126
+ if (!pluginAlreadyPresent) {
127
+ const updated = { ...config, plugin: [...plugins, PLUGIN_PACKAGE] };
128
+ if (!dryRun) {
129
+ mkdirSync(dirname(configPath), { recursive: true });
130
+ writeFileSync(configPath, JSON.stringify(updated, null, 2) + "\n", "utf8");
131
+ }
132
+ details.push(`plugin: ${dryRun ? "would add" : "added"} @rohirik/opencode-ltm`);
133
+ } else {
134
+ details.push("plugin: already present (skipped)");
135
+ }
136
+
137
+ // 2. Deploy agents, skills, plugins alongside the resolved config so the
138
+ // customization always lives in the same opencode directory as the plugin.
139
+ const sourceDir = getOpenCodeSourceDir();
140
+ const targetDir = dirname(configPath);
141
+
142
+ const components = ["agents", "skills", "plugins"];
143
+
144
+ let anyDeployed = !pluginAlreadyPresent;
145
+
146
+ for (const comp of components) {
147
+ const srcPath = join(sourceDir, comp);
148
+ const dstPath = join(targetDir, comp);
149
+
150
+ if (!existsSync(srcPath)) {
151
+ details.push(`${comp}: source not found (skipped)`);
152
+ continue;
153
+ }
154
+
155
+ const alreadyDeployed = existsSync(dstPath) && readdirSync(dstPath).length > 0;
156
+
157
+ if (!alreadyDeployed) {
158
+ if (!dryRun) {
159
+ mkdirSync(dstPath, { recursive: true });
160
+ copyRecursive(srcPath, dstPath);
161
+ }
162
+ details.push(`${comp}: ${dryRun ? "would deploy" : "deployed"}`);
163
+ anyDeployed = true;
164
+ } else {
165
+ details.push(`${comp}: already deployed (skipped)`);
166
+ }
167
+ }
168
+
169
+ return {
170
+ target: "opencode",
171
+ status: anyDeployed ? "installed" : "skipped",
172
+ detail: details.join("; "),
173
+ };
174
+ }
175
+
176
+ // Copy directory recursively, preserving structure. Skips node_modules,
177
+ // lockfiles, and other denylisted entries at every level.
178
+ function copyRecursive(src: string, dst: string): void {
179
+ const entries = readdirSync(src, { withFileTypes: true });
180
+ for (const entry of entries) {
181
+ if (COPY_DENYLIST.has(entry.name) || entry.name.endsWith(".lock")) continue;
182
+
183
+ const srcPath = join(src, entry.name);
184
+ const dstPath = join(dst, entry.name);
185
+
186
+ if (entry.isDirectory()) {
187
+ mkdirSync(dstPath, { recursive: true });
188
+ copyRecursive(srcPath, dstPath);
189
+ } else {
190
+ cpSync(srcPath, dstPath, { force: true });
191
+ }
192
+ }
193
+ }
package/src/cli/pi.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * cli/pi.ts — Installer for Pi coding agent.
3
+ *
4
+ * Delegates to `pi install npm:@rohirik/pi-ltm` — Pi's own package manager
5
+ * writes the entry into ~/.pi/agent/settings.json. This is the only reliable
6
+ * install path; writing to config.toml does not register extensions in Pi.
7
+ *
8
+ * Idempotent: checks `pi list` output before installing.
9
+ */
10
+ import { execSync } from "child_process";
11
+ import type { InstallResult } from "./types.js";
12
+
13
+ const PACKAGE_SOURCE = "npm:@rohirik/pi-ltm";
14
+ const PACKAGE_NAME = "@rohirik/pi-ltm";
15
+
16
+ function findPiCli(): string | null {
17
+ try {
18
+ execSync("which pi", { stdio: "pipe" });
19
+ return "pi";
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function isAlreadyInstalled(piCmd: string): boolean {
26
+ try {
27
+ const out = execSync(`${piCmd} list`, { encoding: "utf8", stdio: "pipe" });
28
+ return out.includes(PACKAGE_NAME);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function installPi(opts: {
35
+ dryRun?: boolean;
36
+ /** Inject a custom pi command path — used in tests. */
37
+ _piCmd?: string;
38
+ }): Promise<InstallResult> {
39
+ const dryRun = opts.dryRun ?? false;
40
+ const piCmd = opts._piCmd ?? findPiCli();
41
+
42
+ if (piCmd && !/^[a-zA-Z0-9/_.-]+$/.test(piCmd)) {
43
+ throw new Error(`Invalid piCmd — unexpected characters: ${piCmd}`);
44
+ }
45
+
46
+ if (!piCmd) {
47
+ return {
48
+ target: "pi",
49
+ status: "skipped",
50
+ detail: "pi CLI not found — install Pi first from https://pi.ai",
51
+ };
52
+ }
53
+
54
+ const alreadyInstalled = isAlreadyInstalled(piCmd);
55
+
56
+ if (alreadyInstalled) {
57
+ return { target: "pi", status: "skipped", detail: "extension already registered" };
58
+ }
59
+
60
+ if (dryRun) {
61
+ return { target: "pi", status: "installed", detail: "dry-run — no files written" };
62
+ }
63
+
64
+ try {
65
+ execSync(`${piCmd} install ${PACKAGE_SOURCE}`, { stdio: "pipe" });
66
+ return { target: "pi", status: "installed", detail: `${piCmd} install ${PACKAGE_SOURCE}` };
67
+ } catch (err) {
68
+ return {
69
+ target: "pi",
70
+ status: "error",
71
+ detail: err instanceof Error ? err.message : String(err),
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * cli/types.ts — Shared type definitions for the LTM CLI installer.
3
+ *
4
+ * The CLI is a thin layer that orchestrates installing LTM into one or more
5
+ * host environments (Claude Code, OpenCode, Pi). This module defines the
6
+ * contracts shared between the top-level bin entrypoint and the per-target
7
+ * install strategies.
8
+ */
9
+
10
+ // ── Agent detection ───────────────────────────────────────────────────────────
11
+
12
+ /** Result of the auto-detect probe: which AI coding agents are installed. */
13
+ export interface DetectResult {
14
+ claude: boolean;
15
+ opencode: boolean;
16
+ pi: boolean;
17
+ }
18
+
19
+ // ── Install result (per-target installer) ─────────────────────────────────────
20
+
21
+ /** Returned by per-target install functions (claude.ts, opencode.ts, pi.ts). */
22
+ export interface InstallResult {
23
+ target: string;
24
+ status: "installed" | "skipped" | "error";
25
+ detail?: string;
26
+ }
27
+
28
+ // ── Install targets ────────────────────────────────────────────────────────────
29
+
30
+ /** Canonical string identifiers for supported host environments. */
31
+ export const InstallTarget = {
32
+ claudeCode: "claude-code",
33
+ openCode: "open-code",
34
+ pi: "pi",
35
+ } as const;
36
+
37
+ export type InstallTargetId = (typeof InstallTarget)[keyof typeof InstallTarget];
38
+
39
+ // ── Options ───────────────────────────────────────────────────────────────────
40
+
41
+ /** Options passed into the install orchestrator. */
42
+ export interface CliInstallOptions {
43
+ /** Which host to install into. */
44
+ target: InstallTargetId;
45
+ /**
46
+ * When true, skip all interactive prompts and use inferred defaults.
47
+ * Required in CI / headless environments.
48
+ */
49
+ nonInteractive?: boolean;
50
+ /**
51
+ * When true, print what would be done without writing any files.
52
+ * Implies nonInteractive.
53
+ */
54
+ dryRun?: boolean;
55
+ /**
56
+ * Override the data directory (defaults to CLAUDE_PLUGIN_DATA env var or
57
+ * the platform-standard location for the chosen target).
58
+ */
59
+ pluginDataDir?: string;
60
+ }
61
+
62
+ // ── Result ────────────────────────────────────────────────────────────────────
63
+
64
+ /** A single recorded action taken (or skipped) during installation. */
65
+ export interface InstallStep {
66
+ label: string;
67
+ status: "done" | "skipped" | "error";
68
+ detail?: string;
69
+ }
70
+
71
+ /** Returned by runInstall and every per-target strategy. */
72
+ export interface CliInstallResult {
73
+ success: boolean;
74
+ /** Human-readable summary line (used in terminal output). */
75
+ message: string;
76
+ /** Ordered list of steps executed during the run. */
77
+ steps: InstallStep[];
78
+ }
package/src/config.ts ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * config.ts — Loader and validator for the LTM config.json (path injected by host adapter)
4
+ */
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ import { existsSync, readFileSync } from "fs";
8
+ import { getDbPath } from "./paths.js";
9
+
10
+ // Lazy-computed config path
11
+ function getConfigPath(): string {
12
+ return join(homedir(), ".claude", "config.json");
13
+ }
14
+
15
+ // ── Types ─────────────────────────────────────────────────────────────────────
16
+
17
+ export interface EmbeddingsConfig {
18
+ provider: "gemini" | "openai" | "ollama" | "disabled";
19
+ apiKey?: string;
20
+ model?: string;
21
+ baseUrl?: string;
22
+ confidenceThreshold: number;
23
+ }
24
+
25
+ export interface LtmConfig {
26
+ dbPath: string;
27
+ decayEnabled: boolean;
28
+ injectTopN: number;
29
+ autoRelate: boolean;
30
+ graphReasoning: boolean;
31
+ evaluateSessionLlm: boolean;
32
+ semanticFallback: boolean;
33
+ gitLearnEnabled: boolean;
34
+ gitLearnMinDiffChars: number;
35
+ gitLearnFileFilter: string[];
36
+ gitLearnIgnorePatterns: string[];
37
+ autoRecall: boolean;
38
+ }
39
+
40
+ export interface ServerConfig {
41
+ apiPort: number;
42
+ uiPort: number;
43
+ }
44
+
45
+ export interface SyncConfig {
46
+ enabled: boolean;
47
+ provider: "s3" | "r2" | null;
48
+ }
49
+
50
+ export interface Config {
51
+ ltm: LtmConfig;
52
+ server: ServerConfig;
53
+ sync: SyncConfig;
54
+ embeddings: EmbeddingsConfig;
55
+ }
56
+
57
+ // ── Defaults ───────────────────────────────────────────────────────────────────
58
+
59
+ const DEFAULT_EMBEDDINGS: EmbeddingsConfig = {
60
+ provider: "disabled",
61
+ confidenceThreshold: 0.6,
62
+ };
63
+
64
+ const DEFAULTS: Config = {
65
+ ltm: {
66
+ dbPath: getDbPath(),
67
+ decayEnabled: true,
68
+ injectTopN: 15,
69
+ autoRelate: true,
70
+ graphReasoning: false,
71
+ evaluateSessionLlm: false,
72
+ semanticFallback: true,
73
+ gitLearnEnabled: false,
74
+ gitLearnMinDiffChars: 200,
75
+ gitLearnFileFilter: [],
76
+ gitLearnIgnorePatterns: [],
77
+ autoRecall: true,
78
+ },
79
+ server: {
80
+ apiPort: 7331,
81
+ uiPort: 7332,
82
+ },
83
+ sync: {
84
+ enabled: false,
85
+ provider: null,
86
+ },
87
+ embeddings: DEFAULT_EMBEDDINGS,
88
+ };
89
+
90
+ // ── Validation ──────────────────────────────────────────────────────────────
91
+
92
+ function validateConfig(raw: Record<string, unknown>): { valid: boolean; errors: string[] } {
93
+ const errors: string[] = [];
94
+ if (raw && typeof raw === "object") {
95
+ const ltm = raw["ltm"] as Record<string, unknown> | undefined;
96
+ if (ltm) {
97
+ if ("decayEnabled" in ltm && typeof ltm["decayEnabled"] !== "boolean") errors.push("ltm.decayEnabled: must be boolean");
98
+ if ("injectTopN" in ltm && typeof ltm["injectTopN"] !== "number") errors.push("ltm.injectTopN: must be number");
99
+ if ("autoRecall" in ltm && typeof ltm["autoRecall"] !== "boolean") errors.push("ltm.autoRecall: must be boolean");
100
+ if ("graphReasoning" in ltm && typeof ltm["graphReasoning"] !== "boolean") errors.push("ltm.graphReasoning: must be boolean");
101
+ if ("autoRelate" in ltm && typeof ltm["autoRelate"] !== "boolean") errors.push("ltm.autoRelate: must be boolean");
102
+ }
103
+ }
104
+ return { valid: errors.length === 0, errors };
105
+ }
106
+
107
+ // ── Loader ─────────────────────────────────────────────────────────────────────
108
+
109
+ export function readConfigSync(): Partial<Config> {
110
+ const configPath = getConfigPath();
111
+ if (!existsSync(configPath)) return {};
112
+ try { return JSON.parse(readFileSync(configPath, "utf8")) as Partial<Config>; }
113
+ catch { return {}; }
114
+ }
115
+
116
+ export async function loadConfig(): Promise<Config> {
117
+ const configPath = getConfigPath();
118
+ if (!existsSync(configPath)) return { ...DEFAULTS, embeddings: { ...DEFAULT_EMBEDDINGS } };
119
+
120
+ let raw: Record<string, unknown>;
121
+ try { raw = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>; }
122
+ catch { return { ...DEFAULTS, embeddings: { ...DEFAULT_EMBEDDINGS } }; }
123
+
124
+ const { valid, errors } = validateConfig(raw);
125
+ if (!valid) process.stderr.write(`[config] Validation: ${errors.join(", ")}\n`);
126
+
127
+ const ltm = (raw["ltm"] ?? {}) as Partial<LtmConfig>;
128
+ const server = (raw["server"] ?? {}) as Partial<ServerConfig>;
129
+ const sync = (raw["sync"] ?? {}) as Partial<SyncConfig>;
130
+ const emb = (raw["embeddings"] ?? {}) as Partial<EmbeddingsConfig>;
131
+
132
+ // Resolve apiKey from env when not set in config
133
+ const resolvedApiKey = emb.apiKey
134
+ ?? (emb.provider === "gemini" ? process.env["GEMINI_API_KEY"] : undefined)
135
+ ?? (emb.provider === "openai" ? process.env["OPENAI_API_KEY"] : undefined)
136
+ ?? (emb.provider === "ollama" ? process.env["OLLAMA_API_KEY"] : undefined);
137
+
138
+ return {
139
+ ltm: {
140
+ dbPath: ltm.dbPath ?? DEFAULTS.ltm.dbPath,
141
+ decayEnabled: ltm.decayEnabled ?? DEFAULTS.ltm.decayEnabled,
142
+ injectTopN: ltm.injectTopN ?? DEFAULTS.ltm.injectTopN,
143
+ autoRelate: ltm.autoRelate ?? DEFAULTS.ltm.autoRelate,
144
+ graphReasoning: ltm.graphReasoning ?? DEFAULTS.ltm.graphReasoning,
145
+ evaluateSessionLlm: ltm.evaluateSessionLlm ?? DEFAULTS.ltm.evaluateSessionLlm,
146
+ semanticFallback: ltm.semanticFallback ?? DEFAULTS.ltm.semanticFallback,
147
+ gitLearnEnabled: ltm.gitLearnEnabled ?? DEFAULTS.ltm.gitLearnEnabled,
148
+ gitLearnMinDiffChars: ltm.gitLearnMinDiffChars ?? DEFAULTS.ltm.gitLearnMinDiffChars,
149
+ gitLearnFileFilter: ltm.gitLearnFileFilter ?? DEFAULTS.ltm.gitLearnFileFilter,
150
+ gitLearnIgnorePatterns: ltm.gitLearnIgnorePatterns ?? DEFAULTS.ltm.gitLearnIgnorePatterns,
151
+ autoRecall: ltm.autoRecall ?? DEFAULTS.ltm.autoRecall,
152
+ },
153
+ server: { apiPort: server.apiPort ?? DEFAULTS.server.apiPort, uiPort: server.uiPort ?? DEFAULTS.server.uiPort },
154
+ sync: { enabled: sync.enabled ?? DEFAULTS.sync.enabled, provider: sync.provider ?? DEFAULTS.sync.provider },
155
+ embeddings: {
156
+ provider: emb.provider ?? DEFAULT_EMBEDDINGS.provider,
157
+ apiKey: resolvedApiKey,
158
+ model: emb.model,
159
+ baseUrl: emb.baseUrl,
160
+ confidenceThreshold: emb.confidenceThreshold ?? DEFAULT_EMBEDDINGS.confidenceThreshold,
161
+ },
162
+ };
163
+ }
package/src/context.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * context.ts — Per-project context items (goals, decisions, progress, gotchas)
3
+ * Replaces the 4 per-project Markdown context files.
4
+ * Used by: PreCompact, UpdateContext, Cleanup, SessionStart hooks.
5
+ */
6
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+ import { getDb, DB_PATH } from "./shared-db.js";
10
+ import { learn } from "./db.js";
11
+
12
+ export { DB_PATH };
13
+ const CLAUDE_DIR = join(homedir(), ".claude");
14
+ const PROJECTS_DIR = join(CLAUDE_DIR, "projects");
15
+
16
+ export type ContextType = "goal" | "decision" | "progress" | "gotcha";
17
+
18
+ export interface ContextItem {
19
+ id: number;
20
+ project_name: string;
21
+ type: ContextType;
22
+ content: string;
23
+ session_id: string | null;
24
+ permanent: number;
25
+ memory_id?: number;
26
+ created_at: string;
27
+ }
28
+
29
+
30
+ /** Budget-capped section for context-summary.md output. */
31
+ function section(label: string, items: ContextItem[], budget: number): string {
32
+ if (items.length === 0) return "";
33
+ const lines = items.map(i => i.content);
34
+ const available = Math.max(0, budget - 2); // header(1) + trailing blank
35
+ if (lines.length <= available) {
36
+ return [`${label}`, ...lines, ""].join("\n");
37
+ }
38
+ const kept = lines.slice(-available);
39
+ return [`${label}`, ...kept, `… (${lines.length - available} more not shown)`, ""].join("\n");
40
+ }
41
+
42
+ /**
43
+ * Add a context item for a project.
44
+ * - goal: one row per project (delete+insert to maintain uniqueness)
45
+ * - decision/gotcha: permanent=1, append only
46
+ * - progress: permanent=0, dedup by session_id
47
+ * @param skipExport Skip regenerating context-summary.md (use during bulk imports)
48
+ */
49
+ export function addItem(
50
+ project: string,
51
+ type: ContextType,
52
+ content: string,
53
+ sessionId?: string,
54
+ skipExport = false
55
+ ): void {
56
+ const db = getDb();
57
+
58
+ if (type === "goal") {
59
+ db.transaction(() => {
60
+ db.run(`DELETE FROM context_items WHERE type='goal' AND project_name=?`, [project]);
61
+ db.run(
62
+ `INSERT INTO context_items (project_name, type, content, session_id, permanent)
63
+ VALUES (?, 'goal', ?, ?, 0)`,
64
+ [project, content, sessionId ?? null]
65
+ );
66
+ })();
67
+ } else if (type === "decision" || type === "gotcha") {
68
+ db.run(
69
+ `INSERT INTO context_items (project_name, type, content, session_id, permanent)
70
+ VALUES (?, ?, ?, ?, 1)`,
71
+ [project, type, content, sessionId ?? null]
72
+ );
73
+ } else {
74
+ // progress — dedup by session_id
75
+ if (sessionId) {
76
+ const existing = db.query<{ id: number }, [string, string]>(
77
+ `SELECT id FROM context_items WHERE type='progress' AND project_name=? AND session_id=? LIMIT 1`
78
+ ).get(project, sessionId);
79
+ if (existing) return;
80
+ }
81
+ db.run(
82
+ `INSERT INTO context_items (project_name, type, content, session_id, permanent)
83
+ VALUES (?, 'progress', ?, ?, 0)`,
84
+ [project, content, sessionId ?? null]
85
+ );
86
+ }
87
+
88
+ if (!skipExport) exportContextMarkdown(project);
89
+ }
90
+
91
+ /**
92
+ * Retrieve context items for a project, optionally filtered by type.
93
+ */
94
+ export function getItems(
95
+ project: string,
96
+ type?: ContextType,
97
+ limit?: number
98
+ ): ContextItem[] {
99
+ const db = getDb();
100
+
101
+ if (type === "progress") {
102
+ const cap = limit ?? 20;
103
+ return db.query<ContextItem, [string]>(
104
+ `SELECT * FROM context_items WHERE type='progress' AND project_name=?
105
+ ORDER BY id DESC LIMIT ${cap}`
106
+ ).all(project).reverse();
107
+ }
108
+
109
+ if (type) {
110
+ return db.query<ContextItem, [string, string]>(
111
+ `SELECT * FROM context_items WHERE type=? AND project_name=? ORDER BY id ASC`
112
+ ).all(type, project);
113
+ }
114
+
115
+ return db.query<ContextItem, [string]>(
116
+ `SELECT * FROM context_items WHERE project_name=? ORDER BY id ASC`
117
+ ).all(project);
118
+ }
119
+
120
+ /**
121
+ * Trim progress items to last N for a project.
122
+ */
123
+ export function trimProgress(project: string, max = 20): void {
124
+ getDb().run(
125
+ `DELETE FROM context_items WHERE type='progress' AND project_name=? AND id NOT IN
126
+ (SELECT id FROM context_items WHERE type='progress' AND project_name=? ORDER BY id DESC LIMIT ?)`,
127
+ [project, project, max]
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Export context-summary.md for a project from DB contents.
133
+ * Keeps the file as a human-readable snapshot and backward-compat fallback.
134
+ */
135
+ export function exportContextMarkdown(project: string): void {
136
+ const projectDir = join(PROJECTS_DIR, project);
137
+ if (!existsSync(projectDir)) mkdirSync(projectDir, { recursive: true });
138
+
139
+ const date = new Date().toISOString().slice(0, 10);
140
+ const summary = [
141
+ `# ${project} | ${date}\n`,
142
+ section("GOAL:", getItems(project, "goal"), 10),
143
+ section("PROGRESS:", getItems(project, "progress", 20), 20),
144
+ section("DEC:", getItems(project, "decision"), 15),
145
+ section("WATCH:", getItems(project, "gotcha"), 15),
146
+ ].join("");
147
+
148
+ writeFileSync(join(projectDir, "context-summary.md"), summary);
149
+ }
150
+
151
+ /**
152
+ * Promote a decision or gotcha context_item into global LTM memories.
153
+ * Returns the new memory id, or null if the item is not promotable.
154
+ */
155
+ export function promote(itemId: number): number | null {
156
+ const db = getDb();
157
+ const item = db.query<ContextItem, [number]>(
158
+ "SELECT * FROM context_items WHERE id = ?"
159
+ ).get(itemId);
160
+ if (!item || !["decision", "gotcha"].includes(item.type)) return null;
161
+ const category = item.type === "decision" ? "architecture" : "gotcha";
162
+ const importance = item.type === "gotcha" ? 4 : 3;
163
+ const result = learn({
164
+ content: item.content,
165
+ category,
166
+ importance,
167
+ project_scope: item.project_name,
168
+ source: "context_item",
169
+ });
170
+ db.prepare("UPDATE context_items SET memory_id = ? WHERE id = ?").run(result.id, itemId);
171
+ return result.id;
172
+ }