diff --git a/LICENSE b/LICENSE index 7c8be99..6f388c9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,105 @@ -MIT License - -Copyright (c) 2026 BYK - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +# Functional Source License, Version 1.1, Apache 2.0 Future License + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2026 Burak Yigit Kaya + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/package.json b/package.json index 26243b2..6a728aa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "lore-monorepo", "private": true, "type": "module", - "license": "MIT", + "license": "FSL-1.1-Apache-2.0", "description": "Monorepo root for Lore — three-tier memory architecture", "main": "./packages/opencode/src/index.ts", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index bda737a..4d8c400 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@loreai/core", "version": "0.12.0", "type": "module", - "license": "MIT", + "license": "FSL-1.1-Apache-2.0", "description": "Shared memory engine for Lore — three-tier storage, distillation, gradient context management", "main": "./dist/node/index.js", "types": "./dist/node/index.d.ts", diff --git a/packages/core/src/agents-file.ts b/packages/core/src/agents-file.ts index ee9e0de..9b6fc3d 100644 --- a/packages/core/src/agents-file.ts +++ b/packages/core/src/agents-file.ts @@ -9,7 +9,7 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; -import { dirname } from "path"; +import { dirname, join } from "path"; import * as ltm from "./ltm"; import { serialize, inline, h, ul, liph, strong, t, root, unescapeMarkdown } from "./markdown"; @@ -34,6 +34,16 @@ const ALL_START_MARKERS = [ "", ] as const; +/** + * Filename for the dedicated lore knowledge file. Always at the project root. + * Unlike the agents file (AGENTS.md / CLAUDE.md), this file is entirely owned + * by lore — no section markers needed, no non-lore content to preserve. + */ +export const LORE_FILE = ".lore.md"; + +const LORE_FILE_HEADER = + ""; + /** Regex matching a valid UUID (v4 or v7) — 8-4-4-4-12 hex groups. */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -270,16 +280,25 @@ function buildSection(projectPath: string): string { // --------------------------------------------------------------------------- /** - * Write current knowledge entries into the AGENTS.md file, preserving all - * non-lore content. Creates the file if it doesn't exist. + * Write a pointer to `.lore.md` inside the agents file (AGENTS.md / CLAUDE.md), + * preserving all non-lore content. Also writes `.lore.md` with the actual + * knowledge entries as a side effect. */ export function exportToFile(input: { projectPath: string; filePath: string; }): void { - const sectionBody = buildSection(input.projectPath); + // Write the actual entries to .lore.md first. + exportLoreFile(input.projectPath); + + // Build a pointer section for the agents file instead of full entries. + const pointerBody = + "\n## Long-term Knowledge\n\n" + + "For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) " + + "(gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) " + + "in the project root.\n"; const newSection = - LORE_SECTION_START + sectionBody + LORE_SECTION_END + "\n"; + LORE_SECTION_START + pointerBody + LORE_SECTION_END + "\n"; let fileContent = ""; if (existsSync(input.filePath)) { @@ -329,11 +348,11 @@ export function shouldImport(input: { } // --------------------------------------------------------------------------- -// Import +// Import helpers // --------------------------------------------------------------------------- /** - * Import knowledge entries from the agents file into the local DB. + * Upsert parsed entries into the local DB. * * Behaviour per entry: * - Known UUID (already in DB) → update content if it changed (manual edit) @@ -341,26 +360,13 @@ export function shouldImport(input: { * - No UUID (hand-written) → create with a new UUIDv7 * - Duplicate UUID in same file → first occurrence wins, rest ignored */ -export function importFromFile(input: { - projectPath: string; - filePath: string; -}): void { - if (!existsSync(input.filePath)) return; - - const fileContent = readFileSync(input.filePath, "utf8"); - const { section, before } = splitFile(fileContent); - - // Determine what to parse: - // - If lore markers exist: parse ONLY the lore section body (avoid re-importing our own output) - // - If no markers: parse the full file (first-time hand-written AGENTS.md import) - const textToParse = section ?? fileContent; - - const fileEntries = parseEntriesFromSection(textToParse); - if (!fileEntries.length) return; - +function _importEntries( + entries: ParsedFileEntry[], + projectPath: string, +): void { const seenIds = new Set(); - for (const entry of fileEntries) { + for (const entry of entries) { if (entry.id !== null) { // Deduplicate: if same UUID appears twice in file, first wins if (seenIds.has(entry.id)) continue; @@ -375,7 +381,7 @@ export function importFromFile(input: { } else { // Unknown UUID — entry came from another machine, preserve its ID ltm.create({ - projectPath: input.projectPath, + projectPath, category: entry.category, title: entry.title, content: entry.content, @@ -387,13 +393,13 @@ export function importFromFile(input: { } else { // Hand-written entry — create with a new UUIDv7 // Check for a near-duplicate by title to avoid double-import on re-runs - const existing = ltm.forProject(input.projectPath, true); + const existing = ltm.forProject(projectPath, true); const titleMatch = existing.find( (e) => e.title.toLowerCase() === entry.title.toLowerCase(), ); if (!titleMatch) { ltm.create({ - projectPath: input.projectPath, + projectPath, category: entry.category, title: entry.title, content: entry.content, @@ -404,3 +410,80 @@ export function importFromFile(input: { } } } + +// --------------------------------------------------------------------------- +// Import from agents file (AGENTS.md / CLAUDE.md) +// --------------------------------------------------------------------------- + +/** + * Import knowledge entries from the agents file into the local DB. + * Used for backward compatibility when `.lore.md` doesn't exist yet. + */ +export function importFromFile(input: { + projectPath: string; + filePath: string; +}): void { + if (!existsSync(input.filePath)) return; + + const fileContent = readFileSync(input.filePath, "utf8"); + const { section } = splitFile(fileContent); + + // Determine what to parse: + // - If lore markers exist: parse ONLY the lore section body (avoid re-importing our own output) + // - If no markers: parse the full file (first-time hand-written AGENTS.md import) + const textToParse = section ?? fileContent; + + const fileEntries = parseEntriesFromSection(textToParse); + if (!fileEntries.length) return; + + _importEntries(fileEntries, input.projectPath); +} + +// --------------------------------------------------------------------------- +// .lore.md — dedicated knowledge file +// --------------------------------------------------------------------------- + +/** + * Returns true if a `.lore.md` file exists in the project root. + */ +export function loreFileExists(projectPath: string): boolean { + return existsSync(join(projectPath, LORE_FILE)); +} + +/** + * Export current knowledge entries to `.lore.md` in the project root. + * The entire file is lore-owned — no section markers, no content to preserve. + */ +export function exportLoreFile(projectPath: string): void { + const sectionBody = buildSection(projectPath); + const content = LORE_FILE_HEADER + "\n" + sectionBody; + writeFileSync(join(projectPath, LORE_FILE), content, "utf8"); +} + +/** + * Returns true if `.lore.md` needs to be imported: + * - File exists and its content differs from what lore would currently produce + */ +export function shouldImportLoreFile(projectPath: string): boolean { + const fp = join(projectPath, LORE_FILE); + if (!existsSync(fp)) return false; + + const fileContent = readFileSync(fp, "utf8"); + const expected = LORE_FILE_HEADER + "\n" + buildSection(projectPath); + return hashSection(fileContent) !== hashSection(expected); +} + +/** + * Import knowledge entries from `.lore.md` into the local DB. + * Parses the full file content (no section markers to split on). + */ +export function importLoreFile(projectPath: string): void { + const fp = join(projectPath, LORE_FILE); + if (!existsSync(fp)) return; + + const fileContent = readFileSync(fp, "utf8"); + const fileEntries = parseEntriesFromSection(fileContent); + if (!fileEntries.length) return; + + _importEntries(fileEntries, projectPath); +} diff --git a/packages/core/src/gradient.ts b/packages/core/src/gradient.ts index f6fd468..36b97f7 100644 --- a/packages/core/src/gradient.ts +++ b/packages/core/src/gradient.ts @@ -892,134 +892,6 @@ function stripToTextOnly(parts: LorePart[]): LorePart[] { return stripped; } -// --- Phase 2: Temporal anchoring at read time --- - -function formatRelativeTime(date: Date, now: Date): string { - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) return "today"; - if (diffDays === 1) return "yesterday"; - if (diffDays < 7) return `${diffDays} days ago`; - if (diffDays < 14) return "1 week ago"; - if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; - if (diffDays < 60) return "1 month ago"; - if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; - return `${Math.floor(diffDays / 365)} year${Math.floor(diffDays / 365) > 1 ? "s" : ""} ago`; -} - -function parseDateFromContent(s: string): Date | null { - // "Month Day, Year" e.g. "January 15, 2026" - const simple = s.match(/([A-Z][a-z]+)\s+(\d{1,2}),?\s+(\d{4})/); - if (simple) { - const d = new Date(`${simple[1]} ${simple[2]}, ${simple[3]}`); - if (!isNaN(d.getTime())) return d; - } - // "Month D-D, Year" range — use start - const range = s.match(/([A-Z][a-z]+)\s+(\d{1,2})-\d{1,2},?\s+(\d{4})/); - if (range) { - const d = new Date(`${range[1]} ${range[2]}, ${range[3]}`); - if (!isNaN(d.getTime())) return d; - } - // "late/early/mid Month Year" - const vague = s.match(/(late|early|mid)[- ]?([A-Z][a-z]+)\s+(\d{4})/i); - if (vague) { - const day = - vague[1].toLowerCase() === "early" - ? 7 - : vague[1].toLowerCase() === "late" - ? 23 - : 15; - const d = new Date(`${vague[2]} ${day}, ${vague[3]}`); - if (!isNaN(d.getTime())) return d; - } - return null; -} - -// Expand "(meaning DATE)" and "(estimated DATE)" annotations with a relative offset. -// Past future-intent lines get "(likely already happened)" appended. -function expandInlineEstimatedDates(text: string, now: Date): string { - return text.replace( - /\(((?:meaning|estimated)\s+)([^)]+\d{4})\)/gi, - (match, prefix: string, dateContent: string) => { - const d = parseDateFromContent(dateContent); - if (!d) return match; - const rel = formatRelativeTime(d, now); - // Detect future-intent by looking backwards on the same line - const matchIdx = text.indexOf(match); - const lineStart = text.lastIndexOf("\n", matchIdx) + 1; - const linePrefix = text.slice(lineStart, matchIdx); - const isFutureIntent = - /\b(?:will|plans?\s+to|planning\s+to|going\s+to|intends?\s+to)\b/i.test( - linePrefix, - ); - if (d < now && isFutureIntent) - return `(${prefix}${dateContent} — ${rel}, likely already happened)`; - return `(${prefix}${dateContent} — ${rel})`; - }, - ); -} - -// Add relative time annotations to "Date: Month D, Year" section headers -// and gap markers between non-consecutive dates. -function addRelativeTimeToObservations(text: string, now: Date): string { - // First pass: expand inline "(meaning DATE)" annotations - const withInline = expandInlineEstimatedDates(text, now); - - // Second pass: annotate date headers and add gap markers - const dateHeaderRe = /^(Date:\s*)([A-Z][a-z]+ \d{1,2}, \d{4})$/gm; - const found: Array<{ - index: number; - date: Date; - full: string; - prefix: string; - ds: string; - }> = []; - let m: RegExpExecArray | null; - while ((m = dateHeaderRe.exec(withInline)) !== null) { - const d = new Date(m[2]); - if (!isNaN(d.getTime())) - found.push({ - index: m.index, - date: d, - full: m[0], - prefix: m[1], - ds: m[2], - }); - } - if (!found.length) return withInline; - - let result = ""; - let last = 0; - for (let i = 0; i < found.length; i++) { - const curr = found[i]; - const prev = found[i - 1]; - result += withInline.slice(last, curr.index); - // Gap marker between non-consecutive dates - if (prev) { - const gapDays = Math.floor( - (curr.date.getTime() - prev.date.getTime()) / 86400000, - ); - if (gapDays > 1) { - const gap = - gapDays < 7 - ? `[${gapDays} days later]` - : gapDays < 14 - ? "[1 week later]" - : gapDays < 30 - ? `[${Math.floor(gapDays / 7)} weeks later]` - : gapDays < 60 - ? "[1 month later]" - : `[${Math.floor(gapDays / 30)} months later]`; - result += `\n${gap}\n\n`; - } - } - result += `${curr.prefix}${curr.ds} (${formatRelativeTime(curr.date, now)})`; - last = curr.index + curr.full.length; - } - result += withInline.slice(last); - return result; -} - // Build synthetic user/assistant message pair wrapping formatted distillation text. // Shared by the cached and non-cached prefix paths. function buildPrefixMessages(formatted: string): MessageWithParts[] { @@ -1081,12 +953,7 @@ function buildPrefixMessages(formatted: string): MessageWithParts[] { // Non-cached path — used by layers 2-4 which already cause full cache invalidation. function distilledPrefix(distillations: Distillation[]): MessageWithParts[] { if (!distillations.length) return []; - const now = new Date(); - const annotated = distillations.map((d) => ({ - ...d, - observations: addRelativeTimeToObservations(d.observations, now), - })); - const formatted = formatDistillations(annotated); + const formatted = formatDistillations(distillations); if (!formatted) return []; return buildPrefixMessages(formatted); } @@ -1159,12 +1026,7 @@ function distilledPrefixCached( // New rows appended — render only the delta and append to cached text const newRows = distillations.slice(prefixCache!.rowCount); - const now = new Date(); - const annotated = newRows.map((d) => ({ - ...d, - observations: addRelativeTimeToObservations(d.observations, now), - })); - const deltaText = formatDistillations(annotated); + const deltaText = formatDistillations(newRows); if (deltaText) { const fullText = prefixCache!.cachedText + "\n\n" + deltaText; @@ -1183,12 +1045,7 @@ function distilledPrefixCached( } // Full re-render: first call or meta-distillation rewrote rows - const now = new Date(); - const annotated = distillations.map((d) => ({ - ...d, - observations: addRelativeTimeToObservations(d.observations, now), - })); - const fullText = formatDistillations(annotated); + const fullText = formatDistillations(distillations); if (!fullText) { sessState.prefixCache = null; return { messages: [], tokens: 0 }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 906011d..ff96c62 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -93,7 +93,16 @@ export { COMPACT_SUMMARY_TEMPLATE, buildCompactPrompt, } from "./prompt"; -export { shouldImport, importFromFile, exportToFile } from "./agents-file"; +export { + shouldImport, + importFromFile, + exportToFile, + exportLoreFile, + importLoreFile, + shouldImportLoreFile, + loreFileExists, + LORE_FILE, +} from "./agents-file"; export { workerSessionIDs, isWorkerSession } from "./worker"; export * as workerModel from "./worker-model"; export { diff --git a/packages/core/test/agents-file.test.ts b/packages/core/test/agents-file.test.ts index be53162..ba84c05 100644 --- a/packages/core/test/agents-file.test.ts +++ b/packages/core/test/agents-file.test.ts @@ -12,9 +12,14 @@ import * as ltm from "../src/ltm"; import { LORE_SECTION_START, LORE_SECTION_END, + LORE_FILE, exportToFile, + exportLoreFile, importFromFile, + importLoreFile, shouldImport, + shouldImportLoreFile, + loreFileExists, parseEntriesFromSection, type ParsedFileEntry, } from "../src/agents-file"; @@ -23,9 +28,11 @@ import { // Test fixtures // --------------------------------------------------------------------------- -const PROJECT = "/test/agents-file/project"; const TMP_DIR = join(import.meta.dir, "__tmp_agents_file__"); +/** Project path doubles as the filesystem directory for .lore.md functions. */ +const PROJECT = TMP_DIR; const AGENTS_FILE = join(TMP_DIR, "AGENTS.md"); +const LORE_FILE_PATH = join(TMP_DIR, LORE_FILE); function agentsPath(name = "AGENTS.md") { return join(TMP_DIR, name); @@ -99,8 +106,9 @@ beforeEach(() => { for (const id of TEST_UUIDS) { db().query("DELETE FROM knowledge WHERE id = ?").run(id); } - // Reset the agents file + // Reset the agents file and .lore.md if (existsSync(AGENTS_FILE)) rmSync(AGENTS_FILE); + if (existsSync(LORE_FILE_PATH)) rmSync(LORE_FILE_PATH); }); afterAll(() => { @@ -230,7 +238,7 @@ describe("parseEntriesFromSection", () => { // --------------------------------------------------------------------------- describe("exportToFile", () => { - test("creates AGENTS.md from scratch when file does not exist", () => { + test("creates AGENTS.md with pointer and .lore.md with entries", () => { ltm.create({ projectPath: PROJECT, category: "decision", @@ -241,15 +249,22 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + // AGENTS.md gets a pointer, not entries expect(existsSync(AGENTS_FILE)).toBe(true); - const content = readFile(); - expect(content).toContain(LORE_SECTION_START); - expect(content).toContain(LORE_SECTION_END); - expect(content).toContain("Auth strategy"); - expect(content).toContain("OAuth2 with PKCE"); + const agentsContent = readFile(); + expect(agentsContent).toContain(LORE_SECTION_START); + expect(agentsContent).toContain(LORE_SECTION_END); + expect(agentsContent).toContain(".lore.md"); + expect(agentsContent).not.toContain("Auth strategy"); + + // .lore.md gets the actual entries + expect(existsSync(LORE_FILE_PATH)).toBe(true); + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain("Auth strategy"); + expect(loreContent).toContain("Using OAuth2 with PKCE"); }); - test("includes marker before each entry", () => { + test(".lore.md includes marker before each entry", () => { const id = ltm.create({ projectPath: PROJECT, category: "pattern", @@ -260,8 +275,11 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); - expect(content).toContain(``); + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain(``); + // AGENTS.md should NOT have the entry marker + const agentsContent = readFile(); + expect(agentsContent).not.toContain(``); }); test("replaces lore section on subsequent export, preserves non-lore content", () => { @@ -277,15 +295,19 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); - expect(content).toContain("# My Project"); - expect(content).toContain("Some hand-written docs."); - expect(content).toContain("## Workflow"); - expect(content).toContain("Do this stuff."); - expect(content).toContain("Test gotcha"); + const agentsContent = readFile(); + expect(agentsContent).toContain("# My Project"); + expect(agentsContent).toContain("Some hand-written docs."); + expect(agentsContent).toContain("## Workflow"); + expect(agentsContent).toContain("Do this stuff."); + expect(agentsContent).toContain(".lore.md"); // Should only have one lore section - const startCount = (content.match(new RegExp(escapeRegex(LORE_SECTION_START), "g")) ?? []).length; + const startCount = (agentsContent.match(new RegExp(escapeRegex(LORE_SECTION_START), "g")) ?? []).length; expect(startCount).toBe(1); + + // Entries go to .lore.md + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain("Test gotcha"); }); test("appends lore section when file exists without markers", () => { @@ -301,23 +323,27 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); - expect(content).toContain("# Existing project docs"); - expect(content).toContain(LORE_SECTION_START); - expect(content).toContain("Stack"); + const agentsContent = readFile(); + expect(agentsContent).toContain("# Existing project docs"); + expect(agentsContent).toContain(LORE_SECTION_START); + expect(agentsContent).toContain(".lore.md"); + + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain("Stack"); }); - test("writes empty lore section when there are no knowledge entries", () => { + test("writes pointer in agents file even when there are no knowledge entries", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); - expect(content).toContain(LORE_SECTION_START); - expect(content).toContain(LORE_SECTION_END); - // No knowledge entries means no bullet points - expect(content).not.toContain("* **"); + const agentsContent = readFile(); + expect(agentsContent).toContain(LORE_SECTION_START); + expect(agentsContent).toContain(LORE_SECTION_END); + expect(agentsContent).toContain(".lore.md"); + // No knowledge entries means no bullet points in either file + expect(agentsContent).not.toContain("* **"); }); - test("writes entries sorted by category then title", () => { + test(".lore.md writes entries sorted by category then title", () => { ltm.create({ projectPath: PROJECT, category: "gotcha", @@ -335,17 +361,15 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); - const decisionPos = content.indexOf("### Decision"); - const gotchaPos = content.indexOf("### Gotcha"); + const loreContent = readFile(LORE_FILE_PATH); + const decisionPos = loreContent.indexOf("### Decision"); + const gotchaPos = loreContent.indexOf("### Gotcha"); expect(decisionPos).toBeGreaterThan(-1); expect(gotchaPos).toBeGreaterThan(-1); expect(decisionPos).toBeLessThan(gotchaPos); }); - test("sorts entries alphabetically by title within a category", () => { - // Create entries in reverse-alpha and reverse-creation order to ensure - // the export sorts by title, not by DB insertion order or updated_at. + test(".lore.md sorts entries alphabetically by title within a category", () => { ltm.create({ projectPath: PROJECT, category: "gotcha", @@ -370,10 +394,10 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); - const alphaPos = content.indexOf("Alpha gotcha"); - const middlePos = content.indexOf("Middle gotcha"); - const zebraPos = content.indexOf("Zebra gotcha"); + const loreContent = readFile(LORE_FILE_PATH); + const alphaPos = loreContent.indexOf("Alpha gotcha"); + const middlePos = loreContent.indexOf("Middle gotcha"); + const zebraPos = loreContent.indexOf("Zebra gotcha"); expect(alphaPos).toBeGreaterThan(-1); expect(middlePos).toBeGreaterThan(-1); expect(zebraPos).toBeGreaterThan(-1); @@ -381,7 +405,7 @@ describe("exportToFile", () => { expect(middlePos).toBeLessThan(zebraPos); }); - test("separates entries with blank lines for merge-friendliness", () => { + test(".lore.md separates entries with blank lines for merge-friendliness", () => { const id1 = ltm.create({ projectPath: PROJECT, category: "decision", @@ -399,13 +423,13 @@ describe("exportToFile", () => { exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); - const content = readFile(); + const loreContent = readFile(LORE_FILE_PATH); // Between the first bullet and the second marker there should be a blank line const pattern = new RegExp("\\* \\*\\*Alpha decision\\*\\*.*\n\n\n* **Auth strategy**: OAuth2 with PKCE\n\n${LORE_SECTION_END}\n`; const content = `# My Project\n\n${dupSection}\n${dupSection}\n\n## Conventions\n\nSome text.\n`; writeFile(content); @@ -876,6 +902,8 @@ describe("exportToFile — self-healing duplicate sections", () => { expect(result).toContain("# My Project"); expect(result).toContain("## Conventions"); expect(result).toContain("Some text."); + // Pointer, not entries in AGENTS.md + expect(result).toContain(".lore.md"); }); test("collapses old-marker section into one new-marker section on export", () => { @@ -887,24 +915,23 @@ describe("exportToFile — self-healing duplicate sections", () => { scope: "project", }); - // File with old marker text (before the rename) const oldSection = `${OLD_LORE_SECTION_START}\n\n## Long-term Knowledge\n\n${LORE_SECTION_END}\n`; writeFile(`# My Project\n\n${oldSection}\n## Extra\n\nStuff.\n`); exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); const result = readFile(); - // Old marker must be gone, new marker must appear exactly once expect(result).not.toContain(OLD_LORE_SECTION_START); const startCount = (result.match(new RegExp(escapeRegex(LORE_SECTION_START), "g")) ?? []).length; expect(startCount).toBe(1); expect(result).toContain(LORE_SECTION_END); - // Non-lore content preserved expect(result).toContain("# My Project"); expect(result).toContain("## Extra"); expect(result).toContain("Stuff."); - // Entry present - expect(result).toContain("Auth strategy"); + // Entry in .lore.md, pointer in AGENTS.md + expect(result).toContain(".lore.md"); + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain("Auth strategy"); }); test("collapses mixed old+new marker sections (the real-world bug) into one", () => { @@ -916,8 +943,6 @@ describe("exportToFile — self-healing duplicate sections", () => { scope: "project", }); - // Replicate the actual AGENTS.md state: old marker section first, - // then several new marker sections appended after. const oldSection = `${OLD_LORE_SECTION_START}\n\n## Long-term Knowledge\n\n${LORE_SECTION_END}\n`; const newSection = `${LORE_SECTION_START}\n\n## Long-term Knowledge\n\n${LORE_SECTION_END}\n`; const badFile = [ @@ -946,12 +971,12 @@ describe("exportToFile — self-healing duplicate sections", () => { expect(endCount).toBe(1); expect(result).toContain(LORE_SECTION_START); expect(result).not.toContain(OLD_LORE_SECTION_START); - expect(result).toContain("Watch this"); + // Entry in .lore.md + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain("Watch this"); }); test("non-lore content between duplicate sections is also removed", () => { - // If there's random text between two lore sections (shouldn't happen but - // good to verify what 'after last section' means). ltm.create({ projectPath: PROJECT, category: "pattern", @@ -998,6 +1023,325 @@ describe("shouldImport — old marker variant", () => { }); }); +// --------------------------------------------------------------------------- +// exportLoreFile +// --------------------------------------------------------------------------- + +describe("exportLoreFile", () => { + test("creates .lore.md with header and entries", () => { + ltm.create({ + projectPath: PROJECT, + category: "decision", + title: "Auth strategy", + content: "OAuth2 with PKCE", + scope: "project", + }); + + exportLoreFile(PROJECT); + + expect(existsSync(LORE_FILE_PATH)).toBe(true); + const content = readFile(LORE_FILE_PATH); + expect(content).toContain(" markers for entries", () => { + const id = ltm.create({ + projectPath: PROJECT, + category: "gotcha", + title: "Watch this", + content: "Something tricky", + scope: "project", + }); + + exportLoreFile(PROJECT); + + const content = readFile(LORE_FILE_PATH); + expect(content).toContain(``); + }); + + test("writes only a header when there are no entries", () => { + exportLoreFile(PROJECT); + + const content = readFile(LORE_FILE_PATH); + expect(content).toContain("\n\n## Long-term Knowledge\n\n### Pattern\n\n* **Hand-written pattern**: Using middleware\n", + LORE_FILE_PATH, + ); + + importLoreFile(PROJECT); + + const entries = ltm.forProject(PROJECT); + const match = entries.find((e) => e.title === "Hand-written pattern"); + expect(match).toBeDefined(); + expect(match!.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + test("does nothing when .lore.md does not exist", () => { + const before = ltm.forProject(PROJECT); + importLoreFile(PROJECT); + const after = ltm.forProject(PROJECT); + expect(after.length).toBe(before.length); + }); + + test("round-trip: exportLoreFile → importLoreFile → exportLoreFile produces identical file", () => { + ltm.create({ + projectPath: PROJECT, + category: "decision", + title: "Auth strategy", + content: "OAuth2 with PKCE flow", + scope: "project", + }); + ltm.create({ + projectPath: PROJECT, + category: "gotcha", + title: "Rebuild server", + content: "Run pnpm build:bin after src change", + scope: "project", + }); + + exportLoreFile(PROJECT); + const first = readFile(LORE_FILE_PATH); + + importLoreFile(PROJECT); + exportLoreFile(PROJECT); + const second = readFile(LORE_FILE_PATH); + + expect(second).toBe(first); + }); +}); + +// --------------------------------------------------------------------------- +// Migration: old AGENTS.md → .lore.md +// --------------------------------------------------------------------------- + +describe("migration from AGENTS.md to .lore.md", () => { + test("entries in old-format AGENTS.md are importable via importFromFile", () => { + const remoteId = TEST_UUIDS[0]; + const section = loreSectionWithEntries([ + { id: remoteId, category: "decision", title: "Auth strategy", content: "OAuth2 with PKCE" }, + ]); + writeFile(section); + + // No .lore.md exists — backward compat path + expect(loreFileExists(PROJECT)).toBe(false); + expect(shouldImport({ projectPath: PROJECT, filePath: AGENTS_FILE })).toBe(true); + + importFromFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + expect(ltm.get(remoteId)).not.toBeNull(); + }); + + test("exportToFile migrates: writes .lore.md (entries) + AGENTS.md (pointer)", () => { + const id = ltm.create({ + projectPath: PROJECT, + category: "decision", + title: "Auth strategy", + content: "OAuth2 with PKCE", + scope: "project", + }); + + // Start with old-format AGENTS.md (entries inside) + const oldSection = loreSectionWithEntries([ + { id, category: "decision", title: "Auth strategy", content: "OAuth2 with PKCE" }, + ]); + writeFile(oldSection); + + // Export triggers migration + exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + + // AGENTS.md now has pointer + const agentsContent = readFile(); + expect(agentsContent).toContain(".lore.md"); + expect(agentsContent).not.toContain("OAuth2 with PKCE"); + + // .lore.md has entries + expect(loreFileExists(PROJECT)).toBe(true); + const loreContent = readFile(LORE_FILE_PATH); + expect(loreContent).toContain("Auth strategy"); + expect(loreContent).toContain("OAuth2 with PKCE"); + expect(loreContent).toContain(``); + }); + + test("full migration cycle: import from old AGENTS.md → export → next import reads .lore.md", () => { + // Step 1: Old-format AGENTS.md with entries + const remoteId = TEST_UUIDS[0]; + const section = loreSectionWithEntries([ + { id: remoteId, category: "decision", title: "Auth strategy", content: "OAuth2 with PKCE" }, + ]); + writeFile(section); + + // Step 2: Import from old AGENTS.md (backward compat) + importFromFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + expect(ltm.get(remoteId)).not.toBeNull(); + + // Step 3: Export — writes .lore.md + pointer in AGENTS.md + exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + expect(loreFileExists(PROJECT)).toBe(true); + + // Step 4: Next startup — .lore.md exists, use it + expect(shouldImportLoreFile(PROJECT)).toBe(false); // just exported, matches DB + + // Step 5: Simulate edit in .lore.md + const loreContent = readFile(LORE_FILE_PATH); + writeFile(loreContent.replace("OAuth2 with PKCE", "OAuth2 with PKCE — updated"), LORE_FILE_PATH); + expect(shouldImportLoreFile(PROJECT)).toBe(true); + + importLoreFile(PROJECT); + const entry = ltm.get(remoteId); + expect(entry!.content).toContain("updated"); + }); + + test("pointer in AGENTS.md is safe for importFromFile (no entries parsed)", () => { + ltm.create({ + projectPath: PROJECT, + category: "decision", + title: "Auth strategy", + content: "OAuth2 with PKCE", + scope: "project", + }); + exportToFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + + // A teammate with old Lore imports the pointer-only AGENTS.md + const before = ltm.forProject(PROJECT).length; + importFromFile({ projectPath: PROJECT, filePath: AGENTS_FILE }); + const after = ltm.forProject(PROJECT).length; + + // No new entries created — pointer text has no bullet entries + expect(after).toBe(before); + }); +}); + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- diff --git a/packages/core/test/embedding.test.ts b/packages/core/test/embedding.test.ts index 600bbfc..7831c2d 100644 --- a/packages/core/test/embedding.test.ts +++ b/packages/core/test/embedding.test.ts @@ -98,8 +98,8 @@ describe("vectorSearch", () => { const PROJECT = "/test/embedding/vectorsearch"; beforeEach(() => { - const pid = ensureProject(PROJECT); - db().query("DELETE FROM knowledge WHERE project_id = ?").run(pid); + // vectorSearch has no project filter — clean ALL knowledge to avoid cross-test leaks + db().query("DELETE FROM knowledge").run(); }); test("returns entries sorted by similarity descending", () => { diff --git a/packages/core/test/temporal.test.ts b/packages/core/test/temporal.test.ts index 47b62f2..9399f5b 100644 --- a/packages/core/test/temporal.test.ts +++ b/packages/core/test/temporal.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach } from "bun:test"; +import { describe, test, expect, beforeAll, beforeEach } from "bun:test"; import { db, ensureProject } from "../src/db"; import * as temporal from "../src/temporal"; import { ftsQuery } from "../src/search"; @@ -55,6 +55,12 @@ function makeParts(messageID: string, text: string): LorePart[] { } describe("temporal", () => { + beforeAll(() => { + // Clean stale data from prior test runs — tests are cumulative within a run + const pid = ensureProject(PROJECT); + db().query("DELETE FROM temporal_messages WHERE project_id = ?").run(pid); + }); + test("store and retrieve messages", () => { const info = makeMessage("msg-1", "user"); const parts = makeParts("msg-1", "How do I set up authentication?"); diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index 3860789..96bdc28 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -118,8 +118,8 @@ const PROJECT_PATH_PATTERNS: RegExp[] = [ /["']?cwd["']?\s*[:=]\s*["']?(\/(?:home|Users)\/[^\s"',}]+)/, // Working directory: /home/user/project /[Ww]orking\s+directory[:=]\s*(\/(?:home|Users)\/[^\s"',]+)/, - // CLAUDE.md / AGENTS.md file path → take the directory - /(\/(?:home|Users)\/[^\s"',]+)\/(?:CLAUDE|AGENTS)\.md/, + // CLAUDE.md / AGENTS.md / .lore.md file path → take the directory + /(\/(?:home|Users)\/[^\s"',]+)\/(?:CLAUDE|AGENTS|\.lore)\.md/, // Generic absolute path starting with /home/ or /Users/ — first occurrence // Captures until whitespace, quote, comma, or bracket. /(\/(?:home|Users)\/[\w./-]+)/, diff --git a/packages/gateway/src/idle.ts b/packages/gateway/src/idle.ts index 7823346..ee4bf32 100644 --- a/packages/gateway/src/idle.ts +++ b/packages/gateway/src/idle.ts @@ -17,6 +17,7 @@ import { log, config as loreConfig, exportToFile, + exportLoreFile, } from "@loreai/core"; import type { LLMClient } from "@loreai/core"; import type { GatewayConfig } from "./config"; @@ -172,16 +173,20 @@ export function buildIdleWorkHandler( log.error("idle pruning error:", e); } - // 5. AGENTS.md export - if (cfg.knowledge.enabled && cfg.agentsFile.enabled) { + // 5. Knowledge export (.lore.md + optional agents file pointer) + if (cfg.knowledge.enabled) { try { const entries = ltm.forProject(projectPath, false); if (entries.length > 0) { - const filePath = join(projectPath, cfg.agentsFile.path); - exportToFile({ projectPath, filePath }); + if (cfg.agentsFile.enabled) { + const filePath = join(projectPath, cfg.agentsFile.path); + exportToFile({ projectPath, filePath }); + } else { + exportLoreFile(projectPath); + } } } catch (e) { - log.error("idle agents-file export error:", e); + log.error("idle knowledge export error:", e); } } diff --git a/packages/gateway/src/llm-adapter.ts b/packages/gateway/src/llm-adapter.ts index b3505b6..e935f7b 100644 --- a/packages/gateway/src/llm-adapter.ts +++ b/packages/gateway/src/llm-adapter.ts @@ -65,6 +65,22 @@ export function createGatewayLLMClient( activeWorkerCalls.add(callID); try { + // System prompt caching for workers: send as block array with 1h TTL. + // Worker calls come in bursts (distillation, curation) separated by + // minutes of user thinking — 5m TTL expires between bursts, but 1h + // survives. The system prompt (DISTILLATION_SYSTEM, etc.) is static + // across all calls → near-100% cache hit rate after the first write. + // Cost: 1.25× base for the initial write, 0.1× for subsequent reads. + const systemPayload = system + ? [ + { + type: "text", + text: system, + cache_control: { type: "ephemeral", ttl: "3600" }, + }, + ] + : undefined; + const response = await fetch(url, { method: "POST", headers: { @@ -78,7 +94,7 @@ export function createGatewayLLMClient( body: JSON.stringify({ model: model.modelID, max_tokens: 8192, - system, + system: systemPayload ?? system, messages: [{ role: "user", content: user }], }), }); diff --git a/packages/gateway/src/pipeline.ts b/packages/gateway/src/pipeline.ts index 8392dae..e5c2562 100644 --- a/packages/gateway/src/pipeline.ts +++ b/packages/gateway/src/pipeline.ts @@ -60,6 +60,7 @@ import { import { buildAnthropicRequest, buildAnthropicNonStreamResponse, + type AnthropicCacheOptions, } from "./translate/anthropic"; import { buildOpenAIUpstreamRequest, @@ -290,6 +291,7 @@ async function forwardToUpstream( req: GatewayRequest, config: GatewayConfig, interceptor?: UpstreamInterceptor, + cache?: AnthropicCacheOptions, ): Promise { let url: string; let headers: Record; @@ -306,7 +308,7 @@ async function forwardToUpstream( headers = result.headers; body = result.body; } else { - const result = buildAnthropicRequest(req); + const result = buildAnthropicRequest(req, cache); url = `${effectiveUpstreamBase}${result.url}`; headers = result.headers; body = result.body; @@ -846,11 +848,13 @@ async function handleConversationTurn( "over time as knowledge accumulates across sessions."; } - // AGENTS.md commit reminder - if (cfg.knowledge.enabled && cfg.agentsFile.enabled) { + // Lore knowledge file commit reminder + if (cfg.knowledge.enabled) { + const filesToTrack = [".lore.md"]; + if (cfg.agentsFile.enabled) filesToTrack.push(cfg.agentsFile.path); modifiedSystem += - `\n\nWhen making git commits, always check if ${cfg.agentsFile.path} has ` + - `unstaged changes and include it in the commit. This file contains ` + + `\n\nWhen making git commits, always check if ${filesToTrack.join(" and ")} ` + + `have unstaged changes and include them in the commit. These files contain ` + `shared project knowledge managed by lore and must be version-controlled.`; } @@ -890,7 +894,21 @@ async function handleConversationTurn( }; // --- 9. Forward to upstream --- - const upstreamResponse = await forwardToUpstream(modifiedReq, config); + // Enable prompt caching for conversation turns: + // - System prompt: explicit breakpoint with 5m TTL (frequent turns) + // - Conversation: breakpoint on last block so Anthropic caches the prefix + // Title/summary passthrough (handlePassthrough) never reaches here — it + // forwards the raw request without buildAnthropicRequest, so no caching. + const cacheOptions: AnthropicCacheOptions = { + systemTTL: "5m", + cacheConversation: true, + }; + const upstreamResponse = await forwardToUpstream( + modifiedReq, + config, + undefined, + cacheOptions, + ); if (!upstreamResponse.ok) { const errorBody = await upstreamResponse.text(); diff --git a/packages/gateway/src/translate/anthropic.ts b/packages/gateway/src/translate/anthropic.ts index 6c3f738..b1945f2 100644 --- a/packages/gateway/src/translate/anthropic.ts +++ b/packages/gateway/src/translate/anthropic.ts @@ -233,6 +233,49 @@ export function parseAnthropicRequest( }; } +// --------------------------------------------------------------------------- +// Caching options +// --------------------------------------------------------------------------- + +/** + * Options controlling Anthropic prompt caching behavior. + * + * Two independent mechanisms: + * 1. **System prompt caching**: sends `system` as a block array with an + * explicit `cache_control` breakpoint. This is the highest-stability + * cache slot — the system prompt rarely changes within a session. + * 2. **Conversation caching**: places an explicit `cache_control` breakpoint + * on the last message block, enabling Anthropic to cache the conversation + * prefix up to that point. Between consecutive stable turns (same gradient + * layer, no distillation arrival, no window eviction), the prefix is + * byte-identical → cache reads at 0.1× base cost vs 1× uncached. + * + * Title/summary passthrough requests should NEVER enable caching — their + * content varies every call, producing 1.25× write cost with zero reads. + */ +export type AnthropicCacheOptions = { + /** + * Cache the system prompt with an explicit breakpoint. + * - `"5m"` — default 5-minute TTL (conversation turns, frequent enough + * for 5m refresh) + * - `"1h"` — extended 1-hour TTL (worker calls that come in bursts + * separated by minutes of user thinking) + * - `false` — no system caching + */ + systemTTL?: "5m" | "1h" | false; + + /** + * Place an explicit `cache_control` breakpoint on the last block of the + * last message, enabling Anthropic to cache the conversation prefix. + * + * When `true`, the gateway adds `cache_control: { type: "ephemeral" }` + * to the final content block. On the next turn, Anthropic's lookback + * window finds the prior breakpoint, reads the cached prefix (0.1× + * cost), and writes only the new tail (1.25×). + */ + cacheConversation?: boolean; +}; + // --------------------------------------------------------------------------- // buildAnthropicRequest // --------------------------------------------------------------------------- @@ -243,8 +286,15 @@ export function parseAnthropicRequest( * * Returns the relative path, headers, and JSON body. The caller prepends * the upstream base URL. + * + * @param req The normalized gateway request + * @param cache Optional caching configuration. When omitted, no + * `cache_control` annotations are added (passthrough behavior). */ -export function buildAnthropicRequest(req: GatewayRequest): { +export function buildAnthropicRequest( + req: GatewayRequest, + cache?: AnthropicCacheOptions, +): { url: string; headers: Record; body: unknown; @@ -278,15 +328,44 @@ export function buildAnthropicRequest(req: GatewayRequest): { // System — only include if non-empty if (req.system) { - body.system = req.system; + const systemTTL = cache?.systemTTL; + if (systemTTL) { + // Send as block array with explicit cache_control breakpoint. + // This creates a stable cache slot for the system prompt — it changes + // only when LTM entries are added/removed or AGENTS.md is updated. + const cacheControl: Record = + systemTTL === "1h" + ? { type: "ephemeral", ttl: "3600" } + : { type: "ephemeral" }; + body.system = [ + { type: "text", text: req.system, cache_control: cacheControl }, + ]; + } else { + body.system = req.system; + } } // Messages - body.messages = req.messages.map((msg) => ({ + const messages = req.messages.map((msg) => ({ role: msg.role, content: msg.content.map(toAnthropicBlock), })); + // Conversation caching: place a breakpoint on the final content block of + // the last message. Anthropic's 20-block lookback finds the prior turn's + // breakpoint, reads the cached prefix, and writes only the new tail. + if (cache?.cacheConversation && messages.length > 0) { + const lastMsg = messages[messages.length - 1]!; + if (lastMsg.content.length > 0) { + const lastBlock = lastMsg.content[lastMsg.content.length - 1]!; + (lastBlock as Record).cache_control = { + type: "ephemeral", + }; + } + } + + body.messages = messages; + // Tools — only include if present if (req.tools.length > 0) { body.tools = req.tools.map((t) => ({ diff --git a/packages/gateway/test/anthropic-caching.test.ts b/packages/gateway/test/anthropic-caching.test.ts new file mode 100644 index 0000000..62b993c --- /dev/null +++ b/packages/gateway/test/anthropic-caching.test.ts @@ -0,0 +1,334 @@ +/** + * Tests for Anthropic prompt caching in buildAnthropicRequest. + * + * Validates three caching strategies: + * 1. System prompt caching with 5m TTL (conversation turns) + * 2. System prompt caching with 1h TTL (worker calls) + * 3. Conversation message caching (breakpoint on last block) + * 4. No caching for passthrough (title/summary requests) + */ +import { describe, test, expect } from "bun:test"; +import { + buildAnthropicRequest, + type AnthropicCacheOptions, +} from "../src/translate/anthropic"; +import type { GatewayRequest } from "../src/translate/types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRequest( + overrides: Partial = {}, +): GatewayRequest { + return { + protocol: "anthropic", + model: "claude-sonnet-4-20250514", + system: "You are a helpful assistant.", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + ], + tools: [], + stream: true, + maxTokens: 4096, + metadata: {}, + rawHeaders: { + "x-api-key": "test-key", + "anthropic-beta": "extended-thinking-2025-04-30", + }, + ...overrides, + }; +} + +function getBody(req: GatewayRequest, cache?: AnthropicCacheOptions) { + return buildAnthropicRequest(req, cache).body as Record; +} + +// --------------------------------------------------------------------------- +// No caching (default / passthrough) +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — no caching", () => { + test("system is a plain string when no cache options", () => { + const body = getBody(makeRequest()); + expect(body.system).toBe("You are a helpful assistant."); + }); + + test("system is a plain string when cache is undefined", () => { + const body = getBody(makeRequest(), undefined); + expect(body.system).toBe("You are a helpful assistant."); + }); + + test("system is a plain string when systemTTL is false", () => { + const body = getBody(makeRequest(), { systemTTL: false }); + expect(body.system).toBe("You are a helpful assistant."); + }); + + test("messages have no cache_control when cacheConversation is false", () => { + const body = getBody(makeRequest(), { cacheConversation: false }); + const messages = body.messages as Array<{ + content: Array>; + }>; + for (const msg of messages) { + for (const block of msg.content) { + expect(block.cache_control).toBeUndefined(); + } + } + }); + + test("messages have no cache_control when no cache options", () => { + const body = getBody(makeRequest()); + const messages = body.messages as Array<{ + content: Array>; + }>; + for (const msg of messages) { + for (const block of msg.content) { + expect(block.cache_control).toBeUndefined(); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// System prompt caching — 5m TTL (conversation turns) +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — system prompt caching (5m)", () => { + test("system becomes a block array with ephemeral cache_control", () => { + const body = getBody(makeRequest(), { systemTTL: "5m" }); + expect(Array.isArray(body.system)).toBe(true); + const blocks = body.system as Array>; + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("text"); + expect(blocks[0].text).toBe("You are a helpful assistant."); + expect(blocks[0].cache_control).toEqual({ type: "ephemeral" }); + }); + + test("5m TTL does not include explicit ttl field (uses Anthropic default)", () => { + const body = getBody(makeRequest(), { systemTTL: "5m" }); + const blocks = body.system as Array>; + const cc = blocks[0].cache_control as Record; + expect(cc.ttl).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// System prompt caching — 1h TTL (worker calls) +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — system prompt caching (1h)", () => { + test("system becomes a block array with 3600s TTL", () => { + const body = getBody(makeRequest(), { systemTTL: "1h" }); + expect(Array.isArray(body.system)).toBe(true); + const blocks = body.system as Array>; + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("text"); + expect(blocks[0].text).toBe("You are a helpful assistant."); + expect(blocks[0].cache_control).toEqual({ + type: "ephemeral", + ttl: "3600", + }); + }); +}); + +// --------------------------------------------------------------------------- +// System prompt edge cases +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — system prompt edge cases", () => { + test("empty system is not included even with caching enabled", () => { + const body = getBody(makeRequest({ system: "" }), { systemTTL: "5m" }); + expect(body.system).toBeUndefined(); + }); + + test("large system prompt is cached correctly", () => { + const longSystem = "x".repeat(50_000); + const body = getBody(makeRequest({ system: longSystem }), { + systemTTL: "5m", + }); + const blocks = body.system as Array>; + expect(blocks[0].text).toBe(longSystem); + expect(blocks[0].cache_control).toEqual({ type: "ephemeral" }); + }); +}); + +// --------------------------------------------------------------------------- +// Conversation caching — breakpoint on last message block +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — conversation caching", () => { + test("last block of last message gets cache_control", () => { + const req = makeRequest({ + messages: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [{ type: "text", text: "Hi there!" }], + }, + { + role: "user", + content: [{ type: "text", text: "What is 2+2?" }], + }, + ], + }); + const body = getBody(req, { cacheConversation: true }); + const messages = body.messages as Array<{ + role: string; + content: Array>; + }>; + + // Last message's last block should have cache_control + const lastMsg = messages[messages.length - 1]!; + const lastBlock = lastMsg.content[lastMsg.content.length - 1]!; + expect(lastBlock.cache_control).toEqual({ type: "ephemeral" }); + + // Earlier messages should NOT have cache_control + for (let i = 0; i < messages.length - 1; i++) { + for (const block of messages[i].content) { + expect(block.cache_control).toBeUndefined(); + } + } + }); + + test("works with multi-block last message (tool_use + text)", () => { + const req = makeRequest({ + messages: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check." }, + { + type: "tool_use", + id: "toolu_01", + name: "bash", + input: { command: "ls" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + toolUseId: "toolu_01", + content: "file1.txt\nfile2.txt", + }, + { type: "text", text: "What files are there?" }, + ], + }, + ], + }); + const body = getBody(req, { cacheConversation: true }); + const messages = body.messages as Array<{ + content: Array>; + }>; + + const lastMsg = messages[messages.length - 1]!; + // Only the LAST block gets the breakpoint + expect(lastMsg.content[0].cache_control).toBeUndefined(); + expect(lastMsg.content[lastMsg.content.length - 1]!.cache_control).toEqual( + { type: "ephemeral" }, + ); + }); + + test("no-op when messages array is empty", () => { + const req = makeRequest({ messages: [] }); + const body = getBody(req, { cacheConversation: true }); + const messages = body.messages as Array; + expect(messages).toHaveLength(0); + }); + + test("no-op when last message has empty content", () => { + const req = makeRequest({ + messages: [{ role: "user", content: [] }], + }); + // Should not throw + const body = getBody(req, { cacheConversation: true }); + const messages = body.messages as Array<{ + content: Array>; + }>; + expect(messages[0].content).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Combined: system + conversation caching (conversation turn config) +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — combined caching (conversation turn)", () => { + test("system gets 5m cache and last message block gets breakpoint", () => { + const req = makeRequest({ + messages: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [{ type: "text", text: "Hi!" }], + }, + { role: "user", content: [{ type: "text", text: "More" }] }, + ], + }); + const body = getBody(req, { + systemTTL: "5m", + cacheConversation: true, + }); + + // System prompt cached + const system = body.system as Array>; + expect(system[0].cache_control).toEqual({ type: "ephemeral" }); + + // Last message block cached + const messages = body.messages as Array<{ + content: Array>; + }>; + const lastMsg = messages[messages.length - 1]!; + const lastBlock = lastMsg.content[lastMsg.content.length - 1]!; + expect(lastBlock.cache_control).toEqual({ type: "ephemeral" }); + }); +}); + +// --------------------------------------------------------------------------- +// Non-caching fields are unaffected +// --------------------------------------------------------------------------- + +describe("buildAnthropicRequest — caching doesn't affect other fields", () => { + test("model, max_tokens, stream, tools, metadata preserved", () => { + const req = makeRequest({ + model: "claude-opus-4-20250514", + maxTokens: 128000, + stream: false, + tools: [ + { + name: "bash", + description: "Run a command", + inputSchema: { type: "object" }, + }, + ], + metadata: { temperature: 0.7, top_p: 0.9 }, + }); + const body = getBody(req, { + systemTTL: "5m", + cacheConversation: true, + }); + + expect(body.model).toBe("claude-opus-4-20250514"); + expect(body.max_tokens).toBe(128000); + expect(body.stream).toBe(false); + expect(body.temperature).toBe(0.7); + expect(body.top_p).toBe(0.9); + expect(Array.isArray(body.tools)).toBe(true); + }); + + test("headers include api key and beta", () => { + const { headers } = buildAnthropicRequest(makeRequest(), { + systemTTL: "5m", + }); + expect(headers["x-api-key"]).toBe("test-key"); + expect(headers["anthropic-beta"]).toBe( + "extended-thinking-2025-04-30", + ); + }); +}); diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 158dcb6..dc1e42b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -2,7 +2,7 @@ "name": "@loreai/opencode", "version": "0.12.0", "type": "module", - "license": "MIT", + "license": "FSL-1.1-Apache-2.0", "description": "Three-tier memory architecture for OpenCode — distillation, not summarization", "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f76efd9..e9b7912 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -29,6 +29,10 @@ import { shouldImport, importFromFile, exportToFile, + exportLoreFile, + importLoreFile, + shouldImportLoreFile, + loreFileExists, latReader, embedding, log, @@ -532,20 +536,28 @@ export const LorePlugin: Plugin = async (ctx) => { }).catch(() => {}); } - // Import from AGENTS.md at startup if it has changed since last export - // (hand-written entries, edits from other machines, or merge conflicts). + // Import knowledge at startup — .lore.md takes precedence, falls back + // to agents file (AGENTS.md/CLAUDE.md) for backward compat / migration. { const cfg = config(); - if (isValidProjectPath(projectPath) && cfg.knowledge.enabled && cfg.agentsFile.enabled) { - const filePath = join(projectPath, cfg.agentsFile.path); - if (shouldImport({ projectPath, filePath })) { - try { - importFromFile({ projectPath, filePath }); - log.info("imported knowledge from", cfg.agentsFile.path); - invalidateLtmCache(); - } catch (e) { - log.error("agents-file import error:", e); + if (isValidProjectPath(projectPath) && cfg.knowledge.enabled) { + try { + if (loreFileExists(projectPath)) { + if (shouldImportLoreFile(projectPath)) { + importLoreFile(projectPath); + log.info("imported knowledge from .lore.md"); + invalidateLtmCache(); + } + } else if (cfg.agentsFile.enabled) { + const filePath = join(projectPath, cfg.agentsFile.path); + if (shouldImport({ projectPath, filePath })) { + importFromFile({ projectPath, filePath }); + log.info("imported knowledge from", cfg.agentsFile.path, "(migrating to .lore.md)"); + invalidateLtmCache(); + } } + } catch (e) { + log.error("knowledge import error:", e); } } } @@ -1048,20 +1060,23 @@ export const LorePlugin: Plugin = async (ctx) => { log.error("pruning error:", e); } - // Export curated knowledge to AGENTS.md after distillation + curation. + // Export curated knowledge to .lore.md (+ pointer in agents file). try { - const agentsCfg = cfg.agentsFile; - if (isValidProjectPath(projectPath) && cfg.knowledge.enabled && agentsCfg.enabled) { + if (isValidProjectPath(projectPath) && cfg.knowledge.enabled) { const entries = ltm.forProject(projectPath, false); if (entries.length === 0) { - log.info("agents-file export: 0 knowledge entries for project, skipping write"); - } else { - const filePath = join(projectPath, agentsCfg.path); + log.info("knowledge export: 0 entries for project, skipping write"); + } else if (cfg.agentsFile.enabled) { + // Writes both .lore.md (entries) and agents file (pointer). + const filePath = join(projectPath, cfg.agentsFile.path); exportToFile({ projectPath, filePath }); + } else { + // Only write .lore.md (no agents file pointer). + exportLoreFile(projectPath); } } } catch (e) { - log.error("agents-file export error:", e); + log.error("knowledge export error:", e); } // Clean dead knowledge cross-references (entries deleted by curation/consolidation). @@ -1243,14 +1258,16 @@ export const LorePlugin: Plugin = async (ctx) => { if (input.sessionID) consumeCameOutOfIdle(input.sessionID); } - // Remind the agent to include the agents file in commits. - // It is always modified after the lore export runs (post-session) so it - // appears as unstaged when the agent goes to commit — the agent must not - // skip it just because it looks auto-generated. - if (cfg.knowledge.enabled && cfg.agentsFile.enabled) { + // Remind the agent to include lore-managed files in commits. + // They are modified after the lore export runs (post-session) so they + // appear as unstaged when the agent goes to commit — the agent must not + // skip them just because they look auto-generated. + if (cfg.knowledge.enabled) { + const filesToTrack = [".lore.md"]; + if (cfg.agentsFile.enabled) filesToTrack.push(cfg.agentsFile.path); output.system.push( - `When making git commits, always check if ${cfg.agentsFile.path} has ` + - `unstaged changes and include it in the commit. This file contains ` + + `When making git commits, always check if ${filesToTrack.join(" and ")} ` + + `have unstaged changes and include them in the commit. These files contain ` + `shared project knowledge managed by lore and must be version-controlled.`, ); } diff --git a/packages/pi/package.json b/packages/pi/package.json index e22fa18..820cb2a 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -2,7 +2,7 @@ "name": "@loreai/pi", "version": "0.12.0", "type": "module", - "license": "MIT", + "license": "FSL-1.1-Apache-2.0", "description": "Lore memory engine as a Pi (@mariozechner/pi-coding-agent) extension", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 1beba32..d9acaf6 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -34,18 +34,22 @@ import { distillation, ensureProject, exportToFile, + exportLoreFile, formatKnowledge, getLtmBudget, importFromFile, + importLoreFile, isFirstRun, load, log, + loreFileExists, ltm, latReader, onIdleResume, setLtmTokens, setModelLimits, shouldImport, + shouldImportLoreFile, temporal, transform, workerSessionIDs, @@ -151,18 +155,27 @@ export default function lorePiExtension(pi: ExtensionAPI): void { return; } - // Startup AGENTS.md import — same logic as OpenCode adapter. + // Import knowledge at startup — .lore.md takes precedence, falls back + // to agents file (AGENTS.md/CLAUDE.md) for backward compat / migration. const cfg = config(); - if (cfg.knowledge.enabled && cfg.agentsFile.enabled) { - const filePath = join(projectPath, cfg.agentsFile.path); + if (cfg.knowledge.enabled) { try { - if (shouldImport({ projectPath, filePath })) { - importFromFile({ projectPath, filePath }); - log.info("pi: imported knowledge from", cfg.agentsFile.path); - invalidateLtmCache(); + if (loreFileExists(projectPath)) { + if (shouldImportLoreFile(projectPath)) { + importLoreFile(projectPath); + log.info("pi: imported knowledge from .lore.md"); + invalidateLtmCache(); + } + } else if (cfg.agentsFile.enabled) { + const filePath = join(projectPath, cfg.agentsFile.path); + if (shouldImport({ projectPath, filePath })) { + importFromFile({ projectPath, filePath }); + log.info("pi: imported knowledge from", cfg.agentsFile.path, "(migrating to .lore.md)"); + invalidateLtmCache(); + } } } catch (err) { - log.error("pi: agents-file import error:", err); + log.error("pi: knowledge import error:", err); } } @@ -421,13 +434,17 @@ export default function lorePiExtension(pi: ExtensionAPI): void { log.error("pi: temporal.prune failed:", err); } - // AGENTS.md export. - if (cfg.knowledge.enabled && cfg.agentsFile.enabled) { + // Knowledge export (.lore.md + optional agents file pointer). + if (cfg.knowledge.enabled) { try { - const filePath = join(projectPath, cfg.agentsFile.path); - exportToFile({ projectPath, filePath }); + if (cfg.agentsFile.enabled) { + const filePath = join(projectPath, cfg.agentsFile.path); + exportToFile({ projectPath, filePath }); + } else { + exportLoreFile(projectPath); + } } catch (err) { - log.error("pi: agents-file export error:", err); + log.error("pi: knowledge export error:", err); } } });