/** * pi-mind Memory Core — pure logic, no pi-coding-agent dependency. * Used by both the extension (index.ts) and benchmark scripts. * * Two-layer memory model (the KG is a derived SQLite index, not a third layer): * knowledge/ — compiled, durable facts and decisions. Source of truth for * the KG via its frontmatter `triples:` field. * raw/ — append-only event stream (sessions, observations, compactions). * * The KG state lives in SQLite tables `kg_entities` / `kg_triples` in * `.pi-mind-index.db`. It is rebuildable from the current set of * knowledge/*.md frontmatter on every syncIndex. There is no * `.pi-mind/graph/` directory. */ import { KnowledgeGraph } from "./knowledge-graph.js"; import { Subject, Tier } from "../../lib/schema.js"; export interface Frontmatter { [key: string]: string | string[] | undefined; } export interface MemoryEntry { filePath: string; date: string; type: string; tags?: string[]; content: string; links?: string[]; _score?: number; } export interface SearchResult { entry: MemoryEntry; score: number; } /** * Input to saveMemory: structured so writers carry both the memory itself * (primary) and the conversation context that produced it. * * We intentionally do NOT carry raw tool results in context. The agent's * message is already the curated digest of what tools returned; capturing * raw tool output would (a) duplicate signal already present in the agent * message and (b) leak pi-mind's own tool calls (e.g. remember_this) back * into the memory, creating a self-observation loop. */ export interface SaveMemoryInput { type: Subject; primary: string; context?: { userPrompt?: string; priorAgentMessage?: string; }; tier?: Tier; tags?: string[]; /** Informational: which writer produced this (explicit, observe, compaction, ...) */ source?: string; /** * Path (relative to PI_MIND_DIR, e.g. "raw/images/abc123.png") to an * image that lives alongside this memory entry. The image itself is * stored by the caller (typically via lib/image-store.ts) before * saveMemory is invoked; this field only records the link in frontmatter. */ image?: string; /** * Optional structured KG relations: each entry is [subject, predicate, object] * (all 3 strings). Written into the knowledge file's frontmatter as * `triples: [["s","p","o"], ...]`; syncIndex then re-derives the SQLite * kg_* tables from this. The frontmatter is the source of truth — * never write to kg_triples directly from here. */ triples?: Array<[string, string, string]>; } /** Load pi-mind-config.json from group dir, fall back to defaults */ export interface WikiConfig { search: { maxInjectTokens: number; l1MaxTokens: number; vectorSimilarityThreshold: number; maxSearchResults: number; }; embedding: { model: string; ollamaUrl: string; maxInputChars: number; }; typeWeights: Record; recency: { maxBoost: number; decayDays: number; }; l1: { perSubjectCap: number; }; } export declare function loadWikiConfig(groupDir: string): WikiConfig; /** Tier axis: recall strategy — L1 = always injected, L2 = on-demand retrieval */ export declare const TIER_L1: Tier; export declare function parseFrontmatter(raw: string): { meta: Frontmatter; body: string; }; export declare function serializeFrontmatter(meta: Frontmatter, body: string): string; export declare function estimateTokens(text: string): number; /** Extract [[link]] targets from content */ export declare function extractLinks(content: string): string[]; export declare class MemoryCore { private db; /** Group root directory (parent of wiki/, raw/, schema/) */ groupDir: string; /** Primary write target: wiki/ */ knowledgeDir: string; /** Read-only source materials: raw/ */ rawDir: string; /** @internal */ config: WikiConfig; private maxInjectTokens; private l1MaxTokens; private ollamaUrl; private embedModel; private _pendingEmbeddings; /** All known .md file paths → slug mapping for [[link]] resolution */ private _slugIndex; /** Knowledge graph (entities + triples) */ kg: KnowledgeGraph; constructor(opts: { groupDir: string; dbPath: string; maxInjectTokens?: number; l1MaxTokens?: number; freshDb?: boolean; ollamaUrl?: string; embedModel?: string; /** Extra scan directories beyond wiki/ and raw/ */ extraScanDirs?: string[]; }); /** Rebuild FTS table to add tier column (FTS5 does not support ALTER TABLE ADD COLUMN) */ private addTierColumnToFts; /** Get all directories to scan for indexing */ private getScanDirs; syncIndex(): Promise; /** Generate wiki/index.md — grouped by subdirectory, with [[links]] */ private generateIndex; /** * Save a memory entry (thread-safe via internal withGroupLock). * * Structured input: writers pass `primary` (the memory itself) and optional * `context` (the conversation that produced it). Rendered into one markdown * body with frontmatter. * * Dedup: hash is computed from (type, primary, canonical triples). * - Two saves with identical (type, primary) but different triples * produce distinct files — structured KG metadata is not silently * dropped on dedup. (The tool layer trims triples before passing * them in, and the hash normalizes triple order so logically- * equivalent triples dedup.) * - Same (type, primary, triples) dedups to null. * Existing file in destDir with the same hash suffix is treated as * duplicate and the new write is skipped. This lets multiple writers * (remember_this, observe, compaction) race on the same content * without polluting. */ saveMemory(input: SaveMemoryInput): Promise; /** Resolve a [[link]] target to a file path */ resolveLink(linkTarget: string): string | null; /** Load linked pages from [[link]] references in content (one level deep) */ resolveLinkedContent(content: string): MemoryEntry[]; flushEmbeddings(): Promise; private getEmbedding; private cosineSimilarity; embedFile(filePath: string, content: string): Promise; searchVector(query: string): Promise; searchFTS5(query: string): Promise; loadL1(): MemoryEntry[]; /** * Parse the `triples:` field from a knowledge file's frontmatter. * Pure function: file content in, [s, p, o][] out. * No filesystem, no SQLite, no I/O. Unit-testable in isolation. * * Format: a single frontmatter line * triples: [["alice", "owns", "auth-service"], ...] * The value is a JSON array of 3-string tuples. */ parseTriplesFromFrontmatter(rawContent: string): Array<[string, string, string]>; /** * Full KG rebuild from the knowledge directory. Wipes kg_triples, * re-derives from each knowledge/*.md file's frontmatter, then * vacuums kg_entities that are no longer referenced by any triple. * * Only files under `knowledgeDir` are ingested — the KG SoT is the * curated knowledge layer, NOT the raw/ event stream. raw/compaction * entries are FTS5/vector-indexed for retrieval but never contribute * to the KG; compaction summaries are conversation-scoped, not * curated entity-relation facts, and folding them in would inject * noise as durable "facts" (e.g. "user said X on Tuesday"). * * Called at the end of every syncIndex(). Cheap (typical repos have * <1000 .md files) and eliminates stale-triple drift by construction: * we never accumulate triples that don't match a current knowledge file. * * Public so the integration tests (and the future memory-audit * "force rebuild" action) can invoke it directly. The optional * `dir` parameter is a test seam — production callers omit it and * the default `this.knowledgeDir` is used. */ rebuildKGFromFiles(dir?: string): { triples: number; entities: number; }; /** * Build a context block from the hybrid retrieval pipeline (L1 + vector + * FTS5 + [[link]] expansion + KG). Used by: * - the before_agent_start hook for automatic RAG injection * - the remember_this and recall_memory tools * * skipL1=true is for callers that already injected L1 separately (the tool * path: the hook already injected L1 at turn start, so the tool result * shouldn't repeat it). The set of L1 file paths is still returned via * the L1 dedup logic so that L2/[[link]] don't re-emit them. */ buildContext(query: string, opts?: { skipL1?: boolean; }): Promise; /** Build knowledge graph context block for a query (delegates to KG) */ buildKGContext(query: string): string; /** * Merge vector-search results and FTS5 results via Reciprocal Rank Fusion. * * Why RRF and not score-addition: vector returns cosine similarity in * roughly [0, 1], FTS5 returns -bm25() which can be any positive number * depending on corpus size. Adding them is meaningless; the relative * ordering within each list is what matters. * * RRF score for a doc = Σ 1 / (k + rank_in_list), summed across both * lists. k=60 is the constant from the original paper (Cormack et al. * 2009) — robust, no tuning needed. * * Dedup is automatic: a filePath that appears in both lists gets its * RRF contributions summed (boosting files that BOTH retrieval signals * agree are relevant). Order ties broken by `sources` alphabetical so * the merge is deterministic across runs. */ mergeHybridResults(vectorResults: SearchResult[], ftsResults: SearchResult[], opts?: { k?: number; }): SearchResult[]; /** * Update a single knowledge/*.md file via exact-match replace. * Returns a structured result; ok=false carries a human-readable error. */ updateMemory(input: { filePath: string; oldText: string; newText: string; reason?: string; }): Promise<{ ok: boolean; error?: string; filePath?: string; /** * The reason that was actually written to frontmatter (post-sanitization). * undefined when no reason was provided OR sanitized to empty (e.g. * pure whitespace). Callers (e.g. the extension layer) MUST use this * value, not the raw input reason, for success messages and audit * logs — otherwise the displayed reason and the on-disk reason * diverge whenever sanitization transformed the input. */ sanitizedReason?: string; }>; /** * Resolve a user-supplied file path to an absolute path under * knowledgeDir, with containment + symlink + extension checks. * Returns ok=false with a human-readable error if anything is off. * * Accepts THREE forms (any one is enough; the resolver tries them in * order and uses the first one whose realpath lands inside * knowledgeDir): * * 1. Absolute path — used as-is. Must be inside knowledgeDir after * canonicalize (realpath follows symlinks). * 2. PI_MIND_DIR-relative — e.g. "knowledge/foo.md" or * ".pi-mind/knowledge/foo.md" (the full path the user sees in * their repo). Resolved against groupDir's parent dirname * first (cwd-style), then against groupDir itself. * 3. knowledgeDir-relative — e.g. "alice.md". Resolved against * knowledgeDir. * * Rejects: * - non-.md files * - any path that escapes knowledgeDir (e.g. ../, symlink to outside) * - paths inside raw/, sessions/, compaction/ (those are not knowledge) * - non-existent files */ private resolveKnowledgeFilePath; /** Count non-overlapping occurrences of needle in haystack. */ private countOccurrences; /** * Sanitize the optional `reason` field before writing it into * frontmatter as `update_reason`. Rules: * - whitespace (any run of \s) is folded to a single space — kills * newlines/tabs that would otherwise break the YAML one-liner; * - leading/trailing whitespace is trimmed; * - result is capped at REASON_MAX chars (200) so a runaway * verbose reason doesn't bloat the file; * - any literal `---` substring is rejected — that's the frontmatter * delimiter and would break the file's parse on next read; * - a sanitized empty string is treated as "no reason provided" * (sanitized = undefined) so the update_reason field is omitted * entirely rather than written as `update_reason: `. */ private sanitizeReason; close(): void; cleanup(): void; } /** * Acquire an exclusive lock for a group directory, run an async function, * then release. Safe to call nested — uses reference counting so the underlying * proper-lockfile lock is acquired once and released after the last nested * call exits. */ export declare function withGroupLock(groupDir: string, fn: () => Promise): Promise; //# sourceMappingURL=core.d.ts.map