@neat.is/core 0.2.5

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 (44) hide show
  1. package/compat.json +120 -0
  2. package/dist/chunk-6JT6L2OV.js +164 -0
  3. package/dist/chunk-6JT6L2OV.js.map +1 -0
  4. package/dist/chunk-6SFEITLJ.js +3371 -0
  5. package/dist/chunk-6SFEITLJ.js.map +1 -0
  6. package/dist/chunk-I5IMCXRO.js +325 -0
  7. package/dist/chunk-I5IMCXRO.js.map +1 -0
  8. package/dist/chunk-T2U4U256.js +462 -0
  9. package/dist/chunk-T2U4U256.js.map +1 -0
  10. package/dist/chunk-WX55TLUT.js +184 -0
  11. package/dist/chunk-WX55TLUT.js.map +1 -0
  12. package/dist/chunk-XOOCA5T7.js +290 -0
  13. package/dist/chunk-XOOCA5T7.js.map +1 -0
  14. package/dist/cli.cjs +5754 -0
  15. package/dist/cli.cjs.map +1 -0
  16. package/dist/cli.d.cts +36 -0
  17. package/dist/cli.d.ts +36 -0
  18. package/dist/cli.js +1175 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/index.cjs +4552 -0
  21. package/dist/index.cjs.map +1 -0
  22. package/dist/index.d.cts +408 -0
  23. package/dist/index.d.ts +408 -0
  24. package/dist/index.js +93 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/neatd.cjs +3070 -0
  27. package/dist/neatd.cjs.map +1 -0
  28. package/dist/neatd.d.cts +1 -0
  29. package/dist/neatd.d.ts +1 -0
  30. package/dist/neatd.js +114 -0
  31. package/dist/neatd.js.map +1 -0
  32. package/dist/otel-grpc-B4XBSI4W.js +9 -0
  33. package/dist/otel-grpc-B4XBSI4W.js.map +1 -0
  34. package/dist/server.cjs +4499 -0
  35. package/dist/server.cjs.map +1 -0
  36. package/dist/server.d.cts +2 -0
  37. package/dist/server.d.ts +2 -0
  38. package/dist/server.js +97 -0
  39. package/dist/server.js.map +1 -0
  40. package/package.json +77 -0
  41. package/proto/opentelemetry/proto/collector/trace/v1/trace_service.proto +31 -0
  42. package/proto/opentelemetry/proto/common/v1/common.proto +46 -0
  43. package/proto/opentelemetry/proto/resource/v1/resource.proto +19 -0
  44. package/proto/opentelemetry/proto/trace/v1/trace.proto +93 -0
@@ -0,0 +1,184 @@
1
+ // src/registry.ts
2
+ import { promises as fs } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import {
6
+ RegistryFileSchema
7
+ } from "@neat.is/types";
8
+ var LOCK_TIMEOUT_MS = 5e3;
9
+ var LOCK_RETRY_MS = 50;
10
+ function neatHome() {
11
+ const override = process.env.NEAT_HOME;
12
+ if (override && override.length > 0) return path.resolve(override);
13
+ return path.join(os.homedir(), ".neat");
14
+ }
15
+ function registryPath() {
16
+ return path.join(neatHome(), "projects.json");
17
+ }
18
+ function registryLockPath() {
19
+ return path.join(neatHome(), "projects.json.lock");
20
+ }
21
+ async function normalizeProjectPath(input) {
22
+ const resolved = path.resolve(input);
23
+ try {
24
+ return await fs.realpath(resolved);
25
+ } catch {
26
+ return resolved;
27
+ }
28
+ }
29
+ async function writeAtomically(target, contents) {
30
+ await fs.mkdir(path.dirname(target), { recursive: true });
31
+ const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
32
+ const fd = await fs.open(tmp, "w");
33
+ try {
34
+ await fd.writeFile(contents, "utf8");
35
+ await fd.sync();
36
+ } finally {
37
+ await fd.close();
38
+ }
39
+ await fs.rename(tmp, target);
40
+ }
41
+ async function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
42
+ const deadline = Date.now() + timeoutMs;
43
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
44
+ while (true) {
45
+ try {
46
+ const fd = await fs.open(lockPath, "wx");
47
+ await fd.close();
48
+ return;
49
+ } catch (err) {
50
+ const code = err.code;
51
+ if (code !== "EEXIST") throw err;
52
+ if (Date.now() >= deadline) {
53
+ throw new Error(
54
+ `neat registry: timed out after ${timeoutMs}ms waiting for ${lockPath}. Another neat process is holding the lock; if no such process exists, remove the file by hand.`
55
+ );
56
+ }
57
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
58
+ }
59
+ }
60
+ }
61
+ async function releaseLock(lockPath) {
62
+ await fs.unlink(lockPath).catch(() => {
63
+ });
64
+ }
65
+ async function withLock(fn) {
66
+ const lock = registryLockPath();
67
+ await acquireLock(lock);
68
+ try {
69
+ return await fn();
70
+ } finally {
71
+ await releaseLock(lock);
72
+ }
73
+ }
74
+ async function readRegistry() {
75
+ const file = registryPath();
76
+ let raw;
77
+ try {
78
+ raw = await fs.readFile(file, "utf8");
79
+ } catch (err) {
80
+ if (err.code === "ENOENT") {
81
+ return { version: 1, projects: [] };
82
+ }
83
+ throw err;
84
+ }
85
+ const parsed = JSON.parse(raw);
86
+ return RegistryFileSchema.parse(parsed);
87
+ }
88
+ async function writeRegistry(reg) {
89
+ const validated = RegistryFileSchema.parse(reg);
90
+ await writeAtomically(registryPath(), JSON.stringify(validated, null, 2) + "\n");
91
+ }
92
+ var ProjectNameCollisionError = class extends Error {
93
+ projectName;
94
+ constructor(name) {
95
+ super(`neat registry: a project named "${name}" is already registered`);
96
+ this.name = "ProjectNameCollisionError";
97
+ this.projectName = name;
98
+ }
99
+ };
100
+ async function addProject(opts) {
101
+ const resolvedPath = await normalizeProjectPath(opts.path);
102
+ return withLock(async () => {
103
+ const reg = await readRegistry();
104
+ const byName = reg.projects.find((p) => p.name === opts.name);
105
+ const byPath = reg.projects.find((p) => p.path === resolvedPath);
106
+ if (byName && byName.path !== resolvedPath) {
107
+ throw new ProjectNameCollisionError(opts.name);
108
+ }
109
+ const now = (/* @__PURE__ */ new Date()).toISOString();
110
+ if (byName && byName.path === resolvedPath) {
111
+ byName.lastSeenAt = now;
112
+ if (opts.languages) byName.languages = opts.languages;
113
+ if (opts.status) byName.status = opts.status;
114
+ await writeRegistry(reg);
115
+ return byName;
116
+ }
117
+ if (byPath && byPath.name !== opts.name) {
118
+ throw new ProjectNameCollisionError(byPath.name);
119
+ }
120
+ const entry = {
121
+ name: opts.name,
122
+ path: resolvedPath,
123
+ registeredAt: now,
124
+ languages: opts.languages ?? [],
125
+ status: opts.status ?? "active"
126
+ };
127
+ reg.projects.push(entry);
128
+ await writeRegistry(reg);
129
+ return entry;
130
+ });
131
+ }
132
+ async function getProject(name) {
133
+ const reg = await readRegistry();
134
+ return reg.projects.find((p) => p.name === name);
135
+ }
136
+ async function listProjects() {
137
+ const reg = await readRegistry();
138
+ return reg.projects;
139
+ }
140
+ async function setStatus(name, status) {
141
+ return withLock(async () => {
142
+ const reg = await readRegistry();
143
+ const entry = reg.projects.find((p) => p.name === name);
144
+ if (!entry) throw new Error(`neat registry: no project named "${name}"`);
145
+ entry.status = status;
146
+ await writeRegistry(reg);
147
+ return entry;
148
+ });
149
+ }
150
+ async function touchLastSeen(name, at = (/* @__PURE__ */ new Date()).toISOString()) {
151
+ await withLock(async () => {
152
+ const reg = await readRegistry();
153
+ const entry = reg.projects.find((p) => p.name === name);
154
+ if (!entry) return;
155
+ entry.lastSeenAt = at;
156
+ await writeRegistry(reg);
157
+ });
158
+ }
159
+ async function removeProject(name) {
160
+ return withLock(async () => {
161
+ const reg = await readRegistry();
162
+ const idx = reg.projects.findIndex((p) => p.name === name);
163
+ if (idx < 0) return void 0;
164
+ const [removed] = reg.projects.splice(idx, 1);
165
+ await writeRegistry(reg);
166
+ return removed;
167
+ });
168
+ }
169
+
170
+ export {
171
+ registryPath,
172
+ registryLockPath,
173
+ normalizeProjectPath,
174
+ writeAtomically,
175
+ readRegistry,
176
+ ProjectNameCollisionError,
177
+ addProject,
178
+ getProject,
179
+ listProjects,
180
+ setStatus,
181
+ touchLastSeen,
182
+ removeProject
183
+ };
184
+ //# sourceMappingURL=chunk-WX55TLUT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/registry.ts"],"sourcesContent":["/**\n * Machine-level project registry (ADR-048).\n *\n * One file: `~/.neat/projects.json`. Per-user, machine-local. Not synced.\n * `registry.ts` is the only module that opens it. Everything else — `init`,\n * `daemon`, `cli` — calls into the helpers below.\n *\n * Two safety properties matter:\n * 1. Atomic writes. We tmp + fsync + rename so the daemon never sees a torn\n * file when init races against it.\n * 2. Cross-process exclusion. We hold an exclusive lock on\n * `~/.neat/projects.json.lock` for the read-modify-write window. Two\n * concurrent `neat init` runs cannot both win and overwrite each other.\n *\n * The lock is a file we exclusively-create (`O_EXCL`), hold while we mutate,\n * and unlink on the way out. Crude but cross-platform; matches what\n * `proper-lockfile` does internally without pulling the dep in.\n */\n\nimport { promises as fs } from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\nimport {\n RegistryFileSchema,\n type RegistryEntry,\n type RegistryFile,\n type RegistryStatus,\n} from '@neat.is/types'\n\nconst LOCK_TIMEOUT_MS = 5_000\nconst LOCK_RETRY_MS = 50\n\n// Resolve `~/.neat/` per call so tests can override `HOME` / `NEAT_HOME`\n// before each run without module-load order mattering.\nfunction neatHome(): string {\n const override = process.env.NEAT_HOME\n if (override && override.length > 0) return path.resolve(override)\n return path.join(os.homedir(), '.neat')\n}\n\nexport function registryPath(): string {\n return path.join(neatHome(), 'projects.json')\n}\n\nexport function registryLockPath(): string {\n return path.join(neatHome(), 'projects.json.lock')\n}\n\n/**\n * Path normalisation per ADR-048 #7. Two `init` calls from different relative\n * paths to the same dir must collapse to one entry. `path.resolve` handles\n * relative-to-cwd; we pass it through `fs.realpath` when the dir exists so\n * symlinked paths land on the same canonical entry too.\n */\nexport async function normalizeProjectPath(input: string): Promise<string> {\n const resolved = path.resolve(input)\n try {\n return await fs.realpath(resolved)\n } catch {\n return resolved\n }\n}\n\n/**\n * tmp + fsync + rename. The fsync on the data fd guarantees the bytes are on\n * disk before rename swaps the inode; rename itself is atomic on POSIX.\n *\n * Exported so the init flow and test harnesses can use the same helper.\n */\nexport async function writeAtomically(target: string, contents: string): Promise<void> {\n await fs.mkdir(path.dirname(target), { recursive: true })\n const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`\n const fd = await fs.open(tmp, 'w')\n try {\n await fd.writeFile(contents, 'utf8')\n await fd.sync()\n } finally {\n await fd.close()\n }\n await fs.rename(tmp, target)\n}\n\nasync function acquireLock(lockPath: string, timeoutMs: number = LOCK_TIMEOUT_MS): Promise<void> {\n const deadline = Date.now() + timeoutMs\n await fs.mkdir(path.dirname(lockPath), { recursive: true })\n while (true) {\n try {\n const fd = await fs.open(lockPath, 'wx')\n await fd.close()\n return\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n if (code !== 'EEXIST') throw err\n if (Date.now() >= deadline) {\n throw new Error(\n `neat registry: timed out after ${timeoutMs}ms waiting for ${lockPath}. ` +\n `Another neat process is holding the lock; if no such process exists, remove the file by hand.`,\n )\n }\n await new Promise((r) => setTimeout(r, LOCK_RETRY_MS))\n }\n }\n}\n\nasync function releaseLock(lockPath: string): Promise<void> {\n await fs.unlink(lockPath).catch(() => {})\n}\n\nasync function withLock<T>(fn: () => Promise<T>): Promise<T> {\n const lock = registryLockPath()\n await acquireLock(lock)\n try {\n return await fn()\n } finally {\n await releaseLock(lock)\n }\n}\n\n/**\n * Read the registry from disk. Returns an empty registry if the file does\n * not exist yet — first run, never registered anything.\n *\n * Throws on parse / schema errors. The contract is single-source-of-truth;\n * a corrupt file is louder than a silent reset.\n */\nexport async function readRegistry(): Promise<RegistryFile> {\n const file = registryPath()\n let raw: string\n try {\n raw = await fs.readFile(file, 'utf8')\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n return { version: 1, projects: [] }\n }\n throw err\n }\n const parsed = JSON.parse(raw)\n return RegistryFileSchema.parse(parsed)\n}\n\nasync function writeRegistry(reg: RegistryFile): Promise<void> {\n // Re-parse before writing to surface schema drift introduced by callers\n // mutating the in-memory object directly.\n const validated = RegistryFileSchema.parse(reg)\n await writeAtomically(registryPath(), JSON.stringify(validated, null, 2) + '\\n')\n}\n\nexport interface AddProjectOptions {\n name: string\n path: string\n languages?: string[]\n status?: RegistryStatus\n}\n\nexport class ProjectNameCollisionError extends Error {\n readonly projectName: string\n constructor(name: string) {\n super(`neat registry: a project named \"${name}\" is already registered`)\n this.name = 'ProjectNameCollisionError'\n this.projectName = name\n }\n}\n\n/**\n * Register a project, or update its `lastSeenAt` if the same path is already\n * registered under the same name (idempotent re-init).\n *\n * Hard error on name collision against a different path — ADR-046 #7. The\n * caller can recover by passing `--project <new-name>`.\n */\nexport async function addProject(opts: AddProjectOptions): Promise<RegistryEntry> {\n const resolvedPath = await normalizeProjectPath(opts.path)\n return withLock(async () => {\n const reg = await readRegistry()\n const byName = reg.projects.find((p) => p.name === opts.name)\n const byPath = reg.projects.find((p) => p.path === resolvedPath)\n\n if (byName && byName.path !== resolvedPath) {\n throw new ProjectNameCollisionError(opts.name)\n }\n\n const now = new Date().toISOString()\n\n if (byName && byName.path === resolvedPath) {\n // Idempotent re-register: same name, same path. Refresh languages /\n // status if the caller passed new ones.\n byName.lastSeenAt = now\n if (opts.languages) byName.languages = opts.languages\n if (opts.status) byName.status = opts.status\n await writeRegistry(reg)\n return byName\n }\n\n if (byPath && byPath.name !== opts.name) {\n // Same dir already registered under a different name. Treat as a\n // collision so the user is forced to decide which name wins.\n throw new ProjectNameCollisionError(byPath.name)\n }\n\n const entry: RegistryEntry = {\n name: opts.name,\n path: resolvedPath,\n registeredAt: now,\n languages: opts.languages ?? [],\n status: opts.status ?? 'active',\n }\n reg.projects.push(entry)\n await writeRegistry(reg)\n return entry\n })\n}\n\nexport async function getProject(name: string): Promise<RegistryEntry | undefined> {\n const reg = await readRegistry()\n return reg.projects.find((p) => p.name === name)\n}\n\nexport async function listProjects(): Promise<RegistryEntry[]> {\n const reg = await readRegistry()\n return reg.projects\n}\n\nexport async function setStatus(name: string, status: RegistryStatus): Promise<RegistryEntry> {\n return withLock(async () => {\n const reg = await readRegistry()\n const entry = reg.projects.find((p) => p.name === name)\n if (!entry) throw new Error(`neat registry: no project named \"${name}\"`)\n entry.status = status\n await writeRegistry(reg)\n return entry\n })\n}\n\nexport async function touchLastSeen(name: string, at: string = new Date().toISOString()): Promise<void> {\n await withLock(async () => {\n const reg = await readRegistry()\n const entry = reg.projects.find((p) => p.name === name)\n if (!entry) return\n entry.lastSeenAt = at\n await writeRegistry(reg)\n })\n}\n\n/**\n * Remove the registry entry for `name`. Per ADR-048 #6: this only removes the\n * registry row. It does **not** touch `neat-out/`, `policy.json`, or any user\n * file in the project directory. SDK-install rollback is a separate flow\n * (`neat-rollback.patch`) that the caller opts in to.\n */\nexport async function removeProject(name: string): Promise<RegistryEntry | undefined> {\n return withLock(async () => {\n const reg = await readRegistry()\n const idx = reg.projects.findIndex((p) => p.name === name)\n if (idx < 0) return undefined\n const [removed] = reg.projects.splice(idx, 1)\n await writeRegistry(reg)\n return removed\n })\n}\n\n"],"mappings":"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","names":[]}
@@ -0,0 +1,290 @@
1
+ // src/search.ts
2
+ import { promises as fs } from "fs";
3
+ import path from "path";
4
+ import { createHash } from "crypto";
5
+ var DEFAULT_LIMIT = 10;
6
+ var NOMIC_DIM = 768;
7
+ var MINI_LM_DIM = 384;
8
+ function shouldEmbed(node) {
9
+ return node.type !== "FrontierNode";
10
+ }
11
+ function embedText(node) {
12
+ const parts = [node.id];
13
+ const name = node.name;
14
+ if (name) parts.push(name);
15
+ switch (node.type) {
16
+ case "ServiceNode": {
17
+ const lang = node.language;
18
+ if (lang) parts.push(`language=${lang}`);
19
+ break;
20
+ }
21
+ case "DatabaseNode": {
22
+ const eng = node.engine;
23
+ const ver = node.engineVersion;
24
+ if (eng) parts.push(`engine=${eng}`);
25
+ if (ver) parts.push(`engineVersion=${ver}`);
26
+ break;
27
+ }
28
+ case "InfraNode": {
29
+ const kind = node.kind;
30
+ if (kind) parts.push(`kind=${kind}`);
31
+ break;
32
+ }
33
+ case "ConfigNode": {
34
+ const filePath = node.path;
35
+ if (filePath) parts.push(`path=${filePath}`);
36
+ break;
37
+ }
38
+ default:
39
+ break;
40
+ }
41
+ return parts.join(" ");
42
+ }
43
+ function attrsHash(node) {
44
+ return createHash("sha1").update(embedText(node)).digest("hex").slice(0, 16);
45
+ }
46
+ function cosine(a, b) {
47
+ if (a.length !== b.length) return 0;
48
+ let dot = 0;
49
+ let na = 0;
50
+ let nb = 0;
51
+ for (let i = 0; i < a.length; i++) {
52
+ const ai = a[i] ?? 0;
53
+ const bi = b[i] ?? 0;
54
+ dot += ai * bi;
55
+ na += ai * ai;
56
+ nb += bi * bi;
57
+ }
58
+ if (na === 0 || nb === 0) return 0;
59
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
60
+ }
61
+ function ollamaHost() {
62
+ return process.env.OLLAMA_HOST ?? null;
63
+ }
64
+ async function ollamaReachable(host) {
65
+ try {
66
+ const res = await fetch(`${host.replace(/\/$/, "")}/api/tags`, {
67
+ signal: AbortSignal.timeout(500)
68
+ });
69
+ return res.ok;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+ function makeOllamaEmbedder(host, model = "nomic-embed-text") {
75
+ const root = host.replace(/\/$/, "");
76
+ return {
77
+ provider: "ollama",
78
+ model,
79
+ dim: NOMIC_DIM,
80
+ async embed(texts) {
81
+ const out = [];
82
+ for (const text of texts) {
83
+ const res = await fetch(`${root}/api/embeddings`, {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({ model, prompt: text })
87
+ });
88
+ if (!res.ok) {
89
+ throw new Error(`ollama embeddings: ${res.status} ${res.statusText}`);
90
+ }
91
+ const data = await res.json();
92
+ out.push(Float32Array.from(data.embedding));
93
+ }
94
+ return out;
95
+ }
96
+ };
97
+ }
98
+ async function makeTransformersEmbedder() {
99
+ let pipelineFn = null;
100
+ try {
101
+ const specifier = "@xenova/transformers";
102
+ const mod = await import(specifier);
103
+ pipelineFn = mod.pipeline;
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!pipelineFn) return null;
108
+ const model = "Xenova/all-MiniLM-L6-v2";
109
+ const extractor = await pipelineFn("feature-extraction", model);
110
+ return {
111
+ provider: "transformers",
112
+ model,
113
+ dim: MINI_LM_DIM,
114
+ async embed(texts) {
115
+ const out = [];
116
+ for (const text of texts) {
117
+ const result = await extractor(text, { pooling: "mean", normalize: true });
118
+ out.push(Float32Array.from(result.data));
119
+ }
120
+ return out;
121
+ }
122
+ };
123
+ }
124
+ async function pickEmbedder() {
125
+ const host = ollamaHost();
126
+ if (host && await ollamaReachable(host)) {
127
+ return makeOllamaEmbedder(host);
128
+ }
129
+ return makeTransformersEmbedder();
130
+ }
131
+ async function readCache(cachePath) {
132
+ try {
133
+ const raw = await fs.readFile(cachePath, "utf8");
134
+ const parsed = JSON.parse(raw);
135
+ if (parsed.version !== 1) return null;
136
+ return parsed;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ async function writeCache(cachePath, cache) {
142
+ await fs.mkdir(path.dirname(cachePath), { recursive: true });
143
+ await fs.writeFile(cachePath, JSON.stringify(cache));
144
+ }
145
+ var VectorIndex = class {
146
+ constructor(embedder, cachePath) {
147
+ this.embedder = embedder;
148
+ this.cachePath = cachePath;
149
+ this.provider = embedder.provider;
150
+ }
151
+ embedder;
152
+ cachePath;
153
+ provider;
154
+ vectors = /* @__PURE__ */ new Map();
155
+ async search(query, limit = DEFAULT_LIMIT) {
156
+ const trimmed = query.trim();
157
+ if (!trimmed || this.vectors.size === 0) {
158
+ return { query: trimmed, provider: this.provider, matches: [] };
159
+ }
160
+ const embedded = await this.embedder.embed([trimmed]);
161
+ const qv = embedded[0];
162
+ if (!qv) {
163
+ return { query: trimmed, provider: this.provider, matches: [] };
164
+ }
165
+ const scored = [];
166
+ for (const { node, vector } of this.vectors.values()) {
167
+ const score = cosine(qv, vector);
168
+ scored.push({ node, score });
169
+ }
170
+ scored.sort((a, b) => b.score - a.score);
171
+ return { query: trimmed, provider: this.provider, matches: scored.slice(0, limit) };
172
+ }
173
+ async refresh(graph) {
174
+ const present = /* @__PURE__ */ new Set();
175
+ const toEmbed = [];
176
+ graph.forEachNode((id, attrs) => {
177
+ const node = attrs;
178
+ if (!shouldEmbed(node)) return;
179
+ present.add(id);
180
+ const hash = attrsHash(node);
181
+ const cached = this.vectors.get(id);
182
+ if (cached && cached.hash === hash) {
183
+ cached.node = node;
184
+ return;
185
+ }
186
+ toEmbed.push({ id, node, hash, text: embedText(node) });
187
+ });
188
+ for (const id of [...this.vectors.keys()]) {
189
+ if (!present.has(id)) this.vectors.delete(id);
190
+ }
191
+ if (toEmbed.length > 0) {
192
+ const vectors = await this.embedder.embed(toEmbed.map((e) => e.text));
193
+ toEmbed.forEach((entry, i) => {
194
+ const v = vectors[i];
195
+ if (!v) return;
196
+ this.vectors.set(entry.id, { node: entry.node, vector: v, hash: entry.hash });
197
+ });
198
+ }
199
+ if (this.cachePath) {
200
+ const entries = [];
201
+ for (const [id, { vector, hash }] of this.vectors) {
202
+ entries.push({ nodeId: id, attrsHash: hash, vector: Array.from(vector) });
203
+ }
204
+ await writeCache(this.cachePath, {
205
+ version: 1,
206
+ provider: this.embedder.provider,
207
+ model: this.embedder.model,
208
+ dim: this.embedder.dim,
209
+ entries
210
+ });
211
+ }
212
+ }
213
+ // Hydrate the in-memory map from a previously-written cache. Validates
214
+ // shape against the current embedder; mismatch → empty start.
215
+ loadFromCache(cache, graph) {
216
+ if (cache.provider !== this.embedder.provider || cache.model !== this.embedder.model || cache.dim !== this.embedder.dim) {
217
+ return;
218
+ }
219
+ const present = /* @__PURE__ */ new Map();
220
+ graph.forEachNode((id, attrs) => {
221
+ const node = attrs;
222
+ if (shouldEmbed(node)) present.set(id, node);
223
+ });
224
+ for (const entry of cache.entries) {
225
+ const node = present.get(entry.nodeId);
226
+ if (!node) continue;
227
+ if (attrsHash(node) !== entry.attrsHash) continue;
228
+ if (entry.vector.length !== this.embedder.dim) continue;
229
+ this.vectors.set(entry.nodeId, {
230
+ node,
231
+ hash: entry.attrsHash,
232
+ vector: Float32Array.from(entry.vector)
233
+ });
234
+ }
235
+ }
236
+ };
237
+ var SubstringIndex = class {
238
+ provider = "substring";
239
+ graph = null;
240
+ async search(query, limit = DEFAULT_LIMIT) {
241
+ const q = query.trim().toLowerCase();
242
+ const out = [];
243
+ if (!q || !this.graph) {
244
+ return { query: q, provider: "substring", matches: [] };
245
+ }
246
+ this.graph.forEachNode((id, attrs) => {
247
+ const node = attrs;
248
+ const name = node.name ?? "";
249
+ if (id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
250
+ out.push({ node, score: 1 });
251
+ }
252
+ });
253
+ return { query: q, provider: "substring", matches: out.slice(0, limit) };
254
+ }
255
+ async refresh(graph) {
256
+ this.graph = graph;
257
+ }
258
+ };
259
+ async function buildSearchIndex(graph, options = {}) {
260
+ let embedder = null;
261
+ if (options.embedder) {
262
+ embedder = options.embedder;
263
+ } else if (options.forceProvider !== "substring") {
264
+ embedder = await pickEmbedder();
265
+ if (options.forceProvider === "ollama" && embedder?.provider !== "ollama") {
266
+ embedder = null;
267
+ }
268
+ if (options.forceProvider === "transformers" && embedder?.provider !== "transformers") {
269
+ embedder = null;
270
+ }
271
+ }
272
+ if (!embedder) {
273
+ const idx2 = new SubstringIndex();
274
+ await idx2.refresh(graph);
275
+ return idx2;
276
+ }
277
+ const cachePath = options.cachePath === void 0 ? null : options.cachePath;
278
+ const idx = new VectorIndex(embedder, cachePath);
279
+ if (cachePath) {
280
+ const cache = await readCache(cachePath);
281
+ if (cache) idx.loadFromCache(cache, graph);
282
+ }
283
+ await idx.refresh(graph);
284
+ return idx;
285
+ }
286
+
287
+ export {
288
+ buildSearchIndex
289
+ };
290
+ //# sourceMappingURL=chunk-XOOCA5T7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/search.ts"],"sourcesContent":["// semantic_search — embedding-based node retrieval with a three-tier\n// fallback chain. The chain is settled in ADR-025; this file is the\n// implementation. Public API:\n//\n// buildSearchIndex(graph, opts) → SearchIndex\n// SearchIndex.search(query, limit) → { provider, matches }\n// SearchIndex.refresh(graph) → re-embeds new/changed nodes,\n// drops vanished ones\n//\n// The `/search` route in api.ts holds a single SearchIndex, refreshing it\n// after any extraction. MCP's `semantic_search` tool reads the same shape.\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { createHash } from 'node:crypto'\nimport type { GraphNode } from '@neat.is/types'\nimport type { NeatGraph } from './graph.js'\n\nexport interface ScoredNode {\n node: GraphNode\n score: number\n}\n\nexport interface SearchResponse {\n query: string\n provider: 'ollama' | 'transformers' | 'substring'\n matches: ScoredNode[]\n}\n\nexport interface SearchIndex {\n readonly provider: SearchResponse['provider']\n search(query: string, limit?: number): Promise<SearchResponse>\n refresh(graph: NeatGraph): Promise<void>\n}\n\ninterface Embedder {\n provider: 'ollama' | 'transformers'\n model: string\n dim: number\n embed(texts: string[]): Promise<Float32Array[]>\n}\n\nconst DEFAULT_LIMIT = 10\nconst NOMIC_DIM = 768\nconst MINI_LM_DIM = 384\n\n// FrontierNodes are noise by design (placeholders that should disappear).\n// Embedding them would just clutter results.\nfunction shouldEmbed(node: GraphNode): boolean {\n return node.type !== 'FrontierNode'\n}\n\n// Deterministic per-node text. Stable keys let the cache hit across\n// extractions when nothing material changed.\nexport function embedText(node: GraphNode): string {\n const parts: string[] = [node.id]\n const name = (node as { name?: string }).name\n if (name) parts.push(name)\n switch (node.type) {\n case 'ServiceNode': {\n const lang = (node as { language?: string }).language\n if (lang) parts.push(`language=${lang}`)\n break\n }\n case 'DatabaseNode': {\n const eng = (node as { engine?: string }).engine\n const ver = (node as { engineVersion?: string }).engineVersion\n if (eng) parts.push(`engine=${eng}`)\n if (ver) parts.push(`engineVersion=${ver}`)\n break\n }\n case 'InfraNode': {\n const kind = (node as { kind?: string }).kind\n if (kind) parts.push(`kind=${kind}`)\n break\n }\n case 'ConfigNode': {\n const filePath = (node as { path?: string }).path\n if (filePath) parts.push(`path=${filePath}`)\n break\n }\n default:\n break\n }\n return parts.join(' ')\n}\n\nfunction attrsHash(node: GraphNode): string {\n return createHash('sha1').update(embedText(node)).digest('hex').slice(0, 16)\n}\n\nexport function cosine(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) return 0\n let dot = 0\n let na = 0\n let nb = 0\n for (let i = 0; i < a.length; i++) {\n const ai = a[i] ?? 0\n const bi = b[i] ?? 0\n dot += ai * bi\n na += ai * ai\n nb += bi * bi\n }\n if (na === 0 || nb === 0) return 0\n return dot / (Math.sqrt(na) * Math.sqrt(nb))\n}\n\n// ---------------------------------------------------------------- Embedders\n\nfunction ollamaHost(): string | null {\n return process.env.OLLAMA_HOST ?? null\n}\n\nasync function ollamaReachable(host: string): Promise<boolean> {\n try {\n const res = await fetch(`${host.replace(/\\/$/, '')}/api/tags`, {\n signal: AbortSignal.timeout(500),\n })\n return res.ok\n } catch {\n return false\n }\n}\n\nfunction makeOllamaEmbedder(host: string, model = 'nomic-embed-text'): Embedder {\n const root = host.replace(/\\/$/, '')\n return {\n provider: 'ollama',\n model,\n dim: NOMIC_DIM,\n async embed(texts: string[]): Promise<Float32Array[]> {\n const out: Float32Array[] = []\n // Ollama's /api/embeddings is one-text-per-request. ≤10K nodes × ~30ms\n // each is fine for a one-shot index build; if it ever isn't, the API\n // also accepts batched input on /api/embed (newer routes).\n for (const text of texts) {\n const res = await fetch(`${root}/api/embeddings`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ model, prompt: text }),\n })\n if (!res.ok) {\n throw new Error(`ollama embeddings: ${res.status} ${res.statusText}`)\n }\n const data = (await res.json()) as { embedding: number[] }\n out.push(Float32Array.from(data.embedding))\n }\n return out\n },\n }\n}\n\ninterface XenovaPipeline {\n (text: string | string[], options?: { pooling?: string; normalize?: boolean }): Promise<{\n data: Float32Array\n }>\n}\n\nasync function makeTransformersEmbedder(): Promise<Embedder | null> {\n let pipelineFn: ((task: string, model: string) => Promise<XenovaPipeline>) | null = null\n try {\n // Lazy require so server.ts boot doesn't pay the WASM init cost when\n // Ollama is available. The package is heavy — only load it on demand.\n // The package is optional so its types may not be installed in every\n // environment. Use a dynamic specifier so tsc keeps the import dynamic\n // and doesn't try to resolve types at build time.\n const specifier = '@xenova/transformers'\n const mod = (await import(specifier)) as unknown as {\n pipeline: (task: string, model: string) => Promise<XenovaPipeline>\n }\n pipelineFn = mod.pipeline\n } catch {\n return null\n }\n if (!pipelineFn) return null\n const model = 'Xenova/all-MiniLM-L6-v2'\n const extractor = await pipelineFn('feature-extraction', model)\n return {\n provider: 'transformers',\n model,\n dim: MINI_LM_DIM,\n async embed(texts: string[]): Promise<Float32Array[]> {\n const out: Float32Array[] = []\n for (const text of texts) {\n // Mean-pooled, L2-normalized → cosine reduces to dot product but\n // we keep the explicit cosine() for clarity.\n const result = await extractor(text, { pooling: 'mean', normalize: true })\n out.push(Float32Array.from(result.data))\n }\n return out\n },\n }\n}\n\n// Picks the highest-tier embedder available. Returns null when only\n// substring is available (caller decides what to build).\nexport async function pickEmbedder(): Promise<Embedder | null> {\n const host = ollamaHost()\n if (host && (await ollamaReachable(host))) {\n return makeOllamaEmbedder(host)\n }\n return makeTransformersEmbedder()\n}\n\n// ------------------------------------------------------------------ Cache\n\ninterface CacheEntry {\n nodeId: string\n attrsHash: string\n vector: number[]\n}\n\ninterface CacheFile {\n version: 1\n provider: 'ollama' | 'transformers'\n model: string\n dim: number\n entries: CacheEntry[]\n}\n\nasync function readCache(cachePath: string): Promise<CacheFile | null> {\n try {\n const raw = await fs.readFile(cachePath, 'utf8')\n const parsed = JSON.parse(raw) as CacheFile\n if (parsed.version !== 1) return null\n return parsed\n } catch {\n return null\n }\n}\n\nasync function writeCache(cachePath: string, cache: CacheFile): Promise<void> {\n await fs.mkdir(path.dirname(cachePath), { recursive: true })\n await fs.writeFile(cachePath, JSON.stringify(cache))\n}\n\n// ----------------------------------------------------------------- Indexes\n\nclass VectorIndex implements SearchIndex {\n readonly provider: 'ollama' | 'transformers'\n private vectors = new Map<string, { node: GraphNode; vector: Float32Array; hash: string }>()\n\n constructor(\n private embedder: Embedder,\n private cachePath: string | null,\n ) {\n this.provider = embedder.provider\n }\n\n async search(query: string, limit = DEFAULT_LIMIT): Promise<SearchResponse> {\n const trimmed = query.trim()\n if (!trimmed || this.vectors.size === 0) {\n return { query: trimmed, provider: this.provider, matches: [] }\n }\n const embedded = await this.embedder.embed([trimmed])\n const qv = embedded[0]\n if (!qv) {\n return { query: trimmed, provider: this.provider, matches: [] }\n }\n const scored: ScoredNode[] = []\n for (const { node, vector } of this.vectors.values()) {\n const score = cosine(qv, vector)\n scored.push({ node, score })\n }\n scored.sort((a, b) => b.score - a.score)\n return { query: trimmed, provider: this.provider, matches: scored.slice(0, limit) }\n }\n\n async refresh(graph: NeatGraph): Promise<void> {\n const present = new Set<string>()\n const toEmbed: { id: string; node: GraphNode; hash: string; text: string }[] = []\n\n graph.forEachNode((id, attrs) => {\n const node = attrs as GraphNode\n if (!shouldEmbed(node)) return\n present.add(id)\n const hash = attrsHash(node)\n const cached = this.vectors.get(id)\n if (cached && cached.hash === hash) {\n cached.node = node\n return\n }\n toEmbed.push({ id, node, hash, text: embedText(node) })\n })\n\n // Drop vanished nodes\n for (const id of [...this.vectors.keys()]) {\n if (!present.has(id)) this.vectors.delete(id)\n }\n\n if (toEmbed.length > 0) {\n const vectors = await this.embedder.embed(toEmbed.map((e) => e.text))\n toEmbed.forEach((entry, i) => {\n const v = vectors[i]\n if (!v) return\n this.vectors.set(entry.id, { node: entry.node, vector: v, hash: entry.hash })\n })\n }\n\n if (this.cachePath) {\n const entries: CacheEntry[] = []\n for (const [id, { vector, hash }] of this.vectors) {\n entries.push({ nodeId: id, attrsHash: hash, vector: Array.from(vector) })\n }\n await writeCache(this.cachePath, {\n version: 1,\n provider: this.embedder.provider,\n model: this.embedder.model,\n dim: this.embedder.dim,\n entries,\n })\n }\n }\n\n // Hydrate the in-memory map from a previously-written cache. Validates\n // shape against the current embedder; mismatch → empty start.\n loadFromCache(cache: CacheFile, graph: NeatGraph): void {\n if (\n cache.provider !== this.embedder.provider ||\n cache.model !== this.embedder.model ||\n cache.dim !== this.embedder.dim\n ) {\n return\n }\n const present = new Map<string, GraphNode>()\n graph.forEachNode((id, attrs) => {\n const node = attrs as GraphNode\n if (shouldEmbed(node)) present.set(id, node)\n })\n for (const entry of cache.entries) {\n const node = present.get(entry.nodeId)\n if (!node) continue\n // Skip cache entries whose attrs no longer match — they'll be\n // re-embedded by the next refresh().\n if (attrsHash(node) !== entry.attrsHash) continue\n if (entry.vector.length !== this.embedder.dim) continue\n this.vectors.set(entry.nodeId, {\n node,\n hash: entry.attrsHash,\n vector: Float32Array.from(entry.vector),\n })\n }\n }\n}\n\nclass SubstringIndex implements SearchIndex {\n readonly provider = 'substring' as const\n private graph: NeatGraph | null = null\n\n async search(query: string, limit = DEFAULT_LIMIT): Promise<SearchResponse> {\n const q = query.trim().toLowerCase()\n const out: ScoredNode[] = []\n if (!q || !this.graph) {\n return { query: q, provider: 'substring', matches: [] }\n }\n this.graph.forEachNode((id, attrs) => {\n const node = attrs as GraphNode\n const name = (node as { name?: string }).name ?? ''\n if (id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {\n out.push({ node, score: 1 })\n }\n })\n return { query: q, provider: 'substring', matches: out.slice(0, limit) }\n }\n\n async refresh(graph: NeatGraph): Promise<void> {\n this.graph = graph\n }\n}\n\n// ------------------------------------------------------------ Public factory\n\nexport interface BuildSearchIndexOptions {\n // Where to read/write the embedding cache. Falls back to in-memory only\n // if not provided. Pass `null` to explicitly disable caching.\n cachePath?: string | null\n // Override the embedder selection. Useful for tests (substring-only mode\n // skips the Ollama probe + the Transformers.js download).\n forceProvider?: 'ollama' | 'transformers' | 'substring'\n // Pre-built embedder (test injection). Wins over forceProvider.\n embedder?: Embedder\n}\n\nexport async function buildSearchIndex(\n graph: NeatGraph,\n options: BuildSearchIndexOptions = {},\n): Promise<SearchIndex> {\n let embedder: Embedder | null = null\n if (options.embedder) {\n embedder = options.embedder\n } else if (options.forceProvider !== 'substring') {\n embedder = await pickEmbedder()\n if (options.forceProvider === 'ollama' && embedder?.provider !== 'ollama') {\n embedder = null\n }\n if (options.forceProvider === 'transformers' && embedder?.provider !== 'transformers') {\n embedder = null\n }\n }\n\n if (!embedder) {\n const idx = new SubstringIndex()\n await idx.refresh(graph)\n return idx\n }\n\n const cachePath = options.cachePath === undefined ? null : options.cachePath\n const idx = new VectorIndex(embedder, cachePath)\n if (cachePath) {\n const cache = await readCache(cachePath)\n if (cache) idx.loadFromCache(cache, graph)\n }\n await idx.refresh(graph)\n return idx\n}\n"],"mappings":"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","names":["idx"]}