feat(learn): consolidation layer + RDF-inspired entities/relationships graph - #84
Merged
Conversation
release: v0.6.0
* feat: recall quality & project inspection (issue #56) - --whole flag for `smriti ingest file`: stores .md as single message (no paragraph splitting); warns without flag - `smriti projects <id>`: rich inspection report — sessions, messages, agents, tags, decisions, recent sessions - `smriti tags`: global or --project-scoped tag usage counts; --available mirrors category tree - `smriti status --project <id>`: scopes all stats (agents, categories) to a single project - 29 new tests in test/recall.test.ts covering all retrieval paths (full-doc, tags, project reports, multi-filter) * refactor: move memory.ts + ollama.ts out of QMD submodule QMD submodule is now a clean upstream fork (d58fedf, v2.1.0+) — no Smriti-specific code lives there. Future upstream syncs are conflict-free. - src/memory.ts: moved from qmd/src/memory.ts; imports updated to ../qmd/src/store.js and ../qmd/src/llm.js; uses QMD's Database type - src/ollama.ts: moved from qmd/src/ollama.ts; self-contained, no changes - src/qmd.ts: re-exports now come from ./memory and ./ollama - qmd submodule: bumped to d58fedf (upstream v2.1.0+34 commits of fixes) Upstream picks up: security dep bumps, db-transaction-type fix, embedding overflow hardening, sqlite-vec actionable errors, GGUF magic error fix, Windows home fallback, status device probe opt-in, and more. * fix(ci): add picomatch@4 as explicit root dep after QMD upstream sync * feat: knowledge density scoring (#62) and smriti digest (#63) Issue #62 — Knowledge Density Scoring: - Add density_score REAL column to smriti_session_meta (migration) - computeDensityScore(): composite 0-1 score from tool calls (25%), file writes (25%), git ops (20%), decision tags (15%), errors (10%), token volume (5%) - Hook into storeSession() so every ingest auto-computes and persists score - Blend density into recallMemories() ranking: final = score*0.8 + density*0.2 - smriti enrich --density: backfill scores for all existing sessions - smriti show <id> extension: --density flag shows bar-chart breakdown Issue #63 — smriti digest: - New src/digest.ts: generateDigest() aggregates sidecar signals for a time window, groups by project, surfaces top tools/errors/costs - formatDigest() and formatDensityBreakdown() added to format.ts - smriti digest [--days N] [--project id] [--synthesize] [--model name] generates work summary; --synthesize calls Ollama for narrative * refactor: SDK migration Phase 1+2 — createStore() init + kill llm.js import (#57) Phase 1: DB init via QMD SDK - New src/store.ts: cycle-free QMDStore singleton (setQmdStore/getQmdStore) - db.ts: remove initializeQmdStore() (duplicate of SDK's initializeDatabase) initSmriti() now calls SDK createStore() and is async - closeDb() delegates to closeQmdStore() Phase 2: Kill ../qmd/src/llm.js deep import in memory.ts - Replace getDefaultLlamaCpp() with getQmdStore().internal.llm - Replace insertEmbedding() call with getQmdStore().internal.insertEmbedding() - formatQueryForEmbedding/formatDocForEmbedding moved to ../qmd/src/store.js import (they are re-exported there; no longer touching llm.ts internals) Downstream: index.ts awaits initSmriti(); test/team.test.ts uses beforeAll for async init * feat: query expansion + reranking in recall pipeline (#58) Default-on quality mode in recallMemories(): - Calls store.internal.expandQuery() to generate lex/vec/hyde query variants - Runs FTS + vec search for each variant with 0.7 weight - Fuses all ranked lists via RRF (original queries at 1.0 weight) - Reranks top deduped candidates with store.internal.rerank() (60/40 blend) - --fast flag skips both steps for low-latency lookups Also fixes team-segmented.test.ts beforeAll to use async initSmriti(). * feat: smriti enrich --queries — retroactive query labeling (#60) Adds smriti_session_queries table + smriti_queries_fts virtual table. expandQuery() generates search aliases per session (lex/vec/hyde variants). searchFiltered() merges alias matches as 'query_alias' source results. storeSession() auto-enriches new ingests non-blocking (fire-and-forget). --dry-run shows generated aliases without writing; --project scopes batch. * feat: smriti ask — RAG question-answering command (#61) Multi-angle recall (expandQuery + rerank default-on) feeds top-N sessions to ollamaAsk() which returns a grounded answer with [N] citations. --no-synthesize returns ranked sources only; --json returns structured output. Graceful fallback to sources when Ollama is unavailable. * feat: --wide flag for cross-project knowledge routing (#64) smriti recall "query" --project X --wide searches all projects (bypassing project filter) and rerankss with intent "relevant to X project context" so the cross-encoder scores cross-project results against local needs. Results from other projects get project badge in output via session meta lookup. --wide without --project is equivalent to global unfiltered recall. * feat: smriti drift — temporal topic evolution command (#65) Recalls all sessions about a topic, sorts chronologically, and synthesizes an evolution narrative via Ollama showing decisions, reversals, refinements. --since <date> filters to recent history; --no-synthesize returns timeline only. --json returns structured timeline array. Graceful "not enough history" when < 2 sessions. * feat: --check-conflicts flag for contradiction detection in recall (#67) ollamaCheckConflicts() sends all top-N results in one batch to Ollama and parses CONFLICT [i] vs [j]: description responses. --check-conflicts on smriti recall flags contradictory pairs in output. --json includes conflicts array. No behavior change without the flag. SMRITI_CONFLICT_THRESHOLD env configures sensitivity (default 0.7). * feat: QMD SDK Migration Phase 4 — index sessions as QMD documents (#59) Dual-write on ingest: each session written to ~/.cache/smriti/sessions/<id>.md. initSmriti() registers smriti-sessions collection when dir exists. storeSession() writes markdown + fires background store.update(). recall() uses store.search() when smriti-sessions has docs; falls back to recallMemories() otherwise (backward compat). rerank=false when --fast. * feat: smriti clusters — semantic session clustering (#66) k-means over session embeddings, Ollama cluster naming, smriti clusters command, enrich --clusters, and recall --cluster <name> filter. * fix: sync tag roundtrip + team config.json with custom categories (#1, #2) #1: parseFrontmatter now parses tags arrays into string[]; syncTeamKnowledge restores all tags from meta.tags with isValidCategory guard; falls back to scalar meta.category for old exports. #2: new src/team/config.ts with readConfig/writeConfig/mergeCategories/ exportCustomCategories; share writes custom categories to config.json (v2); sync reads config.json and upserts categories before scanning files; SyncResult gains categoriesImported; smriti config show/add-category/ sync-categories CLI added. * chore(qmd): bump submodule to upstream main (ddbd6bd) Pulls 49 upstream commits via fast-forward merge. Key changes touching search behavior: - Fix hybrid RRF weighting by query type (#004714a) — expansion-derived lists no longer steal original-query 2x weight when inserted first - CJK FTS support (#d045a8b) — Han/Hiragana/Katakana/Hangul queries now searchable via char-level spacing of CJK runs in documents_fts (one-time migration on first qmd query after upgrade; Smriti's memory_fts is unaffected) - Embed collection filter honored (#5b9f472) - HTTP MCP rerank control (#e36ab96) - Forward candidateLimit through search APIs (#3b7e065) - Preserve docids across case-only renames (#dff6513) - macOS Metal cleanup abort mitigation (#60c75cb) Risk audit: all 11 QMD APIs Smriti imports (createStore, QMDStore, hashContent, chunkDocumentByTokens, reciprocalRankFusion, formatQueryForEmbedding, formatDocForEmbedding, RankedResult, insertEmbedding, initializeMemoryTables, Database) verified backward compatible. insertEmbedding gained an optional 7th param totalChunks (partial-embedding pending state), unused by Smriti. Also restore test scoping ("bun test --cwd ./test") so Smriti's test runner doesn't pick up QMD's own test/ files — two new upstream tests (cli-lazy-llm-import, local-config) hardcode cwd-relative paths and would otherwise fail when discovered from the parent repo. Same fix pattern as cef23f2 from the March 2026 sync. Full plan and verification at qmd/docs/UPSTREAM_MERGE_PLAN.md. * feat(daemon): scaffold server with PID-file single-instance + IPC socket First piece of the v0.8.0 daemon work (#72). Intentionally narrow: just the single-instance guard, IPC socket bind, and signal handlers. No watcher, no debounce queue, no ingest wiring — those are separate modules / commits. Implementation notes (from pre-impl smoke tests against Bun 1.3.6): - Single-instance is enforced via DAEMON_PID_FILE + kill(pid, 0) liveness probe, not Unix-socket bind contention. Bun's net.listen() silently succeeds on duplicate binds and steals connections from the original server — verified with a reproducer. PID-file pattern is the same one QMD uses for `qmd mcp --daemon`. - IPC socket is bound separately for the Claude Stop hook poke, with cleanup of any stale socket file from a previous crash. - SIGTERM/SIGINT install a graceful shutdown that closes the server, removes the socket file, removes the PID file, and exits with conventional 128+signo status for supervisor visibility. - detectRunningDaemon() handles three stale states: missing PID file (returns null), garbage PID file (cleans + null), dead PID via ESRCH (cleans + null). Live PID returns the PID; EPERM also returns the PID (process exists but is foreign — don't start alongside). 10 unit tests cover detectRunningDaemon() across the three stale states plus the live case, and startDaemon() across the happy path, contention path, stale-PID-recovery path, idempotent shutdown, and the onPoke wire. PRD also gains a new "Three pre-impl smoke-test findings" section documenting why chokidar was dropped, why socket-bind isn't the single-instance mechanism, and why ingest() will open a fresh DB handle per debounce flush. Refs #71, #72. * feat(daemon): recursive watcher with macOS-native + Linux walk-and-watch Second module of the daemon (#72). Wraps Node's fs.watch so the queue can subscribe to "anything happened under this root" with a single callback shape, regardless of OS-specific backend differences. - macOS: fs.watch(root, { recursive: true }, cb). Native FSEvents delivers a single watcher per root. - Linux: inotify doesn't implement `recursive`, so we walk the tree at startup and watch each directory. New directories are picked up on the fly by re-watching when we see a `rename` event whose target is a directory. - Windows: same code path as macOS (ReadDirectoryChangesW supports recursive natively). Event paths are normalized to absolute. Null filenames (some FS backends emit them under load) are filtered out. Errors on individual watchers are silently dropped rather than crashing the parent — losing one subdirectory is better than losing the daemon. 7 tests cover: non-existent root rejection, direct-child file creation, deep-subdirectory creation (recursion), content change, absolute-path normalization, close()-stops-events, and the watchedCount() topology assertion (1 on macOS/Windows native, N on Linux). Refs #71, #72. * feat(daemon): per-project debounce queue Third module of the daemon (#72). Coalesces bursts of "this project changed" signals into a single onFlush per project per quiet window. - schedule(projectId) resets the timer for that project. Repeated calls inside the window collapse to one firing — this is what makes a busy agent session not trigger 200 ingests as it writes JSONL. - flush(projectId) is the synchronous hook-poke path: fire onFlush immediately, cancel any pending debounce for that project. - Errors thrown by onFlush are caught and logged via the optional log callback rather than rejecting the timer's microtask. The caller (typically the daemon entry point) decides how to surface ingest errors. - close() cancels everything pending; subsequent schedule() calls become no-ops. Matches the lifecycle of the daemon process itself. Timers are unref'd so they don't keep Node alive on their own — process lifetime is owned by the IPC server, not by pending debounce timers. 9 tests cover: basic schedule/wait, coalescing across rapid schedules, per-project independence, immediate flush(), flush() with no pending timer, close() preventing pending fires, close() blocking subsequent schedules, error isolation from onFlush, and the isPending() inspector. Refs #71, #72. * feat(daemon): agent-root routing helpers Fourth module of the daemon (#72). Pure helpers that turn an FS path into the agent name responsible for it, and produce the default list of (agent, root) pairs the daemon should watch. For v0.8.0 the routing is intentionally coarse — by agent, not by project. A change anywhere under ~/.claude/projects/ schedules a single "ingest all of claude" flush, debounced. ingest() is already incremental at the session level, so unchanged sessions cost almost nothing per flush. A per-project resolution layer can replace this without changing the daemon's structure. getDefaultAgentRoots() filters by existsSync so we don't crash trying to watch a Codex or Cline install that isn't on this machine. Copilot is included only when COPILOT_STORAGE_DIR is set, since its location varies by OS and isn't auto-detected here. resolveAgentForPath() uses a strict prefix-with-separator check to avoid the classic ".claude/projects" matching ".claude/projects- archive/" bug. 6 tests cover the four match cases (exact root, child path, no match, sibling-prefix non-match) plus multi-root dispatch and the empty-root handling. Refs #71, #72. * feat(daemon): lifecycle client for stop / status Fifth and final module of the daemon core (#72). Powers `smriti daemon stop` and `smriti daemon status` without going through the IPC socket. The deliberate choice not to go through the socket: lifecycle commands need to work even when the daemon is wedged in a way that makes it unresponsive on the socket. Working through the PID file + signals is the most robust way to inspect and shut down a process. - getDaemonStatus() reads the PID, probes liveness via the existing detectRunningDaemon() helper, and includes a startedAt timestamp derived from the PID file's birthtime (falls back to ctime on filesystems that don't track birth). The PID-file races (file disappears between detect and stat) report as not-running rather than crashing. - stopDaemon() sends SIGTERM and polls for the PID file to disappear (the daemon's signal handler is responsible for unlinking it as part of graceful shutdown). Three result states: stopped, not- running, timeout. Callers — typically `smriti daemon stop` — decide how to escalate on timeout (could SIGKILL, could surface to the user). 6 tests cover both functions across no-daemon, stale-PID, and live cases. The timeout-path test temporarily swaps out the harness's SIGTERM handler so receiving the signal during the test doesn't kill the test runner. With this commit, #72 has all five core daemon modules: server, watcher, queue, handlers, client. Wiring them into a top-level daemon entry point and the CLI happens in subsequent commits. Refs #71, #72. * feat(daemon): runDaemon() entry point wiring all five modules Top-level wiring for the daemon (#72). Connects watcher → resolveAgent → queue.schedule, plus hook poke → queue.flush("claude"), plus the default onFlush that opens a fresh SQLite handle, calls ingest(), and closes the handle (per smoke-test finding 3). Dependency-injection-friendly: tests pass a mock flushAgent so they can verify the wiring without invoking real ingest() against the user's real DB. Production callers (the CLI) accept defaults and get the real ingest path. A few intentional choices: - One log function flows through every module. Defaults to console.error so foreground daemon output goes to stderr; in production the LaunchAgent/systemd unit redirects stderr to DAEMON_LOG_FILE. Tests pass () => {} to silence. - "No agent roots found" is a soft warning, not a fatal error. The daemon still runs (the hook poke still works for Claude if Claude later writes session files). Avoids the case where installing on a fresh machine fails because no agents have written logs yet. - defaultFlushAgent catches and logs both DB-open errors and ingest errors. One bad flush should not crash the daemon — the next FS event will retry. - shutdown() is idempotent and closes watchers and queue before the server. This guarantees no FS event arrives at a torn-down queue (which would be a no-op but log a misleading "closed" warning). 6 integration tests cover: single-flush via watcher, coalescing across rapid writes, hook poke wired to claude flush, multi-root routing, error isolation from flushAgent, idempotent shutdown. With this commit, #72 is structurally complete. Next step is the CLI wiring (#74) so `smriti daemon` actually invokes runDaemon(). Refs #71, #72. * feat(daemon): LaunchAgent + systemd-user installer (macOS + Linux) Implements #73. Generates the platform-appropriate service file, registers it with the system supervisor, and exposes inverse operations for uninstall. macOS: - Writes ~/Library/LaunchAgents/dev.zero8.smriti.plist - Registers via `launchctl bootstrap gui/<uid> <plist>` (modern) - Falls back to `launchctl load -w` on older macOS where bootstrap isn't available - Treats EEXIST / "already loaded" as success, not failure — that's the idempotent re-install case - Uninstall calls `launchctl bootout`, falls back to `launchctl unload`, then removes the plist Linux: - Writes ~/.config/systemd/user/smriti.service - Registers via `systemctl --user daemon-reload && systemctl --user enable --now smriti` - Service includes Restart=on-failure + RestartSec=5 so a crashed daemon comes back automatically, plus Nice=10 + IOSchedulingClass= idle so background indexing doesn't fight foreground work - Uninstall calls `systemctl --user disable --now`, removes the unit file, then daemon-reload to flush systemd's view Pure template generators (generatePlist, generateSystemdUnit) are exported for unit testing without spawning real launchctl/systemctl. Real-world interaction goes through a RunCmd abstraction that the default install path implements with Bun.spawn — tests inject a recording runner instead, so they can assert which commands would have been called without actually registering anything with the host's service manager. 13 tests cover the plist + systemd-unit generators (incl. XML escaping and ExecStart quoting), the install happy paths (macOS bootstrap + load-fallback, Linux daemon-reload + enable), the idempotent re-install path, the EEXIST-as-success case, error propagation from systemctl, and both uninstall paths. Manual integration testing (real launchctl bootstrap on this machine) will happen during the release-readiness work in #75. Refs #71, #73. * feat(cli): wire smriti daemon subcommands Implements #74. Adds the user-facing entry points for the daemon that #72 and #73 built. Six subcommands: smriti daemon Run in foreground (debugging, systemd target) smriti daemon install LaunchAgent (macOS) / systemd unit (Linux) smriti daemon uninstall Reverse install smriti daemon status PID, uptime, watched agents smriti daemon stop SIGTERM the running daemon smriti daemon logs tail -F the daemon log file Dispatch happens BEFORE initSmriti() because the foreground daemon opens its own DB handle per ingest flush rather than sharing one. Sharing a long-lived connection across many ingest calls was ruled out by pre-impl smoke test 3 (Bun segfault at ~6.8 GB peak RSS). Each subcommand uses lazy imports — the daemon module graph isn't loaded for unrelated commands like `smriti search`. Keeps the hot path cold-start unchanged. `smriti daemon status` formats the uptime in the largest-fitting unit (seconds / minutes / hours / days) so the most common state ("running for 2 days") reads naturally without grep. Logs follow tail -F semantics so the command keeps working across log rotation, which both LaunchAgents and systemd will do over time. HELP text gains a "Daemon options" block alongside Ingest, Search, Recall, etc. Manual verification: $ smriti daemon status daemon: not running PID file: /Users/zero8/.cache/smriti/daemon.pid $ smriti daemon banana Unknown daemon subcommand: banana Usage: smriti daemon [install|uninstall|status|stop|logs] smriti daemon (run in foreground) Refs #71, #74. * chore(release): bump to v0.8.0; document daemon commands in CLAUDE.md - package.json: 0.6.0 → 0.8.0. (v0.7.0 was tagged in git without a matching package.json bump; we skip past it directly to 0.8.0 since the daemon is the headline change.) - CLAUDE.md quick-reference gains a "Daemon (v0.8+)" block covering all six subcommands, plus the recommended Stop-hook template that pokes the socket when the daemon is running and falls back to lockf when it isn't. Refs #71, #75. * docs(release): add release-flow + v0.8.0 release notes Two reference docs to make the v0.8.0 tag a five-minute event: - docs/internal/release-flow.md captures the four-phase release process (feature branch → staging on hardware → checklist → tag). Intended to be reused for every future release, not just v0.8.0. Includes the upgrade-restart gap that will become v0.8.1, the "what lives where" table, and the explicit list of things we do not do (no CI release pipeline, no RC channels, no release branches kept alive past tag). - docs/internal/release-notes-v0.8.0.md is the canonical body for the GitHub release. Written in the "what an engineer would tell a colleague about" voice that we agreed releases should land in. Pulls together the headline (cross-agent capture), the three design constraints that shaped it (smoke-test findings), the recommended Stop-hook update, what's deferred to 0.8.1, and the postmortem provenance that got us here. Issue #75 now contains the real-hardware acceptance checklist that gates tagging — once those boxes are green, the commands in release-flow.md execute the tag. Refs #71, #75. * fix(ingest): parse Codex rollout response_item format Codex CLI (codex_cli_rs >= ~0.40) wraps messages as {type:'response_item', payload:{type:'message', role, content:[{type:'input_text'|'output_text', text}]}}. The parser only understood flat {role, content} entries, so every modern session parsed to zero messages and was silently skipped. Also filters injected context blocks (AGENTS.md, environment_context) recorded as user messages. * fix(ingest): support VS Code .jsonl chatSessions and new field shapes VS Code now writes chatSessions as JSONL with a {kind:0, v:{...session}} snapshot line (single- or multi-line). Request text moved to message.text; response items carry markdown in .value with a kind discriminator (thinking/progress/tool kinds are skipped). Discovery glob now includes *.jsonl. * fix(ingest): honor original timestamps on backfill; bulk-ingest enrichment kill switch addMessage() now accepts options.timestamp and uses it for message and session created_at — backfilled history keeps its real dates instead of collapsing to ingest day (live hook ingests are unchanged: default now). SMRITI_INGEST_NO_ENRICH=1 skips per-session LLM query expansion and collection sync during bulk backfills: 482 queued local-llama inferences pegged the CPU for 20+ minutes. Run smriti enrich/embed explicitly after. * feat(ingest): Cursor globalStorage ingest — real chat history from state.vscdb Cursor's actual history lives in globalStorage/state.vscdb (cursorDiskKV: composerData:* + bubbleId:* keys), not project .cursor/*.json. Adds: - read-only SQLite discovery of all composers (inline conversation and headers+bubble formats; bubble timestamps fall back to composer createdAt) - composerId -> workspace folder mapping from workspaceStorage for project resolution; CURSOR_STORAGE_DIR override; Linux/Windows paths - smriti ingest cursor now works without --project-path (legacy .cursor JSON path retained behind the flag) - --force re-ingest now deletes memory_messages first (was appending duplicates) Recovers 482 sessions / 22k messages on this machine. 14 new tests. * chore(scripts): zero-dependency local server for markdown reports * docs(release): v0.8.0 notes — ingest correctness section + retrospective link
- CI/Release workflows ran 'bun test test/' which substring-matches qmd/test/** from the submodule — those tests need qmd's own devDeps (web-tree-sitter) and QMD's repo layout, so they fail on CI. Switch to 'bun run test' (bun test --cwd ./test): 369 tests, smriti only. - qmd upstream now imports fast-glob from src/store.ts; Bun's file:./qmd link doesn't install the submodule's deps, so fresh installs crashed at 'smriti --version' (same class as the earlier picomatch incident). Declare it explicitly at root. - Release notes: drop retrospective paragraph (matches edited GitHub release body), absolutize doc links that 404'd on the release page.
SQLite's file-lock release lags close() on Windows, so afterEach's rmSync raced it and threw EBUSY, failing all 14 cursor-sqlite tests on the windows-latest matrix only. The temp dir lives under tmpdir() on an ephemeral runner — tolerating the rm failure leaks nothing meaningful.
The daemon is explicitly deferred on Windows (v0.8.0 release notes), and Bun's fs.watch on win32 hard-crashes the test process mid-suite — which silently prevented the remaining ~260 tests (ingest, search, parsers — all of which DO ship on Windows) from running at all. Skipping the unshipped feature's suite restores real Windows coverage for everything that is shipped.
The test pins its own PID in the daemon PID file so stopDaemon() sends SIGTERM to the test runner itself. On Unix a no-op handler swallows it; on Windows process.kill(pid, 'SIGTERM') is TerminateProcess — the runner dies instantly with no summary, which killed the windows-latest job deterministically (only ~109 of 369 tests ever ran). Daemon is unshipped on Windows.
Last remaining windows-latest failure: the test binds a real AF_UNIX socket, which behaves differently on Windows where the daemon is unshipped anyway. With this, the full 369-test suite is green on all three OSes (Windows previously died at 109 tests via self-SIGTERM).
…ention - chore(scripts): add resource-usage sampler for daemon/ingest processes - fix(daemon): dispose LLM backend per flush, skip auto-enrich, serialize flushes, and add busy_timeout - fix(release): bump to v0.8.2; fix release-meta.ts dropping the oldest commit in range
Adds `smriti consolidate` and `smriti learnings`, applying Progressive Summarization: cheap Stage-1 segmentation runs broadly over dense sessions into a new smriti_knowledge_units table, and expensive Stage-2 polish (existing segmentSession/generateDocument pipeline) only runs once a unit proves reuse via recall or scored high relevance at extraction time. - src/db.ts: smriti_knowledge_units table + CRUD helpers (insertKnowledgeUnit, findUnsegmentedDenseSessions, findPromotableUnits, incrementRetrievalCount, promoteKnowledgeUnit, listKnowledgeUnits) - src/search/recall.ts: track retrieval_count on every recall() path - src/learn/consolidate.ts: segment + promote phases, reusing the existing 3-stage segmentation pipeline from src/team/segment.ts and document.ts - src/index.ts, src/format.ts: CLI wiring for `consolidate` and `learnings` CLI-only, not wired into the daemon — consolidation runs two LLM stages, which the daemon's flush path deliberately avoids (see the enrichOnIngest comment in src/daemon/index.ts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnYgLUDwWALu1bUAgrFQDG
Extends the Continuous Knowledge Consolidation Layer with canonical entities and typed subject-predicate-object relationship triples, inspired by RDF's data model (adapted pragmatically: no URIs/SPARQL, just typed (subject_type, subject_id) pairs in SQLite). - src/db.ts: smriti_entities + smriti_relationships tables; new `minEntityReach` promotion criterion in findPromotableUnits (a unit becomes promotable once its entity is independently mentioned by K other units, even at 0 retrievals); seeds a "team" agent (fixes a latent FK bug in syncTeamKnowledge's existing agent fallback) - src/learn/entities.ts: resolveEntity (exact-normalize canonicalization via slugify), insertRelationship/getRelationships (triple store), findRelatedCandidates, findEntity, getUnitsForEntity - src/learn/consolidate.ts: segment phase turns Stage-1 entities into "mentions" edges for free; promote phase adds one bounded LLM call to infer relatesTo/supersedes/contradicts edges against entity-sharing candidates, persisting what ollamaCheckConflicts previously only computed ephemerally - src/team/config.ts, share.ts, sync.ts: entities propagate team/org-wide through the same .smriti/config.json round-trip custom categories already use (exportEntities/mergeEntities mirror exportCustomCategories/mergeCategories); unit-to-unit relationship edges propagate via frontmatter directly, needing no canonicalization since unit ids are already portable UUIDs. Also fixes sync.ts treating "consolidated" pipeline docs as raw conversation transcripts (only "segmented" was previously recognized as single-message). - src/index.ts, src/format.ts: `smriti graph <entity>` command; `--min-entity-reach` flag on `smriti consolidate` Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnYgLUDwWALu1bUAgrFQDG
When two units sharing an entity are promoted in the same consolidate run, each independently asks the LLM "do I supersede/contradict the other" — found via a live demo that this can produce both directions asserted simultaneously (A supersedes B and B supersedes A), which is incoherent for a directional predicate. Skip inserting the reverse edge if the candidate already asserted it in the other direction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnYgLUDwWALu1bUAgrFQDG
The prompt asks for "RELATION [i]: predicate" but Ollama reliably gets the index/predicate right while dropping the literal brackets (observed: "RELATION 0: supersedes"). The regex required them exactly, so correct answers were silently discarded — inferRelationships swallows all errors by design (enrichment, not a promotion precondition), so this failure mode had zero visibility: promotion succeeded, the edge just never appeared. Verified against real Ollama output: a hand-fed prompt produced a correct "supersedes" verdict in 16s that the old regex dropped entirely; after the fix, an end-to-end consolidate run against seeded sessions produced a real relatesTo edge, confirmed via `smriti graph`. Also logs when a non-empty response yields zero parsed lines, since that almost always means format drift rather than every candidate genuinely being unrelated.
Replace regex-parsed "RELATION [i]: predicate" free-text prompting with a native tool call (record_relationships) for promote-time relationship inference — the model returns structured JSON directly, so there's nothing to drift from or fail to parse. classifyRelationshipsTextFormat is kept only as the "before" baseline for the eval comparison in test/eval/relation-inference.eval.ts. Also requires QMD_MEMORY_MODEL to be explicitly set (config.ts's new requireOllamaModel()) instead of every Ollama call site silently falling back to a hardcoded default model.
Live comparison of classifyRelationshipsTextFormat (regex-parsed, "before") vs classifyRelationshipsToolCall (native tool call, "after") against 9 hand-labeled scenarios, run against the real configured Ollama model. Manual-only (.eval.ts suffix, excluded from `bun test`) — run via `bun run eval:relations`.
… eval harness Closes the three gaps found when mapping Smriti against a standard 6-layer AI memory architecture (working memory, episodic store, semantic/facts, store/search/update/forget tools, a consolidation job, and an eval harness) — forget and prune existed only as dead code or not at all, and the only eval was a narrow classifier A/B test. - forget: `smriti forget <session-id>` (soft by default, --hard --yes for real deletion) and `forget --all [filters]` for bulk. Re-exports deleteSession/clearAllSessions from src/qmd.ts, adds forgetSession() orchestration (sidecar cleanup, unpromoted knowledge units + their relationship edges, orphaned vector embeddings) in src/db.ts. Canonical (promoted) units/docs are kept unless --purge-shared. - prune: `smriti consolidate --prune` expires stale never-promoted knowledge units (0 retrievals, low relevance, 30+ days old) and soft-archives canonical units superseded by a newer one (tier 'archived', doc kept with a deprecation banner). Dry-run by default; --yes/--apply to mutate. Pure DB logic, no LLM call. - eval harness: test/eval/fixtures/ (multi-session scenarios covering cross-session recall-over-time, project-filter isolation, density- score tie-breaking, and a semantic-only match). Tier 1 (test/recall-quality.test.ts) is BM25-only and runs in `bun test`; Tier 2 (test/eval/recall-quality.eval.ts, `bun run eval:recall`) adds embedding-dependent scenarios and asserts vector search actually fired rather than trusting a silent BM25 fallback.
mockOllamaFetch's `relation` handler still simulated the old
/api/generate free-text response ("RELATION [i]: predicate"), but
inferRelationships was switched to classifyRelationshipsToolCall, which
calls ollamaChat -> /api/chat with a `messages` body (no `prompt` field)
and expects a native record_relationships tool call back. The mismatch
made the mocked fetch throw (reading .includes on the now-undefined
body.prompt), silently swallowed by inferRelationships' best-effort
try/catch, so both tests asserting on the inferred edges failed with
0 edges instead of 1.
Distinguish /api/generate (stage1/stage2, has body.prompt) from
/api/chat (relation inference, no body.prompt) and return a
tool_calls-shaped response for the latter; relation handlers now return
structured {index, predicate} guesses instead of a free-text string.
Resolves conflicts against dev's: - 9fd16f6 feat: recall quality & project inspection (issue #56) — `smriti projects <id>`, `smriti tags`, `--whole` ingest flag, project-scoped `status`. Ordinary content conflicts in db.ts (auto- merged), format.ts, index.ts (kept both sides' additions). - dbb2eeb refactor: move memory.ts + ollama.ts out of QMD submodule. Add/add conflict on src/memory.ts and src/ollama.ts — kept "ours" in both cases after diffing: this branch's versions are a strict superset (getMemoryLlm via the SDK store, recallMemories's fast/ intent/expandQuery/rerank/density-blending pipeline, cleanupOrphanedMemoryVectors, tool-call support, ollamaAsk/ ollamaDrift/ollamaCheckConflicts) — dev's copies were the bare just-extracted-from-submodule versions with none of that. qmd submodule pointer: kept ours (da67604) — confirmed dev's target (d58fedf) is an ancestor of it, so nothing from dev's bump is lost. - 91effef fix(ci): picomatch@4 root dep — already present via package.json's clean auto-merge. bun.lock: kept ours, verified consistent via `bun install` (no changes made). Verified with `bun test --cwd ./test`: 404 pass, 4 fail — same pre-existing failures as before this merge (learn-entities.test.ts x3, team-segmented.test.ts x1; a known full-suite-only global-fetch-mock isolation issue, unrelated to this merge). Manual smoke test: both `smriti forget`/`smriti consolidate --prune` and dev's `smriti projects`/`smriti tags` work correctly in the merged CLI.
recallMemories' density-blending step queries smriti_session_meta directly, but src/memory.ts moved out of the qmd submodule specifically to stay usable as a clean, Smriti-agnostic layer (see the dev merge) — scripts/bench-qmd.ts runs it against a bare QMD store with no Smriti tables at all, which crashed with "no such table: smriti_session_meta". Wrap it in the same try/catch pattern already used for the vector-search fallback a few lines up: no smriti_session_meta means no density signal, so skip the blend and keep the RRF/rerank-only ordering.
Contributor
Benchmark Scorecard (ci-small)Bench Scorecard (ci-small)threshold: 20.00%
Summary: WARN (4 metrics) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Started as the Continuous Knowledge Consolidation Layer and grew a second piece: an RDF-inspired entities/relationships graph on top of it, after discussing how RDF's triple model (subject-predicate-object, canonical resource identity, typed vocabularies) could strengthen the self-learning loop.
Part 1 — Continuous Knowledge Consolidation (Progressive Summarization): cheap Stage-1 extraction (
segmentSession) runs broadly over dense sessions into a newsmriti_knowledge_unitstable; expensive Stage-2 polish (generateDocument) only runs once a unit proves it's reused (recalled repeatedly) or scored high relevance at extraction time.src/db.ts—smriti_knowledge_unitstable + CRUD helpers (insertKnowledgeUnit,findUnsegmentedDenseSessions,findPromotableUnits,incrementRetrievalCount,promoteKnowledgeUnit,listKnowledgeUnits)src/search/recall.ts— tracksretrieval_counton everyrecall()exit pathsrc/learn/consolidate.ts— segment phase + promote phasesrc/index.ts,src/format.ts—smriti consolidateandsmriti learningsPart 2 — Entities & relationships graph (RDF-inspired, not literal RDF — no URIs/SPARQL, just typed
(subject_type, subject_id)pairs in SQLite): today'sentities: string[]field is write-only — never queried or matched, so "JWT" in one session and "JSON Web Token" in another look unrelated. This closes that gap and also persists whatollamaCheckConflictspreviously only computed ephemerally.src/db.ts—smriti_entities(canonical registry) +smriti_relationships(triple store) tables; newminEntityReachpromotion criterion — a unit becomes promotable once its entity is independently mentioned by K other units, even at 0 retrievalssrc/learn/entities.ts—resolveEntity(exact-normalize canonicalization viaslugify),insertRelationship/getRelationships(triple store),findRelatedCandidates,findEntity,getUnitsForEntitysrc/learn/consolidate.ts— segment phase turns Stage-1 entities intomentionsedges for free (no extra LLM calls); promote phase adds one bounded LLM call (only when entity-sharing candidates exist) to inferrelatesTo/supersedes/contradictsedgessrc/team/config.ts/share.ts/sync.tsroute entities through the exact same.smriti/config.jsongit round-trip custom categories already use (exportEntities/mergeEntitiesmirrorexportCustomCategories/mergeCategories). Unit-to-unit relationship edges propagate via frontmatter directly — no canonicalization needed there, since unit ids are already portable UUIDs once shared.src/index.ts,src/format.ts—smriti graph <entity>command,--min-entity-reachflag onconsolidateTwo pre-existing bugs found and fixed while building the propagation tests (both predate this PR, exposed because nothing previously tested a real share→sync round trip on disk):
syncTeamKnowledge's agent fallback used"team"as an agent id that was never seeded intosmriti_agents, so importing any shared file without an explicitagentfield (true for the entire pre-existing segmented pipeline) hit an FK violation. Fixed by seeding ateamagent.syncTeamKnowledgeonly recognizedpipeline === "segmented"as a single-message document;pipeline === "consolidated"docs were misparsed as raw transcripts and silently skipped on import.Both remain CLI-only, never wired into the daemon — same reasoning as
enrich: LLM work per-flush is unsafe (seesrc/daemon/index.ts'senrichOnIngestcomment).Test plan
test/learn-consolidate.test.ts— segment-phase dedup, promotion threshold, graceful degradation, retrieval trackingtest/learn-entities.test.ts— entity canonicalization (exact-normalize merge, no false-merge on real synonyms), relationship triple insert/dedup/pattern-lookup,mentionsedges from segment phase (zero extra LLM calls),minEntityReachpromotion, LLM-inferred relationship edges (+ best-effort failure handling),findRelatedCandidatestest/learn-entities-sync.test.ts— two independent in-memory DBs standing in for two teammates' machines, connected only through a shared tmp.smriti/directory: confirms an entity converges to the same canonical id via share→sync (not an independently re-slugified duplicate), and thatsupersedesedges between shared units survive the round-tripbun test --cwd ./test— 391 pass, 0 fail across 33 files, no regressionssmriti consolidate,smriti learnings,smriti graph <entity>run cleanly against a fresh DBAlso in this PR: close the working-memory-stack gaps
Mapped Smriti against a standard 6-layer AI memory architecture (working
memory, episodic store, semantic/facts, store/search/update/forget
tools, a consolidation job with summarize/promote/prune, and an
eval harness) and closed the three gaps found:
smriti forget <session-id>— soft delete by default (active=0,already understood by
list --all);--hard --yesfor real deletion(messages, all sidecar tables, unpromoted knowledge units + their
relationship edges, orphaned vector embeddings).
forget --all [--project/--category/--agent]for bulk. Canonical (promoted) unitsand their
.smriti/knowledge/docs are kept unless--purge-shared.deleteSession/clearAllSessionsexisted insrc/memory.tsbut weredead code — never re-exported from
src/qmd.ts, no CLI command.smriti consolidate --prune— expires stale never-promoted units(0 retrievals, relevance below the promotion bar, 30+ days old — hard
delete, safe since nothing external references them) and soft-archives
canonical units superseded by a newer one (
tier='archived', doc filekept in place with a deprecation banner prepended, not deleted/moved).
Dry-run by default, prints a candidate table;
--yes/--applytomutate. Pure DB logic (age/retrieval_count/
supersedes-edges alreadyin SQLite) — no LLM call.
Recall-quality eval harness — the only prior eval
(
test/eval/relation-inference.eval.ts, added earlier in this PR) wasa narrow single-function A/B test, not a "what should the system
remember" check.
test/eval/fixtures/now has multi-session scenarios(cross-session recall-over-time + a same-vocabulary distractor,
project-filter isolation, density-score tie-breaking on a BM25 tie, and
a semantic-only paraphrase match). Tier 1
(
test/recall-quality.test.ts) is BM25-only and deterministic, runs inbun test/CI. Tier 2 (test/eval/recall-quality.eval.ts,bun run eval:recall) adds the embedding-dependent scenario and explicitlyasserts vector search fired rather than trusting a silent BM25
fallback.
Files:
src/qmd.ts,src/memory.ts,src/db.ts,src/index.ts,src/format.ts,src/learn/consolidate.ts,src/learn/entities.ts,package.json,test/forget.test.ts,test/recall-quality.test.ts,test/eval/fixtures/*,test/eval/recall-quality.eval.ts.Test plan (forget/prune/eval)
test/forget.test.ts— soft delete visibility, hard deletecascade (messages/sidecars/relationship edges), canonical units kept
unless
--purge-shared, bulk forgettest/learn-consolidate.test.ts(prune additions) — dry-runreporting, apply deletes stale units + edges, high-relevance units
never pruned, superseded units archived with banner appended, archived
units' relationship edges left untouched
test/recall-quality.test.ts— all CI-safe scenarios passbun test --cwd ./test— 404 pass, 4 fail (all 4 pre-existing, seenote above — not caused by this work; confirmed by isolating these
files with the forget/prune/eval commits reverted)
smriti forget <id>(soft, then--hard --yes),smriti consolidate --prune(dry-run,then
--yes) — all behaved as expected end-to-end