ralph-hero-knowledge-index 0.1.21 → 0.1.23
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/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +109 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +75 -0
- package/dist/config.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +17 -0
- package/dist/db.js.map +1 -1
- package/dist/file-scanner.d.ts +13 -1
- package/dist/file-scanner.js +30 -3
- package/dist/file-scanner.js.map +1 -1
- package/dist/hybrid-search.d.ts +12 -0
- package/dist/hybrid-search.js +74 -5
- package/dist/hybrid-search.js.map +1 -1
- package/dist/ignore.d.ts +29 -0
- package/dist/ignore.js +65 -0
- package/dist/ignore.js.map +1 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +166 -6
- package/dist/index.js.map +1 -1
- package/dist/llm-client.d.ts +41 -0
- package/dist/llm-client.js +98 -0
- package/dist/llm-client.js.map +1 -0
- package/dist/reindex.d.ts +22 -3
- package/dist/reindex.js +60 -8
- package/dist/reindex.js.map +1 -1
- package/dist/search.d.ts +12 -0
- package/dist/search.js +15 -1
- package/dist/search.js.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/config.test.ts +173 -0
- package/src/__tests__/file-scanner.test.ts +88 -0
- package/src/__tests__/hybrid-search.test.ts +107 -0
- package/src/__tests__/ignore.test.ts +86 -0
- package/src/__tests__/index.test.ts +450 -0
- package/src/__tests__/llm-client.test.ts +349 -0
- package/src/__tests__/memory-stats.test.ts +204 -0
- package/src/__tests__/reindex.test.ts +148 -2
- package/src/__tests__/search.test.ts +37 -0
- package/src/config.ts +105 -0
- package/src/db.ts +17 -0
- package/src/file-scanner.ts +28 -3
- package/src/hybrid-search.ts +88 -5
- package/src/ignore.ts +82 -0
- package/src/index.ts +202 -7
- package/src/llm-client.ts +136 -0
- package/src/reindex.ts +80 -9
- package/src/search.ts +27 -1
package/dist/search.js
CHANGED
|
@@ -66,8 +66,18 @@ export class FtsSearch {
|
|
|
66
66
|
return '""';
|
|
67
67
|
return tokens.map(t => '"' + t.replace(/"/g, '""') + '"').join(" ");
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Returns true when the `documents.memory_tier` column exists (schema v3+).
|
|
71
|
+
* On v2 schemas this is false and the memoryTier filter is silently ignored.
|
|
72
|
+
*/
|
|
73
|
+
memoryTierColumnExists() {
|
|
74
|
+
const rows = this.db.db
|
|
75
|
+
.prepare("PRAGMA table_info(documents)")
|
|
76
|
+
.all();
|
|
77
|
+
return rows.some((r) => r.name === "memory_tier");
|
|
78
|
+
}
|
|
69
79
|
search(query, options = {}) {
|
|
70
|
-
const { type, tags, includeSuperseded = false, limit = 20 } = options;
|
|
80
|
+
const { type, tags, includeSuperseded = false, limit = 20, memoryTier } = options;
|
|
71
81
|
const conditions = ["documents_fts MATCH @query"];
|
|
72
82
|
const params = { query: this.escapeFts5Query(query), limit };
|
|
73
83
|
if (!includeSuperseded) {
|
|
@@ -77,6 +87,10 @@ export class FtsSearch {
|
|
|
77
87
|
conditions.push("d.type = @type");
|
|
78
88
|
params.type = type;
|
|
79
89
|
}
|
|
90
|
+
if (memoryTier && memoryTier !== "any" && this.memoryTierColumnExists()) {
|
|
91
|
+
conditions.push("d.memory_tier = @memoryTier");
|
|
92
|
+
params.memoryTier = memoryTier;
|
|
93
|
+
}
|
|
80
94
|
let joinClause = "";
|
|
81
95
|
if (tags && tags.length > 0) {
|
|
82
96
|
joinClause = "JOIN tags t ON t.doc_id = d.id";
|
package/dist/search.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ralph-hero-knowledge-index",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"graphology-simple-path": "^0.2.0",
|
|
32
32
|
"graphology-traversal": "^0.3.1",
|
|
33
33
|
"graphology-types": "^0.24.8",
|
|
34
|
+
"ignore": "^5.3.2",
|
|
34
35
|
"sqlite-vec": "^0.1.7-alpha.10",
|
|
35
36
|
"yaml": "^2.7.0",
|
|
36
37
|
"zod": "^3.25.0"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir, homedir } from "node:os";
|
|
5
|
+
import { loadConfig, expandHome, resolveConfigPath } from "../config.js";
|
|
6
|
+
|
|
7
|
+
describe("expandHome", () => {
|
|
8
|
+
it("returns input unchanged when it does not start with ~", () => {
|
|
9
|
+
expect(expandHome("/absolute/path")).toBe("/absolute/path");
|
|
10
|
+
expect(expandHome("relative/path")).toBe("relative/path");
|
|
11
|
+
expect(expandHome("")).toBe("");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("expands a lone ~", () => {
|
|
15
|
+
expect(expandHome("~")).toBe(homedir());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("expands ~/ prefix to homedir/rest", () => {
|
|
19
|
+
expect(expandHome("~/thoughts")).toBe(join(homedir(), "thoughts"));
|
|
20
|
+
expect(expandHome("~/foo/bar")).toBe(join(homedir(), "foo/bar"));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("loadConfig", () => {
|
|
25
|
+
let originalEnv: string | undefined;
|
|
26
|
+
let tmpDir: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
originalEnv = process.env.RALPH_KNOWLEDGE_CONFIG;
|
|
30
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ralph-config-"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
if (originalEnv === undefined) {
|
|
35
|
+
delete process.env.RALPH_KNOWLEDGE_CONFIG;
|
|
36
|
+
} else {
|
|
37
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = originalEnv;
|
|
38
|
+
}
|
|
39
|
+
if (existsSync(tmpDir)) {
|
|
40
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns {} when the config file is missing", () => {
|
|
45
|
+
// Point env var at a nonexistent path so we don't read the real ~/.ralph file.
|
|
46
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = join(tmpDir, "nope.json");
|
|
47
|
+
expect(loadConfig()).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns {} and warns on malformed JSON", () => {
|
|
51
|
+
const configPath = join(tmpDir, "broken.json");
|
|
52
|
+
writeFileSync(configPath, "{ not: valid json");
|
|
53
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
54
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
55
|
+
const result = loadConfig();
|
|
56
|
+
expect(result).toEqual({});
|
|
57
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
58
|
+
const msg = warn.mock.calls[0][0] as string;
|
|
59
|
+
expect(msg).toContain("Malformed JSON");
|
|
60
|
+
warn.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns {} and warns when top-level is not an object", () => {
|
|
64
|
+
const configPath = join(tmpDir, "array.json");
|
|
65
|
+
writeFileSync(configPath, JSON.stringify(["not", "an", "object"]));
|
|
66
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
67
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
68
|
+
expect(loadConfig()).toEqual({});
|
|
69
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
70
|
+
warn.mockRestore();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("expands ~ prefixes in roots[] to absolute paths", () => {
|
|
74
|
+
const configPath = join(tmpDir, "tilde.json");
|
|
75
|
+
writeFileSync(
|
|
76
|
+
configPath,
|
|
77
|
+
JSON.stringify({ roots: ["~/thoughts", "/absolute/dir"] }),
|
|
78
|
+
);
|
|
79
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
80
|
+
const cfg = loadConfig();
|
|
81
|
+
expect(cfg.roots).toEqual([
|
|
82
|
+
join(homedir(), "thoughts"),
|
|
83
|
+
"/absolute/dir",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("expands ~ in dbPath", () => {
|
|
88
|
+
const configPath = join(tmpDir, "db.json");
|
|
89
|
+
writeFileSync(
|
|
90
|
+
configPath,
|
|
91
|
+
JSON.stringify({ dbPath: "~/.ralph-hero/knowledge.db" }),
|
|
92
|
+
);
|
|
93
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
94
|
+
const cfg = loadConfig();
|
|
95
|
+
expect(cfg.dbPath).toBe(join(homedir(), ".ralph-hero/knowledge.db"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("loads ignorePatterns as provided (no expansion)", () => {
|
|
99
|
+
const configPath = join(tmpDir, "ignore.json");
|
|
100
|
+
writeFileSync(
|
|
101
|
+
configPath,
|
|
102
|
+
JSON.stringify({ ignorePatterns: ["**/drafts/**", "*.bak"] }),
|
|
103
|
+
);
|
|
104
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
105
|
+
const cfg = loadConfig();
|
|
106
|
+
expect(cfg.ignorePatterns).toEqual(["**/drafts/**", "*.bak"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("honors RALPH_KNOWLEDGE_CONFIG env var override", () => {
|
|
110
|
+
const configPath = join(tmpDir, "override.json");
|
|
111
|
+
writeFileSync(
|
|
112
|
+
configPath,
|
|
113
|
+
JSON.stringify({ roots: ["/x"], ignorePatterns: ["y/**"], dbPath: "/z.db" }),
|
|
114
|
+
);
|
|
115
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
116
|
+
const cfg = loadConfig();
|
|
117
|
+
expect(cfg).toEqual({
|
|
118
|
+
roots: ["/x"],
|
|
119
|
+
ignorePatterns: ["y/**"],
|
|
120
|
+
dbPath: "/z.db",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("drops non-string roots and ignorePatterns entries", () => {
|
|
125
|
+
const configPath = join(tmpDir, "mixed.json");
|
|
126
|
+
writeFileSync(
|
|
127
|
+
configPath,
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
roots: ["/a", 42, null, "/b"],
|
|
130
|
+
ignorePatterns: ["good", 7, "more"],
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = configPath;
|
|
134
|
+
const cfg = loadConfig();
|
|
135
|
+
expect(cfg.roots).toEqual(["/a", "/b"]);
|
|
136
|
+
expect(cfg.ignorePatterns).toEqual(["good", "more"]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("resolveConfigPath", () => {
|
|
141
|
+
let originalEnv: string | undefined;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
originalEnv = process.env.RALPH_KNOWLEDGE_CONFIG;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
if (originalEnv === undefined) {
|
|
149
|
+
delete process.env.RALPH_KNOWLEDGE_CONFIG;
|
|
150
|
+
} else {
|
|
151
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = originalEnv;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("defaults to ~/.ralph/knowledge.config.json when env var is unset", () => {
|
|
156
|
+
delete process.env.RALPH_KNOWLEDGE_CONFIG;
|
|
157
|
+
expect(resolveConfigPath()).toBe(
|
|
158
|
+
join(homedir(), ".ralph", "knowledge.config.json"),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("expands ~ prefix in RALPH_KNOWLEDGE_CONFIG", () => {
|
|
163
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = "~/custom/knowledge.json";
|
|
164
|
+
expect(resolveConfigPath()).toBe(
|
|
165
|
+
join(homedir(), "custom/knowledge.json"),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("uses RALPH_KNOWLEDGE_CONFIG absolute path verbatim", () => {
|
|
170
|
+
process.env.RALPH_KNOWLEDGE_CONFIG = "/etc/ralph/knowledge.json";
|
|
171
|
+
expect(resolveConfigPath()).toBe("/etc/ralph/knowledge.json");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { findMarkdownFiles } from "../file-scanner.js";
|
|
6
|
+
import { loadIgnoreForRoot } from "../ignore.js";
|
|
7
|
+
|
|
8
|
+
describe("findMarkdownFiles with IgnoreMatcher", () => {
|
|
9
|
+
it("excludes files matched by a .ralphignore entry", () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
|
|
11
|
+
writeFileSync(join(dir, "a.md"), "# A");
|
|
12
|
+
writeFileSync(join(dir, "b.md"), "# B");
|
|
13
|
+
writeFileSync(join(dir, "c.md"), "# C");
|
|
14
|
+
writeFileSync(join(dir, ".ralphignore"), "b.md\n");
|
|
15
|
+
|
|
16
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
17
|
+
const files = findMarkdownFiles(dir, matcher);
|
|
18
|
+
expect(files).toHaveLength(2);
|
|
19
|
+
expect(files.some(f => f.endsWith("a.md"))).toBe(true);
|
|
20
|
+
expect(files.some(f => f.endsWith("c.md"))).toBe(true);
|
|
21
|
+
expect(files.some(f => f.endsWith("b.md"))).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("skips a whole subdirectory covered by subdir/** pattern", () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
|
|
26
|
+
writeFileSync(join(dir, "keep.md"), "# keep");
|
|
27
|
+
mkdirSync(join(dir, "subdir"));
|
|
28
|
+
writeFileSync(join(dir, "subdir", "skip.md"), "# skip");
|
|
29
|
+
writeFileSync(join(dir, "subdir", "also-skip.md"), "# skip2");
|
|
30
|
+
writeFileSync(join(dir, ".ralphignore"), "subdir/**\n");
|
|
31
|
+
|
|
32
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
33
|
+
const files = findMarkdownFiles(dir, matcher);
|
|
34
|
+
expect(files).toHaveLength(1);
|
|
35
|
+
expect(files[0].endsWith("keep.md")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("honors caller-supplied global patterns via loadIgnoreForRoot", () => {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
|
|
40
|
+
writeFileSync(join(dir, "readme.md"), "# r");
|
|
41
|
+
mkdirSync(join(dir, "drafts"));
|
|
42
|
+
writeFileSync(join(dir, "drafts", "wip.md"), "# wip");
|
|
43
|
+
|
|
44
|
+
const matcher = loadIgnoreForRoot(dir, ["drafts/**"]);
|
|
45
|
+
const files = findMarkdownFiles(dir, matcher);
|
|
46
|
+
expect(files).toHaveLength(1);
|
|
47
|
+
expect(files[0].endsWith("readme.md")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("preserves back-compat when called without a matcher", () => {
|
|
51
|
+
const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
|
|
52
|
+
writeFileSync(join(dir, "a.md"), "# A");
|
|
53
|
+
writeFileSync(join(dir, "b.md"), "# B");
|
|
54
|
+
mkdirSync(join(dir, "nested"));
|
|
55
|
+
writeFileSync(join(dir, "nested", "c.md"), "# C");
|
|
56
|
+
|
|
57
|
+
const files = findMarkdownFiles(dir);
|
|
58
|
+
expect(files).toHaveLength(3);
|
|
59
|
+
expect(files.every(f => f.endsWith(".md"))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("still skips dot- and underscore-prefixed directories even without a matcher", () => {
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
|
|
64
|
+
writeFileSync(join(dir, "top.md"), "# top");
|
|
65
|
+
mkdirSync(join(dir, ".hidden"));
|
|
66
|
+
writeFileSync(join(dir, ".hidden", "hidden.md"), "# h");
|
|
67
|
+
mkdirSync(join(dir, "_private"));
|
|
68
|
+
writeFileSync(join(dir, "_private", "private.md"), "# p");
|
|
69
|
+
|
|
70
|
+
const files = findMarkdownFiles(dir);
|
|
71
|
+
expect(files).toHaveLength(1);
|
|
72
|
+
expect(files[0].endsWith("top.md")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("applies default ignore globals (node_modules/, dist/) via loadIgnoreForRoot", () => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), "fs-ignore-"));
|
|
77
|
+
writeFileSync(join(dir, "root.md"), "# root");
|
|
78
|
+
mkdirSync(join(dir, "node_modules"));
|
|
79
|
+
writeFileSync(join(dir, "node_modules", "pkg.md"), "# pkg");
|
|
80
|
+
mkdirSync(join(dir, "dist"));
|
|
81
|
+
writeFileSync(join(dir, "dist", "out.md"), "# out");
|
|
82
|
+
|
|
83
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
84
|
+
const files = findMarkdownFiles(dir, matcher);
|
|
85
|
+
expect(files).toHaveLength(1);
|
|
86
|
+
expect(files[0].endsWith("root.md")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -73,6 +73,34 @@ beforeEach(() => {
|
|
|
73
73
|
hybrid = new HybridSearch(db, fts, vec, mockEmbedFn);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Ensure the v3 schema extensions (memory_tier column, chunks table) exist.
|
|
78
|
+
* Phase 1 (GH-762) owns the production migration; tests add them so Phase 8
|
|
79
|
+
* features can be exercised independently of Phase 1's merge order.
|
|
80
|
+
*/
|
|
81
|
+
function ensureV3Schema(targetDb: KnowledgeDB): void {
|
|
82
|
+
const rows = targetDb.db
|
|
83
|
+
.prepare("PRAGMA table_info(documents)")
|
|
84
|
+
.all() as Array<{ name: string }>;
|
|
85
|
+
if (!rows.some((r) => r.name === "memory_tier")) {
|
|
86
|
+
targetDb.db.exec(
|
|
87
|
+
"ALTER TABLE documents ADD COLUMN memory_tier TEXT NOT NULL DEFAULT 'doc' CHECK(memory_tier IN ('doc','raw','reflection'))",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
targetDb.db.exec(
|
|
91
|
+
`CREATE TABLE IF NOT EXISTS chunks (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
94
|
+
chunk_index INTEGER NOT NULL,
|
|
95
|
+
content TEXT NOT NULL,
|
|
96
|
+
char_start INTEGER NOT NULL,
|
|
97
|
+
char_end INTEGER NOT NULL,
|
|
98
|
+
context_prefix TEXT NOT NULL DEFAULT '',
|
|
99
|
+
UNIQUE(document_id, chunk_index)
|
|
100
|
+
)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
describe("HybridSearch", () => {
|
|
77
105
|
it("returns results combining FTS and vector scores", async () => {
|
|
78
106
|
const results = await hybrid.search("cache");
|
|
@@ -110,3 +138,82 @@ describe("HybridSearch", () => {
|
|
|
110
138
|
expect(ids).not.toContain("cache-doc");
|
|
111
139
|
});
|
|
112
140
|
});
|
|
141
|
+
|
|
142
|
+
describe("HybridSearch memory_tier filter", () => {
|
|
143
|
+
it("filters to reflection when memoryTier='reflection'", async () => {
|
|
144
|
+
// Rebuild fixture with memory_tier column populated
|
|
145
|
+
ensureV3Schema(db);
|
|
146
|
+
db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("doc", "cache-doc");
|
|
147
|
+
db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("reflection", "auth-doc");
|
|
148
|
+
// FTS must be rebuilt to pick up the new column for its JOIN
|
|
149
|
+
fts.rebuildIndex();
|
|
150
|
+
|
|
151
|
+
const results = await hybrid.search("cache OR auth", { memoryTier: "reflection" });
|
|
152
|
+
const ids = results.map((r) => r.id);
|
|
153
|
+
expect(ids).toContain("auth-doc");
|
|
154
|
+
expect(ids).not.toContain("cache-doc");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns all tiers when memoryTier='any' (default)", async () => {
|
|
158
|
+
ensureV3Schema(db);
|
|
159
|
+
db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("raw", "cache-doc");
|
|
160
|
+
db.db.prepare("UPDATE documents SET memory_tier = ? WHERE id = ?").run("reflection", "auth-doc");
|
|
161
|
+
fts.rebuildIndex();
|
|
162
|
+
|
|
163
|
+
const results = await hybrid.search("cache OR auth", { memoryTier: "any" });
|
|
164
|
+
const ids = results.map((r) => r.id);
|
|
165
|
+
expect(ids).toContain("cache-doc");
|
|
166
|
+
expect(ids).toContain("auth-doc");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("passes silently on v2 DB where memory_tier column is absent", async () => {
|
|
170
|
+
// Do NOT call ensureV3Schema — simulate v2 schema.
|
|
171
|
+
const results = await hybrid.search("cache", { memoryTier: "reflection" });
|
|
172
|
+
// Schema has no tier info; filter treats all docs as 'doc', so reflection
|
|
173
|
+
// filter drops everything on a v2 DB — no error thrown.
|
|
174
|
+
expect(Array.isArray(results)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("HybridSearch chunk metadata enrichment", () => {
|
|
179
|
+
it("populates bestChunkId + chunk meta when vec returns chunk ids", async () => {
|
|
180
|
+
ensureV3Schema(db);
|
|
181
|
+
|
|
182
|
+
// Seed a chunk row for cache-doc and mirror its id in the vec table
|
|
183
|
+
db.db
|
|
184
|
+
.prepare(
|
|
185
|
+
`INSERT INTO chunks (id, document_id, chunk_index, content, char_start, char_end, context_prefix)
|
|
186
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
187
|
+
)
|
|
188
|
+
.run(
|
|
189
|
+
"cache-doc#c0",
|
|
190
|
+
"cache-doc",
|
|
191
|
+
0,
|
|
192
|
+
"Analysis of cache invalidation patterns...",
|
|
193
|
+
0,
|
|
194
|
+
40,
|
|
195
|
+
"Research: cache-invalidation context.",
|
|
196
|
+
);
|
|
197
|
+
// Replace the doc-level vec entry with a chunk-level one.
|
|
198
|
+
vec.deleteEmbedding("cache-doc");
|
|
199
|
+
vec.upsertEmbedding("cache-doc#c0", mockEmbedding(1));
|
|
200
|
+
|
|
201
|
+
const results = await hybrid.search("cache");
|
|
202
|
+
const hit = results.find((r) => r.id === "cache-doc");
|
|
203
|
+
expect(hit).toBeDefined();
|
|
204
|
+
expect(hit!.bestChunkId).toBe("cache-doc#c0");
|
|
205
|
+
expect(hit!.chunkIndex).toBe(0);
|
|
206
|
+
expect(hit!.charStart).toBe(0);
|
|
207
|
+
expect(hit!.charEnd).toBe(40);
|
|
208
|
+
expect(hit!.contextPrefix).toBe("Research: cache-invalidation context.");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not populate chunk meta when vec returns doc-level ids", async () => {
|
|
212
|
+
// Fixture as-is: vec stores doc ids, not chunk ids.
|
|
213
|
+
const results = await hybrid.search("cache");
|
|
214
|
+
const hit = results.find((r) => r.id === "cache-doc");
|
|
215
|
+
expect(hit).toBeDefined();
|
|
216
|
+
expect(hit!.bestChunkId).toBeUndefined();
|
|
217
|
+
expect(hit!.chunkIndex).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { loadIgnoreForRoot, DEFAULT_IGNORE_PATTERNS } from "../ignore.js";
|
|
6
|
+
|
|
7
|
+
describe("loadIgnoreForRoot", () => {
|
|
8
|
+
it("applies default globals when no .ralphignore and no caller globals are provided", () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
10
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
11
|
+
expect(matcher.isIgnored("node_modules/foo.js")).toBe(true);
|
|
12
|
+
expect(matcher.isIgnored(".claude/settings.json")).toBe(true);
|
|
13
|
+
expect(matcher.isIgnored("dist/index.js")).toBe(true);
|
|
14
|
+
expect(matcher.isIgnored("debug.log")).toBe(true);
|
|
15
|
+
expect(matcher.isIgnored("thoughts/research/doc.md")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("honors glob pattern **/node_modules/** against nested paths", () => {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
20
|
+
writeFileSync(join(dir, ".ralphignore"), "**/node_modules/**\n");
|
|
21
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
22
|
+
expect(matcher.isIgnored("foo/node_modules/bar.js")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("honors negation that overrides an earlier *.md pattern", () => {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
27
|
+
writeFileSync(join(dir, ".ralphignore"), "*.md\n!keep-me.md\n");
|
|
28
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
29
|
+
expect(matcher.isIgnored("foo.md")).toBe(true);
|
|
30
|
+
expect(matcher.isIgnored("keep-me.md")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("treats directory-only patterns as directories", () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
35
|
+
writeFileSync(join(dir, ".ralphignore"), "dist/\n");
|
|
36
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
37
|
+
// `dist/file.js` matches the directory pattern
|
|
38
|
+
expect(matcher.isIgnored("dist/file.js")).toBe(true);
|
|
39
|
+
// A file literally named `dist` should NOT be matched by the directory-only pattern
|
|
40
|
+
expect(matcher.isIgnored("dist")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("honors caller-provided globals when .ralphignore is absent", () => {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
45
|
+
const matcher = loadIgnoreForRoot(dir, ["custom/**"]);
|
|
46
|
+
expect(matcher.isIgnored("custom/inside.md")).toBe(true);
|
|
47
|
+
expect(matcher.isIgnored("other/inside.md")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not throw when the .ralphignore file is missing", () => {
|
|
51
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
52
|
+
expect(() => loadIgnoreForRoot(dir)).not.toThrow();
|
|
53
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
54
|
+
// Baseline defaults still apply
|
|
55
|
+
expect(matcher.isIgnored("node_modules/x.js")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("combines default globals, caller globals, and .ralphignore file together", () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
60
|
+
writeFileSync(join(dir, ".ralphignore"), "local/**\n");
|
|
61
|
+
const matcher = loadIgnoreForRoot(dir, ["global/**"]);
|
|
62
|
+
// From defaults
|
|
63
|
+
expect(matcher.isIgnored("node_modules/y.js")).toBe(true);
|
|
64
|
+
// From caller globals
|
|
65
|
+
expect(matcher.isIgnored("global/a.md")).toBe(true);
|
|
66
|
+
// From per-root file
|
|
67
|
+
expect(matcher.isIgnored("local/b.md")).toBe(true);
|
|
68
|
+
// Unmatched
|
|
69
|
+
expect(matcher.isIgnored("thoughts/keep.md")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns false for empty or root-level relative paths", () => {
|
|
73
|
+
const dir = mkdtempSync(join(tmpdir(), "ralph-ignore-"));
|
|
74
|
+
const matcher = loadIgnoreForRoot(dir);
|
|
75
|
+
expect(matcher.isIgnored("")).toBe(false);
|
|
76
|
+
expect(matcher.isIgnored("/")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("exports the documented default patterns", () => {
|
|
80
|
+
expect(DEFAULT_IGNORE_PATTERNS).toContain(".claude/");
|
|
81
|
+
expect(DEFAULT_IGNORE_PATTERNS).toContain("node_modules/");
|
|
82
|
+
expect(DEFAULT_IGNORE_PATTERNS).toContain("dist/");
|
|
83
|
+
expect(DEFAULT_IGNORE_PATTERNS).toContain(".git/");
|
|
84
|
+
expect(DEFAULT_IGNORE_PATTERNS).toContain("*.log");
|
|
85
|
+
});
|
|
86
|
+
});
|