@geoql/mdr 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +152 -0
  3. package/USAGE.md +59 -0
  4. package/bin/detect-user.sh +53 -0
  5. package/bin/index-conversations.ts +34 -0
  6. package/bin/macrodata-daemon.ts +28 -0
  7. package/bin/macrodata-hook.sh +277 -0
  8. package/dist/bin/index-conversations.js +31 -0
  9. package/dist/bin/macrodata-daemon.js +30 -0
  10. package/dist/opencode/context.js +210 -0
  11. package/dist/opencode/conversations.js +367 -0
  12. package/dist/opencode/index.js +155 -0
  13. package/dist/opencode/journal.js +108 -0
  14. package/dist/opencode/logger.js +29 -0
  15. package/dist/opencode/search.js +210 -0
  16. package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
  17. package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  18. package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  19. package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  20. package/dist/opencode/tools.js +367 -0
  21. package/dist/src/config.js +55 -0
  22. package/dist/src/conversations.js +513 -0
  23. package/dist/src/daemon.js +582 -0
  24. package/dist/src/detect-user.js +73 -0
  25. package/dist/src/embeddings.js +190 -0
  26. package/dist/src/index.js +413 -0
  27. package/dist/src/indexer.js +286 -0
  28. package/opencode/context.ts +322 -0
  29. package/opencode/conversations.ts +467 -0
  30. package/opencode/index.ts +208 -0
  31. package/opencode/journal.ts +153 -0
  32. package/opencode/logger.ts +32 -0
  33. package/opencode/search.ts +288 -0
  34. package/opencode/skills/macrodata-distill/SKILL.md +171 -0
  35. package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
  36. package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
  37. package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
  38. package/opencode/tools.ts +453 -0
  39. package/package.json +87 -0
  40. package/src/config.ts +66 -0
  41. package/src/conversations.ts +709 -0
  42. package/src/daemon.ts +785 -0
  43. package/src/detect-user.ts +97 -0
  44. package/src/embeddings.ts +262 -0
  45. package/src/index.ts +726 -0
  46. package/src/indexer.ts +394 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Matt Kane (original author, ascorbic/macrodata)
4
+ Copyright (c) 2026 Vinayak Kulkarni (geoql/macrodata fork)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @geoql/mdr
2
+
3
+ > Persistent, self-maintaining memory and autonomous scheduling for coding agents (OpenCode, Claude Code).
4
+
5
+ [![npm](https://img.shields.io/npm/v/@geoql/mdr)](https://www.npmjs.com/package/@geoql/mdr)
6
+ [![JSR](https://jsr.io/badges/@geoql/mdr)](https://jsr.io/@geoql/mdr)
7
+ [![Pipeline](https://github.com/geoql/mdr/actions/workflows/pipeline.yml/badge.svg)](https://github.com/geoql/mdr/actions/workflows/pipeline.yml)
8
+ [![codecov](https://codecov.io/gh/geoql/mdr/branch/main/graph/badge.svg)](https://codecov.io/gh/geoql/mdr)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
10
+
11
+ Macrodata gives an AI coding agent layered, local-first memory: a searchable journal, entity files for people and projects, always-injected state files, semantic search across every past session, a background daemon for scheduled reminders, and overnight self-maintenance. All memory is plain markdown and JSON on disk — nothing phones home, and the whole system runs inside the agent's existing permission model with no new APIs or attack surface.
12
+
13
+ ## Hard fork of ascorbic/macrodata
14
+
15
+ `@geoql/mdr` is a **hard fork of [ascorbic/macrodata](https://github.com/ascorbic/macrodata) by [Matt Kane](https://github.com/ascorbic)** (published upstream as [`@macrodata/opencode`](https://www.npmjs.com/package/@macrodata/opencode)). The layered-memory architecture, the tools, the skills, and the design are Matt's work — this fork stands entirely on that foundation.
16
+
17
+ ### Why this fork
18
+
19
+ - **Node/pnpm runtime alignment.** OpenCode no longer ships Bun. Upstream targets the Bun runtime (`bun:sqlite`, `bun:test`, bundled-Bun daemon); this fork replaces those with Node equivalents (`node:sqlite`) so the plugin runs on the same Node runtime as its host, in a pnpm workspace.
20
+ - **Merged daemon-hardening + incremental-indexing fixes** ([upstream PR #34](https://github.com/ascorbic/macrodata/pull/34), fixes #25). Hung scheduled children can no longer wedge the daemon forever; a heartbeat lets a stale daemon self-heal on the next session.
21
+ - **`@huggingface/transformers` replacing `sharp` postinstall pain** ([upstream PR #35](https://github.com/ascorbic/macrodata/pull/35)). Kills the native-binary postinstall failure under blocked-lifecycle-script package managers. Same model, same 384-dim embeddings; existing indexes stay valid.
22
+ - **Optional remote OpenAI-compatible embeddings** ([upstream PR #36](https://github.com/ascorbic/macrodata/pull/36)). Offload embedding generation to an API instead of running the local model.
23
+ - **Full Vitest suite at 100% coverage.** Migrated off `bun:test`, with a hard statements/branches/functions/lines gate.
24
+ - **geoql release automation.** release-please → npm (OIDC provenance) + JSR, coverage gate, husky/commitlint/lint-staged.
25
+
26
+ The **data directory (`~/.config/macrodata/`), the MCP server id (`macrodata`), the `macrodata_*` tool names, and the `MACRODATA_*` environment variables are unchanged** from upstream — an existing install keeps its memories and index across the migration.
27
+
28
+ ## Requirements
29
+
30
+ - macOS or Linux (WSL on Windows is untested).
31
+ - [Node.js](https://nodejs.org/) `>=24.11.0`.
32
+
33
+ ## Installation
34
+
35
+ ### OpenCode
36
+
37
+ Add the plugin to **`~/.config/opencode/opencode.json`**:
38
+
39
+ ```json
40
+ {
41
+ "plugin": ["@geoql/mdr@latest"]
42
+ }
43
+ ```
44
+
45
+ Launch OpenCode and ask it to set up Macrodata.
46
+
47
+ ### Claude Code
48
+
49
+ ```bash
50
+ /plugin marketplace add geoql/mdr
51
+ /plugin install refiner@mdr
52
+ ```
53
+
54
+ ## What it does
55
+
56
+ Every session starts with context injection — identity, current projects, daily focus, and recent activity — so the agent knows who you are and what you're doing before you type anything.
57
+
58
+ - **Working memory (state files).** A small, always-present working set: `identity.md` (how the agent behaves), `human.md` (who you are), `today.md` (daily focus), `workspace.md` (project context), and topic files. State is a bounded working set, byte-capped per section on injection, with a `flags.md` channel that carries items to you across sessions.
59
+ - **Journal.** Observations, decisions, and learnings get appended to a searchable, date-partitioned JSONL journal.
60
+ - **Entities.** People, projects, and researched topics live as markdown files under `entities/`, indexed for semantic search.
61
+ - **Semantic search.** A [Vectra](https://github.com/Stevenic/vectra) vector index over the journal, entities, and past conversations. By default embeddings are generated locally and offline with `all-MiniLM-L6-v2` (384-dim) via [`@huggingface/transformers`](https://github.com/huggingface/transformers.js); optionally offloaded to a remote endpoint.
62
+ - **Conversation analysis.** Indexes past OpenCode and Claude Code sessions so the agent can retrieve relevant context from prior work.
63
+ - **Scheduling & autonomy.** A background daemon (a cron runner built on [croner](https://github.com/hexagon/croner)) fires reminders, morning prep, distillation, and overnight "dream time" reflection through the same agent instance with the same permissions you already granted.
64
+
65
+ ## Architecture
66
+
67
+ ```
68
+ plugins/macrodata/
69
+ ├── .claude-plugin/plugin.json # Claude Code plugin manifest (hooks + MCP server)
70
+ ├── opencode/ # OpenCode plugin variant
71
+ │ ├── index.ts # Plugin entry: context injection + tools + daemon supervision
72
+ │ ├── tools.ts # macrodata_* memory tools
73
+ │ ├── context.ts # State-file context builder
74
+ │ ├── search.ts # Semantic search over the memory index
75
+ │ └── skills/ # Distill, dreamtime, memory-maintenance, onboarding
76
+ ├── src/ # MCP server + shared core
77
+ │ ├── index.ts # MCP server (server id: "macrodata")
78
+ │ ├── daemon.ts # Scheduler / reminder logic
79
+ │ ├── indexer.ts # Vectra index management
80
+ │ ├── embeddings.ts # Local + optional remote embedding generation
81
+ │ └── config.ts # Root + config-path resolution
82
+ └── bin/
83
+ ├── macrodata-daemon.ts # Background daemon entry point
84
+ └── index-conversations.ts # One-shot conversation indexer
85
+ ```
86
+
87
+ **Storage** (default `~/.config/macrodata/`):
88
+
89
+ ```
90
+ identity.md # Agent persona
91
+ state/ # human.md, today.md, workspace.md, flags.md
92
+ entities/ # people/, projects/, topics/ as markdown
93
+ journal/ # date-partitioned JSONL
94
+ .index/ # Vectra embeddings cache
95
+ reminders/ # scheduled reminder definitions
96
+ config.json # optional (remote embedding provider, etc.)
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ ### Environment variables
102
+
103
+ | Variable | Purpose |
104
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------- |
105
+ | `MACRODATA_ROOT` | Override the memory root directory (default `~/.config/macrodata`). |
106
+ | `MACRODATA_CONFIG_PATH` | Override the path to `config.json` (default `~/.config/macrodata/config.json`). |
107
+ | `MACRODATA_CHILD_TIMEOUT_MS` | Hard timeout for scheduled agent children before the daemon kills the process group (default 10 min). |
108
+
109
+ ### Remote embedding provider (optional)
110
+
111
+ By default embeddings run locally and offline. To offload them to an OpenAI-compatible endpoint, add an `embedding` block to `~/.config/macrodata/config.json`:
112
+
113
+ ```json
114
+ {
115
+ "embedding": {
116
+ "provider": "openai-compatible",
117
+ "endpoint": "https://api.example.com/v1",
118
+ "api_key": "sk-...",
119
+ "model": "baai/bge-m3",
120
+ "input_type": "passage",
121
+ "query_input_type": "query",
122
+ "batch_size": 64,
123
+ "extra_body": {}
124
+ }
125
+ }
126
+ ```
127
+
128
+ Use `api_key_env` instead of `api_key` to read the key from an environment variable. When an `embedding` block is present, the local model is never loaded. Switching to a provider with a different embedding dimension requires rebuilding existing indexes.
129
+
130
+ ## Security
131
+
132
+ Macrodata runs inside the agent's existing permission model. It uses only the tools you've already installed and approved — no external APIs (unless you opt into a remote embedding endpoint), no third-party skill downloads, no new attack surface. Scheduled tasks run through the same agent instance with the same permissions. The daemon is a simple cron runner that spawns the agent when reminders fire. All state is local markdown and JSON. Nothing phones home.
133
+
134
+ ## Development
135
+
136
+ ```sh
137
+ pnpm install
138
+ pnpm run lint # oxlint
139
+ pnpm run typecheck # oxlint --type-aware --type-check
140
+ pnpm run build # tsdown
141
+ pnpm run coverage # vitest run --coverage (100% gate)
142
+ ```
143
+
144
+ Requires Node `>=24.11.0` and pnpm `11.9.0`.
145
+
146
+ ## Inspiration
147
+
148
+ The layered-memory architecture is inspired by [Letta](https://www.letta.com/), and particularly the [Void](https://cameron.stream/blog/void/) bot by Cameron Pfiffer. The ambient-compute and dream-time concepts are inspired by [Strix](https://timkellogg.me/blog/2025/12/15/strix) by Tim Kellogg. These ideas were first explored by Matt Kane in his Acme agent and released as [ascorbic/macrodata](https://github.com/ascorbic/macrodata).
149
+
150
+ ## License
151
+
152
+ [MIT](./LICENSE) © [Matt Kane](https://github.com/ascorbic) (original author) and [Vinayak Kulkarni](https://vinayakkulkarni.dev) (fork).
package/USAGE.md ADDED
@@ -0,0 +1,59 @@
1
+ ## How to Use Macrodata Memory
2
+
3
+ You have access to Macrodata: persistent, layered memory that you control. You can remember who the user is, what they're working on, and how they like to work. You log observations to a journal, maintain entity files, keep a small set of state files that are always in context, and raise flags when something needs the user's attention.
4
+
5
+ The layers have different jobs. State is the small, always-present working set. The journal and entities are your durable long-term memory — reliable and searchable, not in context until you pull them. Put each thing where it belongs and trust the system to hold it. You do not need to keep everything in front of you to avoid losing it; that's what the journal is for.
6
+
7
+ ### State Files
8
+
9
+ These are injected into context every session, so space is scarce and **each file has a budget** (a screenful — a few hundred words). Anything over budget is **truncated at injection** with a marker, so an oversized file doesn't help you — it just hides its own tail. Treat state as a _working set_, not a log: only what's live and load-bearing _right now_.
10
+
11
+ This means eviction is part of the job, not a failure. When an item is resolved, gone quiet, or aging into history, **move the detail to the journal or an entity file and cut it down to a one-line pointer, or drop it**. Pruning state is not losing information — the information lives in the journal/entities; you're just clearing your desk. A state file you only ever append to is broken.
12
+
13
+ **`state/today.md`** — focus for the session. Current priorities, what you're on right now, brief carryover. Roll it over each day; yesterday's detail goes to the journal.
14
+
15
+ **`state/workspace.md`** — active projects and open threads, each as a _one-line_ status with a pointer (`entities/projects/x.md`, or "search: <term>") for the detail. Not a project history — the history is in entities and the journal.
16
+
17
+ **`state/flags.md`** — things that need the user. See below.
18
+
19
+ **`state/human.md`** — who the user is: preferences, communication style, work context, timezone. Update when you learn something durable about them.
20
+
21
+ **`state/identity.md`** — who _you_ are: persona, values, learned patterns, how you operate. Mostly stable; revise during reflection or when your values genuinely shift.
22
+
23
+ ### Flags — reaching the user
24
+
25
+ `state/flags.md` is how something crosses from your autonomous/background runs into the user's next interactive session. If you find a bug, hit a decision only they can make, or finish something awaiting their review — and you can't act on it yourself — it goes here. **Without this, your findings die in a file they never open.** That's the single most important thing this memory does: surface the signal.
26
+
27
+ Keep each flag to **one line + a pointer** to the journal/entity holding the full writeup. Don't restate the whole investigation here every run — that's the hoarding trap. Clear flags when they're addressed or no longer true; a stale flag list is noise and the user stops reading it.
28
+
29
+ ### Entities
30
+
31
+ Create `entities/{type}/{name}.md` for persistent knowledge that deserves its own file — and **this is where project/topic detail belongs**, not state. They're indexed for semantic search; a list of them is kept in working memory, but you read/create/maintain them proactively. This is your filing system; keep it organized and let it grow. During distillation you review and consolidate.
32
+
33
+ **When to create one:**
34
+
35
+ - Significant details about a person → `entities/people/name.md`
36
+ - A project with enough context to track → `entities/projects/name.md`
37
+ - A topic you've researched in depth → `entities/topics/name.md`
38
+ - Anything long-form or worth maintaining → `entities/{category}/name.md`
39
+
40
+ **Create new categories freely** — just make the directory.
41
+
42
+ ### Journal — your durable memory
43
+
44
+ `log_journal(topic, content)` is reliable long-term storage: append-only, timestamped, and **retrievable via `search_memory`**. Writing something to the journal is _remembering_ it, not forgetting it — searching is how recall normally works, not a last resort. So log freely and in detail: decisions and why, things learned, events, debugging traces, the full version of anything you're about to compress out of state. Use entity files instead when you expect to _update_ the thing later; the journal is for point-in-time records.
45
+
46
+ ### Search
47
+
48
+ `search_memory` finds context across entities and journal. Search before saying you don't know something — it may already be in your memory. Stuck on a problem? Search for how you handled similar ones. Recall is a normal first move, not a fallback.
49
+
50
+ ### Quick Reference
51
+
52
+ | What you have | Where it goes |
53
+ | ------------------------------ | ---------------------------------------------- |
54
+ | Needs the user's eyes/action | `state/flags.md` (one line + pointer) |
55
+ | Live, load-bearing right now | State file (briefly) |
56
+ | Persistent, evolving knowledge | Entity file |
57
+ | Point-in-time record / detail | Journal entry |
58
+ | Resolved or aging item | Out of state → journal/entity, leave a pointer |
59
+ | Future / recurring task | `schedule` |
@@ -0,0 +1,53 @@
1
+ #!/bin/bash
2
+ #
3
+ # Detect user information for onboarding
4
+ # Outputs JSON with all detected info
5
+ #
6
+
7
+ # System info
8
+ USERNAME=$(whoami)
9
+ FULLNAME=$(id -F 2>/dev/null || getent passwd "$USERNAME" 2>/dev/null | cut -d: -f5 | cut -d, -f1 || echo "")
10
+
11
+ # Timezone
12
+ if [ -f /etc/timezone ]; then
13
+ TIMEZONE=$(cat /etc/timezone)
14
+ elif [ -L /etc/localtime ]; then
15
+ TIMEZONE=$(readlink /etc/localtime | sed 's|.*/zoneinfo/||')
16
+ else
17
+ TIMEZONE=""
18
+ fi
19
+
20
+ # Git config
21
+ GIT_NAME=$(git config --global user.name 2>/dev/null || echo "")
22
+ GIT_EMAIL=$(git config --global user.email 2>/dev/null || echo "")
23
+
24
+ # GitHub CLI (if authenticated)
25
+ if command -v gh &>/dev/null; then
26
+ GH_INFO=$(gh api user --jq '{login: .login, name: .name, blog: .blog, bio: .bio}' 2>/dev/null || echo "{}")
27
+ else
28
+ GH_INFO="{}"
29
+ fi
30
+
31
+ # Code directories that exist
32
+ CODE_DIRS=""
33
+ for dir in ~/Repos ~/repos ~/Code ~/code ~/Projects ~/projects ~/Developer ~/dev ~/src; do
34
+ if [ -d "$dir" ]; then
35
+ CODE_DIRS="$CODE_DIRS\"$dir\","
36
+ fi
37
+ done
38
+ CODE_DIRS="[${CODE_DIRS%,}]"
39
+
40
+ # Output as JSON
41
+ cat <<EOF
42
+ {
43
+ "username": "$USERNAME",
44
+ "fullName": "$FULLNAME",
45
+ "timezone": "$TIMEZONE",
46
+ "git": {
47
+ "name": "$GIT_NAME",
48
+ "email": "$GIT_EMAIL"
49
+ },
50
+ "github": $GH_INFO,
51
+ "codeDirs": $CODE_DIRS
52
+ }
53
+ EOF
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Index conversations incrementally
4
+ *
5
+ * Called by hooks at session end / after compact to keep the conversation index fresh.
6
+ */
7
+
8
+ import { updateConversationIndex } from "../src/conversations.js";
9
+
10
+ export async function main(
11
+ update: typeof updateConversationIndex = updateConversationIndex,
12
+ ): Promise<number> {
13
+ try {
14
+ const result = await update();
15
+ console.log(
16
+ `Indexed conversations: ${result.filesUpdated} updated, ${result.skipped} skipped, ${result.exchangeCount} total`,
17
+ );
18
+ return 0;
19
+ } catch (err) {
20
+ console.error("Failed to index conversations:", err);
21
+ return 1;
22
+ }
23
+ }
24
+
25
+ export function isRunAsMain(argv1: string | undefined, moduleUrl: string): boolean {
26
+ return Boolean(argv1) && moduleUrl === `file://${argv1}`;
27
+ }
28
+
29
+ /* v8 ignore next 3 -- entry-point glue: only runs when this file is the process
30
+ entry (node dist/bin/index-conversations.js), a subprocess vitest cannot
31
+ instrument. main() and isRunAsMain() are covered directly. */
32
+ if (isRunAsMain(process.argv[1], import.meta.url)) {
33
+ void main().then((code) => process.exit(code));
34
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Macrodata Local Daemon entry point.
4
+ *
5
+ * All logic lives in `src/daemon.ts` (importable and unit-tested). This file is
6
+ * a thin CLI wrapper so the compiled `dist/bin/macrodata-daemon.js` stays the
7
+ * spawn target for the plugin and hook script.
8
+ *
9
+ * Usage:
10
+ * MACRODATA_ROOT=~/.config/macrodata node macrodata-daemon.js
11
+ *
12
+ * Environment:
13
+ * MACRODATA_AGENT=opencode|claude (default: auto-detect)
14
+ * MACRODATA_ROOT=/path/to/state
15
+ */
16
+
17
+ import { runDaemon } from "../src/daemon.js";
18
+
19
+ export function isRunAsMain(argv1: string | undefined, moduleUrl: string): boolean {
20
+ return Boolean(argv1) && moduleUrl === `file://${argv1}`;
21
+ }
22
+
23
+ /* v8 ignore next 3 -- entry-point glue: only runs when this file is the process
24
+ entry (node dist/bin/macrodata-daemon.js), a subprocess vitest cannot
25
+ instrument. runDaemon() is covered directly in the daemon tests. */
26
+ if (isRunAsMain(process.argv[1], import.meta.url)) {
27
+ runDaemon();
28
+ }
@@ -0,0 +1,277 @@
1
+ #!/bin/bash
2
+ #
3
+ # Macrodata Local Hook Script
4
+ #
5
+ # Usage:
6
+ # macrodata-hook.sh session-start - Launch daemon if not running, inject context
7
+ # macrodata-hook.sh prompt-submit - Check daemon, inject pending context
8
+ #
9
+
10
+ # Get the directory where this script lives
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ # Resolve compiled daemon: alongside when run from dist/bin, else ../dist/bin.
13
+ if [ -f "$SCRIPT_DIR/macrodata-daemon.js" ]; then
14
+ DAEMON="$SCRIPT_DIR/macrodata-daemon.js"
15
+ else
16
+ DAEMON="$SCRIPT_DIR/../dist/bin/macrodata-daemon.js"
17
+ fi
18
+
19
+ # State directory (configurable via MACRODATA_ROOT, config file, or defaults to ~/.config/macrodata)
20
+ DEFAULT_ROOT="$HOME/.config/macrodata"
21
+ CONFIG_FILE="$DEFAULT_ROOT/config.json"
22
+ if [ -n "$MACRODATA_ROOT" ]; then
23
+ STATE_ROOT="$MACRODATA_ROOT"
24
+ elif [ -f "$CONFIG_FILE" ]; then
25
+ STATE_ROOT=$(jq -r '.root // empty' "$CONFIG_FILE" 2>/dev/null)
26
+ STATE_ROOT="${STATE_ROOT:-$DEFAULT_ROOT}"
27
+ else
28
+ STATE_ROOT="$DEFAULT_ROOT"
29
+ fi
30
+
31
+ # Output locations (PID file now follows STATE_ROOT for testing isolation)
32
+ PIDFILE="$STATE_ROOT/.daemon.pid"
33
+ PENDING_CONTEXT="$STATE_ROOT/.pending-context"
34
+ LOGFILE="$STATE_ROOT/.daemon.log"
35
+ JOURNAL_DIR="$STATE_ROOT/journal"
36
+
37
+ # State files
38
+ IDENTITY="$STATE_ROOT/state/identity.md"
39
+ TODAY="$STATE_ROOT/state/today.md"
40
+ HUMAN="$STATE_ROOT/state/human.md"
41
+ WORKSPACE="$STATE_ROOT/state/workspace.md"
42
+ FLAGS="$STATE_ROOT/state/flags.md"
43
+
44
+ # Per-section injection caps (bytes) — a backstop against runaway growth, not a
45
+ # tight corset. State is bounded working memory; a file over cap is truncated here
46
+ # with a marker. The skills keep files well under this; the cap only catches failures.
47
+ CAP_IDENTITY=4000
48
+ CAP_TODAY=4000
49
+ CAP_HUMAN=4000
50
+ CAP_WORKSPACE=4000
51
+ CAP_FLAGS=4000
52
+
53
+ is_daemon_running() {
54
+ if [ -f "$PIDFILE" ]; then
55
+ local pid=$(cat "$PIDFILE")
56
+ if kill -0 "$pid" 2>/dev/null; then
57
+ return 0
58
+ fi
59
+ fi
60
+ return 1
61
+ }
62
+
63
+ start_daemon() {
64
+ if is_daemon_running; then
65
+ return 0
66
+ fi
67
+
68
+ local NODE="node"
69
+ # Ensure state directory exists
70
+ mkdir -p "$STATE_ROOT"
71
+ # Start daemon in background, redirect output to log
72
+ # Note: daemon writes its own PID file, we don't write it here
73
+ MACRODATA_ROOT="$STATE_ROOT" nohup "$NODE" "$DAEMON" >> "$LOGFILE" 2>&1 &
74
+
75
+ # Wait briefly for daemon to write PID file (up to 2 seconds)
76
+ local attempts=0
77
+ while [ $attempts -lt 20 ]; do
78
+ sleep 0.1
79
+ if is_daemon_running; then
80
+ return 0
81
+ fi
82
+ attempts=$((attempts + 1))
83
+ done
84
+ }
85
+
86
+ signal_daemon_reload() {
87
+ if [ -f "$PIDFILE" ]; then
88
+ local pid=$(cat "$PIDFILE")
89
+ if kill -0 "$pid" 2>/dev/null; then
90
+ kill -HUP "$pid" 2>/dev/null
91
+ fi
92
+ fi
93
+ }
94
+
95
+ inject_pending_context() {
96
+ if [ -s "$PENDING_CONTEXT" ]; then
97
+ cat "$PENDING_CONTEXT"
98
+ : > "$PENDING_CONTEXT" # Clear the file
99
+ fi
100
+ }
101
+
102
+ get_recent_journal() {
103
+ local count="${1:-5}"
104
+
105
+ if [ ! -d "$JOURNAL_DIR" ]; then
106
+ return
107
+ fi
108
+
109
+ # Get most recent journal files and extract entries
110
+ local entries=""
111
+ for file in $(ls -t "$JOURNAL_DIR"/*.jsonl 2>/dev/null | head -3); do
112
+ if [ -f "$file" ]; then
113
+ # Get last N entries from each file, format as "- [topic] content"
114
+ entries="$entries$(tail -n "$count" "$file" 2>/dev/null | jq -r '"\n- [\(.topic)] \(.content | split("\n")[0])"' 2>/dev/null)"
115
+ fi
116
+ done
117
+
118
+ echo "$entries" | head -n "$count"
119
+ }
120
+
121
+ list_state_files() {
122
+ local files=""
123
+
124
+ # State files
125
+ if [ -d "$STATE_ROOT/state" ]; then
126
+ for f in "$STATE_ROOT/state"/*.md; do
127
+ [ -f "$f" ] && files="$files\n- state/$(basename "$f")"
128
+ done
129
+ fi
130
+
131
+ # Entity files (scan all subdirs dynamically)
132
+ if [ -d "$STATE_ROOT/entities" ]; then
133
+ for subdir in "$STATE_ROOT/entities"/*/; do
134
+ [ -d "$subdir" ] || continue
135
+ local subdir_name=$(basename "$subdir")
136
+ for f in "$subdir"*.md; do
137
+ [ -f "$f" ] && files="$files\n- entities/$subdir_name/$(basename "$f")"
138
+ done
139
+ done
140
+ fi
141
+
142
+ if [ -z "$files" ]; then
143
+ echo "_No files yet_"
144
+ else
145
+ echo -e "$files"
146
+ fi
147
+ }
148
+
149
+ get_usage() {
150
+ cat <<'USAGE'
151
+ - **state/** files are always here but BOUNDED (a screenful each, hard-capped). Keep only what's live; move detail out.
152
+ - **flags.md** is how things reach the user across sessions — one line + pointer. Surface, don't hoard.
153
+ - **journal** (`log_journal`) is durable and retrievable via `search_memory`. Writing it down is remembering, not forgetting — put detail here.
154
+ - **entities/** hold persistent per-project/topic notes (searchable). Eviction is expected: resolved/aging items leave state for the journal/entity, leaving a pointer.
155
+ - Search before claiming ignorance. Full guide: `plugins/macrodata/USAGE.md`.
156
+ USAGE
157
+ }
158
+
159
+ get_schedules() {
160
+ local reminders_dir="$STATE_ROOT/reminders"
161
+
162
+ if [ ! -d "$reminders_dir" ]; then
163
+ echo "_No active schedules_"
164
+ return
165
+ fi
166
+
167
+ local schedules=""
168
+ for f in "$reminders_dir"/*.json; do
169
+ [ -f "$f" ] || continue
170
+ local line=$(jq -r '"- \(.description) (\(.type): \(.expression))"' "$f" 2>/dev/null)
171
+ [ -n "$line" ] && schedules="$schedules$line\n"
172
+ done
173
+
174
+ if [ -z "$schedules" ]; then
175
+ echo "_No active schedules_"
176
+ else
177
+ echo -e "$schedules"
178
+ fi
179
+ }
180
+
181
+ cap_file() {
182
+ local file="$1" max="$2"
183
+ if [ ! -f "$file" ]; then echo "_Empty_"; return; fi
184
+ local size
185
+ size=$(wc -c < "$file" | tr -d ' ')
186
+ if [ "$size" -le "$max" ]; then
187
+ cat "$file"
188
+ else
189
+ head -c "$max" "$file" | sed -e '$ d'
190
+ printf '\n[…truncated: %s of %s bytes shown. This file is over its budget — compact it. Detail lives in the journal; use search_memory / get_recent_journal.]\n' "$max" "$size"
191
+ fi
192
+ }
193
+
194
+ inject_static_context() {
195
+ # For local plugin, we inject everything needed for a normal session
196
+ local CONTEXT_FILE="$STATE_ROOT/.claude-context.md"
197
+
198
+ # Build context content
199
+ local CONTEXT=""
200
+
201
+ # Check if this is first run (no identity file)
202
+ if [ ! -f "$IDENTITY" ]; then
203
+ # Detect user info to avoid multiple permission prompts during onboarding
204
+ local USER_INFO=$("$SCRIPT_DIR/detect-user.sh" 2>/dev/null || echo '{}')
205
+
206
+ CONTEXT="<macrodata>
207
+ <macrodata-first-run state-root=\"$STATE_ROOT\">
208
+ Macrodata local memory is not yet configured. Run \`/onboarding\` to set up.
209
+ </macrodata-first-run>
210
+
211
+ <macrodata-detected-user>
212
+ $USER_INFO
213
+ </macrodata-detected-user>
214
+ </macrodata>"
215
+ else
216
+ CONTEXT="<macrodata>
217
+ <macrodata-flags>
218
+ $([ -s "$FLAGS" ] && cap_file "$FLAGS" "$CAP_FLAGS" || echo "_No open flags_")
219
+ </macrodata-flags>
220
+
221
+ <macrodata-today>
222
+ $(cap_file "$TODAY" "$CAP_TODAY")
223
+ </macrodata-today>
224
+
225
+ <macrodata-identity>
226
+ $(cap_file "$IDENTITY" "$CAP_IDENTITY")
227
+ </macrodata-identity>
228
+
229
+ <macrodata-workspace>
230
+ $(cap_file "$WORKSPACE" "$CAP_WORKSPACE")
231
+ </macrodata-workspace>
232
+
233
+ <macrodata-journal>
234
+ $(get_recent_journal 3)
235
+ </macrodata-journal>
236
+
237
+ <macrodata-schedules>
238
+ $(get_schedules)
239
+ </macrodata-schedules>
240
+
241
+ <macrodata-usage>
242
+ $(get_usage)
243
+ </macrodata-usage>
244
+
245
+ <macrodata-files root=\"$STATE_ROOT\">
246
+ $(list_state_files)
247
+ </macrodata-files>
248
+ </macrodata>"
249
+ fi
250
+
251
+ # Write to file for global CLAUDE.md reference
252
+ mkdir -p "$STATE_ROOT"
253
+ echo "$CONTEXT" > "$CONTEXT_FILE"
254
+
255
+ # Also output to stdout for session context
256
+ echo "$CONTEXT"
257
+ }
258
+
259
+ case "$1" in
260
+ session-start)
261
+ start_daemon
262
+ signal_daemon_reload
263
+ inject_static_context
264
+ ;;
265
+ prompt-submit)
266
+ # Restart daemon if dead
267
+ start_daemon
268
+ # Inject only the daemon's targeted deltas. The full context is injected
269
+ # once at session-start and stays in the cached prefix; re-dumping it here
270
+ # would bloat the running context every turn and defeat prompt caching.
271
+ inject_pending_context
272
+ ;;
273
+ *)
274
+ echo "Usage: $0 {session-start|prompt-submit}" >&2
275
+ exit 1
276
+ ;;
277
+ esac
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { updateConversationIndex } from "../src/conversations.js";
3
+
4
+ //#region bin/index-conversations.ts
5
+ /**
6
+ * Index conversations incrementally
7
+ *
8
+ * Called by hooks at session end / after compact to keep the conversation index fresh.
9
+ */
10
+ async function main(update = updateConversationIndex) {
11
+ try {
12
+ const result = await update();
13
+ console.log(`Indexed conversations: ${result.filesUpdated} updated, ${result.skipped} skipped, ${result.exchangeCount} total`);
14
+ return 0;
15
+ } catch (err) {
16
+ console.error("Failed to index conversations:", err);
17
+ return 1;
18
+ }
19
+ }
20
+ function isRunAsMain(argv1, moduleUrl) {
21
+ return Boolean(argv1) && moduleUrl === `file://${argv1}`;
22
+ }
23
+ /* v8 ignore next 3 -- entry-point glue: only runs when this file is the process
24
+ entry (node dist/bin/index-conversations.js), a subprocess vitest cannot
25
+ instrument. main() and isRunAsMain() are covered directly. */
26
+ if (isRunAsMain(process.argv[1], import.meta.url)) {
27
+ void main().then((code) => process.exit(code));
28
+ }
29
+
30
+ //#endregion
31
+ export { isRunAsMain, main };