Skip to content

Commit 1a546f3

Browse files
authored
fix: prevent cross-project knowledge entries from leaking into AGENTS.md (#36)
## Problem Cross-project knowledge entries from unrelated projects were appearing in a project's AGENTS.md (reported on getsentry/devinfra-deployment-service#832). Entries like "Consola prompt cancel returns truthy Symbol", "Zod z.coerce.number() converts null to 0", and "Craft v2 GitHub App must be installed per-repo" appeared in devinfra-deployment-service's AGENTS.md — none of which are relevant to that repo. ## Root Causes Three interrelated issues: 1. **Curator creates project-scoped duplicates**: The curator saw all cross-project entries via `ltm.forProject(path, cfg.crossProject=true)`, then the LLM created near-duplicates scoped to the current project. The title dedup guard only checked same `project_id`, missing cross-project originals. 2. **importFromFile defaults crossProject to true**: Entries imported from AGENTS.md got `crossProject: true` (the `ltm.create()` default), leaking them into other projects' system prompts. 3. **Consolidation count inflated**: `ltm.forProject(projectPath)` included cross-project entries from all repos, triggering unnecessary consolidation. ## Fix ### Source changes (5 files) - **`src/curator.ts`**: Curator and consolidation now use `forProject(path, false)` — only see project-specific entries - **`src/ltm.ts`**: - Default `crossProject` changed from `true` to `false` in `create()` - Dedup guard extended to also check cross-project entries by title - `forProject(path, false)` no longer includes `project_id IS NULL` entries - **`src/index.ts`**: Consolidation count uses `forProject(path, false)` - **`src/config.ts`**: `crossProject` config default changed to `false` - **`src/agents-file.ts`**: Both import paths explicitly set `crossProject: false` ### Backward compatibility - `forSession()` is **unaffected** — it has its own SQL queries with relevance gating for cross-project entries, so they still appear in system prompts when relevant - Existing cross-project entries remain in the DB and accessible via `forSession()` ### Tests 7 new tests added covering: - `create()` defaults `crossProject` to `false` - Dedup guard catches title matches against cross-project entries - `importFromFile` creates entries with `cross_project = 0` - Cross-project entries don't appear in `exportToFile` output - Cross-project entries don't inflate `forProject(path, false)` count All 194 tests pass (187 existing + 7 new).
1 parent bdd6d59 commit 1a546f3

File tree

8 files changed

+190
-14
lines changed

8 files changed

+190
-14
lines changed

AGENTS.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
### Architecture
55

66
<!-- lore:019c904b-791e-772a-ab2b-93ac892a960c -->
7-
* **buildSection() renders AGENTS.md directly without formatKnowledge**: AGENTS.md export/import: buildSection() iterates DB entries grouped by category, emitting \`\<!-- lore:UUID -->\` markers, serialized via remark. splitFile() scans ALL\_START\_MARKERS (current + historical) — self-healing: N duplicate sections collapse to 1 on next export. Import dedup handled by curator LLM at startup when file changed. Missing marker = hand-written; duplicate UUID = first wins. ltm.create() has title-based dedup guard (case-insensitive, skipped for explicit IDs from cross-machine import). Marker text says 'maintained by the coding agent' (not 'auto-maintained') so LLM agents include it in commits.
7+
* **buildSection() renders AGENTS.md directly without formatKnowledge**: AGENTS.md export/import: buildSection() iterates DB entries grouped by category, emitting \`\<!-- lore:UUID -->\` markers via remark. splitFile() scans ALL\_START\_MARKERS (current + historical) — self-healing: N duplicate sections collapse to 1 on next export. Import dedup handled by curator LLM at startup when file changed. Missing marker = hand-written; duplicate UUID = first wins. ltm.create() has title-based dedup guard (case-insensitive, skipped for explicit IDs from cross-machine import).
88

99
<!-- lore:019c94bd-042d-73c0-b850-192d1d62fa68 -->
1010
* **Knowledge entry distribution across projects — worktree sessions create separate project IDs**: Knowledge entries are scoped by project\_id from ensureProject(projectPath). OpenCode worktree sessions (paths like ~/.local/share/opencode/worktree/\<hash>/\<slug>/) each get their own project\_id. A single repo can have multiple project\_ids: one for the real path, separate ones per worktree session. Project-specific entries (cross\_project=0) are invisible across different project\_ids. Cross-project entries (cross\_project=1) are shared globally.
@@ -16,7 +16,7 @@
1616
* **Lore temporal pruning runs after distillation and curation on session.idle**: In src/index.ts, session.idle awaits backgroundDistill and backgroundCurate sequentially before running temporal.prune(). Ordering is critical: pruning must not delete unprocessed messages. Pruning defaults: 120-day retention, 1GB max storage (in .lore.json under pruning.retention and pruning.maxStorage). These generous defaults were chosen because the system was new — earlier proposals of 7d/200MB were based on insufficient data.
1717

1818
<!-- lore:019c94bd-042b-7215-b0a0-05719fcd39b2 -->
19-
* **LTM injection pipeline: system transform → forSession → formatKnowledge → gradient deduction**: LTM is injected via experimental.chat.system.transform hook. Flow: getLtmBudget() computes ceiling as (contextLimit - outputReserved - overhead) \* ltmFraction (default 10%, configurable 2-30%). forSession() loads project-specific entries unconditionally + cross-project entries scored by term overlap, greedy-packs into budget. formatKnowledge() renders as markdown. setLtmTokens() records consumption so gradient deducts it. Key: LTM goes into output.system (system prompt), not the message array — invisible to tryFit(), counts against overhead budget.
19+
* **LTM injection pipeline: system transform → forSession → formatKnowledge → gradient deduction**: LTM injected via experimental.chat.system.transform hook. getLtmBudget() computes ceiling as (contextLimit - outputReserved - overhead) \* ltmFraction (default 10%, configurable 2-30%). forSession() loads project-specific entries unconditionally + cross-project entries scored by term overlap, greedy-packs into budget. formatKnowledge() renders as markdown. setLtmTokens() records consumption so gradient deducts it. Key: LTM goes into output.system (system prompt) — invisible to tryFit(), counts against overhead budget.
2020

2121
### Decision
2222

@@ -34,17 +34,14 @@
3434
<!-- lore:019cc40e-e56e-71e9-bc5d-545f97df732b -->
3535
* **Consola prompt cancel returns truthy Symbol, not false**: When a user cancels a \`consola\` / \`@clack/prompts\` confirmation prompt (Ctrl+C), the return value is \`Symbol(clack:cancel)\`, not \`false\`. Since Symbols are truthy in JavaScript, checking \`!confirmed\` will be \`false\` and the code falls through as if the user confirmed. Fix: use \`confirmed !== true\` (strict equality) instead of \`!confirmed\` to correctly handle cancel, false, and any other non-true values.
3636

37-
<!-- lore:019cc484-f0e7-7a64-bea1-f3f98e9c56c1 -->
38-
* **Craft v2 GitHub App must be installed per-repo**: The Craft v2 release/publish workflows use \`actions/create-github-app-token@v1\` which requires the GitHub App to be installed on the specific repository. If the app is configured for "Only select repositories", adding a new repo to the Craft pipeline requires manually adding it at GitHub Settings → Installations → \[App] → Configure. The \`APP\_ID\` variable and \`APP\_PRIVATE\_KEY\` secret are set in the \`production\` environment, not at repo level. Symptom: 404 on \`GET /repos/{owner}/{repo}/installation\`.
39-
4037
<!-- lore:019cb615-0b10-7bbc-a7db-50111118c200 -->
41-
* **Lore auto-recovery can infinite-loop without re-entrancy guard**: Three bugs in v0.5.2 caused excessive background LLM requests: (1) Auto-recovery infinite loop — session.error overflow handler injected recovery prompt via session.prompt, which could overflow again → another session.error → loop of 2+ LLM calls/cycle. Fix: recoveringSessions Set as re-entrancy guard. (2) Curator ran every idle — \`onIdle || afterTurns\` short-circuited because onIdle=true. Fix: change \`||\` to \`&&\`. Lesson: boolean flag gating numeric threshold needs AND not OR. (3) shouldSkip() fell back to session.list() on every unknown session (short IDs fail session.get). Fix: remove list fallback, cache in activeSessions after first check.
38+
* **Lore auto-recovery can infinite-loop without re-entrancy guard**: Three v0.5.2 bugs causing excessive background LLM requests: (1) Auto-recovery loop — session.error handler injected recovery prompt could overflow again → loop. Fix: recoveringSessions Set as re-entrancy guard. (2) Curator ran every idle — \`onIdle || afterTurns\` short-circuited (onIdle=true). Fix: \`||\` \`&&\`. Lesson: boolean flag gating numeric threshold needs AND not OR. (3) shouldSkip() fell back to session.list() on unknown sessions. Fix: remove list fallback, cache in activeSessions.
4239

4340
<!-- lore:019cb3e6-da66-7534-a573-30d2ecadfd53 -->
4441
* **Returning bare promises loses async function from error stack traces**: When an \`async\` function returns another promise without \`await\`, the calling function disappears from error stack traces if the inner promise rejects. A function that drops \`async\` and does \`return someAsyncCall()\` loses its frame entirely. Fix: keep the function \`async\` and use \`return await someAsyncCall()\`. This matters for debugging — the intermediate function name in the stack trace helps locate which code path triggered the failure. ESLint rule \`no-return-await\` is outdated; modern engines optimize \`return await\` in async functions.
4542

4643
<!-- lore:019cd20d-f42c-71bf-9da5-b2dd52c5014d -->
47-
* **sgdisk reserves 33 sectors for backup GPT, shrinking partition vs original layout**: When recreating a GPT partition entry with \`sgdisk\`, it sets \`LastUsableLBA\` conservatively — 33 sectors short of disk end to reserve space for the backup GPT table. If the original partition extended to the very last sector (common for factory-formatted exFAT SD cards), the recreated partition will be 33 sectors too small. Windows strictly validates that the exFAT VolumeLength in the VBR matches the GPT partition size and refuses to mount on mismatch ("drive not formatted" error). Fix: patch the exFAT VBR's VolumeLength to match the GPT partition size (PartitionLastLBA - PartitionFirstLBA + 1), then recalculate the exFAT boot region checksum (sector 11). Do NOT extend LastUsableLBA to the disk's last sector — that's where the backup GPT header lives, and Windows will reject the GPT as corrupt if usable range overlaps it.
44+
* **sgdisk reserves 33 sectors for backup GPT, shrinking partition vs original layout**: When recreating a GPT partition with \`sgdisk\`, it sets LastUsableLBA 33 sectors short of disk end for backup GPT. If the original partition extended to the last sector (common for factory-formatted exFAT SD cards), the recreated partition is too small. Windows validates exFAT VolumeLength matches GPT partition size mismatch causes 'drive not formatted' error. Fix: patch the exFAT VBR's VolumeLength to match GPT partition size (LastLBA - FirstLBA + 1), then recalculate boot region checksum (sector 11). Do NOT extend LastUsableLBA past backup GPT header location.
4845

4946
<!-- lore:019c8f4f-67ca-7212-a8c4-8a75b230ceea -->
5047
* **Test DB isolation via LORE\_DB\_PATH and Bun test preload**: Lore test suite uses isolated temp DB via test/setup.ts preload (bunfig.toml). Preload sets LORE\_DB\_PATH to mkdtempSync path before any imports of src/db.ts; afterAll cleans up. src/db.ts checks LORE\_DB\_PATH first. agents-file.test.ts needs beforeEach cleanup for intra-file isolation and TEST\_UUIDS cleanup in afterAll (shared with ltm.test.ts). Individual test files don't need close() calls — preload handles DB lifecycle.
@@ -58,7 +55,7 @@
5855
* **Lore logging: LORE\_DEBUG gating for info/warn, always-on for errors**: src/log.ts provides three levels: log.info() and log.warn() are suppressed unless LORE\_DEBUG=1 or LORE\_DEBUG=true; log.error() always emits. All write to stderr with \[lore] prefix. This exists because OpenCode TUI renders all stderr as red error text — routine status messages (distillation counts, pruning stats, consolidation) were alarming users. Rule: use log.info() for successful operations and status, log.warn() for non-actionable oddities (e.g. dropping trailing messages), log.error() only in catch blocks for real failures. Never use console.error directly in plugin source files.
5956

6057
<!-- lore:019cb12a-c957-7e24-b3f5-6869f3429d13 -->
61-
* **Lore release process: craft + issue-label publish**: Release flow: (1) Trigger release.yml via workflow\_dispatch with version='auto' — uses getsentry/craft to determine version from commits and create a GitHub issue titled 'publish: BYK/opencode-lore@X.Y.Z'. (2) Label that issue 'accepted' — triggers publish.yml which runs craft publish with npm OIDC trusted publishing, then closes the issue. Do NOT create a release/X.Y.Z branch or bump package.json manually — craft handles versioning with 'auto'. The repo uses a GitHub App token (APP\_ID + APP\_PRIVATE\_KEY) for checkout in both workflows.
58+
* **Lore release process: craft + issue-label publish**: Lore/Craft release pipeline and gotchas: (1) Trigger release.yml via workflow\_dispatch with version='auto' — craft determines version and creates GitHub issue. Label 'accepted' publish.yml runs craft publish with npm OIDC. Don't create release branches or bump package.json manually. (2) GitHub App must be installed per-repo ('Only select repositories' → add at Settings → Installations). APP\_ID/APP\_PRIVATE\_KEY in \`production\` environment. Symptom: 404 on GET /repos/.../installation. (3) npm OIDC only works for publish — \`npm info\` needs NPM\_TOKEN for private packages (public works without auth).
6259

6360
<!-- lore:019cb200-0001-7000-8000-000000000001 -->
6461
* **PR workflow for opencode-lore: branch → PR → auto-merge**: All changes (including minor fixes and test-only changes) must go through a branch + PR + auto-merge, never pushed directly to main. Workflow: (1) git checkout -b \<type>/\<slug>, (2) commit, (3) git push -u origin HEAD, (4) gh pr create --title "..." --body "..." --base main, (5) gh pr merge --auto --squash \<PR#>. Branch name conventions follow merged PR history: fix/\<slug>, feat/\<slug>, chore/\<slug>. Auto-merge with squash is required (merge commits disallowed). Never push directly to main even for trivial changes.

src/agents-file.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ export function importFromFile(input: {
378378
title: entry.title,
379379
content: entry.content,
380380
scope: "project",
381+
crossProject: false,
381382
id: entry.id,
382383
});
383384
}
@@ -395,6 +396,7 @@ export function importFromFile(input: {
395396
title: entry.title,
396397
content: entry.content,
397398
scope: "project",
399+
crossProject: false,
398400
});
399401
}
400402
}

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const LoreConfig = z.object({
5050
maxStorage: z.number().min(50).default(1024),
5151
})
5252
.default({ retention: 120, maxStorage: 1024 }),
53-
crossProject: z.boolean().default(true),
53+
crossProject: z.boolean().default(false),
5454
agentsFile: z
5555
.object({
5656
/** Set to false to disable all AGENTS.md export/import behaviour. */

src/curator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export async function run(input: {
8282
if (recent.length < 3) return { created: 0, updated: 0, deleted: 0 };
8383

8484
const text = recent.map((m) => `[${m.role}] ${m.content}`).join("\n\n");
85-
const existing = ltm.forProject(input.projectPath, cfg.crossProject);
85+
const existing = ltm.forProject(input.projectPath, false);
8686
const existingForPrompt = existing.map((e) => ({
8787
id: e.id,
8888
category: e.category,
@@ -189,7 +189,7 @@ export async function consolidate(input: {
189189
const cfg = config();
190190
if (!cfg.curator.enabled) return { updated: 0, deleted: 0 };
191191

192-
const entries = ltm.forProject(input.projectPath, cfg.crossProject);
192+
const entries = ltm.forProject(input.projectPath, false);
193193
if (entries.length <= cfg.curator.maxEntries) return { updated: 0, deleted: 0 };
194194

195195
const entriesForPrompt = entries.map((e) => ({

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ export const LorePlugin: Plugin = async (ctx) => {
359359
// Runs after normal curation so newly created entries are counted.
360360
// Only triggers when truly over the limit to avoid redundant LLM calls.
361361
if (cfg.knowledge.enabled) try {
362-
const allEntries = ltm.forProject(projectPath);
362+
const allEntries = ltm.forProject(projectPath, false);
363363
if (allEntries.length > cfg.curator.maxEntries) {
364364
log.info(
365365
`entry count ${allEntries.length} exceeds maxEntries ${cfg.curator.maxEntries} — running consolidation`,

src/ltm.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@ export function create(input: {
4040
// Dedup guard: if an entry with the same project_id + title already exists,
4141
// update its content instead of inserting a duplicate. This prevents the
4242
// curator from creating multiple entries for the same concept across sessions.
43+
// Also checks cross-project entries to prevent the curator from creating
44+
// project-scoped duplicates of globally-shared knowledge.
4345
// Note: when an explicit id is provided (cross-machine import), skip dedup —
4446
// the caller (importFromFile) already handles duplicate detection by UUID.
4547
if (!input.id) {
48+
// First check same project_id
4649
const existing = (
4750
pid !== null
4851
? db()
@@ -61,6 +64,19 @@ export function create(input: {
6164
update(existing.id, { content: input.content });
6265
return existing.id;
6366
}
67+
68+
// Also check cross-project entries — prevents creating project-scoped
69+
// duplicates of entries that already exist as cross-project knowledge.
70+
const crossExisting = db()
71+
.query(
72+
"SELECT id FROM knowledge WHERE cross_project = 1 AND LOWER(title) = LOWER(?) AND confidence > 0 LIMIT 1",
73+
)
74+
.get(input.title) as { id: string } | null;
75+
76+
if (crossExisting) {
77+
update(crossExisting.id, { content: input.content });
78+
return crossExisting.id;
79+
}
6480
}
6581

6682
const id = input.id ?? uuidv7();
@@ -77,7 +93,7 @@ export function create(input: {
7793
input.title,
7894
input.content,
7995
input.session ?? null,
80-
(input.crossProject ?? true) ? 1 : 0,
96+
(input.crossProject ?? false) ? 1 : 0,
8197
now,
8298
now,
8399
);
@@ -128,7 +144,7 @@ export function forProject(
128144
return db()
129145
.query(
130146
`SELECT * FROM knowledge
131-
WHERE (project_id = ? OR project_id IS NULL)
147+
WHERE project_id = ?
132148
AND confidence > 0.2
133149
ORDER BY confidence DESC, updated_at DESC`,
134150
)

test/agents-file.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,92 @@ describe("round-trip stability", () => {
755755
});
756756
});
757757

758+
// ---------------------------------------------------------------------------
759+
// Cross-project isolation
760+
// ---------------------------------------------------------------------------
761+
762+
const OTHER_PROJECT = "/test/agents-file/other-project";
763+
764+
describe("cross-project isolation", () => {
765+
test("importFromFile creates entries with cross_project = 0", () => {
766+
const remoteId = "019505a1-7c00-7000-8000-aabbccddeeff";
767+
const section = loreSectionWithEntries([
768+
{ id: remoteId, category: "decision", title: "Auth strategy", content: "OAuth2 with PKCE" },
769+
]);
770+
writeFile(section);
771+
772+
importFromFile({ projectPath: PROJECT, filePath: AGENTS_FILE });
773+
774+
const entry = ltm.get(remoteId);
775+
expect(entry).not.toBeNull();
776+
expect(entry!.cross_project).toBe(0);
777+
});
778+
779+
test("hand-written entries imported from AGENTS.md are project-scoped", () => {
780+
writeFile(`${LORE_SECTION_START}\n\n## Long-term Knowledge\n\n### Pattern\n\n* **Hand-written pattern**: Using middleware\n\n${LORE_SECTION_END}\n`);
781+
782+
importFromFile({ projectPath: PROJECT, filePath: AGENTS_FILE });
783+
784+
const entries = ltm.forProject(PROJECT, false);
785+
const match = entries.find((e) => e.title === "Hand-written pattern");
786+
expect(match).toBeDefined();
787+
expect(match!.cross_project).toBe(0);
788+
});
789+
790+
test("cross-project entries from another project do not appear in exportToFile", () => {
791+
// Create a cross-project entry scoped to a different project
792+
ltm.create({
793+
category: "gotcha",
794+
title: "Unrelated gotcha from other project",
795+
content: "This should not leak into PROJECT's AGENTS.md",
796+
scope: "global",
797+
crossProject: true,
798+
});
799+
800+
// Create a project-specific entry for PROJECT
801+
ltm.create({
802+
projectPath: PROJECT,
803+
category: "decision",
804+
title: "Project-specific decision",
805+
content: "This belongs to this project",
806+
scope: "project",
807+
crossProject: false,
808+
});
809+
810+
exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE });
811+
812+
const content = readFile();
813+
expect(content).toContain("Project-specific decision");
814+
expect(content).not.toContain("Unrelated gotcha from other project");
815+
});
816+
817+
test("cross-project entries from another project do not inflate forProject(path, false) count", () => {
818+
// Create cross-project entries in "other" project
819+
ltm.create({
820+
category: "pattern",
821+
title: "Other project pattern",
822+
content: "Cross-project from elsewhere",
823+
scope: "global",
824+
crossProject: true,
825+
});
826+
827+
// Create one entry for PROJECT
828+
ltm.create({
829+
projectPath: PROJECT,
830+
category: "decision",
831+
title: "Only project entry",
832+
content: "Project-scoped",
833+
scope: "project",
834+
crossProject: false,
835+
});
836+
837+
const projectOnly = ltm.forProject(PROJECT, false);
838+
const projectOnlyTitles = projectOnly.map((e) => e.title);
839+
expect(projectOnlyTitles).toContain("Only project entry");
840+
expect(projectOnlyTitles).not.toContain("Other project pattern");
841+
});
842+
});
843+
758844
// ---------------------------------------------------------------------------
759845
// Multi-section deduplication (self-healing)
760846
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)