Skip to content

Commit ed7cfba

Browse files
committed
feat: path regions, recovery, and agent instruction channels (v0.2.0)
Adds the path-region model that lets Pathrule own a bounded section of an existing agent instruction file and restore it if it is edited or removed by hand, so opening a brownfield project no longer rewrites files the project already had. Also declares the better-sqlite3 major the engine is actually built and tested against, and takes plain `vitest run` for the contract suite. Signed-off-by: Sertan Helvacı <sertanhelvaci@icloud.com>
1 parent 10ce4dc commit ed7cfba

36 files changed

Lines changed: 1272 additions & 306 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export async function installCliHookScript(
9494
env: NodeJS.ProcessEnv = process.env,
9595
opts: HookScriptInstallOptions = {},
9696
): Promise<HookScriptInstallResult> {
97-
const { binDir, scriptPath, shimPath, hookCommandPath, embedHelperPath } =
97+
const { scriptPath, shimPath, hookCommandPath, embedHelperPath } =
9898
resolveCliHookScriptPaths(env);
9999
const platform = cliPlatform(env);
100100
const source = opts.scriptSource ?? EMBEDDED_HOOK_SCRIPT;

packages/core/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"types": "./src/index.ts",
1111
"exports": {
1212
".": "./src/index.ts",
13-
"./backend/knowledge-compiler.js": "./src/backend/knowledge-compiler.ts"
13+
"./backend/knowledge-compiler.js": "./src/backend/knowledge-compiler.ts",
14+
"./paths/*.js": "./src/paths/*.ts"
1415
},
1516
"scripts": {
1617
"typecheck": "tsc --noEmit",
@@ -24,7 +25,7 @@
2425
},
2526
"dependencies": {
2627
"@pathrule/shared": "workspace:*",
27-
"better-sqlite3": "^11"
28+
"better-sqlite3": "^12"
2829
},
2930
"repository": {
3031
"type": "git",

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,19 @@ export function runKnowledgeBackendContract(
8686
expect((await b.deleteMemory({ id: "nope" })).status).toBe("rejected");
8787
});
8888

89+
it("updateMemory refuses a stale optimistic version", async () => {
90+
const b = makeBackend();
91+
const m = await b.writeMemory({ workspaceId: WS, title: "t", content: "c" });
92+
await expect(
93+
b.updateMemory({
94+
id: m.id,
95+
content: "must not overwrite",
96+
expectedVersionId: "stale-version",
97+
}),
98+
).rejects.toThrow("content_version_conflict");
99+
expect((await b.readMemory(m.id))?.content).toBe("c");
100+
});
101+
89102
it("scopes lists by workspace", async () => {
90103
const b = makeBackend();
91104
await b.writeMemory({ workspaceId: WS, title: "a", content: "x" });
@@ -175,6 +188,38 @@ export function runKnowledgeBackendContract(
175188
const cleared = await b.updateSkill({ id: s.id, description: null });
176189
expect(cleared.description).toBeNull();
177190
});
191+
192+
it("rule and skill updates refuse stale optimistic versions", async () => {
193+
const b = makeBackend();
194+
const rule = await b.writeRule({
195+
workspaceId: WS,
196+
name: "rule",
197+
content: "original",
198+
scopeType: "project",
199+
});
200+
await expect(
201+
b.updateRule({
202+
id: rule.id,
203+
content: "must not overwrite",
204+
expectedVersionId: "stale-version",
205+
}),
206+
).rejects.toThrow("content_version_conflict");
207+
expect((await b.readRule(rule.id))?.content).toBe("original");
208+
209+
const skill = await b.writeSkill({
210+
workspaceId: WS,
211+
name: "skill",
212+
content: "original",
213+
});
214+
await expect(
215+
b.updateSkill({
216+
id: skill.id,
217+
content: "must not overwrite",
218+
expectedVersionId: "stale-version",
219+
}),
220+
).rejects.toThrow("content_version_conflict");
221+
expect((await b.readSkill(skill.id))?.content).toBe("original");
222+
});
178223
});
179224

180225
describe("tree", () => {

packages/core/src/backend/delta-delivery.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ describe("delta delivery (engine-level efficiency)", () => {
8686

8787
it("re-injects only the edited item on a later turn", () => {
8888
const inp = input();
89-
const warehouse = assembleWarehouse(inp);
9089
let ledger = applyDelta([], computeDelta(selectedFrom(inp), []).emit);
9190

9291
// edit m2's body → new warehouse + new hash

packages/core/src/backend/in-memory-backend.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ export class InMemoryKnowledgeBackend implements KnowledgeBackend {
354354
async updateMemory(input: UpdateMemoryInput): Promise<Memory> {
355355
const existing = this.memories.get(input.id);
356356
if (!existing) throw new Error(`memory ${input.id} not found`);
357+
if (input.expectedVersionId && input.expectedVersionId !== existing.versionId) {
358+
throw new Error("content_version_conflict");
359+
}
357360
const ts = this.now();
358361
const next: Memory = {
359362
...existing,
@@ -450,9 +453,12 @@ export class InMemoryKnowledgeBackend implements KnowledgeBackend {
450453
return Promise.resolve(rule);
451454
}
452455

453-
updateRule(input: UpdateRuleInput): Promise<Rule> {
456+
async updateRule(input: UpdateRuleInput): Promise<Rule> {
454457
const existing = this.rules.get(input.id);
455458
if (!existing) throw new Error(`rule ${input.id} not found`);
459+
if (input.expectedVersionId && input.expectedVersionId !== existing.versionId) {
460+
throw new Error("content_version_conflict");
461+
}
456462
const ts = this.now();
457463
const next: Rule = {
458464
...existing,
@@ -468,7 +474,7 @@ export class InMemoryKnowledgeBackend implements KnowledgeBackend {
468474
};
469475
this.rules.set(next.id, next);
470476
if (input.nodeId) this.ruleNodes.set(next.id, input.nodeId);
471-
return Promise.resolve(next);
477+
return next;
472478
}
473479

474480
deleteRule(input: DeleteContentInput): Promise<DeleteContentResult> {
@@ -550,9 +556,12 @@ export class InMemoryKnowledgeBackend implements KnowledgeBackend {
550556
return Promise.resolve(skill);
551557
}
552558

553-
updateSkill(input: UpdateSkillInput): Promise<Skill> {
559+
async updateSkill(input: UpdateSkillInput): Promise<Skill> {
554560
const existing = this.skills.get(input.id);
555561
if (!existing) throw new Error(`skill ${input.id} not found`);
562+
if (input.expectedVersionId && input.expectedVersionId !== existing.versionId) {
563+
throw new Error("content_version_conflict");
564+
}
556565
const ts = this.now();
557566
const effectiveSource = input.source ?? existing.source;
558567
const next: Skill = {
@@ -575,7 +584,7 @@ export class InMemoryKnowledgeBackend implements KnowledgeBackend {
575584
};
576585
this.skills.set(next.id, next);
577586
if (input.nodeId) this.skillNodes.set(next.id, input.nodeId);
578-
return Promise.resolve(next);
587+
return next;
579588
}
580589

581590
deleteSkill(input: DeleteContentInput): Promise<DeleteContentResult> {

packages/core/src/backend/inputs.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface WriteMemoryInput {
4646
}
4747
export interface UpdateMemoryInput {
4848
id: string;
49+
/** Optional optimistic guard. A mismatched current version must not be overwritten. */
50+
expectedVersionId?: string;
4951
title?: string;
5052
content?: string;
5153
/** Re-home the memory to a different node (the move_to_path target, already resolved). */
@@ -90,6 +92,8 @@ export interface WriteRuleInput {
9092
}
9193
export interface UpdateRuleInput {
9294
id: string;
95+
/** Optional optimistic guard. A mismatched current version must not be overwritten. */
96+
expectedVersionId?: string;
9397
name?: string;
9498
content?: string;
9599
scopeType?: "folder" | "file_type" | "project";
@@ -116,6 +120,8 @@ export interface WriteSkillInput {
116120
}
117121
export interface UpdateSkillInput {
118122
id: string;
123+
/** Optional optimistic guard. A mismatched current version must not be overwritten. */
124+
expectedVersionId?: string;
119125
name?: string;
120126
content?: string;
121127
/** null clears the description; undefined keeps it. */
@@ -149,6 +155,23 @@ export interface ActivityFriction {
149155
toolFailureCount?: number;
150156
toolFailureCodes?: string[];
151157
}
158+
export interface ActivityTeamIntelligenceSignal {
159+
score: number;
160+
category:
161+
| "architecture"
162+
| "coding_style"
163+
| "debugging"
164+
| "design"
165+
| "testing"
166+
| "review"
167+
| "planning"
168+
| "release"
169+
| "security"
170+
| "collaboration"
171+
| "ai_usage"
172+
| "other";
173+
surface: "chat" | "tasks" | "design";
174+
}
152175
export interface LogActivityInput {
153176
workspaceId: string;
154177
nodePath?: string;
@@ -169,6 +192,22 @@ export interface LogActivityInput {
169192
* (the affinity model is a hosted-only curation surface).
170193
*/
171194
appliedMemoryIds?: string[];
195+
/**
196+
* Exploration-suppression signal (observational). Read-only discovery
197+
* calls (Grep/Glob/Read/...), file-writing calls, and the derived sufficiency
198+
* verdict for this turn. The hosted edition persists them on the activity row;
199+
* LocalBackend ignores (the measurement surface is hosted-only). Omit for a turn
200+
* with no tool stream (then the row stays `unknown` / NULL).
201+
*/
202+
exploreCalls?: number;
203+
editCalls?: number;
204+
contextSufficiency?: "sufficient" | "explored" | "unknown";
205+
/**
206+
* Studio Team Intelligence signal derived from an explicit prompt preference.
207+
* Hosted-only, compact, and absent for ordinary activities. The raw prompt is
208+
* never carried here.
209+
*/
210+
teamIntelligenceSignal?: ActivityTeamIntelligenceSignal;
172211
}
173212
/** The persisted activity row, returned by `logActivity` so callers can echo id/created_at. */
174213
export interface ActivityRecord {
@@ -189,6 +228,13 @@ export interface ActivityRecord {
189228
toolCallCount?: number;
190229
toolFailureCount?: number;
191230
toolFailureCodes?: string[];
231+
/** Exploration-suppression signal (hosted-only; undefined on LocalBackend / legacy rows). */
232+
exploreCalls?: number;
233+
editCalls?: number;
234+
contextSufficiency?: "sufficient" | "explored" | "unknown";
235+
teamIntelligenceScore?: number;
236+
teamIntelligenceCategory?: ActivityTeamIntelligenceSignal["category"];
237+
teamIntelligenceSurface?: ActivityTeamIntelligenceSignal["surface"];
192238
}
193239
/** Lean activity projection for `recentActivities` / context assembly. */
194240
export interface Activity {

packages/core/src/backend/knowledge-backend.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import type {
3232
import type { TreeNode } from "@pathrule/shared/node-types.js";
3333
import type {
3434
RoutingResult,
35-
SubtreeMemoryIndexEntry,
3635
SubtreeMemoryIndexResult,
3736
} from "@pathrule/shared/routing-types.js";
3837
import type { DedupCheckArgs, DedupCheckResult } from "@pathrule/shared/tools/dedup-types.js";

packages/core/src/backend/knowledge-compiler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* Pure and deterministic: same input, same bytes. No I/O, no clock.
1616
*/
1717
import type { HookIndexInput } from "./hook-index.js";
18+
import { isDirectoryLeafName } from "../paths/leaf-type.js";
1819

1920
/** Per-directory budget for compiled knowledge (chars ≈ tokens × 4). */
2021
const DIR_BUDGET_CHARS = 12_000;
@@ -69,7 +70,7 @@ const PRIORITY_RANK: Record<string, number> = { high: 0, medium: 1, low: 2 };
6970
function toDirPath(nodePath: string): { dir: string; leaf?: string } {
7071
const clean = nodePath === "" ? "/" : nodePath;
7172
const last = clean.split("/").filter(Boolean).pop() ?? "";
72-
const looksLikeFile = /\.[A-Za-z0-9]{1,8}$/.test(last);
73+
const looksLikeFile = !isDirectoryLeafName(last);
7374
if (!looksLikeFile) return { dir: clean || "/" };
7475
const idx = clean.lastIndexOf("/");
7576
const dir = idx <= 0 ? "/" : clean.slice(0, idx);

packages/core/src/backend/local/bootstrap.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ import { join } from "node:path";
77
import { LocalBackend } from "./local-backend.js";
88
import { SCHEMA_VERSION } from "./schema.js";
99
import { resolveLocalPrincipal } from "./identity.js";
10+
import { resolveSqliteNativeBinding } from "./native-binding.js";
11+
12+
/**
13+
* Open a DB the way LocalBackend itself does.
14+
*
15+
* The white-box `user_version` probes below construct `Database` directly, so
16+
* they must honour the same PATHRULE_SQLITE_NATIVE_DIR override the backend
17+
* applies. Without it they load better-sqlite3's default binding, which in this
18+
* monorepo is the ELECTRON-ABI copy that packages/app rebuilds in node_modules,
19+
* and the probe dies on NODE_MODULE_VERSION while the backend beside it works.
20+
*/
21+
function openRaw(path: string): Database.Database {
22+
const nativeBinding = resolveSqliteNativeBinding();
23+
return new Database(path, nativeBinding ? { nativeBinding } : {});
24+
}
1025

1126
// The canonical-store bootstrap + numbered-migration runner.
1227
describe("LocalBackend bootstrap", () => {
@@ -51,7 +66,7 @@ describe("LocalBackend bootstrap", () => {
5166
}
5267

5368
// White-box: user_version reflects the latest applied migration.
54-
const raw = new Database(dbPath);
69+
const raw = openRaw(dbPath);
5570
expect(raw.pragma("user_version", { simple: true })).toBe(SCHEMA_VERSION);
5671
raw.close();
5772

@@ -93,7 +108,7 @@ describe("LocalBackend bootstrap", () => {
93108
// Second open re-runs runMigrations(); must not throw or downgrade user_version.
94109
const b = LocalBackend.openForWorkspace("ws-y", env);
95110
b.close();
96-
const raw = new Database(join(home, "ws-y", "pathrule.db"));
111+
const raw = openRaw(join(home, "ws-y", "pathrule.db"));
97112
expect(raw.pragma("user_version", { simple: true })).toBe(SCHEMA_VERSION);
98113
raw.close();
99114
});

packages/core/src/backend/local/local-backend.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -540,23 +540,30 @@ export class LocalBackend implements KnowledgeBackend {
540540
async updateMemory(input: UpdateMemoryInput): Promise<Memory> {
541541
const existing = await this.readMemory(input.id);
542542
if (!existing) throw new Error(`memory ${input.id} not found`);
543+
if (input.expectedVersionId && input.expectedVersionId !== existing.versionId) {
544+
throw new Error("content_version_conflict");
545+
}
543546
const ts = this.now();
544-
this.db
547+
const nextVersionId = this.genId();
548+
const result = this.db
545549
.prepare(
546550
`UPDATE memories SET title = ?, content = ?, node_id = ?, version_id = ?, version_number = ?,
547-
last_edited_by = ?, last_edited_at = ?, updated_at = ? WHERE id = ?`,
551+
last_edited_by = ?, last_edited_at = ?, updated_at = ?
552+
WHERE id = ? AND version_id = ?`,
548553
)
549554
.run(
550555
input.title ?? existing.title,
551556
input.content ?? existing.content,
552557
input.nodeId ?? existing.nodeId,
553-
this.genId(),
558+
nextVersionId,
554559
existing.versionNumber + 1,
555560
this.principal,
556561
ts,
557562
ts,
558563
input.id,
564+
input.expectedVersionId ?? existing.versionId,
559565
);
566+
if (result.changes !== 1) throw new Error("content_version_conflict");
560567
const updated = await this.readMemory(input.id);
561568
if (!updated) throw new Error(`memory ${input.id} vanished after update`);
562569
// Re-embed only when the embedded text actually changed. A node-only re-home
@@ -734,24 +741,31 @@ export class LocalBackend implements KnowledgeBackend {
734741
async updateRule(input: UpdateRuleInput): Promise<Rule> {
735742
const existing = await this.readRule(input.id);
736743
if (!existing) throw new Error(`rule ${input.id} not found`);
744+
if (input.expectedVersionId && input.expectedVersionId !== existing.versionId) {
745+
throw new Error("content_version_conflict");
746+
}
737747
const ts = this.now();
738-
this.db
748+
const nextVersionId = this.genId();
749+
const result = this.db
739750
.prepare(
740751
`UPDATE rules SET name = ?, content = ?, scope_type = ?, priority = ?, version_id = ?,
741-
version_number = ?, last_edited_by = ?, last_edited_at = ?, updated_at = ? WHERE id = ?`,
752+
version_number = ?, last_edited_by = ?, last_edited_at = ?, updated_at = ?
753+
WHERE id = ? AND version_id = ?`,
742754
)
743755
.run(
744756
input.name ?? existing.name,
745757
input.content ?? existing.content,
746758
input.scopeType ?? existing.scopeType,
747759
input.priority ?? existing.priority,
748-
this.genId(),
760+
nextVersionId,
749761
existing.versionNumber + 1,
750762
this.principal,
751763
ts,
752764
ts,
753765
input.id,
766+
input.expectedVersionId ?? existing.versionId,
754767
);
768+
if (result.changes !== 1) throw new Error("content_version_conflict");
755769
if (input.nodeId) {
756770
// Re-home: replace any existing attachments with one pointing at the new node.
757771
this.db.prepare("DELETE FROM node_rules WHERE rule_id = ?").run(input.id);
@@ -858,6 +872,9 @@ export class LocalBackend implements KnowledgeBackend {
858872
async updateSkill(input: UpdateSkillInput): Promise<Skill> {
859873
const existing = await this.readSkill(input.id);
860874
if (!existing) throw new Error(`skill ${input.id} not found`);
875+
if (input.expectedVersionId && input.expectedVersionId !== existing.versionId) {
876+
throw new Error("content_version_conflict");
877+
}
861878
const ts = this.now();
862879
// Patch only supplied fields (null clears description/github_url; never reorder).
863880
const sets = [
@@ -880,8 +897,11 @@ export class LocalBackend implements KnowledgeBackend {
880897
sets.push("content_fetched_at = ?");
881898
vals.push(ts);
882899
}
883-
vals.push(input.id);
884-
this.db.prepare(`UPDATE skills SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
900+
vals.push(input.id, input.expectedVersionId ?? existing.versionId);
901+
const result = this.db
902+
.prepare(`UPDATE skills SET ${sets.join(", ")} WHERE id = ? AND version_id = ?`)
903+
.run(...vals);
904+
if (result.changes !== 1) throw new Error("content_version_conflict");
885905
if (input.nodeId) {
886906
this.db.prepare("DELETE FROM node_skills WHERE skill_id = ?").run(input.id);
887907
this.db

0 commit comments

Comments
 (0)