Skip to content

Commit 277151b

Browse files
committed
feat: per-prompt relevance injection (v0.1.0)
The engine now embeds each prompt and ranks the workspace's memories, rules, and skills, so the hook delivers only the few most relevant full bodies per turn with a graceful lexical fallback. Cross-lingual matching leans on embeddings; the local backend stays cloud-free behind the KnowledgeBackend seam. Signed-off-by: Sertan Helvacı <sertanhelvaci@icloud.com>
1 parent 03786c9 commit 277151b

47 files changed

Lines changed: 4158 additions & 59 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ call that provider):
136136
| Env var | Enables | Without it |
137137
| --- | --- | --- |
138138
| `PATHRULE_AI_ROUTE_KEY` (Anthropic) | LLM intent routing for sharper context-depth selection | deterministic router (default, instant) |
139-
| `PATHRULE_EMBEDDING_PROVIDER` = `voyage` \| `openai` + `PATHRULE_EMBEDDING_API_KEY` | Semantic memory search: embeddings computed on write, cosine-ranked at query time, stored locally | lexical + path-scoped retrieval |
139+
| `PATHRULE_EMBEDDING_PROVIDER` = `voyage` \| `openai` + `PATHRULE_EMBEDDING_API_KEY` | Semantic relevance, two places: (1) `get_context` memory search, and (2) the hooks — each prompt is embedded and the most relevant memory/skill bodies are ranked and injected just-in-time. Embeddings are computed on write and stored locally; only the prompt is embedded at runtime. | lexical + path-scoped retrieval (hooks still inject, ranked by keyword overlap instead of meaning) |
140140

141141
Both degrade gracefully. On timeout or a missing key, Pathrule falls back to the deterministic
142-
path, the same fallback discipline the cloud edition uses.
142+
path, the same fallback discipline the cloud edition uses. The embedding key is what turns the
143+
hooks from "inject the path's titles" into "inject the few bodies this prompt actually needs," so
144+
it is the highest-leverage key for context quality and token cost.
143145

144146
## 🏷 Editions
145147

packages/cli-local/src/hook-script-install.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ import { atomicWrite } from "@pathrule/shared/local-runtime/atomic-write.js";
2828
import { cliPlatform } from "./platform.js";
2929

3030
declare const __BUILD_HOOK_SCRIPT_SOURCE__: string;
31+
declare const __BUILD_EMBED_HELPER_SOURCE__: string;
3132

3233
// In tests / dev / type-checking the define is not substituted; fall back
3334
// to an empty string so we can mock or read the source from disk.
3435
const EMBEDDED_HOOK_SCRIPT: string =
3536
typeof __BUILD_HOOK_SCRIPT_SOURCE__ === "string" ? __BUILD_HOOK_SCRIPT_SOURCE__ : "";
37+
// The prompt-embedding helper the hook spawns for relevance ranking. Written
38+
// next to the hook; if absent the hook degrades to lexical ranking.
39+
const EMBEDDED_EMBED_HELPER: string =
40+
typeof __BUILD_EMBED_HELPER_SOURCE__ === "string" ? __BUILD_EMBED_HELPER_SOURCE__ : "";
3641

3742
const WINDOWS_CMD_SHIM = `@echo off
3843
node "%~dp0pathrule-hook.js" %*
@@ -52,6 +57,8 @@ export interface HookScriptInstallResult {
5257
export interface HookScriptInstallOptions {
5358
/** Override the embedded script source (tests only). */
5459
scriptSource?: string;
60+
/** Override the embedded embed-query.cjs source (tests only). */
61+
embedHelperSource?: string;
5562
}
5663

5764
/**
@@ -65,6 +72,7 @@ export function resolveCliHookScriptPaths(env: NodeJS.ProcessEnv = process.env):
6572
scriptPath: string;
6673
shimPath: string | null;
6774
hookCommandPath: string;
75+
embedHelperPath: string;
6876
} {
6977
const platform = cliPlatform(env);
7078
const home = pathruleHome(env);
@@ -73,7 +81,8 @@ export function resolveCliHookScriptPaths(env: NodeJS.ProcessEnv = process.env):
7381
const shimPath =
7482
platform === "win32" ? joinPathForPlatform(platform, binDir, "pathrule-hook.cmd") : null;
7583
const hookCommandPath = platform === "win32" && shimPath ? shimPath : scriptPath;
76-
return { binDir, scriptPath, shimPath, hookCommandPath };
84+
const embedHelperPath = joinPathForPlatform(platform, binDir, "embed-query.cjs");
85+
return { binDir, scriptPath, shimPath, hookCommandPath, embedHelperPath };
7786
}
7887

7988
/**
@@ -85,7 +94,8 @@ export async function installCliHookScript(
8594
env: NodeJS.ProcessEnv = process.env,
8695
opts: HookScriptInstallOptions = {},
8796
): Promise<HookScriptInstallResult> {
88-
const { binDir, scriptPath, shimPath, hookCommandPath } = resolveCliHookScriptPaths(env);
97+
const { binDir, scriptPath, shimPath, hookCommandPath, embedHelperPath } =
98+
resolveCliHookScriptPaths(env);
8999
const platform = cliPlatform(env);
90100
const source = opts.scriptSource ?? EMBEDDED_HOOK_SCRIPT;
91101

@@ -99,6 +109,14 @@ export async function installCliHookScript(
99109

100110
// atomicWrite (via writeIfChanged) creates the parent dir, so no explicit mkdir.
101111
const changed = await writeIfChanged(scriptPath, source);
112+
113+
// Write the embed helper next to the hook. Best-effort: an empty source (dev /
114+
// type-check, where the define is not substituted) just skips the write — the
115+
// hook then degrades to lexical ranking rather than failing the whole sync.
116+
const embedSource = opts.embedHelperSource ?? EMBEDDED_EMBED_HELPER;
117+
if (embedSource && embedSource.length > 0) {
118+
await writeIfChanged(embedHelperPath, embedSource);
119+
}
102120
if (platform !== "win32") {
103121
// Best-effort executable bit so users running the script directly get
104122
// a friendly error instead of "permission denied".

packages/cli-local/src/local/sync-local.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { describe, it, expect, afterEach } from "vitest";
77
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
88
import { tmpdir } from "node:os";
99
import { join } from "node:path";
10+
import { LocalBackend } from "@pathrule/core";
1011
import { initLocalWorkspace } from "./init-local.js";
1112
import { syncLocalWorkspace } from "./sync-local.js";
1213

@@ -74,6 +75,54 @@ describe("syncLocalWorkspace", () => {
7475
expect(existsSync(join(cwd, ".pathrule/managed-files.json"))).toBe(true);
7576
});
7677

78+
it("renders the per-directory compiled knowledge files from the local store — no login", async () => {
79+
const home = freshDir("pathrule-cli-home-");
80+
const cwd = freshDir("pathrule-cli-ws-");
81+
const env = { PATHRULE_HOME: home } as NodeJS.ProcessEnv;
82+
83+
const ws = await initLocalWorkspace({ cwd, env, genWorkspaceId: () => "ws-knowledge" });
84+
85+
// Seed knowledge: a root-scoped memory + a path-scoped one under /src.
86+
const backend = LocalBackend.openForWorkspace(ws.workspaceId, env);
87+
try {
88+
const root = await backend.ensureNodeForPath(ws.workspaceId, "/");
89+
await backend.writeMemory({
90+
workspaceId: ws.workspaceId,
91+
nodeId: root.id,
92+
title: "Root convention",
93+
content: "Always run pnpm typecheck before committing.",
94+
});
95+
const src = await backend.ensureNodeForPath(ws.workspaceId, "/src");
96+
await backend.writeMemory({
97+
workspaceId: ws.workspaceId,
98+
nodeId: src.id,
99+
title: "Src module rule",
100+
content: "Components live under src and export via index.ts.",
101+
});
102+
} finally {
103+
backend.close();
104+
}
105+
106+
const result = await syncLocalWorkspace(env, cwd, ws.workspaceId, {
107+
hookScriptSource: HOOK_SCRIPT_SOURCE,
108+
});
109+
110+
expect(result.ok).toBe(true);
111+
expect(result.companion.ok).toBe(true);
112+
expect(result.companion.written).toBeGreaterThan(0);
113+
114+
// Root-scoped knowledge → .claude/rules/pathrule-knowledge.md.
115+
const rootKnowledge = readFileSync(
116+
join(cwd, ".claude/rules/pathrule-knowledge.md"),
117+
"utf8",
118+
);
119+
expect(rootKnowledge).toContain("Root convention");
120+
121+
// Path-scoped knowledge → src/CLAUDE.md (Claude Code's native lazy-load channel).
122+
const srcClaudeMd = readFileSync(join(cwd, "src/CLAUDE.md"), "utf8");
123+
expect(srcClaudeMd).toContain("Src module rule");
124+
});
125+
77126
it("is idempotent — a second run rewrites nothing", async () => {
78127
const home = freshDir("pathrule-cli-home-");
79128
const cwd = freshDir("pathrule-cli-ws-");

packages/cli-local/src/local/sync-local.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,24 @@
66
// also registers the hook command path the settings merger writes),
77
// 2. install the Pre/Post/UserPromptSubmit hook into <cwd>/.claude/settings.json
88
// via the pure settings merger,
9-
// 3. write the static `.claude/rules/pathrule-protocol.md` (no backend needed;
10-
// the dynamic CLAUDE.md render is deliberately skipped — the hook-index
11-
// already injects path context),
12-
// 4. warm `~/.pathrule/cache/<ws>/hook-index.json` from the LocalBackend.
9+
// 3. write the static `.claude/rules/pathrule-protocol.md` (no backend needed),
10+
// 4. render the per-directory compiled knowledge files (claude-code's
11+
// CLAUDE.md + .claude/rules/pathrule-knowledge.md, plus the other enabled
12+
// clients' files) from the LocalBackend — brings the native
13+
// compilation win to the no-login edition (MCP-less, turn-zero path context),
14+
// 5. warm `~/.pathrule/cache/<ws>/hook-index.json` from the LocalBackend.
1315
//
1416
// No org, no auth, no remote calls, no preflight. Idempotent — every step is
1517
// write-if-changed or a fresh assembly.
1618

1719
import { join } from "node:path";
1820

19-
import { LocalBackend } from "@pathrule/core";
21+
import { LocalBackend, resolveLocalPrincipal } from "@pathrule/core";
2022
import {
2123
ensureClaudeSettingsHook,
2224
renderProtocolRulesFile,
2325
} from "@pathrule/shared/pathrule-protocol.js";
26+
import { rerenderMultiClientLocal } from "@pathrule/shared/client-renderers/pipeline.js";
2427
import { atomicWrite, readIfExists } from "@pathrule/shared/local-runtime/atomic-write.js";
2528
import {
2629
syncHookIndex,
@@ -46,6 +49,15 @@ export interface LocalSyncResult {
4649
skipped: number;
4750
errors: Array<{ path: string; message: string }>;
4851
};
52+
companion: {
53+
ok: boolean;
54+
enabled: string[];
55+
written: number;
56+
skipped: number;
57+
removed: number;
58+
errors: Array<{ path: string; message: string }>;
59+
error?: string;
60+
};
4961
hook_index: HookIndexSyncResult;
5062
error?: string;
5163
}
@@ -138,9 +150,42 @@ export async function syncLocalWorkspace(
138150
});
139151
}
140152

141-
// 4. Warm the offline hook-index from the local store.
153+
// 4 + 5 share one LocalBackend handle: render the per-directory compiled
154+
// knowledge files, then warm the offline hook-index from the same store.
155+
let companion: LocalSyncResult["companion"];
142156
let hookIndex: HookIndexSyncResult;
143157
const backend = LocalBackend.openForWorkspace(workspaceId, env);
158+
try {
159+
const outcome = await rerenderMultiClientLocal({
160+
backend,
161+
workspaceId,
162+
workspaceName: backend.getWorkspaceName(workspaceId) ?? workspaceId,
163+
workspaceRoot: cwd,
164+
userId: resolveLocalPrincipal(env),
165+
runtimeOwner: CLI_MANAGED_FILE_OWNER,
166+
runtimeVersion: CLI_VERSION,
167+
});
168+
companion = {
169+
ok: outcome.ok,
170+
enabled: outcome.enabled,
171+
written: outcome.disk.written,
172+
skipped: outcome.disk.skipped,
173+
removed: outcome.disk.removed,
174+
errors: outcome.disk.errors,
175+
error: outcome.error,
176+
};
177+
} catch (err) {
178+
companion = {
179+
ok: false,
180+
enabled: [],
181+
written: 0,
182+
skipped: 0,
183+
removed: 0,
184+
errors: [],
185+
error: err instanceof Error ? err.message : String(err),
186+
};
187+
}
188+
144189
try {
145190
hookIndex = await syncHookIndex({
146191
backend,
@@ -160,20 +205,24 @@ export async function syncLocalWorkspace(
160205
backend.close();
161206
}
162207

163-
const ok = hookScript.ok && files.errors.length === 0 && hookIndex.ok;
208+
const ok =
209+
hookScript.ok && files.errors.length === 0 && companion.ok && hookIndex.ok;
164210
return {
165211
ok,
166212
workspace_id: workspaceId,
167213
workspace_root: cwd,
168214
hook_script: hookScript,
169215
files,
216+
companion,
170217
hook_index: hookIndex,
171218
error: ok
172219
? undefined
173220
: !hookScript.ok
174221
? "hook_script_install_failed"
175222
: files.errors.length > 0
176223
? "local_file_sync_failed"
177-
: "hook_index_sync_failed",
224+
: !companion.ok
225+
? "companion_render_failed"
226+
: "hook_index_sync_failed",
178227
};
179228
}

packages/core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
"private": true,
66
"license": "Apache-2.0",
77
"type": "module",
8+
"sideEffects": false,
89
"main": "./src/index.ts",
910
"types": "./src/index.ts",
1011
"exports": {
11-
".": "./src/index.ts"
12+
".": "./src/index.ts",
13+
"./backend/knowledge-compiler.js": "./src/backend/knowledge-compiler.ts"
1214
},
1315
"scripts": {
1416
"typecheck": "tsc --noEmit",

packages/core/src/backend/contract-suite.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,13 @@ export function runKnowledgeBackendContract(
743743
expect(idx!.skill_invocation_index?.["deploy"]?.[0]?.name).toBe("Deploy");
744744
expect(idx!.filename_index?.["config.json"]).toContain(mem.id);
745745
expect(idx!.pending_refresh_count).toBe(0);
746+
747+
// The warehouse carries FULL bodies keyed by id, with content_hash
748+
// matching the index stub (so the delta gate can compare).
749+
const warehouse = await b.buildWarehousePayload?.(WS);
750+
expect(warehouse).toBeTruthy();
751+
expect(warehouse![mem.id]).toMatchObject({ type: "memory", title: "config.json setup", body: "how to configure" });
752+
expect(warehouse![mem.id]!.content_hash).toBe(idx!.path_memories["/api"]![0]!.content_hash);
746753
});
747754

748755
it("logActivity returns the persisted row, defaults node_path, and normalizes subjects", async () => {
@@ -888,6 +895,41 @@ export function runKnowledgeBackendContract(
888895
expect(res?.payload?.searched_scope.matched_node_path).toBe("/");
889896
});
890897

898+
it("buildEmbeddingsPayload projects the on-write store: id→vector, active only", async () => {
899+
const b = makeBackend();
900+
if (!b.capabilities().semantic || !b.buildEmbeddingsPayload) return;
901+
const alpha = await b.writeMemory({
902+
workspaceId: WS,
903+
nodeId: "n1",
904+
title: "alpha topic",
905+
content: "all about alpha",
906+
});
907+
const beta = await b.writeMemory({
908+
workspaceId: WS,
909+
nodeId: "n1",
910+
title: "beta topic",
911+
content: "beta beta",
912+
});
913+
// Skills are embedded on demand into the same payload.
914+
const skill = await b.writeSkill({
915+
workspaceId: WS,
916+
name: "alpha skill",
917+
content: "all about alpha",
918+
});
919+
const payload = await b.buildEmbeddingsPayload(WS);
920+
expect(payload).toBeTruthy();
921+
expect(Object.keys(payload!).sort()).toEqual([alpha.id, beta.id, skill.id].sort());
922+
expect(payload![alpha.id]).toHaveLength(3); // CONTRACT_TEST_EMBED dims
923+
expect(payload![skill.id]).toHaveLength(3); // skill embedded too
924+
expect(Array.isArray(payload![alpha.id])).toBe(true);
925+
926+
// A deleted (soft) memory drops out of the payload — both backends join
927+
// only active memories; the skill stays.
928+
await b.deleteMemory({ id: beta.id });
929+
const after = await b.buildEmbeddingsPayload(WS);
930+
expect(Object.keys(after ?? {}).sort()).toEqual([alpha.id, skill.id].sort());
931+
});
932+
891933
it("drops direct (already-shown) ids and marks lexical overlap on title-only ids", async () => {
892934
const b = makeBackend();
893935
if (!b.capabilities().semantic || !b.semanticCandidates) return;

0 commit comments

Comments
 (0)