@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.
- package/LICENSE +22 -0
- package/README.md +152 -0
- package/USAGE.md +59 -0
- package/bin/detect-user.sh +53 -0
- package/bin/index-conversations.ts +34 -0
- package/bin/macrodata-daemon.ts +28 -0
- package/bin/macrodata-hook.sh +277 -0
- package/dist/bin/index-conversations.js +31 -0
- package/dist/bin/macrodata-daemon.js +30 -0
- package/dist/opencode/context.js +210 -0
- package/dist/opencode/conversations.js +367 -0
- package/dist/opencode/index.js +155 -0
- package/dist/opencode/journal.js +108 -0
- package/dist/opencode/logger.js +29 -0
- package/dist/opencode/search.js +210 -0
- package/dist/opencode/skills/macrodata-distill/SKILL.md +171 -0
- package/dist/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
- package/dist/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
- package/dist/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
- package/dist/opencode/tools.js +367 -0
- package/dist/src/config.js +55 -0
- package/dist/src/conversations.js +513 -0
- package/dist/src/daemon.js +582 -0
- package/dist/src/detect-user.js +73 -0
- package/dist/src/embeddings.js +190 -0
- package/dist/src/index.js +413 -0
- package/dist/src/indexer.js +286 -0
- package/opencode/context.ts +322 -0
- package/opencode/conversations.ts +467 -0
- package/opencode/index.ts +208 -0
- package/opencode/journal.ts +153 -0
- package/opencode/logger.ts +32 -0
- package/opencode/search.ts +288 -0
- package/opencode/skills/macrodata-distill/SKILL.md +171 -0
- package/opencode/skills/macrodata-dreamtime/SKILL.md +120 -0
- package/opencode/skills/macrodata-memory-maintenance/SKILL.md +96 -0
- package/opencode/skills/macrodata-onboarding/SKILL.md +346 -0
- package/opencode/tools.ts +453 -0
- package/package.json +87 -0
- package/src/config.ts +66 -0
- package/src/conversations.ts +709 -0
- package/src/daemon.ts +785 -0
- package/src/detect-user.ts +97 -0
- package/src/embeddings.ts +262 -0
- package/src/index.ts +726 -0
- 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
|
+
[](https://www.npmjs.com/package/@geoql/mdr)
|
|
6
|
+
[](https://jsr.io/@geoql/mdr)
|
|
7
|
+
[](https://github.com/geoql/mdr/actions/workflows/pipeline.yml)
|
|
8
|
+
[](https://codecov.io/gh/geoql/mdr)
|
|
9
|
+
[](./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 };
|