"use strict";
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.RagStore = void 0;
exports.ragStoreRequiresLicense = ragStoreRequiresLicense;
/**
 * RagStore — local file-backed storage for RAG (Retrieval-Augmented
 * Generation) chunks. Serves Plan 55 (content RAG over blog / AOSP / OEM
 * SDK) and Plan 44 (project memory exposure as a RAG corpus).
 *
 * M0 scope (this file):
 * - JSON file persistence with atomic temp + rename writes
 * - In-memory cache as the source of truth between writes
 * - License gate at insert time for `aosp` / `oem_sdk` source kinds
 * - Token-overlap keyword search (no embeddings yet — M2 may switch to
 *   sqlite-vec when chunk count breaches the BM25 threshold; the
 *   `search()` shape is stable so a driver swap stays local)
 * - Retrieval-level `unsupportedReason` for the three distinguishable
 *   failure modes: empty index, all-blocked, and no-match (the last
 *   intentionally has no unsupportedReason so callers know the query
 *   itself didn't hit, not that the infrastructure failed)
 *
 * @module ragStore
 */
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const sparkContracts_1 = require("../types/sparkContracts");
const scopedKnowledgeStore_1 = require("./scopedKnowledgeStore");
/** Source kinds that require a `license` field at ingestion time. */
const LICENSE_REQUIRED_KINDS = new Set([
    'aosp',
    'oem_sdk',
    'kernel_source',
]);
/** All RagSourceKind values. Kept here so getStats() can initialize a
 * complete record without reaching into the type system at runtime. When
 * the union in sparkContracts.ts grows, update this list and getStats()'s
 * initial accumulator will pick up the new kind automatically. */
const ALL_RAG_SOURCE_KINDS = [
    'androidperformance.com',
    'aosp',
    'oem_sdk',
    'project_memory',
    'world_memory',
    'case_library',
    'app_source',
    'kernel_source',
];
const KNOWLEDGE_KIND = 'rag_chunk';
const RAG_ROW_SCOPE_PREFIX = 'rag:';
/** Tokenize: lower-case, keep alphanumerics + underscores, drop tokens
 * shorter than two characters. The token set is intersected with the
 * query token set to score chunks. */
function tokenize(text) {
    return text
        .toLowerCase()
        .split(/[^a-z0-9_]+/)
        .filter(t => t.length >= 2);
}
/** Build a fresh stats accumulator with every known source kind set to
 * zero. Callers iterate chunks to populate it. */
function emptyStats() {
    const stats = {};
    for (const kind of ALL_RAG_SOURCE_KINDS) {
        stats[kind] = { chunkCount: 0 };
    }
    return stats;
}
function defaultRegistryOrigin(kind) {
    switch (kind) {
        case 'androidperformance.com':
        case 'aosp':
        case 'oem_sdk':
            return 'legacy_plan55';
        case 'project_memory':
        case 'world_memory':
            return 'plan44_memory';
        case 'case_library':
            return 'plan54_cases';
        case 'app_source':
        case 'kernel_source':
            return 'codebase_registry';
    }
}
function normalizeChunkForStorage(chunk) {
    const registryOrigin = chunk.registryOrigin ?? defaultRegistryOrigin(chunk.kind);
    if ((chunk.kind === 'app_source' || chunk.kind === 'kernel_source') && !chunk.codebaseId) {
        throw new Error(`Code-aware source chunk '${chunk.chunkId}' (${chunk.kind}) requires codebaseId`);
    }
    if (registryOrigin === 'codebase_registry' && !chunk.codebaseId) {
        throw new Error(`Codebase registry chunk '${chunk.chunkId}' requires codebaseId`);
    }
    return {
        ...chunk,
        registryOrigin,
    };
}
function backfillChunk(chunk) {
    if (chunk.registryOrigin)
        return chunk;
    if (chunk.kind === 'app_source' || chunk.kind === 'kernel_source') {
        return {
            ...chunk,
            unsupportedReason: chunk.unsupportedReason ?? 'pre_v3_2_invalid_kind_origin_combo',
        };
    }
    return {
        ...chunk,
        registryOrigin: defaultRegistryOrigin(chunk.kind),
    };
}
/**
 * Local file-backed RAG store. Single instance per storage path is
 * enough for M0 — concurrent writers in the same process serialize via
 * the synchronous persistence path; cross-process writers are not
 * supported (caller's responsibility to coordinate).
 */
class RagStore {
    constructor(storagePath) {
        this.chunks = new Map();
        this.loaded = false;
        this.storagePath = storagePath;
    }
    /** Idempotently load the on-disk store into memory. */
    load() {
        if (this.loaded)
            return;
        this.loaded = true;
        if (!fs.existsSync(this.storagePath))
            return;
        try {
            const raw = fs.readFileSync(this.storagePath, 'utf-8');
            const parsed = JSON.parse(raw);
            if ((parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) || !Array.isArray(parsed.chunks)) {
                // Schema mismatch: leave in-memory empty; do not delete the file
                // so the operator can inspect it.
                return;
            }
            for (const c of parsed.chunks) {
                this.chunks.set(c.chunkId, backfillChunk(c));
            }
        }
        catch {
            // Corrupted JSON: same policy as schema mismatch — empty cache,
            // file preserved for inspection.
        }
    }
    /**
     * Add or replace a chunk. Throws when the kind requires a license and
     * the chunk lacks one — the caller is expected to surface this back
     * to the operator rather than silently dropping the chunk.
     */
    addChunk(chunk, scope) {
        this.load();
        const normalized = normalizeChunkForStorage(chunk);
        if (LICENSE_REQUIRED_KINDS.has(chunk.kind) && !chunk.license) {
            throw new Error(`License required for source kind '${chunk.kind}' but missing on chunk '${chunk.chunkId}'`);
        }
        if ((0, scopedKnowledgeStore_1.legacyKnowledgeFilesystemWritesEnabled)()) {
            this.chunks.set(normalized.chunkId, normalized);
        }
        if ((0, scopedKnowledgeStore_1.enterpriseKnowledgeDbWritesEnabled)()) {
            (0, scopedKnowledgeStore_1.upsertScopedKnowledgeRecord)(KNOWLEDGE_KIND, normalized.chunkId, ragRowScope(normalized.kind), normalized, scope, { createdAt: normalized.indexedAt, updatedAt: normalized.indexedAt });
        }
    }
    /** Remove a chunk. Returns whether anything was actually removed. */
    removeChunk(chunkId, scope) {
        let removed = false;
        if ((0, scopedKnowledgeStore_1.enterpriseKnowledgeDbWritesEnabled)()) {
            removed = (0, scopedKnowledgeStore_1.removeScopedKnowledgeRecord)(KNOWLEDGE_KIND, chunkId, scope) || removed;
        }
        if ((0, scopedKnowledgeStore_1.legacyKnowledgeFilesystemWritesEnabled)()) {
            this.load();
            const had = this.chunks.delete(chunkId);
            if (had)
                this.persist();
            removed = had || removed;
        }
        return removed;
    }
    /** Get a chunk by id, or undefined when absent. */
    getChunk(chunkId, scope) {
        if ((0, scopedKnowledgeStore_1.enterpriseKnowledgeStoreEnabled)()) {
            return (0, scopedKnowledgeStore_1.getScopedKnowledgeRecord)(KNOWLEDGE_KIND, chunkId, scope)?.record;
        }
        this.load();
        return this.chunks.get(chunkId);
    }
    /** Per-kind chunk counts plus the freshest indexedAt seen for each. */
    getStats(scope) {
        this.load();
        const stats = emptyStats();
        const chunks = (0, scopedKnowledgeStore_1.enterpriseKnowledgeStoreEnabled)()
            ? (0, scopedKnowledgeStore_1.listScopedKnowledgeRecords)(KNOWLEDGE_KIND, scope, { rowScopePrefix: RAG_ROW_SCOPE_PREFIX, includeSystem: true }).map(row => row.record)
            : Array.from(this.chunks.values());
        for (const c of chunks) {
            const s = stats[c.kind];
            s.chunkCount++;
            if (s.lastIndexedAt === undefined || c.indexedAt > s.lastIndexedAt) {
                s.lastIndexedAt = c.indexedAt;
            }
        }
        return stats;
    }
    /**
     * Keyword search. Hits are ranked by token-overlap fraction relative
     * to the query token set. Chunks carrying `unsupportedReason` are
     * excluded — they live on for audit but the agent must not see them.
     *
     * Three retrieval-level failure modes carry an `unsupportedReason`:
     * (1) the index is empty; (2) every chunk in the probed kinds is
     * blocked / out of scope; the third — a legitimate zero-match query —
     * intentionally returns an empty `results` array with NO
     * `unsupportedReason`, so the agent can tell "I didn't find anything"
     * apart from "the infrastructure refused to answer".
     */
    search(query, opts = {}) {
        this.load();
        const topK = opts.topK ?? 5;
        const kindFilter = opts.kinds ? new Set(opts.kinds) : null;
        const chunks = (0, scopedKnowledgeStore_1.enterpriseKnowledgeStoreEnabled)()
            ? (0, scopedKnowledgeStore_1.listScopedKnowledgeRecords)(KNOWLEDGE_KIND, opts.scope, { rowScopePrefix: RAG_ROW_SCOPE_PREFIX, includeSystem: true }).map(row => row.record)
            : Array.from(this.chunks.values());
        const codebaseFilter = opts.codebaseIds ? new Set(opts.codebaseIds) : null;
        const languageFilter = opts.languages ? new Set(opts.languages) : null;
        const probed = opts.kinds
            ? [...opts.kinds]
            : Array.from(new Set(chunks.map(c => c.kind)));
        const queryTokens = new Set(tokenize(query));
        const candidates = [];
        let eligibleSeen = 0;
        for (const chunk of chunks) {
            if (kindFilter && !kindFilter.has(chunk.kind))
                continue;
            if (chunk.unsupportedReason)
                continue;
            if (codebaseFilter && (!chunk.codebaseId || !codebaseFilter.has(chunk.codebaseId)))
                continue;
            if (opts.vendor && chunk.vendor !== opts.vendor)
                continue;
            if (opts.buildId && chunk.buildId !== opts.buildId)
                continue;
            if (opts.pathPrefix && !(chunk.filePath ?? chunk.uri).startsWith(opts.pathPrefix))
                continue;
            if (opts.symbolExact && chunk.symbol !== opts.symbolExact)
                continue;
            if (opts.filePathExact && (chunk.filePath ?? chunk.uri) !== opts.filePathExact)
                continue;
            if (languageFilter && (!chunk.language || !languageFilter.has(chunk.language)))
                continue;
            eligibleSeen++;
            const chunkTokens = new Set([
                ...tokenize(chunk.snippet),
                ...tokenize(chunk.title ?? ''),
                ...tokenize(chunk.symbol ?? ''),
                ...tokenize(chunk.filePath ?? ''),
            ]);
            let overlap = 0;
            for (const t of queryTokens) {
                if (chunkTokens.has(t))
                    overlap++;
            }
            const exactSymbol = Boolean(opts.symbolExact && chunk.symbol === opts.symbolExact);
            const exactFile = Boolean(opts.filePathExact && (chunk.filePath ?? chunk.uri) === opts.filePathExact);
            const pathPrefix = Boolean(opts.pathPrefix && (chunk.filePath ?? chunk.uri).startsWith(opts.pathPrefix));
            const tier = exactSymbol || exactFile ? 3 : pathPrefix ? 2 : 1;
            if (overlap === 0 && tier === 1)
                continue;
            const score = overlap / Math.max(queryTokens.size, 1);
            candidates.push({ chunk, score, tier });
        }
        candidates.sort((a, b) => (b.tier - a.tier) || (b.score - a.score));
        const top = candidates.slice(0, topK);
        const results = top.map(c => ({
            chunkId: c.chunk.chunkId,
            score: c.score,
            chunk: c.chunk,
        }));
        let unsupportedReason;
        if (chunks.length === 0) {
            unsupportedReason = 'index empty';
        }
        else if (eligibleSeen === 0) {
            // Either every chunk in the probed kinds is blocked, or the kind
            // filter excluded every chunk. Both deserve a clear signal so the
            // agent does not invent content.
            unsupportedReason = kindFilter
                ? 'no eligible chunks for the requested kinds'
                : 'all chunks blocked by unsupportedReason';
        }
        // legitimate zero-match (eligibleSeen > 0, results empty) gets no
        // unsupportedReason — the index works, the query just didn't hit.
        return {
            ...(0, sparkContracts_1.makeSparkProvenance)({
                source: 'ragStore.search',
                ...(unsupportedReason ? { unsupportedReason } : {}),
            }),
            query,
            results,
            probed,
            retrievedAt: Date.now(),
        };
    }
    /** Flush in-memory chunks to disk. Call once after a bulk ingest loop. */
    flush() {
        if ((0, scopedKnowledgeStore_1.legacyKnowledgeFilesystemWritesEnabled)()) {
            this.persist();
        }
    }
    /** Atomic write: temp file + rename so a crashed process leaves the
     * existing on-disk file intact. */
    persist() {
        const dir = path.dirname(this.storagePath);
        if (!fs.existsSync(dir))
            fs.mkdirSync(dir, { recursive: true });
        // Per-process unique tmp suffix — Codex round E P1#5 cross-process
        // collision guard. Single-process is the documented contract, but a
        // unique suffix costs nothing and removes the foot-gun.
        const tmp = `${this.storagePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
        const envelope = {
            schemaVersion: 2,
            chunks: Array.from(this.chunks.values()),
        };
        fs.writeFileSync(tmp, JSON.stringify(envelope, null, 2), 'utf-8');
        fs.renameSync(tmp, this.storagePath);
    }
}
exports.RagStore = RagStore;
/** Whether the given source kind needs a `license` field at ingest time. */
function ragStoreRequiresLicense(kind) {
    return LICENSE_REQUIRED_KINDS.has(kind);
}
function ragRowScope(kind) {
    return `${RAG_ROW_SCOPE_PREFIX}${kind}`;
}
//# sourceMappingURL=ragStore.js.map