/** * Data accessor for brain.db — CRUD operations for decisions, patterns, * learnings, and memory links. * * Wraps drizzle ORM queries over the brain.db singleton. All methods are * async (sqlite-proxy) and return typed rows from memory-schema.ts. * * @epic T5149 * @task T5128 */ import type { DatabaseSync } from 'node:sqlite'; import type { OperationExecutionContext } from '@cleocode/contracts/jobs'; import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite'; import { type AutoRowRuleId } from '../memory/auto-row-rules.js'; import type { BrainAttentionRow, BrainConsolidationEventRow, BrainDecisionRow, BrainLearningRow, BrainMemoryLinkRow, BrainModulatorInsert, BrainModulatorRow, BrainObservationRow, BrainPageEdgeRow, BrainPageNodeRow, BrainPatternRow, BrainStickyNoteRow, BrainWeightHistoryInsert, BrainWeightHistoryRow, NewBrainAttentionRow, NewBrainDecisionRow, NewBrainLearningRow, NewBrainMemoryLinkRow, NewBrainObservationRow, NewBrainPageEdgeRow, NewBrainPageNodeRow, NewBrainPatternRow, NewBrainStickyNoteRow } from './schema/memory-schema.js'; import * as brainSchema from './schema/memory-schema.js'; /** * Upsert computed embeddings into `brain_embeddings` on the given handle, * tagged with the model that produced them (T13740). * * Lives with the brain accessor because it is a raw write on a brain table * (gate 28). Callers must be the brain single-writer chokepoint: the `embed` write op's * handler (`brain-writer-handlers.ts`, worker or inline under the lease and * mutex) and the host's write-time embed, which runs inside the writer. * * @param nativeDb - The writer's brain handle. * @param model - `EmbeddingProvider.model` of the provider that computed the vectors. * @param rows - Memory row ids and their vectors. * @returns Rows written. * @task T13218 * @task T13740 */ export declare function upsertEmbeddingRowsNative(nativeDb: DatabaseSync, model: string, rows: ReadonlyArray<{ id: string; vector: Float32Array; }>): number; export declare class BrainDataAccessor { private db; constructor(db: NodeSQLiteDatabase); addDecision(row: NewBrainDecisionRow): Promise; /** * Insert a decision while allocating the next sequential `Dnnn` id * **atomically inside the INSERT statement** (T11552). * * The `id` value is computed by an in-SQL subquery * (`MAX(numeric suffix) + 1`) evaluated as part of the same INSERT, so the * id read and the row write are a single indivisible operation. Two * concurrent callers therefore can never read the same `MAX(id)` and collide * on the primary key — unlike a read-then-write in application code, which * yields the event loop between the `SELECT MAX(id)` and the `INSERT` and so * races (the original defect). * * Properties: * - **Race-free under concurrency** — node:sqlite executes the statement * synchronously and atomically; no JS `await` boundary splits read from write. * - **Numeric ordering** — `CAST(substr(id,2) AS INTEGER)` so the sequence * advances correctly past `D999 → D1000` (lexical ordering would regress). * - **Legacy-id tolerant** — `GLOB 'D[0-9]*'` restricts the max scan to * canonical `Dnnn` ids, ignoring foreign shapes like `D-abcd`. * - **Never silently drops** — uses a real INSERT (not `INSERT OR IGNORE`) * and returns the persisted row. * * The caller MUST NOT pre-set a meaningful `row.id`; any value present is * overridden by the computed id. * * @param row - The decision row to insert (its `id` field is ignored). * @returns The persisted decision row, including the allocated `id`. * * @task T11552 */ addDecisionWithSequentialId(row: NewBrainDecisionRow): Promise; getDecision(id: string): Promise; findDecisions(params?: { type?: (typeof brainSchema.BRAIN_DECISION_TYPES)[number]; confidence?: (typeof brainSchema.BRAIN_CONFIDENCE_LEVELS)[number]; outcome?: (typeof brainSchema.BRAIN_OUTCOME_TYPES)[number]; contextTaskId?: string; limit?: number; /** Include invalidated and superseded records for historical inspection. */ includeHistory?: boolean; /** * T1830: when false (default), AGT-* agent dispatch rows * (`decision_category = 'agent_dispatch'`) are excluded from results. * Pass true to include all categories. */ includeAgentDispatch?: boolean; }): Promise; updateDecision(id: string, updates: Partial): Promise; /** * Insert and read back a pattern within an optional captured write boundary. * @param row - Caller-selected complete pattern row. * @param execution - Original lifetime and optional job fence, never renewed. * @returns The actual stored row after successful commit. * @remarks Scoped insertion and readback share a synchronous transaction. Failure * rolls back the transaction; cancellation after commit cannot undo its result. * @example * ```ts * const stored = await accessor.addPattern(row, execution); * ``` */ addPattern(row: NewBrainPatternRow, execution?: OperationExecutionContext): Promise; getPattern(id: string): Promise; findPatterns(params?: { type?: (typeof brainSchema.BRAIN_PATTERN_TYPES)[number]; impact?: (typeof brainSchema.BRAIN_IMPACT_LEVELS)[number]; minFrequency?: number; limit?: number; /** Include invalidated and superseded records for historical inspection. */ includeHistory?: boolean; }): Promise; /** * Update a pattern within an optional original operation lifetime. * @param id - Existing pattern identity. * @param updates - Requested row fields. * @param execution - Captured deadline, cancellation and optional job fence. * @returns Completion of the committed update, or an explicit failure. * @remarks Scoped writes check cancellation and the persisted job fence immediately * before synchronous SQL. No timer can preempt a committed synchronous statement. * @example * ```ts * await accessor.updatePattern(id, { frequency: 2 }, execution); * ``` */ updatePattern(id: string, updates: Partial, execution?: OperationExecutionContext): Promise; addLearning(row: NewBrainLearningRow): Promise; getLearning(id: string): Promise; findLearnings(params?: { minConfidence?: number; actionable?: boolean; limit?: number; includeHistory?: boolean; }): Promise; updateLearning(id: string, updates: Partial): Promise; /** * Insert and read back one observation with an optional guarded synchronous boundary. * @param row - Complete observation payload, including its caller-selected identity. * @param execution - Captured lifetime checked immediately before SQL execution. * @returns The actual stored row after insertion or an exact fenced replay. * @throws Error if cancellation, an existing native transaction, or SQL prevents insertion. * @remarks Scoped writes use one synchronous transaction and cannot join an unrelated * native transaction. Cancellation during admitted synchronous SQL cannot preempt it; * its committed row remains the result. Fenced replay checks all supplied fields * except the new attempt timestamp and preserves the original stored timestamp. * Conflicting or retracted rows are never overwritten. Legacy callers retain their async path. * @example * ```ts * const stored = await accessor.addObservation(row, execution); * ``` */ addObservation(row: NewBrainObservationRow, execution?: OperationExecutionContext): Promise; getObservation(id: string): Promise; findObservations(params?: { type?: (typeof brainSchema.BRAIN_OBSERVATION_TYPES)[number]; project?: string; sourceType?: (typeof brainSchema.BRAIN_OBSERVATION_SOURCE_TYPES)[number]; sourceSessionId?: string; /** T417: filter by agent provenance name (Wave 8 mental models). */ agent?: string; limit?: number; /** Include invalidated and superseded records for historical inspection. */ includeHistory?: boolean; }): Promise; updateObservation(id: string, updates: Partial): Promise; /** * Set or clear `invalid_at` on listed rows of one memory table, changing only * rows whose current `invalid_at` equals `expected` (T13951 auto-row * quarantine). Nothing else on the row is written. * * @param table - `patterns` or `observations`. * @param ids - Row ids, written in one transaction. * @param invalidAt - New value: the quarantine timestamp, or `null` to restore. * @param expected - Required current value: `null` to quarantine, the timestamp to restore. * @param guardRules - Optional auto-row rule ids; a row must still match one of them, * current and unverified, in the same statement (a row that stopped matching is skipped). * @returns Ids of the rows that changed. * @example * ```ts * await accessor.setInvalidAtWhere('patterns', ['P-1'], quarantinedAt, null); * ``` */ setInvalidAtWhere(table: 'patterns' | 'observations', ids: readonly string[], invalidAt: string | null, expected: string | null, guardRules?: readonly AutoRowRuleId[]): Promise; addLink(row: NewBrainMemoryLinkRow): Promise; getLinksForMemory(memoryType: (typeof brainSchema.BRAIN_MEMORY_TYPES)[number], memoryId: string): Promise; getLinksForTask(taskId: string): Promise; removeLink(memoryType: (typeof brainSchema.BRAIN_MEMORY_TYPES)[number], memoryId: string, taskId: string, linkType: (typeof brainSchema.BRAIN_LINK_TYPES)[number]): Promise; /** * Parse a `tags_json` text column into a deduplicated string-array. * * Used to keep the {@link brainStickyTags} junction in sync with the * legacy whole-array column. Invalid / non-array JSON yields an empty list. */ private static parseStickyTags; /** * Replace the junction rows for one sticky so they exactly mirror its tags. * * Delete-then-insert keeps `brain_sticky_tags` authoritative without RMW races: * the prior tag set is dropped and the supplied set re-inserted. Called on * every sticky create/update (T11355). * * @param stickyId - The owning sticky note id. * @param tags - The full tag set the junction should reflect. */ private syncStickyTags; /** * Refuse a sticky write before its first statement while the store is * degraded by a failed twin collapse (T12535): no note row without its tags. */ private assertStickyWritable; addStickyNote(row: NewBrainStickyNoteRow): Promise; getStickyNote(id: string): Promise; /** * Find sticky notes with optional column filters and an index-backed, * SQL-side tag filter via the {@link brainStickyTags} junction. * * When `tags` is supplied the query keeps only notes that contain ALL of the * requested tags (membership runs through a junction subquery, never a * load-all-then-JS-filter). The `limit` is applied at the SQL layer in every * case — including with a tag filter — so callers never over-fetch. * * @param params - Status/color/priority equality filters, an all-of `tags` * membership filter, and an optional row `limit`. * @returns Matching sticky-note rows, newest first. */ findStickyNotes(params?: { status?: (typeof brainSchema.BRAIN_STICKY_STATUSES)[number]; color?: (typeof brainSchema.BRAIN_STICKY_COLORS)[number]; priority?: (typeof brainSchema.BRAIN_STICKY_PRIORITIES)[number]; tags?: string[]; limit?: number; }): Promise; updateStickyNote(id: string, updates: Partial): Promise; deleteStickyNote(id: string): Promise; /** * Insert one attention (jot) row. * * Row-per-item: each jot is its own row so it decays and scopes * independently. `tags` is a JSONB BLOB written via the {@link jsonb} helper * (`toDriver` wraps in `jsonb()`), never serialized TEXT. Reads MUST use * `json_each` / `json(col)` — never the raw BLOB. * * @param row - New attention row (scope already resolved by the caller). * @returns The persisted row (tags read back via `json(col)` to satisfy the * JSONB read rule). * @task T11372 */ addAttention(row: NewBrainAttentionRow): Promise; /** * Fetch one attention row by id, reading `tags` whole-value via `json(col)`. * * A plain `select()` of the JSONB column would hit the {@link jsonb} * `fromDriver` guard and throw, so `tags` is projected with `jsonbText`. * * @param id - Attention item id. * @returns The row, or `null` when absent. * @task T11372 */ getAttention(id: string): Promise; /** * List attention items for a scope set, applying live-item + tag filters * entirely in SQL (never load-all-then-JS-filter). * * Scope filtering is by exact `(scope_kind, scope_id)` pairs — the visible * scope chain the caller resolved (narrowest → broader ancestors). Because * visibility is the scope key, items outside the chain are never read, so * cross-agent leakage is impossible by construction. * * Tag filtering uses `json_each(tags)` membership ("contains ALL" requested * tags) — a GROUP BY + HAVING COUNT(DISTINCT) subquery, exactly mirroring the * index-backed sticky-tags pattern but over the JSONB column directly. * * The default (`openOnly`, the common path) excludes items that are * `consolidated`/`discarded`, past `expires_at`, or below `decayThreshold`. * * @param params - Scope chain, optional tag filter, liveness controls, limit. * @returns Matching rows, newest first, `tags` read via `json(col)`. * @task T11372 */ findAttention(params: { /** Visible scope chain as `(kind, id)` pairs. Empty ⇒ no scope restriction. */ scopes?: Array<{ kind: (typeof brainSchema.BRAIN_ATTENTION_SCOPE_KINDS)[number]; id: string; }>; /** "Contains ALL" tag membership filter via `json_each`. */ tags?: string[]; /** When true (default) only live `open` items are returned. */ openOnly?: boolean; /** Decay floor; items with `decay_score < threshold` are excluded. */ decayThreshold?: number; /** Reference time (unix ms) for the TTL predicate. Defaults to `Date.now()`. */ now?: number; /** Max rows (SQL LIMIT — applied even with a tag filter). */ limit?: number; }): Promise; /** * Sweep expired / decayed open items to `status = 'discarded'`. * * Idempotent: only flips rows that are currently `open` AND past * `expires_at` (or, when `decayThreshold` is given, below the floor). Returns * the number of rows discarded so callers can log the sweep. * * @param params - Reference time + optional decay floor. * @returns Count of rows transitioned to `discarded`. * @task T11372 */ expireAttention(params?: { now?: number; decayThreshold?: number; }): Promise; /** * Set the lifecycle status of one attention item (e.g. `consolidated`). * * @param id - Attention item id. * @param status - New status. * @task T11372 */ setAttentionStatus(id: string, status: (typeof brainSchema.BRAIN_ATTENTION_STATUSES)[number]): Promise; /** * Shared SELECT that projects `tags` whole-value via `json(col)` so the * JSONB `fromDriver` guard is never hit. Newest-first ordering. * * @internal */ private selectAttention; addPageNode(node: NewBrainPageNodeRow): Promise; getPageNode(id: string): Promise; findPageNodes(params?: { nodeType?: (typeof brainSchema.BRAIN_NODE_TYPES)[number]; minQualityScore?: number; limit?: number; }): Promise; updatePageNode(id: string, updates: Partial): Promise; removePageNode(id: string): Promise; addPageEdge(edge: NewBrainPageEdgeRow): Promise; findPageEdges(params?: { edgeType?: (typeof brainSchema.BRAIN_EDGE_TYPES)[number]; provenance?: string; limit?: number; }): Promise; getPageEdges(nodeId: string, direction?: 'in' | 'out' | 'both'): Promise; getNeighbors(nodeId: string, edgeType?: (typeof brainSchema.BRAIN_EDGE_TYPES)[number]): Promise; removePageEdge(fromId: string, toId: string, edgeType: (typeof brainSchema.BRAIN_EDGE_TYPES)[number]): Promise; } /** * Factory: get a BrainDataAccessor backed by the brain.db singleton. */ export declare function getBrainAccessor(cwd?: string): Promise; /** The columns a main-brain observation is written with. */ export interface GlobalObservationInsert { id: string; type: string; title: string; narrative: string; contentHash: string; sourceType: string; agent: string | null; sourceConfidence: string; verified: boolean; qualityScore: number; createdAt: string; } /** The columns a main-brain decision is written with (its id is allocated in SQL). */ export interface GlobalDecisionInsert { type: string; decision: string; rationale: string; confidence: string; outcome: string; alternativesJson: string | null; adrPath: string | null; confirmationState: string; decidedBy: string; createdAt: string; } /** * Insert one observation into the global store's `brain_observations`, unless a * current observation of the same type with the same content hash already exists * there. The type is part of the match, so a lesson whose text also lives as a * plain observation is still stored as a lesson (T13988). * * The check and the insert run in one `BEGIN IMMEDIATE` transaction, so two * writers observing the same text cannot both insert (T13949). * * @param row - The observation's columns. * @returns The id of the existing duplicate (nothing inserted), or `null` when * the row was inserted. * @task T13720 * @task T13949 */ export declare function addGlobalObservation(row: GlobalObservationInsert): Promise; /** * Set or clear `invalid_at` on listed rows of one main-brain memory table, * changing only rows whose current `invalid_at` equals `expected` (T13951). * Runs in one capture frame so the change syncs as one transaction. * * @param table - `patterns` or `observations`. * @param ids - Row ids. * @param invalidAt - New value: the quarantine timestamp, or `null` to restore. * @param expected - Required current value: `null` to quarantine, the timestamp to restore. * @param guardRules - Optional auto-row rule ids; a row must still match one of them, * current and unverified, in the same statement (a row that stopped matching is skipped). * @returns Ids of the rows that changed. * @task T13951 */ export declare function setGlobalInvalidAtWhere(table: 'patterns' | 'observations', ids: readonly string[], invalidAt: string | null, expected: string | null, guardRules?: readonly AutoRowRuleId[]): Promise; /** * Insert one decision into the global store's `brain_decisions`, allocating * the next `Dnnn` id inside the INSERT (race-free, as * {@link BrainDataAccessor.addDecisionWithSequentialId}). * * @param row - The decision's columns. * @returns The allocated decision id. * @task T13720 */ export declare function addGlobalDecision(row: GlobalDecisionInsert): Promise; /** * Insert one row into brain_weight_history. * Called by writeWeightHistory() in brain-stdp.ts for each LTP, LTD, Hebbian, * or prune event that crosses the 1e-6 negligibility threshold. * * @param cwd - Project root (locates brain.db). Defaults to process.cwd(). * @param input - Row data. `changedAt` defaults to SQLite datetime('now'). * @returns The inserted row with its generated id. * * @task T697 * @epic T673 */ export declare function insertWeightHistoryRow(cwd: string | undefined, input: BrainWeightHistoryInsert): Promise; /** * Insert one row into brain_modulators. * Called by backfillRewardSignals() for each task outcome it processes. * Both this INSERT and the retrieval_log UPDATE MUST run in the same logical * pass but in separate transactions (no ATTACH) — see spec §4.3. * * @param cwd - Project root (locates brain.db). Defaults to process.cwd(). * @param input - Row data. `createdAt` defaults to SQLite datetime('now'). * @returns The inserted row with its generated id. * * @task T699 * @epic T673 */ export declare function insertModulatorRow(cwd: string | undefined, input: BrainModulatorInsert): Promise; /** * Open a consolidation event row in brain_consolidation_events. * Call this at the START of runConsolidation before any steps execute. * Returns the new row id — pass it to logConsolidationComplete() when done. * * @param cwd - Project root (locates brain.db). * @param trigger - What initiated this consolidation run. * @param sessionId - Active session ID, if any. * @returns The id of the newly inserted row. * * @task T701 * @epic T673 */ export declare function logConsolidationStart(cwd: string | undefined, trigger: string, sessionId?: string): Promise; /** * Complete a consolidation event row by updating it with final results. * Call this at the END of runConsolidation after all steps complete. * * @param cwd - Project root (locates brain.db). * @param id - Row id returned by logConsolidationStart. * @param stats - JSON-serializable step results object. * @param durationMs - Total wall-clock duration in milliseconds. * @param succeeded - Whether the run completed without error. * @returns The updated row. * * @task T701 * @epic T673 */ export declare function logConsolidationComplete(cwd: string | undefined, id: number, stats: Record, durationMs: number, succeeded?: boolean): Promise; //# sourceMappingURL=memory-accessor.d.ts.map