autoctxd 0.4.1
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/CHANGELOG.md +62 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/SECURITY.md +81 -0
- package/package.json +55 -0
- package/scripts/install-hooks.ts +80 -0
- package/scripts/install.ps1 +71 -0
- package/scripts/install.sh +67 -0
- package/scripts/uninstall-hooks.ts +57 -0
- package/src/ai/active-guard.ts +96 -0
- package/src/ai/adaptive-ranker.ts +48 -0
- package/src/ai/classifier.ts +256 -0
- package/src/ai/compressor.ts +129 -0
- package/src/ai/decision-chains.ts +100 -0
- package/src/ai/decision-extractor.ts +148 -0
- package/src/ai/pattern-detector.ts +147 -0
- package/src/ai/proactive.ts +78 -0
- package/src/cli/doctor.ts +171 -0
- package/src/cli/embeddings.ts +209 -0
- package/src/cli/index.ts +574 -0
- package/src/cli/reclassify.ts +134 -0
- package/src/context/builder.ts +97 -0
- package/src/context/formatter.ts +109 -0
- package/src/context/ranker.ts +84 -0
- package/src/db/sqlite/decisions.ts +56 -0
- package/src/db/sqlite/feedback.ts +92 -0
- package/src/db/sqlite/observations.ts +58 -0
- package/src/db/sqlite/schema.ts +366 -0
- package/src/db/sqlite/sessions.ts +50 -0
- package/src/db/sqlite/summaries.ts +69 -0
- package/src/db/vector/client.ts +134 -0
- package/src/db/vector/embeddings.ts +119 -0
- package/src/db/vector/providers/factory.ts +99 -0
- package/src/db/vector/providers/minilm.ts +90 -0
- package/src/db/vector/providers/ollama.ts +92 -0
- package/src/db/vector/providers/tfidf.ts +98 -0
- package/src/db/vector/providers/types.ts +39 -0
- package/src/db/vector/search.ts +131 -0
- package/src/hooks/post-tool-use.ts +205 -0
- package/src/hooks/pre-tool-use.ts +305 -0
- package/src/hooks/stop.ts +334 -0
- package/src/mcp/server.ts +293 -0
- package/src/server/dashboard.html +268 -0
- package/src/server/dashboard.ts +170 -0
- package/src/util/debug.ts +56 -0
- package/src/util/ignore.ts +171 -0
- package/src/util/metrics.ts +236 -0
- package/src/util/path.ts +57 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Public embedding API. Delegates to the configured provider (see providers/factory.ts).
|
|
2
|
+
//
|
|
3
|
+
// Vectors are L2-normalized by every provider, so cosineSimilarity reduces to a
|
|
4
|
+
// dot product. The cache table is partitioned by provider name — switching
|
|
5
|
+
// providers does not invalidate cached vectors of the previous one, but
|
|
6
|
+
// queries only see the rows that match the active provider.
|
|
7
|
+
|
|
8
|
+
import { getDb } from "../sqlite/schema";
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
import { getProvider } from "./providers/factory";
|
|
11
|
+
import type { EmbeddingProvider, EmbeddingProviderName } from "./providers/types";
|
|
12
|
+
|
|
13
|
+
export type { EmbeddingProvider, EmbeddingProviderName };
|
|
14
|
+
|
|
15
|
+
/** Dim of the active provider's vectors. Use this instead of a constant. */
|
|
16
|
+
export function getActiveDim(): number {
|
|
17
|
+
return getProvider().dim;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getActiveProviderName(): EmbeddingProviderName {
|
|
21
|
+
return getProvider().name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function generateEmbedding(text: string): Promise<Float32Array> {
|
|
25
|
+
const provider = getProvider();
|
|
26
|
+
const hash = hashText(text);
|
|
27
|
+
const cached = getCachedEmbedding(hash, provider.name, provider.dim);
|
|
28
|
+
if (cached) return cached;
|
|
29
|
+
|
|
30
|
+
const embedding = await provider.embed(text);
|
|
31
|
+
cacheEmbedding(hash, provider.name, embedding);
|
|
32
|
+
return embedding;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function generateEmbeddingsBatch(texts: string[]): Promise<Float32Array[]> {
|
|
36
|
+
const provider = getProvider();
|
|
37
|
+
const out: Float32Array[] = new Array(texts.length);
|
|
38
|
+
const missingIdx: number[] = [];
|
|
39
|
+
const missingTexts: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < texts.length; i++) {
|
|
42
|
+
const cached = getCachedEmbedding(hashText(texts[i]), provider.name, provider.dim);
|
|
43
|
+
if (cached) {
|
|
44
|
+
out[i] = cached;
|
|
45
|
+
} else {
|
|
46
|
+
missingIdx.push(i);
|
|
47
|
+
missingTexts.push(texts[i]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (missingTexts.length > 0) {
|
|
52
|
+
const fresh = provider.embedBatch
|
|
53
|
+
? await provider.embedBatch(missingTexts)
|
|
54
|
+
: await Promise.all(missingTexts.map((t) => provider.embed(t)));
|
|
55
|
+
for (let k = 0; k < missingIdx.length; k++) {
|
|
56
|
+
const i = missingIdx[k];
|
|
57
|
+
out[i] = fresh[k];
|
|
58
|
+
cacheEmbedding(hashText(texts[i]), provider.name, fresh[k]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hashText(text: string): string {
|
|
66
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getCachedEmbedding(textHash: string, provider: string, expectedDim: number): Float32Array | null {
|
|
70
|
+
try {
|
|
71
|
+
const db = getDb();
|
|
72
|
+
const row = db
|
|
73
|
+
.prepare("SELECT embedding FROM embeddings_cache WHERE text_hash = ? AND provider = ?")
|
|
74
|
+
.get(textHash, provider) as { embedding?: Buffer } | undefined;
|
|
75
|
+
if (row?.embedding) {
|
|
76
|
+
const buffer = row.embedding;
|
|
77
|
+
const vec = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4);
|
|
78
|
+
if (vec.length !== expectedDim) return null; // stale cache from a different model variant
|
|
79
|
+
return vec;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// cache miss
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cacheEmbedding(textHash: string, provider: string, embedding: Float32Array): void {
|
|
88
|
+
try {
|
|
89
|
+
const db = getDb();
|
|
90
|
+
const buffer = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
91
|
+
db.prepare(
|
|
92
|
+
"INSERT OR IGNORE INTO embeddings_cache (text_hash, provider, embedding) VALUES (?, ?, ?)"
|
|
93
|
+
).run(textHash, provider, buffer);
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore cache write errors
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function clearEmbeddingCache(provider?: EmbeddingProviderName): void {
|
|
100
|
+
try {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
if (provider) {
|
|
103
|
+
db.prepare("DELETE FROM embeddings_cache WHERE provider = ?").run(provider);
|
|
104
|
+
} else {
|
|
105
|
+
db.prepare("DELETE FROM embeddings_cache").run();
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
113
|
+
let dot = 0;
|
|
114
|
+
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
115
|
+
return dot; // vectors are L2-normalized
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @deprecated Kept for backwards compatibility with old callers. Use getActiveDim(). */
|
|
119
|
+
export const VECTOR_DIM = 128;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Provider selection and config persistence.
|
|
2
|
+
//
|
|
3
|
+
// Resolution order (highest priority first):
|
|
4
|
+
// 1. AUTOCTXD_EMBEDDING env var
|
|
5
|
+
// 2. data/config.json -> embeddingProvider
|
|
6
|
+
// 3. Default: "tfidf"
|
|
7
|
+
//
|
|
8
|
+
// Switching providers means subsequent embeddings live in a different vector
|
|
9
|
+
// space — re-embed existing summaries via `autoctxd embeddings switch <name>`
|
|
10
|
+
// (handled in the CLI).
|
|
11
|
+
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import type { EmbeddingProvider, EmbeddingProviderName } from "./types";
|
|
15
|
+
import { TfidfProvider } from "./tfidf";
|
|
16
|
+
import { MiniLMProvider } from "./minilm";
|
|
17
|
+
import { OllamaProvider } from "./ollama";
|
|
18
|
+
|
|
19
|
+
interface Config {
|
|
20
|
+
embeddingProvider?: EmbeddingProviderName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dataDir(): string {
|
|
24
|
+
return process.env.AUTOCTXD_DATA_DIR || join(import.meta.dir, "..", "..", "..", "..", "data");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function configPath(): string {
|
|
28
|
+
return process.env.AUTOCTXD_CONFIG_PATH || join(dataDir(), "config.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readConfig(): Config {
|
|
32
|
+
const path = configPath();
|
|
33
|
+
if (!existsSync(path)) return {};
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path, "utf8")) as Config;
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeConfig(cfg: Config): void {
|
|
42
|
+
const path = configPath();
|
|
43
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
44
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const ALL_PROVIDER_NAMES: readonly EmbeddingProviderName[] = ["tfidf", "minilm", "ollama"] as const;
|
|
48
|
+
|
|
49
|
+
function instantiate(name: EmbeddingProviderName): EmbeddingProvider {
|
|
50
|
+
switch (name) {
|
|
51
|
+
case "tfidf":
|
|
52
|
+
return new TfidfProvider();
|
|
53
|
+
case "minilm":
|
|
54
|
+
return new MiniLMProvider();
|
|
55
|
+
case "ollama":
|
|
56
|
+
return new OllamaProvider();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveProviderName(): EmbeddingProviderName {
|
|
61
|
+
const env = process.env.AUTOCTXD_EMBEDDING?.toLowerCase();
|
|
62
|
+
if (env === "tfidf" || env === "minilm" || env === "ollama") return env;
|
|
63
|
+
|
|
64
|
+
const cfg = readConfig();
|
|
65
|
+
if (cfg.embeddingProvider && ALL_PROVIDER_NAMES.includes(cfg.embeddingProvider)) {
|
|
66
|
+
return cfg.embeddingProvider;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return "tfidf";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let _cached: EmbeddingProvider | null = null;
|
|
73
|
+
let _cachedName: EmbeddingProviderName | null = null;
|
|
74
|
+
|
|
75
|
+
export function getProvider(): EmbeddingProvider {
|
|
76
|
+
const name = resolveProviderName();
|
|
77
|
+
if (_cached && _cachedName === name) return _cached;
|
|
78
|
+
_cached = instantiate(name);
|
|
79
|
+
_cachedName = name;
|
|
80
|
+
return _cached;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build a fresh provider instance without affecting the singleton. For benchmarks. */
|
|
84
|
+
export function buildProvider(name: EmbeddingProviderName): EmbeddingProvider {
|
|
85
|
+
return instantiate(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function setProvider(name: EmbeddingProviderName): Promise<void> {
|
|
89
|
+
const cfg = readConfig();
|
|
90
|
+
cfg.embeddingProvider = name;
|
|
91
|
+
writeConfig(cfg);
|
|
92
|
+
if (_cached?.dispose) await _cached.dispose().catch(() => {});
|
|
93
|
+
_cached = null;
|
|
94
|
+
_cachedName = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getConfiguredProviderName(): EmbeddingProviderName {
|
|
98
|
+
return resolveProviderName();
|
|
99
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// MiniLM (sentence-transformers/all-MiniLM-L6-v2) provider via @xenova/transformers.
|
|
2
|
+
//
|
|
3
|
+
// 384-dim, mean-pooled, L2-normalized. Runs on CPU via onnxruntime-web bundled
|
|
4
|
+
// with transformers.js. First use downloads the quantized model (~25MB) into
|
|
5
|
+
// AUTOCTXD_MODEL_DIR (default: ~/.claude/autoctxd/models).
|
|
6
|
+
//
|
|
7
|
+
// @xenova/transformers is an *optional* dependency — if it isn't installed,
|
|
8
|
+
// `check()` returns a friendly error pointing at `autoctxd embeddings install minilm`.
|
|
9
|
+
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import type { EmbeddingProvider } from "./types";
|
|
13
|
+
|
|
14
|
+
const MODEL_ID = "Xenova/all-MiniLM-L6-v2";
|
|
15
|
+
const VECTOR_DIM = 384;
|
|
16
|
+
|
|
17
|
+
function modelDir(): string {
|
|
18
|
+
return process.env.AUTOCTXD_MODEL_DIR || join(homedir(), ".claude", "autoctxd", "models");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let _extractor: any = null;
|
|
22
|
+
let _loadPromise: Promise<any> | null = null;
|
|
23
|
+
|
|
24
|
+
async function loadExtractor(): Promise<any> {
|
|
25
|
+
if (_extractor) return _extractor;
|
|
26
|
+
if (_loadPromise) return _loadPromise;
|
|
27
|
+
|
|
28
|
+
_loadPromise = (async () => {
|
|
29
|
+
let mod: any;
|
|
30
|
+
try {
|
|
31
|
+
mod = await import("@xenova/transformers");
|
|
32
|
+
} catch (e) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"@xenova/transformers is not installed. Run `autoctxd embeddings install minilm` to add it."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
mod.env.allowLocalModels = false;
|
|
39
|
+
mod.env.cacheDir = modelDir();
|
|
40
|
+
|
|
41
|
+
_extractor = await mod.pipeline("feature-extraction", MODEL_ID, {
|
|
42
|
+
quantized: true,
|
|
43
|
+
});
|
|
44
|
+
return _extractor;
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return await _loadPromise;
|
|
49
|
+
} finally {
|
|
50
|
+
_loadPromise = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class MiniLMProvider implements EmbeddingProvider {
|
|
55
|
+
readonly name = "minilm" as const;
|
|
56
|
+
readonly dim = VECTOR_DIM;
|
|
57
|
+
readonly label = "MiniLM-L6-v2 via transformers.js (384-dim, ~25MB local model)";
|
|
58
|
+
|
|
59
|
+
async check() {
|
|
60
|
+
try {
|
|
61
|
+
await import("@xenova/transformers");
|
|
62
|
+
return { ok: true } as const;
|
|
63
|
+
} catch {
|
|
64
|
+
return {
|
|
65
|
+
ok: false as const,
|
|
66
|
+
reason: "@xenova/transformers not installed — run `autoctxd embeddings install minilm`",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async embed(text: string): Promise<Float32Array> {
|
|
72
|
+
const extractor = await loadExtractor();
|
|
73
|
+
const out = await extractor(text, { pooling: "mean", normalize: true });
|
|
74
|
+
return new Float32Array(out.data as Float32Array);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async embedBatch(texts: string[]): Promise<Float32Array[]> {
|
|
78
|
+
const extractor = await loadExtractor();
|
|
79
|
+
const results: Float32Array[] = [];
|
|
80
|
+
for (const t of texts) {
|
|
81
|
+
const out = await extractor(t, { pooling: "mean", normalize: true });
|
|
82
|
+
results.push(new Float32Array(out.data as Float32Array));
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async dispose() {
|
|
88
|
+
_extractor = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Ollama provider — speaks to a local Ollama daemon at $AUTOCTXD_OLLAMA_URL
|
|
2
|
+
// (default http://localhost:11434) using the embedding model in
|
|
3
|
+
// $AUTOCTXD_OLLAMA_MODEL (default nomic-embed-text, 768-dim).
|
|
4
|
+
//
|
|
5
|
+
// Zero install on our side: we just call /api/embeddings. The user is expected
|
|
6
|
+
// to have Ollama installed and the model pulled (`ollama pull nomic-embed-text`).
|
|
7
|
+
// The dim is read from the first response and cached.
|
|
8
|
+
|
|
9
|
+
import type { EmbeddingProvider } from "./types";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_URL = "http://localhost:11434";
|
|
12
|
+
const DEFAULT_MODEL = "nomic-embed-text";
|
|
13
|
+
|
|
14
|
+
function ollamaUrl(): string {
|
|
15
|
+
return process.env.AUTOCTXD_OLLAMA_URL || DEFAULT_URL;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ollamaModel(): string {
|
|
19
|
+
return process.env.AUTOCTXD_OLLAMA_MODEL || DEFAULT_MODEL;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let _dim: number | null = null;
|
|
23
|
+
|
|
24
|
+
async function fetchEmbedding(text: string): Promise<Float32Array> {
|
|
25
|
+
const res = await fetch(`${ollamaUrl()}/api/embeddings`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ model: ollamaModel(), prompt: text }),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new Error(`Ollama returned ${res.status}: ${await res.text().catch(() => "")}`);
|
|
32
|
+
}
|
|
33
|
+
const data = (await res.json()) as { embedding?: number[] };
|
|
34
|
+
if (!data.embedding || !Array.isArray(data.embedding)) {
|
|
35
|
+
throw new Error("Ollama response missing 'embedding' array");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// L2-normalize so cosine === dot product downstream
|
|
39
|
+
const raw = data.embedding;
|
|
40
|
+
let norm = 0;
|
|
41
|
+
for (let i = 0; i < raw.length; i++) norm += raw[i] * raw[i];
|
|
42
|
+
norm = Math.sqrt(norm);
|
|
43
|
+
const out = new Float32Array(raw.length);
|
|
44
|
+
if (norm > 0) {
|
|
45
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw[i] / norm;
|
|
46
|
+
} else {
|
|
47
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw[i];
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class OllamaProvider implements EmbeddingProvider {
|
|
53
|
+
readonly name = "ollama" as const;
|
|
54
|
+
readonly label = `Ollama (${ollamaModel()} at ${ollamaUrl()})`;
|
|
55
|
+
|
|
56
|
+
get dim(): number {
|
|
57
|
+
// Default to nomic-embed-text's known dim; updated after first successful embed.
|
|
58
|
+
return _dim ?? 768;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async check() {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${ollamaUrl()}/api/tags`, {
|
|
64
|
+
signal: AbortSignal.timeout(2000),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
return { ok: false as const, reason: `Ollama responded ${res.status} at ${ollamaUrl()}` };
|
|
68
|
+
}
|
|
69
|
+
const data = (await res.json()) as { models?: Array<{ name: string }> };
|
|
70
|
+
const want = ollamaModel();
|
|
71
|
+
const found = data.models?.some((m) => m.name === want || m.name.startsWith(want + ":"));
|
|
72
|
+
if (!found) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false as const,
|
|
75
|
+
reason: `Ollama is up but model '${want}' isn't pulled — run \`ollama pull ${want}\``,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { ok: true } as const;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false as const,
|
|
82
|
+
reason: `Ollama not reachable at ${ollamaUrl()} — install from https://ollama.com and start the daemon`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async embed(text: string): Promise<Float32Array> {
|
|
88
|
+
const v = await fetchEmbedding(text);
|
|
89
|
+
if (_dim === null) _dim = v.length;
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// TF-IDF feature-hashing provider — the zero-dep default.
|
|
2
|
+
//
|
|
3
|
+
// 128-dim vectors built from token + bigram hashing with log-scaled TF and a
|
|
4
|
+
// sign hash. Fast, deterministic, no network, no model download. Quality is
|
|
5
|
+
// adequate for keyword/semantic overlap on developer text — for richer
|
|
6
|
+
// retrieval, switch to MiniLM or Ollama.
|
|
7
|
+
|
|
8
|
+
import type { EmbeddingProvider } from "./types";
|
|
9
|
+
|
|
10
|
+
const VECTOR_DIM = 128;
|
|
11
|
+
|
|
12
|
+
const STOP_WORDS = new Set([
|
|
13
|
+
"the", "is", "at", "of", "on", "and", "or", "to", "in", "for",
|
|
14
|
+
"it", "an", "be", "as", "by", "was", "are", "do", "if", "so",
|
|
15
|
+
"no", "not", "but", "from", "this", "that", "with", "have", "has",
|
|
16
|
+
"had", "will", "would", "could", "should", "been", "being", "which",
|
|
17
|
+
"their", "there", "then", "than", "them", "they", "what", "when",
|
|
18
|
+
"where", "who", "how", "all", "each", "every", "both", "few",
|
|
19
|
+
"more", "most", "other", "some", "such", "only", "own", "same",
|
|
20
|
+
"also", "can", "just", "any", "now", "new", "one", "two",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function murmurhash(key: string, seed: number): number {
|
|
24
|
+
let h = seed;
|
|
25
|
+
for (let i = 0; i < key.length; i++) {
|
|
26
|
+
h = Math.imul(h ^ key.charCodeAt(i), 0x5bd1e995);
|
|
27
|
+
h ^= h >>> 13;
|
|
28
|
+
}
|
|
29
|
+
return Math.abs(h);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tokenize(text: string): string[] {
|
|
33
|
+
return text
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^\w\s/._-]/g, " ")
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.filter((t) => t.length > 1 && t.length < 50)
|
|
38
|
+
.map((t) =>
|
|
39
|
+
t
|
|
40
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
41
|
+
.replace(/_/g, " ")
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
)
|
|
45
|
+
.flat()
|
|
46
|
+
.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function tfidfEmbed(text: string): Float32Array {
|
|
50
|
+
const vec = new Float32Array(VECTOR_DIM);
|
|
51
|
+
const tokens = tokenize(text);
|
|
52
|
+
const tokenFreq = new Map<string, number>();
|
|
53
|
+
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
tokenFreq.set(token, (tokenFreq.get(token) || 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const [token, freq] of tokenFreq) {
|
|
59
|
+
const h1 = murmurhash(token, 0) % VECTOR_DIM;
|
|
60
|
+
const h2 = murmurhash(token, 1) % 2 === 0 ? 1 : -1;
|
|
61
|
+
const tf = Math.log(1 + freq);
|
|
62
|
+
vec[h1] += h2 * tf;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
66
|
+
const bigram = tokens[i] + " " + tokens[i + 1];
|
|
67
|
+
const h1 = murmurhash(bigram, 2) % VECTOR_DIM;
|
|
68
|
+
const h2 = murmurhash(bigram, 3) % 2 === 0 ? 1 : -1;
|
|
69
|
+
vec[h1] += h2 * 0.5;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let norm = 0;
|
|
73
|
+
for (let i = 0; i < VECTOR_DIM; i++) norm += vec[i] * vec[i];
|
|
74
|
+
norm = Math.sqrt(norm);
|
|
75
|
+
if (norm > 0) {
|
|
76
|
+
for (let i = 0; i < VECTOR_DIM; i++) vec[i] /= norm;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return vec;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class TfidfProvider implements EmbeddingProvider {
|
|
83
|
+
readonly name = "tfidf" as const;
|
|
84
|
+
readonly dim = VECTOR_DIM;
|
|
85
|
+
readonly label = "TF-IDF feature hashing (128-dim, zero deps)";
|
|
86
|
+
|
|
87
|
+
async check() {
|
|
88
|
+
return { ok: true } as const;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async embed(text: string): Promise<Float32Array> {
|
|
92
|
+
return tfidfEmbed(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async embedBatch(texts: string[]): Promise<Float32Array[]> {
|
|
96
|
+
return texts.map(tfidfEmbed);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Pluggable embedding providers.
|
|
2
|
+
//
|
|
3
|
+
// Adding a provider:
|
|
4
|
+
// 1. Implement EmbeddingProvider in a new file.
|
|
5
|
+
// 2. Register it in factory.ts under PROVIDERS.
|
|
6
|
+
// 3. Add a row to the docs table in README and the `embeddings list` output.
|
|
7
|
+
//
|
|
8
|
+
// Vectors must be L2-normalized so cosine similarity reduces to a dot product
|
|
9
|
+
// downstream. Providers that don't normalize natively must do it themselves.
|
|
10
|
+
|
|
11
|
+
export type EmbeddingProviderName = "tfidf" | "minilm" | "ollama";
|
|
12
|
+
|
|
13
|
+
export interface EmbeddingProvider {
|
|
14
|
+
/** Stable identifier persisted to disk and the cache table. */
|
|
15
|
+
readonly name: EmbeddingProviderName;
|
|
16
|
+
|
|
17
|
+
/** Vector dimension. Must be stable for the lifetime of the provider instance. */
|
|
18
|
+
readonly dim: number;
|
|
19
|
+
|
|
20
|
+
/** Human label for `embeddings list` / status output. */
|
|
21
|
+
readonly label: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns `{ ok: true }` if the provider can run on this machine right now,
|
|
25
|
+
* otherwise `{ ok: false, reason }` with an actionable message
|
|
26
|
+
* (e.g. "run `autoctxd embeddings install minilm`").
|
|
27
|
+
* Should be cheap — no network calls beyond a localhost ping.
|
|
28
|
+
*/
|
|
29
|
+
check(): Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
30
|
+
|
|
31
|
+
/** Compute one embedding. Must return a Float32Array of length `dim`, L2-normalized. */
|
|
32
|
+
embed(text: string): Promise<Float32Array>;
|
|
33
|
+
|
|
34
|
+
/** Optional: compute many in one go. Default impl serializes via embed(). */
|
|
35
|
+
embedBatch?(texts: string[]): Promise<Float32Array[]>;
|
|
36
|
+
|
|
37
|
+
/** Optional: free model resources. Called by the CLI when switching providers. */
|
|
38
|
+
dispose?(): Promise<void>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// High-level semantic search combining LanceDB and SQLite FTS
|
|
2
|
+
|
|
3
|
+
import { generateEmbedding, cosineSimilarity } from "./embeddings";
|
|
4
|
+
import { searchSimilar, type VectorRecord } from "./client";
|
|
5
|
+
import { searchObservations } from "../sqlite/observations";
|
|
6
|
+
import { searchDecisions } from "../sqlite/decisions";
|
|
7
|
+
|
|
8
|
+
export interface SearchResult {
|
|
9
|
+
source: "vector" | "fts_observations" | "fts_decisions";
|
|
10
|
+
text: string;
|
|
11
|
+
score: number;
|
|
12
|
+
metadata: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function hybridSearch(
|
|
16
|
+
query: string,
|
|
17
|
+
options: {
|
|
18
|
+
limit?: number;
|
|
19
|
+
projectPath?: string;
|
|
20
|
+
includeObservations?: boolean;
|
|
21
|
+
includeDecisions?: boolean;
|
|
22
|
+
} = {}
|
|
23
|
+
): Promise<SearchResult[]> {
|
|
24
|
+
const {
|
|
25
|
+
limit = 10,
|
|
26
|
+
projectPath,
|
|
27
|
+
includeObservations = true,
|
|
28
|
+
includeDecisions = true,
|
|
29
|
+
} = options;
|
|
30
|
+
|
|
31
|
+
const results: SearchResult[] = [];
|
|
32
|
+
|
|
33
|
+
// 1. Vector/semantic search
|
|
34
|
+
try {
|
|
35
|
+
const queryEmbedding = await generateEmbedding(query);
|
|
36
|
+
const vectorResults = await searchSimilar(
|
|
37
|
+
Array.from(queryEmbedding),
|
|
38
|
+
limit,
|
|
39
|
+
projectPath
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
for (const vr of vectorResults) {
|
|
43
|
+
results.push({
|
|
44
|
+
source: "vector",
|
|
45
|
+
text: vr.text,
|
|
46
|
+
score: (vr as any)._distance !== undefined ? 1 / (1 + (vr as any)._distance) : 0.5,
|
|
47
|
+
metadata: {
|
|
48
|
+
session_id: vr.session_id,
|
|
49
|
+
project_path: vr.project_path,
|
|
50
|
+
level: vr.level,
|
|
51
|
+
created_at: vr.created_at,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Vector search failed, continue with FTS
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. FTS search on observations
|
|
60
|
+
if (includeObservations) {
|
|
61
|
+
try {
|
|
62
|
+
const ftsQuery = sanitizeFtsQuery(query);
|
|
63
|
+
const obsResults = searchObservations(ftsQuery, limit);
|
|
64
|
+
for (const obs of obsResults) {
|
|
65
|
+
results.push({
|
|
66
|
+
source: "fts_observations",
|
|
67
|
+
text: obs.summary,
|
|
68
|
+
score: 0.6, // FTS results get moderate score
|
|
69
|
+
metadata: {
|
|
70
|
+
type: obs.type,
|
|
71
|
+
tool_name: obs.tool_name,
|
|
72
|
+
session_id: obs.session_id,
|
|
73
|
+
importance: obs.importance_score,
|
|
74
|
+
timestamp: obs.timestamp,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// FTS search failed
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. FTS search on decisions
|
|
84
|
+
if (includeDecisions) {
|
|
85
|
+
try {
|
|
86
|
+
const ftsQuery = sanitizeFtsQuery(query);
|
|
87
|
+
const decResults = searchDecisions(ftsQuery, limit);
|
|
88
|
+
for (const dec of decResults) {
|
|
89
|
+
results.push({
|
|
90
|
+
source: "fts_decisions",
|
|
91
|
+
text: `${dec.title}: ${dec.decision_text}`,
|
|
92
|
+
score: 0.8, // Decisions are high value
|
|
93
|
+
metadata: {
|
|
94
|
+
project_path: dec.project_path,
|
|
95
|
+
alternatives: dec.alternatives,
|
|
96
|
+
rationale: dec.rationale,
|
|
97
|
+
created_at: dec.created_at,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// FTS search failed
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sort by score descending and deduplicate
|
|
107
|
+
results.sort((a, b) => b.score - a.score);
|
|
108
|
+
|
|
109
|
+
// Simple dedup by text similarity
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
const deduped: SearchResult[] = [];
|
|
112
|
+
for (const r of results) {
|
|
113
|
+
const key = r.text.slice(0, 80).toLowerCase();
|
|
114
|
+
if (!seen.has(key)) {
|
|
115
|
+
seen.add(key);
|
|
116
|
+
deduped.push(r);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return deduped.slice(0, limit);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sanitizeFtsQuery(query: string): string {
|
|
124
|
+
// FTS5 query syntax: wrap each word in quotes to avoid syntax errors
|
|
125
|
+
return query
|
|
126
|
+
.replace(/[^\w\s]/g, " ")
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.filter(w => w.length > 1)
|
|
129
|
+
.map(w => `"${w}"`)
|
|
130
|
+
.join(" OR ");
|
|
131
|
+
}
|