Skip to content

feat(learn): consolidation layer + RDF-inspired entities/relationships graph - #84

Merged
ashu17706 merged 18 commits into
devfrom
claude/refine-local-plan-ff68rg
Aug 2, 2026
Merged

feat(learn): consolidation layer + RDF-inspired entities/relationships graph#84
ashu17706 merged 18 commits into
devfrom
claude/refine-local-plan-ff68rg

Conversation

@ashu17706

@ashu17706 ashu17706 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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 new smriti_knowledge_units table; 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.tssmriti_knowledge_units table + CRUD helpers (insertKnowledgeUnit, findUnsegmentedDenseSessions, findPromotableUnits, incrementRetrievalCount, promoteKnowledgeUnit, listKnowledgeUnits)
  • src/search/recall.ts — tracks retrieval_count on every recall() exit path
  • src/learn/consolidate.ts — segment phase + promote phase
  • src/index.ts, src/format.tssmriti consolidate and smriti learnings

Part 2 — Entities & relationships graph (RDF-inspired, not literal RDF — no URIs/SPARQL, just typed (subject_type, subject_id) pairs in SQLite): today's entities: 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 what ollamaCheckConflicts previously only computed ephemerally.

  • src/db.tssmriti_entities (canonical registry) + smriti_relationships (triple store) tables; new minEntityReach promotion criterion — a unit becomes promotable once its entity is independently mentioned by K other units, even at 0 retrievals
  • src/learn/entities.tsresolveEntity (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 (no extra LLM calls); promote phase adds one bounded LLM call (only when entity-sharing candidates exist) to infer relatesTo/supersedes/contradicts edges
  • Team/org propagation — an entity's canonical id is only meaningful if every teammate's machine agrees on it. src/team/config.ts/share.ts/sync.ts route entities through the exact same .smriti/config.json git round-trip custom categories already use (exportEntities/mergeEntities mirror exportCustomCategories/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.tssmriti graph <entity> command, --min-entity-reach flag on consolidate

Two 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 into smriti_agents, so importing any shared file without an explicit agent field (true for the entire pre-existing segmented pipeline) hit an FK violation. Fixed by seeding a team agent.
  • syncTeamKnowledge only recognized pipeline === "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 (see src/daemon/index.ts's enrichOnIngest comment).

Test plan

  • test/learn-consolidate.test.ts — segment-phase dedup, promotion threshold, graceful degradation, retrieval tracking
  • test/learn-entities.test.ts — entity canonicalization (exact-normalize merge, no false-merge on real synonyms), relationship triple insert/dedup/pattern-lookup, mentions edges from segment phase (zero extra LLM calls), minEntityReach promotion, LLM-inferred relationship edges (+ best-effort failure handling), findRelatedCandidates
  • test/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 that supersedes edges between shared units survive the round-trip
  • bun test --cwd ./test — 391 pass, 0 fail across 33 files, no regressions
  • Manual smoke test: smriti consolidate, smriti learnings, smriti graph <entity> run cleanly against a fresh DB

Note: the tool-call switch (classifyRelationshipsToolCall now wired into inferRelationships, mirrored by the "Also in this PR" commits below) broke the mock format in 2 tests under test/learn-entities.test.ts (promote phase persists LLM-inferred relatesTo/supersedes/contradicts edges, promote phase never asserts a directional predicate in both directions for the same pair — they still mock the old free-text RELATION [i]: predicate response). Confirmed pre-existing as of this push, not caused by the memory-stack work below. Worth a follow-up fix before merging.

Also 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 --yes for real deletion
    (messages, all sidecar tables, unpromoted knowledge units + their
    relationship edges, orphaned vector embeddings). forget --all [--project/--category/--agent] for bulk. Canonical (promoted) units
    and their .smriti/knowledge/ docs are kept unless --purge-shared.
    deleteSession/clearAllSessions existed in src/memory.ts but were
    dead 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 file
    kept in place with a deprecation banner prepended, not deleted/moved).
    Dry-run by default, prints a candidate table; --yes/--apply to
    mutate. Pure DB logic (age/retrieval_count/supersedes-edges already
    in SQLite) — no LLM call.

  • Recall-quality eval harness — the only prior eval
    (test/eval/relation-inference.eval.ts, added earlier in this PR) was
    a 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 in
    bun test/CI. Tier 2 (test/eval/recall-quality.eval.ts, bun run eval:recall) adds the embedding-dependent scenario and explicitly
    asserts 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 delete
    cascade (messages/sidecars/relationship edges), canonical units kept
    unless --purge-shared, bulk forget
  • test/learn-consolidate.test.ts (prune additions) — dry-run
    reporting, 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 pass
  • bun test --cwd ./test — 404 pass, 4 fail (all 4 pre-existing, see
    note above — not caused by this work; confirmed by isolating these
    files with the forget/prune/eval commits reverted)
  • Manual CLI smoke test against a scratch DB: smriti forget <id> (soft, then --hard --yes), smriti consolidate --prune (dry-run,
    then --yes) — all behaved as expected end-to-end

ashu17706 and others added 10 commits March 14, 2026 14:55
* 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
@ashu17706 ashu17706 changed the title feat(learn): add continuous knowledge consolidation layer feat(learn): consolidation layer + RDF-inspired entities/relationships graph Jul 26, 2026
claude and others added 5 commits July 26, 2026 16:22
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.
@ashu17706
ashu17706 changed the base branch from main to dev August 2, 2026 08:44
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.
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Benchmark Scorecard (ci-small)

Bench Scorecard (ci-small)

threshold: 20.00%

metric baseline current (median) delta status
ingest_throughput_msgs_per_sec 1735.800 419.350 -75.84% WARN
ingest_p95_ms_per_session 6.960 27.218 +291.06% WARN
fts_p95_ms 0.410 0.815 +98.78% WARN
recall_p95_ms 0.436 1.093 +150.69% WARN

Summary: WARN (4 metrics)

@ashu17706
ashu17706 merged commit 56d69e1 into dev Aug 2, 2026
8 of 9 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 2, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants