Skip to content

v0.8.0: Smriti daemon — cross-agent capture, auto-start on macOS + Linux#76

Open
ashu17706 wants to merge 25 commits into
mainfrom
feat/daemon-core
Open

v0.8.0: Smriti daemon — cross-agent capture, auto-start on macOS + Linux#76
ashu17706 wants to merge 25 commits into
mainfrom
feat/daemon-core

Conversation

@ashu17706
Copy link
Copy Markdown
Contributor

@ashu17706 ashu17706 commented May 19, 2026

Closes #71, #72, #73, #74. Sets up #75 for release.

The headline feature for v0.8.0: a long-running `smriti daemon` that captures sessions from every agent in the background. Open Cursor, Codex, or Claude — your sessions land in Smriti's index without typing a command.

What's in this PR

Daemon core (`src/daemon/` — five modules)

Module Purpose Tests
`server.ts` PID-file single-instance guard, IPC Unix socket, signal-driven graceful shutdown 10
`watcher.ts` Recursive `fs.watch` on macOS (native), walk-and-watch on Linux 7
`queue.ts` Per-project debounce; coalesces bursts into one ingest per quiet window 9
`handlers.ts` Path → agent routing with strict prefix matching 6
`client.ts` `smriti daemon stop / status` via PID file + signals 6
`index.ts` (runDaemon) Wires watcher → queue → ingest; fresh DB per flush 6

Service installer (`src/daemon/install.ts`)

  • macOS: writes `~/Library/LaunchAgents/dev.zero8.smriti.plist`, registers via `launchctl bootstrap` (falls back to `launchctl load` on older macOS).
  • Linux: writes `~/.config/systemd/user/smriti.service`, registers via `systemctl --user enable --now smriti`.
  • Idempotent install / clean uninstall. 13 tests.

CLI (`src/index.ts`)

```
smriti daemon # Run in foreground (debugging, systemd target)
smriti daemon install [--force] # Install LaunchAgent / systemd unit, register
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
```

Docs

  • `docs/internal/daemon-prd.md` — full design + the three pre-impl smoke-test findings that shaped the implementation
  • `docs/papers/stop-hook-never-stopped.md` — the postmortem that started this work
  • `docs/papers/only-by-staying.md` — companion reflection
  • `CLAUDE.md` quick reference — new "Daemon (v0.8+)" block including the recommended Stop-hook template

Version

`package.json`: 0.6.0 → 0.8.0.

Design constraints baked in (from pre-impl smoke tests)

  1. Single-instance is PID file + `kill(pid, 0)` probe, not socket-bind. Bun's `net.createServer().listen(path)` silently succeeds on duplicate binds and steals connections from the first server — verified with a reproducer.
  2. FS watching uses native `fs.watch`, not chokidar. Chokidar 5.0.0 under Bun 1.3.6 fires zero events; native `fs.watch({ recursive: true })` works perfectly on macOS.
  3. DB connection per ingest flush, not per daemon lifetime. Calling `ingest()` five times in a row in one Bun process leaked 20 FDs, climbed to 6.8 GB peak RSS, and segfaulted Bun. Fresh handle per flush sidesteps it entirely; cost is ~30ms inside a 30s debounce window.

All three are documented in `docs/internal/daemon-prd.md`.

Test results

```
bun test test/daemon-*.test.ts
57 pass / 0 fail / 121 expect() calls — Ran 57 tests across 7 files in 4.11s
```

Full suite still passes (the 2 pre-existing QMD-submodule path failures are unrelated).

What's left for v0.8.0 (after this PR merges)

  • Manual reboot + 24-hour soak test on macOS + Linux
  • Verify the Stop-hook template change works end-to-end
  • Tag v0.8.0 + GitHub release notes
  • Optionally: file the Bun ingest-crash upstream with a minimal repro

Why one big PR for the whole daemon

Each daemon module is small (50–250 LOC each), but they're tightly coupled — the watcher's events feed the queue, the queue invokes ingest, the hook poke routes to the queue, the lifecycle commands inspect the PID file the server writes. Reviewing them in isolation loses the most important property of the change (that they compose into a working daemon). Committing them as a sequence of small, individually-tested commits on one branch gives reviewers both the local view (commit-by-commit) and the global view (this PR description).

Commit history (9 commits)

```
chore(release): bump to v0.8.0; document daemon commands in CLAUDE.md
feat(cli): wire smriti daemon subcommands
feat(daemon): LaunchAgent + systemd-user installer (macOS + Linux)
feat(daemon): runDaemon() entry point wiring all five modules
feat(daemon): lifecycle client for stop / status
feat(daemon): agent-root routing helpers
feat(daemon): per-project debounce queue
feat(daemon): recursive watcher with macOS-native + Linux walk-and-watch
feat(daemon): scaffold server with PID-file single-instance + IPC socket
```

Refs #71, #72, #73, #74, #75.

ashu17706 added 24 commits May 3, 2026 12:34
- --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)
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.
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
…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
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().
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.
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.
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.
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.
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).
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.
k-means over session embeddings, Ollama cluster naming, smriti clusters
command, enrich --clusters, and recall --cluster <name> filter.
…#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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
@ashu17706 ashu17706 changed the title v0.8 [1/4]: Daemon core — server first, watcher next v0.8.0: Smriti daemon — cross-agent capture, auto-start on macOS + Linux May 19, 2026
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.
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.

Phase 1: cross-agent capture daemon (macOS + Linux)

1 participant