diff --git a/alembic/versions/0070_notes_data_jsonb.py b/alembic/versions/0070_notes_data_jsonb.py new file mode 100644 index 0000000..5518c36 --- /dev/null +++ b/alembic/versions/0070_notes_data_jsonb.py @@ -0,0 +1,60 @@ +"""add notes.data JSONB — queryable structured fields for typed records + +Revision ID: 0070 +Revises: 0069 +Create Date: 2026-07-26 + +Snippets (note_type='snippet') carry structured fields — name, language, +signature, and a list of canonical locations (repo · path · symbol). Those were +stored as a markdown body-convention, which reads well and feeds the embedding +but cannot be QUERIED: answering "which snippets live in this file?" meant +scanning every snippet and regexing its body. + +This adds a general `data` JSONB column plus a GIN index, so those fields become +indexable. The body stays exactly as it was — it is still the human-readable +form and still what gets embedded. `data` is a queryable mirror of the same +facts, not a replacement, and the code itself is deliberately NOT copied into it +(the body already holds it; duplicating a blob to index fields around it would +be waste). + +Relationship to 0069: that migration DROPPED `notes.metadata`, a JSONB column +which only ever held person/place/list entity fields, when those surfaces were +removed. This is not a revival of it — different name, different purpose, and +nothing reads the old shape. The column is named `data` rather than `metadata` +because `metadata` collides with SQLAlchemy's declarative `Base.metadata`, which +is why the old model had to map an awkward `entity_metadata` attribute onto it. + +Nullable with no backfill, deliberately: rows written before this migration keep +working because the service falls back to parsing the body when `data` is +absent. That means no migration deadline and no risk of a backfill mangling a +hand-edited body. + +Downgrade drops the index and the column. Any structured fields it held remain +recoverable from the body convention, which is the same source they mirror. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +revision = "0070" +down_revision = "0069" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("notes", sa.Column("data", JSONB, nullable=True)) + # GIN supports containment (`data @> '{"locations":[{"repo":"x"}]}'`), which + # covers exact repo/path/symbol and language lookups. Path PREFIX matching + # ("everything under frontend/src/") is not an index-served operation here + # and still filters after the fact — acceptable while snippet counts are + # small, and a generated column is the escape hatch if that changes. + op.create_index( + "ix_notes_data_gin", "notes", ["data"], postgresql_using="gin", + ) + + +def downgrade() -> None: + op.drop_index("ix_notes_data_gin", table_name="notes") + op.drop_column("notes", "data") diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 6b4a025..fa7ef03 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -20,6 +20,9 @@ export interface SnippetFields { path: string; symbol: string; locations: SnippetLocation[]; + /** Ids of the snippets folded into this one by merge, oldest first. Read-only: + * merge is the only thing that adds to it, and an edit carries it forward. */ + merged_from: number[]; code: string; } @@ -77,13 +80,26 @@ export interface SnippetInput { force?: boolean; } +/** `repo`/`path`/`symbol` are the reverse lookup — "what already lives here?" + * They must all match the same recorded location; `path` also matches anything + * beneath it. Combinable with `q`. */ export async function listSnippets( - params: { q?: string; tag?: string; projectId?: number | null } = {}, + params: { + q?: string; + tag?: string; + projectId?: number | null; + repo?: string; + path?: string; + symbol?: string; + } = {}, ): Promise<{ snippets: SnippetListItem[]; total: number }> { const qs = new URLSearchParams(); if (params.q) qs.set("q", params.q); if (params.tag) qs.set("tag", params.tag); if (params.projectId) qs.set("project_id", String(params.projectId)); + if (params.repo) qs.set("repo", params.repo); + if (params.path) qs.set("path", params.path); + if (params.symbol) qs.set("symbol", params.symbol); const query = qs.toString(); return apiGet(`/api/snippets${query ? `?${query}` : ""}`); } diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 4d3b3b0..1321cc4 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -22,6 +22,7 @@ const retentionSaved = ref(false); const kbInjectEnabled = ref(true); const kbInjectThreshold = ref("0.55"); const kbInjectTopK = ref("3"); +const kbWritePathEnabled = ref(true); const savingKbInject = ref(false); const kbInjectSaved = ref(false); @@ -76,6 +77,9 @@ async function saveKbInject() { kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false', kb_autoinject_threshold: String(t), kb_autoinject_top_k: String(k), + // Its own switch, but deliberately the same threshold/ceiling — see + // WRITEPATH_ENABLED_KEY in services/plugin_context.py. + kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); @@ -459,6 +463,7 @@ onMounted(async () => { if (allSettings.kb_autoinject_top_k !== undefined) { kbInjectTopK.value = allSettings.kb_autoinject_top_k; } + kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false"; if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; } @@ -1193,6 +1198,19 @@ function formatUserDate(iso: string): string { />

Ceiling on titles surfaced at once (1–10).

+
+ +

+ Checks the file Claude is about to write or edit against your recorded + snippets — what's already kept at that path, and what resembles the code + being written — so a helper you already have is offered before it's + rewritten. Uses the same threshold and ceiling above, and never blocks the + edit. Off = prior art surfaces only on your own prompts. +

+
+
+ + +
+ + + +
@@ -138,15 +209,24 @@ function languageOf(tags: string[]): string {
❭_

- {{ search.trim() ? "No snippets match your search" : "No snippets kept yet" }} + {{ locationActive + ? "Nothing kept at that location yet" + : search.trim() + ? "No snippets match your search" + : "No snippets kept yet" }}

- {{ search.trim() - ? "Try a different term, or clear the search." - : "Record a reusable function or component and it will be offered back to you later." }} + {{ locationActive + ? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter." + : search.trim() + ? "Try a different term, or clear the search." + : "Record a reusable function or component and it will be offered back to you later." }}

+