Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions .lore.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@

### Decision

<!-- lore:019e2026-9cda-7927-81d1-48ad964263d6 -->
* **recordDeclined/wasDeclined renamed to recordOffered/wasOffered — fires unconditionally**: In \`packages/core/src/import/import-auto.ts\`, \`recordDeclined\`/\`wasDeclined\`/\`DECLINED\_SOURCE\_ID\` were renamed to \`recordOffered\`/\`wasOffered\`/\`OFFERED\_SOURCE\_ID\`. The call fires unconditionally after the auto-import prompt regardless of user answer — semantics are 'we already offered for this directory', not 'user declined'. \`wasOffered()\` must check for both \`'\_\_offered\_\_'\` and \`'\_\_declined\_\_'\` for backwards compatibility with existing DB rows. Remove \`recordDeclined\` import from \`import.ts\` (it was imported but never called there).

<!-- lore:019e2026-28d0-75bf-8be7-2236a0185e13 -->
* **recordDeclined/wasDeclined renamed to recordOffered/wasOffered — fires unconditionally after prompt**: In \`packages/gateway/src/import-auto.ts\`, \`recordDeclined\`/\`wasDeclined\` were renamed to \`recordOffered\`/\`wasOffered\` because the marker fires regardless of the user's answer (yes or no). The sentinel \`\_\_declined\_\_\` → \`\_\_offered\_\_\`. \`wasOffered()\` must check BOTH \`\_\_offered\_\_\` AND \`\_\_declined\_\_\` for backwards compatibility with existing DBs. This design is superseded if \`last\_import\_at\` is adopted on the projects table — that approach eliminates sentinel rows entirely.

<!-- lore:019e1df9-df5a-70b7-aa49-0f569fa9ea01 -->
* **Switched from relaxed (AND→OR cascade) to direct OR semantics for recall-oriented matching of paraphrased instructions; rationale: repetition detection needs recall**: switched from relaxed (AND→OR cascade) to direct OR semantics for recall-oriented matching of paraphrased instructions; rationale: repetition detection needs recall,

Expand Down Expand Up @@ -56,6 +62,9 @@
<!-- lore:019e1de2-7710-794e-872c-3e76e6f3ed57 -->
* **agents-file.ts dedup scope: use forProject(path, false) not forSession()**: In \`packages/core/src/agents-file.ts\`, deduplication of hand-written \`.lore.md\` entries must use \`forProject(path, false)\` (project-local entries only) — NOT \`forSession()\` which includes cross-project global entries. Using \`forSession()\` causes cross-project entries from OTHER projects to silently suppress import of valid project-specific entries.

<!-- lore:019e2026-28c1-79c0-aa5e-2bde5bbe1699 -->
* **Auto-import race condition: record 'offered' synchronously before background import fires**: In \`packages/gateway/src/import-auto.ts\`, \`maybeAutoImport()\` fires \`runBackgroundImport()\` as fire-and-forget. If the user says 'yes' but nothing is recorded synchronously, a second \`lore run\` before the import completes sees \`isFreshProject()=true\` and no offered marker — re-prompts. Fix: update \`last\_import\_at\` (or equivalent marker) immediately after the prompt completes, regardless of the user's answer, before firing the background import. Both yes and no paths must set the marker — the user's answer is irrelevant to the 'don't ask again' logic.

<!-- lore:019e1de2-eb6f-7c65-8622-3b8703d4079f -->
* **Auto-TTL downgrade lacks hysteresis — single tool use causes compounding cache busts**: In \`packages/gateway/src/pipeline.ts\`, a single tool-use turn (no user text) triggered an immediate 1h→5m TTL downgrade, and subsequent curations would re-upgrade, oscillating and busting the cache repeatedly. Fix: add \`ttlDowngradeStreak\` counter to \`SessionState\`; only downgrade after 3 consecutive tool-only turns. On user-text turn, reset the streak to 0.

Expand Down Expand Up @@ -377,9 +386,6 @@
<!-- lore:019e1b2d-2bbb-7f18-b0cd-8c393aa6693d -->
* **OpenCode plugin spawns gateway via src/index.ts path, but npm publish ships only dist/**: OpenCode/Pi plugins start gateway in-process via \`startInProcess()\` (\`loadConfig()\` + \`startServer()\` from \`@loreai/gateway\`). \`probeGateway()\` runs first; EADDRINUSE falls back to probing. In monorepo, Bun resolves \`@loreai/gateway\` to \`src/index.ts\` via \`'bun'\` export condition; via npm resolves to \`dist/index.cjs\`. \`dist/index.d.cts\` only exists after building — CI typechecks fail. Fix: break static analysis with \`const mod = '@loreai/gateway'; await import(mod)\`. Pi plugin must also externalize \`@loreai/gateway\` in esbuild. \`NODE\_ENV === 'test'\` skips gateway init — export \`startInProcess\`/\`probeGateway\` directly for tests. Use port \`0\` (ephemeral) in tests.

<!-- lore:019e1de2-7681-76e9-91bf-6e64a90780f6 -->
* **Pi plugin directly imports @loreai/core for compaction — breaks remote gateway model**: The Pi plugin's \`session\_before\_compact\` hook imports \`distill()\` from \`@loreai/core\` directly, running distillation in-process. This means compaction never goes through the gateway, preventing it from working against a remote/hosted gateway. Fix: the hook should POST to \`{gatewayUrl}/v1/compact\` and let the gateway handle distillation, same as how other hooks redirect to gateway endpoints.

<!-- lore:019e1d55-bb26-76a9-a85b-1af0524f48f9 -->
* **Pipeline accumulator only handles Anthropic-format upstream responses — OpenAI upstream is broken**: Pipeline accumulator + streaming translator dispatch by protocol: \`effectiveProtocol\` in \`UpstreamResult\` drives both accumulator branches (non-streaming) and streaming translator selection. Supported: OpenAI Chat Completions (JSON + SSE), OpenAI Responses API (JSON + SSE), Anthropic Messages. Each maps \`prompt\_tokens\_details.cached\_tokens\` → \`cacheReadInputTokens\`. Streaming translators live in \`packages/gateway/src/translators/\`. Adding a new protocol requires both an accumulator branch and a streaming translator. Gateway invariant: internally always Anthropic-format; server handlers re-translate to client protocol.

Expand Down Expand Up @@ -592,9 +598,18 @@

### Pattern

<!-- lore:019e2026-9ccc-7a49-91ab-a781878e41cc -->
* **Auto-import guard: last\_import\_at timestamp replaces sentinel \_\_declined\_\_ rows**: In \`packages/core/src/import/\`, the \`maybeAutoImport()\` guard uses a \`last\_import\_at\` timestamp on the project record instead of a sentinel \`\_\_declined\_\_\`/\`\_\_offered\_\_\` row in \`import\_history\`. This serves dual purpose: (1) prevents re-prompting after auto-import was offered (any answer), (2) enables incremental imports — subsequent \`lore import\` calls only import conversations newer than \`last\_import\_at\`. Call \`recordOffered(projectPath)\` (sets \`last\_import\_at\`) unconditionally after the prompt fires, before the user's answer is processed, so both yes and no paths mark the project. \`wasOffered()\` checks \`last\_import\_at IS NOT NULL\` for backwards compat.

<!-- lore:019e2026-28b1-7c3e-bbad-01c519d9852d -->
* **Auto-import offered marker: use last\_import\_at timestamp on project, not sentinel rows**: In \`packages/gateway/src/import-auto.ts\`, the 'don't re-prompt' guard should use a \`last\_import\_at\` timestamp on the projects table rather than sentinel import\_history rows (\`\_\_declined\_\_\`/\`\_\_offered\_\_\`). This serves three purposes: (1) prevents re-prompting after auto-import is offered, (2) enables incremental imports (only conversations newer than \`last\_import\_at\`), (3) eliminates the misleading \`recordDeclined\`/\`wasDeclined\` naming. \`maybeAutoImport()\` checks \`last\_import\_at IS NOT NULL\` to skip. Explicit \`lore import\` updates \`last\_import\_at\` on completion. Backwards compat: \`\_\_declined\_\_\` sentinel rows in existing DBs should still be respected during transition.

<!-- lore:019e1c62-320d-7f08-8907-cf6ca29eb0d7 -->
* **curator.ts parseOps/applyOps extracted as shared helpers for import pipeline**: \`parseOps()\` and \`applyOps()\` are exported from \`curator.ts\` as named exports so the import extraction pipeline (\`packages/core/src/import/extract.ts\`) can reuse the same op parsing and application logic without duplicating it. When modifying curator op handling, update \`curator.ts\` — the import pipeline inherits the change automatically.

<!-- lore:019e2025-3eb4-7852-aaad-dd9e88e8296e -->
* **maybeAutoImport: recordOffered fires unconditionally after prompt, not per-answer**: In \`packages/gateway/src/import/import-auto.ts\`, \`recordOffered(projectPath)\` (renamed from \`recordDeclined\`) must fire unconditionally right after the prompt completes — before the \`if (!ok)\` branch — so both yes and no paths mark the directory as offered. The function name \`recordDeclined\` was misleading: the marker means 'auto-import was offered here', not 'user said no'. \`wasOffered()\` must check for both \`'\_\_offered\_\_'\` AND \`'\_\_declined\_\_'\` sentinel values for backwards compat. Explicit \`lore import\` is unaffected — it uses per-session \`isImported()\` idempotency.

<!-- lore:019e1de2-7714-78af-8dff-2e421ede261f -->
* **OpenAI streaming translation: Anthropic SSE → OpenAI Chat/Responses format**: True incremental streaming translation lives in \`packages/gateway/src/stream/openai.ts\` (Chat Completions) and \`stream/openai-responses.ts\` (Responses API). These consume Anthropic SSE events and emit OpenAI-format SSE events incrementally — no accumulate-then-re-emit. The pipeline's \`effectiveProtocol\` field in \`UpstreamResult\` determines which translator is used. When adding new protocol support, extend \`UpstreamResult.effectiveProtocol\` union and add a streaming translator alongside the existing accumulator.

Expand All @@ -612,5 +627,14 @@

### Preference

<!-- lore:019e2026-9cd7-7a95-bdda-765a3d890127 -->
* **Auto-import prompt must fire before agent selection in run.ts**: In \`packages/gateway/src/cli/run.ts\`, \`maybeAutoImport(config)\` must be called before \`resolveLaunchTarget()\` so the import prompt appears before the user is asked which agent to launch. This is a repeated instruction — always maintain this ordering.

<!-- lore:019e2026-28c9-7db9-b467-1c129a835012 -->
* **maybeAutoImport must run before resolveLaunchTarget in run.ts**: Always place \`maybeAutoImport(config)\` before \`resolveLaunchTarget()\` in \`packages/gateway/src/cli/run.ts\` so the auto-import prompt appears before the user is asked which agent to launch. This is the correct UX order — import history should be offered first.

<!-- lore:019e2025-3eb9-7e97-88e5-614d2acb6332 -->
* **maybeAutoImport prompt must appear before agent selection in run.ts**: Always call \`maybeAutoImport(config)\` before \`resolveLaunchTarget()\` in \`run.ts\` so the auto-import prompt appears before the user is asked which agent to launch. This ordering was explicitly required across multiple sessions.

<!-- lore:019e1c54-02ef-7ca8-90ce-b75d97917b96 -->
* **Never use --admin flag for PR merges unless explicitly told**: Always watch CI after submitting a PR — never use \`--admin\` to bypass branch protection and never use \`gh pr merge --auto\`. Fix any CI failures until green, then merge with \`gh pr merge --squash\`. Also: always check for merge conflicts when CI doesn't start — a PR with merge conflicts won't trigger CI. Resolve by rebasing onto main (\`git rebase origin/main\`), then force-pushing the branch.
48 changes: 48 additions & 0 deletions packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,27 @@ const MIGRATIONS: string[] = [
ALTER TABLE session_state ADD COLUMN avoided_compactions INTEGER NOT NULL DEFAULT 0;
ALTER TABLE session_state ADD COLUMN avoided_compaction_cost REAL NOT NULL DEFAULT 0;
`,
`
-- Version 22: Track when conversation import was last offered/run.
-- NULL means import has never been offered for this project.
-- Used by auto-import to avoid re-prompting, and by explicit
-- \`lore import\` for incremental imports (only newer conversations).
ALTER TABLE projects ADD COLUMN last_import_at INTEGER;

-- Backfill: migrate legacy __declined__ sentinel rows so existing
-- users who previously declined are not re-prompted after upgrading.
UPDATE projects SET last_import_at = (
SELECT ih.imported_at FROM import_history ih
WHERE ih.project_id = projects.id
AND ih.source_id = '__declined__'
LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM import_history ih
WHERE ih.project_id = projects.id
AND ih.source_id = '__declined__'
);
`,
];

/** Return the resolved path of the SQLite database file. */
Expand Down Expand Up @@ -842,6 +863,33 @@ export function isFirstRun(): boolean {
return row.count === 0;
}

// ---------------------------------------------------------------------------
// Conversation import tracking
// ---------------------------------------------------------------------------

/**
* Get the timestamp of the last conversation import offer/run for a project.
* Returns null if import has never been offered for this project.
*/
export function getLastImportAt(projectPath: string): number | null {
const id = ensureProject(projectPath);
const row = db()
.query("SELECT last_import_at FROM projects WHERE id = ?")
.get(id) as { last_import_at: number | null } | null;
return row?.last_import_at ?? null;
}

/**
* Record that conversation import was offered/run for a project.
* Prevents auto-import from re-prompting, and enables incremental imports.
*/
export function setLastImportAt(projectPath: string, timestamp: number): void {
const id = ensureProject(projectPath);
db()
.query("UPDATE projects SET last_import_at = ? WHERE id = ?")
.run(timestamp, id);
}

// ---------------------------------------------------------------------------
// Persistent session state (error recovery)
// ---------------------------------------------------------------------------
Expand Down
46 changes: 3 additions & 43 deletions packages/core/src/import/history.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
/**
* Import history — tracks which external agent sessions have been imported
* to prevent re-importing unchanged sources.
*
* Also tracks user-declined imports via a special "__declined__" source_id
* so we don't re-prompt on every `lore run`.
*/
import { db, ensureProject } from "../db";

Expand All @@ -18,8 +15,6 @@ export type ImportRecord = {
imported_at: number;
};

const DECLINED_SOURCE_ID = "__declined__";

/**
* Check if a specific source has already been imported with the same hash.
*
Expand Down Expand Up @@ -76,54 +71,19 @@ export function recordImport(
);
}

/**
* Record that the user declined import for this project.
* We record one "declined" entry per agent so we know not to ask again.
*/
export function recordDeclined(projectPath: string): void {
const projectId = ensureProject(projectPath);
db()
.query(
`INSERT OR REPLACE INTO import_history
(id, project_id, agent_name, source_id, source_hash, entries_created, entries_updated, imported_at)
VALUES (?, ?, ?, ?, ?, 0, 0, ?)`,
)
.run(
crypto.randomUUID(),
projectId,
"__all__",
DECLINED_SOURCE_ID,
"",
Date.now(),
);
}

/**
* Check if the user has previously declined import for this project.
*/
export function wasDeclined(projectPath: string): boolean {
const projectId = ensureProject(projectPath);
const row = db()
.query(
`SELECT 1 FROM import_history
WHERE project_id = ? AND agent_name = '__all__' AND source_id = ?`,
)
.get(projectId, DECLINED_SOURCE_ID);
return row != null;
}

/**
* Get all import records for a project.
* Excludes legacy "__declined__" sentinel rows from pre-v22 databases.
*/
export function listImports(projectPath: string): ImportRecord[] {
const projectId = ensureProject(projectPath);
return db()
.query(
`SELECT * FROM import_history
WHERE project_id = ? AND source_id != ?
WHERE project_id = ? AND source_id != '__declined__'
ORDER BY imported_at DESC`,
)
.all(projectId, DECLINED_SOURCE_ID) as ImportRecord[];
.all(projectId) as ImportRecord[];
}

/**
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/import/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ export { extractKnowledge, type ExtractionProgress, type ExtractionResult } from
export {
isImported,
recordImport,
recordDeclined,
wasDeclined,
computeHash,
listImports,
type ImportRecord,
} from "./history";

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export {
db,
dbPath,
ensureProject,
getLastImportAt,
setLastImportAt,
isFirstRun,
projectId,
projectName,
Expand Down
35 changes: 33 additions & 2 deletions packages/core/test/db.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { db, close, ensureProject, projectId, mergeProjectInternal, loadForceMinLayer, saveForceMinLayer, getMeta, setMeta, getInstanceId, saveSessionCosts, loadSessionCosts, loadAllSessionCosts } from "../src/db";
import { db, close, ensureProject, projectId, mergeProjectInternal, loadForceMinLayer, saveForceMinLayer, getMeta, setMeta, getInstanceId, saveSessionCosts, loadSessionCosts, loadAllSessionCosts, getLastImportAt, setLastImportAt } from "../src/db";


describe("db", () => {
Expand All @@ -23,7 +23,7 @@ describe("db", () => {
const row = db().query("SELECT version FROM schema_version").get() as {
version: number;
};
expect(row.version).toBe(21);
expect(row.version).toBe(22);
});

test("distillation_fts virtual table exists", () => {
Expand Down Expand Up @@ -409,6 +409,37 @@ describe("db", () => {
expect(loadSessionCosts("nonexistent-session")).toBeNull();
});

// -------------------------------------------------------------------------
// Migration v22: last_import_at on projects
// -------------------------------------------------------------------------

test("projects table has last_import_at column (migration v22)", () => {
const cols = db()
.query("PRAGMA table_info(projects)")
.all() as Array<{ name: string }>;
expect(cols.map((c) => c.name)).toContain("last_import_at");
});

test("getLastImportAt returns null for new project", () => {
const result = getLastImportAt("/test/import-tracking/new");
expect(result).toBeNull();
});

test("setLastImportAt and getLastImportAt round-trip", () => {
const path = "/test/import-tracking/roundtrip";
const ts = Date.now();
setLastImportAt(path, ts);
expect(getLastImportAt(path)).toBe(ts);
});

test("setLastImportAt updates existing value", () => {
const path = "/test/import-tracking/update";
setLastImportAt(path, 1000);
expect(getLastImportAt(path)).toBe(1000);
setLastImportAt(path, 2000);
expect(getLastImportAt(path)).toBe(2000);
});

test("loadAllSessionCosts returns only sessions with cost data", () => {
const sid1 = `test-costs-all-1-${crypto.randomUUID()}`;
const sid2 = `test-costs-all-2-${crypto.randomUUID()}`;
Expand Down
19 changes: 0 additions & 19 deletions packages/core/test/import/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { ensureProject } from "../../src/db";
import {
isImported,
recordImport,
recordDeclined,
wasDeclined,
listImports,
computeHash,
} from "../../src/import/history";
Expand Down Expand Up @@ -61,33 +59,16 @@ describe("import history", () => {
});
});

describe("declined", () => {
const DECLINED_PROJECT = "/test/declined-project";

test("wasDeclined returns false for new project", () => {
ensureProject(DECLINED_PROJECT);
expect(wasDeclined(DECLINED_PROJECT)).toBe(false);
});

test("recordDeclined + wasDeclined round-trip", () => {
recordDeclined(DECLINED_PROJECT);
expect(wasDeclined(DECLINED_PROJECT)).toBe(true);
});
});

describe("listImports", () => {
test("lists import records excluding declined entries", () => {
const LIST_PROJECT = "/test/list-project";
ensureProject(LIST_PROJECT);

recordImport(LIST_PROJECT, "agent-a", "src-a", "h1", { created: 5, updated: 0 });
recordImport(LIST_PROJECT, "agent-b", "src-b", "h2", { created: 2, updated: 1 });
recordDeclined(LIST_PROJECT);

const imports = listImports(LIST_PROJECT);
expect(imports.length).toBe(2);
// Should not include the declined marker
expect(imports.every((r) => r.source_id !== "__declined__")).toBe(true);
});
});

Expand Down
Loading
Loading