@vasilvestre/opencode-dream-memory 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,83 @@
1
+ // dream-memory — OpenCode plugin (v2).
2
+ //
3
+ // Injects shared memory plus the memory of the current project into the system
4
+ // prompt on every agent-loop LLM request, so it survives context compaction.
5
+ // AGENTS.md does not: it is summarized away when a session gets long.
6
+ //
7
+ // Files are read on each request, never cached, so a nightly rewrite shows up in
8
+ // the next turn. A missing or unreadable file contributes nothing: this hook
9
+ // runs on every request, and throwing here would break all of them.
10
+ //
11
+ // Layout under $DREAM_HOME (default ~/dreams):
12
+ // MEMORY.md facts true in every project
13
+ // projects/<name>/MEMORY.md facts about one project
14
+ //
15
+ // <name> is the base name of the project directory, the same tag dream-corpus
16
+ // writes into the corpus and dream-deep uses as a file name.
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ const HOME = process.env.DREAM_HOME || path.join(os.homedir(), 'dreams');
23
+
24
+ // Cut points, not writer budgets: dream-deep is told to stay below them (see
25
+ // dream/agents/dream-deep.md). Shared memory is injected into every session,
26
+ // so its share is the smaller one.
27
+ const ROOT_MAX = 1000;
28
+ const PROJECT_MAX = 3000;
29
+
30
+ const HEADER =
31
+ 'Cross-session memory, written by a nightly pass from earlier sessions and possibly stale. ' +
32
+ 'Trust the repository and the live system over this block.';
33
+
34
+ // Drop the title, cut on a line boundary, and say what was dropped. A hard byte
35
+ // cut would drop a fact without telling the model.
36
+ function read(file, max, label) {
37
+ let text;
38
+ try {
39
+ text = fs.readFileSync(file, 'utf8');
40
+ } catch {
41
+ return '';
42
+ }
43
+ const body = text
44
+ .split('\n')
45
+ .filter((line) => !line.startsWith('# '))
46
+ .join('\n')
47
+ .trim();
48
+ if (!body) return '';
49
+ if (body.length <= max) return body;
50
+ const cut = body.lastIndexOf('\n', max);
51
+ const kept = cut > 0 ? cut : max;
52
+ return (
53
+ `${body.slice(0, kept)}\n` +
54
+ `[${label} truncated: ${body.length - kept} characters dropped, ` +
55
+ `prune ${path.relative(HOME, file)} to fit]`
56
+ );
57
+ }
58
+
59
+ function memoryBlock(directory) {
60
+ const parts = [read(path.join(HOME, 'MEMORY.md'), ROOT_MAX, 'memory')];
61
+ const name = path.basename(directory || '');
62
+ if (name && name !== '.' && name !== '..' && !name.startsWith('.')) {
63
+ const body = read(path.join(HOME, 'projects', name, 'MEMORY.md'), PROJECT_MAX, `${name} memory`);
64
+ if (body) parts.push(`Project memory (${name}):\n${body}`);
65
+ }
66
+ const body = parts.filter(Boolean).join('\n\n');
67
+ return body ? `${HEADER}\n\n${body}` : '';
68
+ }
69
+
70
+ export default {
71
+ id: 'vasilvestre.opencode-dream-memory',
72
+ async setup(ctx) {
73
+ // OpenCode creates one plugin instance per project location, so the project
74
+ // is known before a session exists. Worktrees share their canonical path.
75
+ const location = ctx.location ?? {};
76
+ const directory =
77
+ location.project?.canonical || location.project?.directory || location.directory;
78
+ await ctx.session.hook('context', (event) => {
79
+ const block = memoryBlock(directory);
80
+ if (block) event.system.push({ type: 'text', text: block });
81
+ });
82
+ },
83
+ };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vasilvestre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,207 @@
1
- # Temporary Holding Version
1
+ # opencode-dream-memory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Two pieces that give an OpenCode agent memory across sessions:
4
+
5
+ 1. **A plugin** that injects memory files into the system prompt of every
6
+ request, so the memory survives context compaction.
7
+ 2. **A nightly job** that writes those files from past sessions.
8
+
9
+ `AGENTS.md` does not survive compaction. When a session gets long, OpenCode
10
+ summarizes the conversation and the instructions file goes with it. A plugin
11
+ hook runs on every agent-loop request, so the same block is re-sent each turn.
12
+
13
+ ## Install
14
+
15
+ Requirements: `opencode` and `jq` on `PATH`, `curl` or `wget` to download, and
16
+ systemd for the timer.
17
+
18
+ ```bash
19
+ curl -fsSL https://raw.githubusercontent.com/vasilvestre/opencode-dream-memory/main/install.sh | sh
20
+ ```
21
+
22
+ One command installs both pieces. The plugin lands in
23
+ `~/.config/opencode/plugins`, the job in `$BIN_DIR` (default `~/.local/bin`)
24
+ next to its two agents and two systemd user units. Then restart the service to
25
+ load the plugin:
26
+
27
+ ```bash
28
+ opencode service restart
29
+ ```
30
+
31
+ Do **not** also list the plugin file path in `plugins` in `opencode.jsonc`:
32
+ OpenCode warns that a configured plugin path must be a directory, and a path
33
+ entry placed next to a discovered copy loads the plugin twice. Two instances
34
+ with the same id make OpenCode drop every plugin.
35
+
36
+ The installer leaves the nightly timer disabled on purpose. `dream` spends two
37
+ agent runs per invocation, and a one-line install cannot ask a question, so a
38
+ job that spends money must not start by itself. Enable it once a manual run
39
+ looks right, with `-s --` passing the flag to the script:
40
+
41
+ ```bash
42
+ curl -fsSL https://raw.githubusercontent.com/vasilvestre/opencode-dream-memory/main/install.sh | sh -s -- --enable
43
+ ```
44
+
45
+ The installer also creates `~/dreams` with `MEMORY.md`, an empty `projects/`
46
+ tree, `DREAMS.md`, an empty project list and a commented `dream-models` file. It
47
+ rewrites the unit's `ExecStart` to the chosen `BIN_DIR`, and it never overwrites
48
+ `MEMORY.md`, `DREAMS.md`, `dream-projects`, `dream-models` or a project memory
49
+ file. An existing symlink at
50
+ `~/.config/opencode/plugins/dream-memory.js` stays as it is, so a clone that
51
+ symlinks its own copy keeps working.
52
+
53
+ The deep pass leaves an audit trail: `dreaming/deep/<date>.md` lists each
54
+ promoted fact with the light-report line that supports it, so a fact stays
55
+ checkable after the corpus is gone.
56
+
57
+ If `opencode` is not on the systemd default `PATH`, write its directory to
58
+ `~/.config/dream.env`, which the unit reads when present:
59
+
60
+ ```bash
61
+ echo 'PATH=/opt/opencode/bin:/usr/local/bin:/usr/bin:/bin' > ~/.config/dream.env
62
+ ```
63
+
64
+ ### From a clone instead
65
+
66
+ ```bash
67
+ git clone https://github.com/vasilvestre/opencode-dream-memory
68
+ cd opencode-dream-memory
69
+ sh install.sh # install, timer stays disabled
70
+ sh install.sh --enable # install, then enable and start the 04:30 timer
71
+ ```
72
+
73
+ The clone itself is what to symlink when you want the plugin to track edits:
74
+
75
+ ```bash
76
+ ln -sf "$PWD/.opencode/plugins/dream-memory.js" ~/.config/opencode/plugins/dream-memory.js
77
+ ```
78
+
79
+ ## Memory layout
80
+
81
+ ```
82
+ ~/dreams/
83
+ MEMORY.md shared facts, injected in every session
84
+ projects/homelab/MEMORY.md one project's facts, injected there only
85
+ projects/<name>/MEMORY.md
86
+ DREAMS.md the diary
87
+ dreaming/{light,deep}/<date>.md staging and audit reports
88
+ .dreams/ corpus and cursor, gitignored
89
+ ```
90
+
91
+ Only `.dreams/` is gitignored. `MEMORY.md`, `projects/` and `dreaming/` stay
92
+ tracked, so a `$DREAM_HOME` git repo keeps a history of every promotion.
93
+
94
+ ## What the plugin injects
95
+
96
+ Two files, read fresh on every request:
97
+
98
+ | File | Content | Cut at |
99
+ |---|---|---|
100
+ | `~/dreams/MEMORY.md` | facts true in every project | 1 000 characters |
101
+ | `~/dreams/projects/<name>/MEMORY.md` | facts about the current project | 3 000 characters |
102
+
103
+ `<name>` is the base name of the project directory: `~/homelab` reads
104
+ `projects/homelab/MEMORY.md`. The corpus uses the same tag, so a candidate
105
+ lands in the file of the project it came from. OpenCode creates one plugin
106
+ instance per project location, so the hook knows which project it serves; a
107
+ worktree uses the name of its main repository.
108
+
109
+ The block opens with a header saying the content comes from a nightly pass and
110
+ may be stale. Top-level titles are dropped. A part that is missing, empty or
111
+ unreadable contributes nothing: the hook runs on every request, so it fails
112
+ silent instead of breaking the session. Move the whole tree with `DREAM_HOME`.
113
+
114
+ Nothing is injected when both parts are empty.
115
+
116
+ ## Privacy
117
+
118
+ The corpus is built from your real sessions. Each message contributes up to 400
119
+ characters, and the whole corpus is sent to whichever model `dream` runs.
120
+
121
+ - Inspect `~/dreams/.dreams/session-corpus/<date>.txt` before enabling the
122
+ timer, and again after any session where you pasted something sensitive.
123
+ - The corpus skips the dream's own sessions, so the job cannot feed on its own
124
+ output. It also redacts labelled secrets (`API_KEY=`, `token:`, `password=`,
125
+ `secret=`, `bearer ...`). A bare key with no label still gets through, so this
126
+ is a net, not a guarantee.
127
+ - `opencode session export --sanitize` is not an option: it replaces every
128
+ message with `[redacted:text:...]`, which makes the corpus useless.
129
+ - For a private setup, set a local model in `~/.config/opencode/dream-models`
130
+ (see below), or set `DREAM_HOME` on an encrypted volume and skip the timer.
131
+ - The dream job is instructed never to write secrets into a memory file, and the
132
+ plugin injects those files into every request. A secret that reaches one is
133
+ re-sent on every turn.
134
+ - Before the deep pass, `dream` commits `$DREAM_HOME` when it is a git repo, so
135
+ a bad rewrite is one `git revert` away. The corpus is excluded from that
136
+ commit even when your `.gitignore` forgets it.
137
+
138
+ ### Which model runs each stage
139
+
140
+ `dream` reads `~/.config/opencode/dream-models`, one line per stage. The first
141
+ word is the stage, the rest is a list of models:
142
+
143
+ ```
144
+ light opencode-go/deepseek-v4.1-flash
145
+ deep opencode-go/deepseek-v4-pro
146
+ ```
147
+
148
+ The script tries the models left to right and keeps the first that runs, so a
149
+ fallback after the first choice is fine. A stage with no line uses the script's
150
+ own defaults: `deepseek-v4.1-flash` for `light`, which reads the raw session
151
+ text, and `deepseek-v4-pro` for `deep`, which only sees staged candidates. Both
152
+ are paid models on purpose. A free model may train on its input, and a nightly
153
+ job that silently falls back to one sends your session text there.
154
+
155
+ Do not set the model in `dream/agents/*.md` for the job: `dream` passes `-m`,
156
+ and that flag beats the agent's pinned model. Override the file path with
157
+ `DREAM_MODELS`.
158
+
159
+ ## Limits
160
+
161
+ - Each part is cut on its own: 1 000 characters for shared memory, 3 000 for a
162
+ project file. Past that, the tail is dropped on a line boundary and the block
163
+ says how much was dropped.
164
+ - `dream-deep` is told to keep the files under 900 and 2 800 characters, below
165
+ the cuts. It deletes older entries to stay there, because a memory file that
166
+ only grows eventually loses its own tail.
167
+ - One project's memory cannot squeeze out another's: the files share no budget.
168
+ - A run takes a lock on `$DREAM_HOME/.dreams/run.lock`. A second run exits at
169
+ once instead of interleaving writes, so the 04:30 timer cannot collide with a
170
+ manual run.
171
+ - The files are read on each request, never cached, so a nightly rewrite shows
172
+ up in the next turn at the cost of two small reads.
173
+
174
+ ## Commands
175
+
176
+ | Command | What it does |
177
+ |---|---|
178
+ | `dream` | builds the corpus, then the light and deep passes |
179
+ | `dream corpus` | only the corpus, no model, no cost |
180
+ | `dream status` | one screen of state, read-only |
181
+ | `dream reset` | deletes memory, diary, staging and corpus under `DREAM_HOME` |
182
+
183
+ `dream reset` asks for confirmation; add `--yes` to skip it. It never touches
184
+ your projects, and it refuses a `DREAM_HOME` that does not look like a dream
185
+ home. When `$DREAM_HOME` is a git repo, the preimage commits keep the old
186
+ content one `git checkout` away.
187
+
188
+ `dream-corpus` and `dream-status` stay installed as standalone commands; they do
189
+ the same thing as the two subcommands.
190
+
191
+ ## Test
192
+
193
+ ```bash
194
+ npm test
195
+ ```
196
+
197
+ The plugin checks: the hook registers, missing files inject nothing, shared
198
+ memory alone injects one block, the current project's file is appended after it
199
+ while another project's stays out, an oversized file is truncated without the
200
+ whole block growing, and titles are dropped. Then the corpus checks: the dream's
201
+ own sessions stay out, a labelled secret is redacted, a torn session export is
202
+ skipped instead of fatal, and a git data directory gets a preimage commit before
203
+ the deep pass.
204
+
205
+ ## Licence
206
+
207
+ MIT.
@@ -0,0 +1,107 @@
1
+ ---
2
+ description: Promotes staged memory candidates and writes the dream diary
3
+ mode: primary
4
+ model: opencode-go/deepseek-v4-pro
5
+ permissions:
6
+ - action: "*"
7
+ resource: "*"
8
+ effect: deny
9
+ - action: read
10
+ resource: "*"
11
+ effect: allow
12
+ - action: glob
13
+ resource: "*"
14
+ effect: allow
15
+ - action: grep
16
+ resource: "*"
17
+ effect: allow
18
+ - action: edit
19
+ resource: "MEMORY.md"
20
+ effect: allow
21
+ - action: edit
22
+ resource: "projects/*/MEMORY.md"
23
+ effect: allow
24
+ - action: edit
25
+ resource: "DREAMS.md"
26
+ effect: allow
27
+ - action: edit
28
+ resource: "dreaming/deep/*"
29
+ effect: allow
30
+ ---
31
+
32
+ You are the deep-sleep pass of a session-memory system. You turn staged
33
+ candidates into durable memory, then write the diary.
34
+
35
+ Input: `dreaming/light/<today>.md`.
36
+
37
+ ## 1. Promote
38
+
39
+ Promote a candidate only when it is durable: a preference or rule the user
40
+ stated, a correction they made, a verified fact about their machines, or the
41
+ cause of a failure likely to repeat.
42
+
43
+ Two destinations:
44
+
45
+ - `MEMORY.md` holds facts that are true in every project: how the user wants
46
+ work done, their general preferences. Keep it under **900 characters**.
47
+ - `projects/<project>/MEMORY.md` holds facts about one project. The
48
+ `project:` line of the candidate gives the name: `project: homelab` means
49
+ `projects/homelab/MEMORY.md`. Keep each file under **2 800 characters**.
50
+
51
+ The plugin injects shared memory into every session, and the current project's
52
+ file only. It cuts shared memory at 1 000 characters and a project file at
53
+ 3 000, so a longer file loses its tail silently at 04:30. When an addition
54
+ would exceed a budget, delete or merge older lines in that same file until it
55
+ fits. Deleting a stale fact is part of this pass, not an exception to it.
56
+
57
+ - Add or update the entry in place. Never duplicate one.
58
+ - Remove entries this candidate makes false. A wrong memory is worse than a
59
+ missing one.
60
+ - Keep each file curated: short lines, one fact each, no chronology.
61
+ - Write a fact about a machine, a repository or a setup into that project's
62
+ file, not into shared memory.
63
+ - If shared memory holds a fact about one project, move it into that project's
64
+ file and delete it from shared memory.
65
+ - Never write a secret, a token, or a password.
66
+ - If a project file does not exist, create it with a `# <project>` title. Never
67
+ create one for a project that has no candidate today.
68
+
69
+ ## 2. Report
70
+
71
+ Write `dreaming/deep/<today>.md`:
72
+
73
+ ```
74
+ # Deep Sleep
75
+
76
+ - Ranked <n> candidate(s) for durable promotion.
77
+ - Promoted <n> candidate(s) into memory.
78
+ - Removed <n> stale entr(y|ies).
79
+ - Pruned <n> candidate(s) as one-off.
80
+ - Changed: MEMORY.md, projects/<name>/MEMORY.md.
81
+
82
+ ## Sources
83
+
84
+ - <short fact promoted> (dreaming/light/<today>.md:<line>)
85
+ - <another fact> (dreaming/light/<today>.md:<line>)
86
+ ```
87
+
88
+ List one line per promoted fact, with the line of the light report that
89
+ supports it. The corpus is deleted eventually; this report is what lets a
90
+ reader check a fact afterwards. This is an audit trail, not memory: never add
91
+ these references to a memory file.
92
+
93
+ ## 3. Dream
94
+
95
+ Append one entry to `DREAMS.md`, above the existing entries, after the
96
+ `<!-- opencode:dreaming:diary:start -->` marker.
97
+
98
+ If an entry dated today already exists, rewrite that entry instead of adding a
99
+ second one for the same day.
100
+
101
+ The entry is dated (`*October 21, 2026*`), separated by `---`, and written as
102
+ first-person reflection on the day: what was tried, what resisted, what
103
+ changed. It is a diary, not a report: images are welcome, code is not. Between
104
+ 120 and 200 words. No secrets, no file dumps, no task list.
105
+
106
+ If nothing happened today, still write two sentences saying so, in the same
107
+ voice.
@@ -0,0 +1,54 @@
1
+ ---
2
+ description: Extracts staged memory candidates from a session corpus
3
+ mode: primary
4
+ model: opencode-go/deepseek-v4.1-flash
5
+ permissions:
6
+ - action: "*"
7
+ resource: "*"
8
+ effect: deny
9
+ - action: read
10
+ resource: "*"
11
+ effect: allow
12
+ - action: glob
13
+ resource: "*"
14
+ effect: allow
15
+ - action: grep
16
+ resource: "*"
17
+ effect: allow
18
+ - action: edit
19
+ resource: "dreaming/light/*"
20
+ effect: allow
21
+ ---
22
+
23
+ You are the light-sleep pass of a session-memory system. You read what the user
24
+ did today and stage candidate memories. You do not promote anything.
25
+
26
+ Input: `.dreams/session-corpus/<today>.txt`. Each line is one message of one
27
+ session, numbered from 1, prefixed with the project it came from
28
+ (`[homelab] User: ...`).
29
+
30
+ Output: `dreaming/light/<today>.md`, starting with `# Light Sleep`.
31
+
32
+ For each candidate, write:
33
+
34
+ ```
35
+ - Candidate: <one sentence, under 200 characters>
36
+ - project: <the tag from the corpus>
37
+ - confidence: <0.00 to 1.00>
38
+ - evidence: .dreams/session-corpus/<today>.txt:<line>
39
+ - recalls: 0
40
+ - status: staged
41
+ ```
42
+
43
+ Rules:
44
+
45
+ - At most 15 candidates. Keep the ones a future session would actually need.
46
+ - Prefer what concerns the user: their preferences, corrections, decisions,
47
+ recurring problems, and verified facts about their machines.
48
+ - Ignore project documentation that already lives in the repository.
49
+ - Never record secrets, tokens, passwords, API keys, or file contents that
50
+ look like credentials. If the corpus holds one, do not quote it.
51
+ - Skip anything already written in `MEMORY.md`, in `projects/<project>/MEMORY.md`
52
+ or in a previous light file.
53
+ - If the corpus is empty or holds nothing durable, write only the heading and
54
+ the line `- No candidates today.`
package/dream/dream ADDED
@@ -0,0 +1,149 @@
1
+ #!/bin/sh
2
+ # Nightly session-memory pass.
3
+ #
4
+ # Usage: dream corpus, then the light and deep passes (two agent runs)
5
+ # dream corpus corpus only, no model, no cost
6
+ # dream status one screen of state, read-only
7
+ # dream reset delete memory, diary, staging and corpus (asks first)
8
+ #
9
+ # Everything is written under $DREAM_HOME (default ~/dreams), never in the
10
+ # projects themselves: this is the agent's memory of the user, not project docs.
11
+ # Shared facts land in MEMORY.md, project facts in projects/<name>/MEMORY.md.
12
+ set -eu
13
+
14
+ # Resolve next to this script, so a custom BIN_DIR works without a PATH.
15
+ here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
16
+ root="${DREAM_HOME:-$HOME/dreams}"
17
+
18
+ # One entry point. The standalone scripts stay installed and delegated to.
19
+ case "${1:-}" in
20
+ status) exec "$here/dream-status" ;;
21
+ corpus) exec "$here/dream-corpus" ;;
22
+ reset)
23
+ # Only $DREAM_HOME, and only when it looks like a dream home: a mistyped
24
+ # DREAM_HOME must not delete something else.
25
+ case $root in ''|/|"$HOME"|"$HOME/"|.) echo "dream: refusing to reset '$root'" >&2; exit 1 ;; esac
26
+ if [ -e "$root" ] && [ ! -d "$root/.dreams" ] && [ ! -d "$root/dreaming" ]; then
27
+ echo "dream: $root does not look like a dream home, refusing" >&2
28
+ exit 1
29
+ fi
30
+ if [ "${2:-}" != "--yes" ]; then
31
+ printf 'Delete every memory, diary and corpus under %s? [y/N] ' "$root" >&2
32
+ read -r answer || answer=
33
+ case $answer in y|Y|yes) ;; *) echo "dream: reset cancelled" >&2; exit 1 ;; esac
34
+ fi
35
+ rm -rf "$root/MEMORY.md" "$root/DREAMS.md" "$root/dreaming" "$root/projects" "$root/.dreams"
36
+ mkdir -p "$root/dreaming/light" "$root/dreaming/deep" "$root/projects" "$root/.dreams/session-corpus"
37
+ cat > "$root/MEMORY.md" <<'EOF'
38
+ # Shared memory
39
+
40
+ Facts true in every project, one per line. Project facts live in
41
+ `projects/<project>/MEMORY.md`.
42
+ EOF
43
+ cat > "$root/DREAMS.md" <<'EOF'
44
+ # Dream Diary
45
+
46
+ One entry per night, newest first. Durable facts live in [MEMORY.md](MEMORY.md).
47
+
48
+ <!-- opencode:dreaming:diary:start -->
49
+ EOF
50
+ echo "dream: reset done, $root is empty" >&2
51
+ exit 0
52
+ ;;
53
+ '') ;;
54
+ *) echo "dream: unknown command '$1' (try: dream, dream status, dream corpus, dream reset)" >&2; exit 2 ;;
55
+ esac
56
+
57
+ list="${DREAM_PROJECTS:-$HOME/.config/opencode/dream-projects}"
58
+ day=$(date +%F)
59
+ # One run at a time: two overlapping passes interleave writes and commits, and
60
+ # the 04:30 timer can fire while a manual run still spends its two models.
61
+ mkdir -p "$root/.dreams"
62
+ if command -v flock >/dev/null 2>&1; then
63
+ exec 9>"$root/.dreams/run.lock"
64
+ flock -n 9 || { echo "dream: another run is in progress" >&2; exit 0; }
65
+ fi
66
+ command -v opencode >/dev/null || { echo "dream: opencode not on PATH" >&2; exit 1; }
67
+ [ -r "$list" ] || { echo "dream: no project list at $list" >&2; exit 1; }
68
+
69
+ corpus_bin="${DREAM_CORPUS_BIN:-$here/dream-corpus}"
70
+ "$corpus_bin"
71
+
72
+ corpus="$root/.dreams/session-corpus/$day.txt"
73
+ [ -s "$corpus" ] || { echo "dream: empty corpus, nothing to do" >&2; exit 0; }
74
+
75
+ mkdir -p "$root/dreaming/light" "$root/dreaming/deep"
76
+ cd "$root"
77
+
78
+ # One memory file per tracked project, so the deep pass only ever edits files
79
+ # that exist. Named after the project directory, the same tag dream-corpus
80
+ # writes into the corpus.
81
+ while IFS= read -r project; do
82
+ case $project in ''|\#*) continue ;; esac
83
+ name=$(basename "$project")
84
+ case $name in ''|.|..) continue ;; esac
85
+ memory="$root/projects/$name/MEMORY.md"
86
+ [ -f "$memory" ] && continue
87
+ mkdir -p "$(dirname "$memory")"
88
+ printf '# %s\n' "$name" > "$memory"
89
+ done < "$list"
90
+
91
+ # Per-stage model choice: "<stage> <model> [<model> ...]", first that works wins.
92
+ # The light pass reads raw session text, so this is where a local or paid
93
+ # provider matters. Missing file or missing stage falls back to the paid
94
+ # defaults below. Never fall back to a free model: sending the corpus to a
95
+ # provider that trains on its input is a downgrade nobody asked for.
96
+ models="${DREAM_MODELS:-$HOME/.config/opencode/dream-models}"
97
+
98
+ default_models() {
99
+ case $1 in
100
+ deep) echo "opencode-go/deepseek-v4-pro opencode-go/deepseek-v4.1-flash" ;;
101
+ *) echo "opencode-go/deepseek-v4.1-flash opencode-go/deepseek-v4-pro" ;;
102
+ esac
103
+ }
104
+
105
+ models_for() {
106
+ found=
107
+ if [ -r "$models" ]; then
108
+ found=$(awk -v stage="$1" '$1 == stage { sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+/, ""); print; exit }' "$models")
109
+ fi
110
+ printf '%s\n' "${found:-$(default_models "$1")}"
111
+ }
112
+
113
+ # --agent does not carry the model: a session keeps its own selected model, so
114
+ # pass -m explicitly, and fall back down the stage's list.
115
+ run_agent() {
116
+ agent=$1; prompt=$2; title=$3
117
+ for model in $(models_for "${agent#dream-}"); do
118
+ echo "dream: $agent on $model" >&2
119
+ if opencode run --agent "$agent" -m "$model" --title "$title" "$prompt"; then
120
+ return 0
121
+ fi
122
+ echo "dream: $model unavailable, trying the next one" >&2
123
+ done
124
+ echo "dream: $agent failed on every model" >&2
125
+ return 1
126
+ }
127
+
128
+ echo "dream: light"
129
+ run_agent dream-light \
130
+ "Stage today's candidates from .dreams/session-corpus/$day.txt." "light $day"
131
+
132
+ # Preimage: a bad promotion then stays one `git checkout` away. Only when the
133
+ # data directory is a repo; install.sh does not create one. The corpus is
134
+ # excluded even when a .gitignore forgets it: session text must not enter git.
135
+ if git -C "$root" rev-parse --git-dir >/dev/null 2>&1; then
136
+ git -C "$root" add -A -- . ':!.dreams' >/dev/null 2>&1 || true
137
+ if ! git -C "$root" diff --cached --quiet; then
138
+ git -C "$root" commit -qm "dream: before deep $day" ||
139
+ echo "dream: preimage commit failed, check the git identity in $root" >&2
140
+ fi
141
+ fi
142
+
143
+ echo "dream: deep"
144
+ if run_agent dream-deep \
145
+ "Promote today's candidates and write the diary entry for $day." "deep $day"; then
146
+ # Advance the cursor only after both passes succeeded, so a failed run is
147
+ # retried on the same slice instead of losing it.
148
+ date +%s%3N > "$root/.dreams/last-run"
149
+ fi
@@ -0,0 +1,71 @@
1
+ #!/bin/sh
2
+ # Build the dream corpus: one line per message, for each session touched in the
3
+ # last 24 hours, in every listed project. Deterministic: no model, no cost.
4
+ # The dream's own runs are skipped: their output must not become tomorrow's
5
+ # source. Labelled secrets are redacted on the way in.
6
+ # Output: $DREAM_HOME/.dreams/session-corpus/YYYY-MM-DD.txt (default ~/dreams)
7
+ set -eu
8
+
9
+ root="${DREAM_HOME:-$HOME/dreams}"
10
+ list="${DREAM_PROJECTS:-$HOME/.config/opencode/dream-projects}"
11
+ day=$(date +%F)
12
+ now=$(( $(date +%s) * 1000 ))
13
+ # Incremental: start where the last successful run stopped, never earlier than
14
+ # 24h ago. Without this, a session spanning midnight lands in two corpora.
15
+ state="$root/.dreams/last-run"
16
+ last=0
17
+ [ -r "$state" ] && last=$(cat "$state" 2>/dev/null || true)
18
+ case $last in ''|*[!0-9]*) last=0 ;; esac
19
+ cutoff=$(( now - 86400000 ))
20
+ [ "$last" -gt "$cutoff" ] && cutoff=$last
21
+ command -v opencode >/dev/null || { echo "dream-corpus: opencode not on PATH" >&2; exit 1; }
22
+
23
+ out="$root/.dreams/session-corpus/$day.txt"
24
+ mkdir -p "$(dirname "$out")"
25
+ # truncate on the first run of the day, append afterwards so a later run keeps
26
+ # the earlier slice. shortcut: a retry after a failed run re-appends the same
27
+ # messages; dedupe with sort -u if that ever shows up.
28
+ [ -f "$out" ] || : > "$out"
29
+
30
+ # One line per message. A labelled secret never leaves the machine; a bare key
31
+ # with no label still gets through, so this is a net, not a guarantee.
32
+ filter='
33
+ def clean:
34
+ gsub("\n"; " ")
35
+ | gsub("(?i)(?<k>(api[_-]?key|token|password|passwd|secret|bearer)[[:space:]]*[:=][[:space:]]*)[^[:space:]]+"; "\(.k)[redacted]")
36
+ | .[0:400];
37
+ .messages[] |
38
+ if .type == "user" then
39
+ "[" + $name + "] User: " + (.text // "" | clean)
40
+ elif .type == "assistant" then
41
+ (.content[]? | select(.type == "text") | .text | clean | "[" + $name + "] Assistant: " + .)
42
+ else empty end
43
+ '
44
+
45
+ while IFS= read -r project; do
46
+ case $project in ''|\#*) continue ;; esac
47
+ [ -d "$project/.git" ] || { echo "dream-corpus: skip $project (not a git repo)" >&2; continue; }
48
+ name=$(basename "$project")
49
+ cd "$project"
50
+
51
+ before=$(wc -l < "$out")
52
+ opencode session list --format json --max-count 100 2>/dev/null |
53
+ jq -r --argjson cutoff "$cutoff" '.[] | select(.updated >= $cutoff) | .id' |
54
+ while IFS= read -r sid; do
55
+ [ -n "$sid" ] || continue
56
+ tmp=$(mktemp)
57
+ if opencode session export "$sid" > "$tmp" 2>/dev/null; then
58
+ # Never learn from the dream's own runs: they are output, not source.
59
+ agent=$(jq -r '.info.agent // ""' "$tmp" 2>/dev/null) || agent=
60
+ case $agent in
61
+ dream-*) echo "dream-corpus: skip $sid (own $agent run)" >&2 ;;
62
+ *) jq -r --arg name "$name" "$filter" "$tmp" >> "$out" ||
63
+ echo "dream-corpus: skip $sid (unreadable export)" >&2 ;;
64
+ esac
65
+ fi
66
+ rm -f "$tmp"
67
+ done
68
+ echo "dream-corpus: $name +$(( $(wc -l < "$out") - before )) lignes"
69
+ done < "$list"
70
+
71
+ echo "dream-corpus: $out ($(wc -l < "$out") lignes)"
@@ -0,0 +1,41 @@
1
+ #!/bin/sh
2
+ # One screen of state for the dream job. Read-only, no model, no cost.
3
+ set -eu
4
+
5
+ root="${DREAM_HOME:-$HOME/dreams}"
6
+ list="${DREAM_PROJECTS:-$HOME/.config/opencode/dream-projects}"
7
+ day=$(date +%F)
8
+
9
+ timer=$(systemctl --user is-active dream.timer 2>/dev/null || true)
10
+ [ -n "$timer" ] || timer=inactive
11
+ next=$(systemctl --user list-timers dream.timer --no-legend 2>/dev/null | awk 'NR==1 {print $1" "$2" "$3}' || true)
12
+ echo "root: $root"
13
+ echo "timer: $timer${next:+ (next $next)}"
14
+
15
+ corpus_dir="$root/.dreams/session-corpus"
16
+ n_corpus=$(find "$corpus_dir" -name '*.txt' 2>/dev/null | wc -l)
17
+ last_corpus=$(ls -1 "$corpus_dir" 2>/dev/null | tail -1 || true)
18
+ last_lines=0
19
+ [ -n "$last_corpus" ] && last_lines=$(wc -l < "$corpus_dir/$last_corpus")
20
+
21
+ n_light=$(find "$root/dreaming/light" -name '*.md' 2>/dev/null | wc -l)
22
+ n_deep=$(find "$root/dreaming/deep" -name '*.md' 2>/dev/null | wc -l)
23
+ n_mem=$(grep -c '.' "$root/MEMORY.md" 2>/dev/null || true); [ -n "$n_mem" ] || n_mem=0
24
+ mem_files=$(find "$root/projects" -name MEMORY.md 2>/dev/null | sort)
25
+ n_proj=$(printf '%s\n' "$mem_files" | grep -c . 2>/dev/null || true); [ -n "$n_proj" ] || n_proj=0
26
+ n_dreams=$(grep -c '^\*[A-Z]' "$root/DREAMS.md" 2>/dev/null || true); [ -n "$n_dreams" ] || n_dreams=0
27
+ today=$([ -f "$root/dreaming/light/$day.md" ] && echo oui || echo non)
28
+
29
+ echo "corpus: $n_corpus jour(s), dernier ${last_corpus:-aucun} ($last_lines lignes)"
30
+ echo "light: $n_light fichier(s), aujourd'hui: $today"
31
+ echo "deep: $n_deep fichier(s)"
32
+ echo "MEMORY: $n_mem ligne(s) partagées, $n_proj projet(s)"
33
+ mem_report() { [ -f "$1" ] && printf ' %6s %s\n' "$(wc -m < "$1" | tr -d ' ')" "${1#"$root"/}"; }
34
+ mem_report "$root/MEMORY.md"
35
+ while IFS= read -r f; do mem_report "$f"; done <<EOF
36
+ $mem_files
37
+ EOF
38
+ echo "DREAMS: $n_dreams entrée(s)"
39
+ echo
40
+ echo "projets suivis:"
41
+ sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$list" 2>/dev/null | sed 's/^/ /' || echo " (aucun)"
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=Consolidate OpenCode sessions into a memory file
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ # Portable defaults. If `opencode` lives outside them, put its directory in
7
+ # ~/.config/dream.env as PATH=/your/bin:/usr/local/bin:/usr/bin:/bin
8
+ Environment="PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin"
9
+ EnvironmentFile=-%h/.config/dream.env
10
+ # install.sh rewrites the line below to the chosen BIN_DIR.
11
+ ExecStart=%h/.local/bin/dream
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Daily OpenCode memory consolidation
3
+
4
+ [Timer]
5
+ OnCalendar=*-*-* 04:30:00
6
+ RandomizedDelaySec=10m
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target
package/install.sh ADDED
@@ -0,0 +1,119 @@
1
+ #!/bin/sh
2
+ # Install the dream job and its plugin: corpus builder, light/deep agents,
3
+ # systemd units, memory home.
4
+ #
5
+ # curl -fsSL https://raw.githubusercontent.com/vasilvestre/opencode-dream-memory/main/install.sh | sh
6
+ # sh install.sh # same thing from a clone, timer disabled
7
+ # sh install.sh --enable # install, then enable and start the nightly timer
8
+ #
9
+ # Never overwrites MEMORY.md, DREAMS.md or dream-projects: those hold your data
10
+ # and your choices. Safe to re-run after a pull.
11
+ set -eu
12
+
13
+ ENABLE=0
14
+ [ "${1:-}" = "--enable" ] && ENABLE=1
15
+
16
+ BIN="${BIN_DIR:-$HOME/.local/bin}"
17
+ AGENTS="${AGENTS_DIR:-$HOME/.config/opencode/agents}"
18
+ PLUGINS="${PLUGINS_DIR:-$HOME/.config/opencode/plugins}"
19
+ UNITS="${UNITS_DIR:-$HOME/.config/systemd/user}"
20
+ HOME_DREAM="${DREAM_HOME:-$HOME/dreams}"
21
+ LIST="$HOME/.config/opencode/dream-projects"
22
+ MODELS="$HOME/.config/opencode/dream-models"
23
+
24
+ ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
25
+
26
+ command -v opencode >/dev/null || { echo "install: opencode is not on PATH" >&2; exit 1; }
27
+ command -v jq >/dev/null || { echo "install: jq is not on PATH" >&2; exit 1; }
28
+
29
+ # A piped install has no dream/ next to the script, so fetch the archive.
30
+ if [ ! -d "$ROOT/dream" ]; then
31
+ url=https://github.com/vasilvestre/opencode-dream-memory/archive/refs/heads/main.tar.gz
32
+ tmp=$(mktemp -d)
33
+ trap 'rm -rf "$tmp"' EXIT
34
+ echo "install: downloading $url"
35
+ if command -v curl >/dev/null 2>&1; then
36
+ curl -fsSL "$url" | tar xz -C "$tmp"
37
+ elif command -v wget >/dev/null 2>&1; then
38
+ wget -qO- "$url" | tar xz -C "$tmp"
39
+ else
40
+ echo "install: curl or wget is required to download the archive" >&2
41
+ exit 1
42
+ fi
43
+ ROOT=$tmp/opencode-dream-memory-main
44
+ fi
45
+ SRC=$ROOT/dream
46
+
47
+ mkdir -p "$BIN" "$AGENTS" "$UNITS" "$PLUGINS" "$(dirname "$LIST")" "$HOME_DREAM/dreaming/light" "$HOME_DREAM/dreaming/deep" "$HOME_DREAM/.dreams/session-corpus" "$HOME_DREAM/projects"
48
+
49
+ for f in dream dream-corpus dream-status; do
50
+ install -m 755 "$SRC/$f" "$BIN/$f"
51
+ done
52
+ for f in dream-light.md dream-deep.md; do
53
+ install -m 644 "$SRC/agents/$f" "$AGENTS/$f"
54
+ done
55
+ # One copy only: a second file with the same id makes OpenCode drop every plugin.
56
+ # An existing symlink stays, so a developer keeps editing the file they cloned.
57
+ [ -L "$PLUGINS/dream-memory.js" ] ||
58
+ install -m 644 "$ROOT/.opencode/plugins/dream-memory.js" "$PLUGINS/dream-memory.js"
59
+ for f in dream.service dream.timer; do
60
+ install -m 644 "$SRC/systemd/$f" "$UNITS/$f"
61
+ done
62
+ # The unit must point at the chosen BIN_DIR, which systemd cannot guess.
63
+ sed -i "s|^ExecStart=.*|ExecStart=$BIN/dream|" "$UNITS/dream.service"
64
+
65
+ [ -f "$HOME_DREAM/MEMORY.md" ] || cat > "$HOME_DREAM/MEMORY.md" <<'EOF'
66
+ # Shared memory
67
+
68
+ Facts true in every project, one per line. Project facts live in
69
+ `projects/<project>/MEMORY.md`.
70
+ EOF
71
+
72
+ [ -f "$HOME_DREAM/DREAMS.md" ] || cat > "$HOME_DREAM/DREAMS.md" <<'EOF'
73
+ # Dream Diary
74
+
75
+ One entry per night, newest first. Durable facts live in [MEMORY.md](MEMORY.md).
76
+
77
+ <!-- opencode:dreaming:diary:start -->
78
+ EOF
79
+
80
+ [ -f "$HOME_DREAM/.gitignore" ] || printf '.dreams/\n' > "$HOME_DREAM/.gitignore"
81
+
82
+ if [ ! -f "$LIST" ]; then
83
+ cat > "$LIST" <<'EOF'
84
+ # Projects the dream job consolidates, one absolute path per line.
85
+ # A project opts in by having a MEMORY.md file at its root.
86
+ # Add your projects below, for example /home/you/code/my-project
87
+ EOF
88
+ fi
89
+
90
+ if [ ! -f "$MODELS" ]; then
91
+ cat > "$MODELS" <<'EOF'
92
+ # Model per dream stage: <stage> <model> [<model> ...]. The script tries them
93
+ # left to right and keeps the first that runs.
94
+ # The light pass reads raw session text; the deep pass reads staged candidates.
95
+ # With no matching line, the script uses its own paid defaults.
96
+ #
97
+ # light opencode-go/deepseek-v4.1-flash
98
+ # deep opencode-go/deepseek-v4-pro
99
+ EOF
100
+ fi
101
+
102
+ systemctl --user daemon-reload 2>/dev/null || true
103
+
104
+ echo "installed:"
105
+ echo " $BIN/dream{,-corpus,-status}"
106
+ echo " $AGENTS/dream-{light,deep}.md"
107
+ echo " $PLUGINS/dream-memory.js"
108
+ echo " $UNITS/dream.{service,timer}"
109
+ echo " $HOME_DREAM (MEMORY.md, projects/, dreaming/, DREAMS.md)"
110
+
111
+ if [ "$ENABLE" -eq 1 ]; then
112
+ systemctl --user enable --now dream.timer
113
+ echo "timer: enabled (daily 04:30)"
114
+ elif systemctl --user is-enabled dream.timer >/dev/null 2>&1; then
115
+ echo "timer: already enabled (daily 04:30)"
116
+ else
117
+ echo "timer: disabled. Enable it when the output looks right:"
118
+ echo " systemctl --user enable --now dream.timer"
119
+ fi
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@vasilvestre/opencode-dream-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Injects shared and per-project memory files into every OpenCode request, so they survive context compaction.",
5
+ "keywords": [
6
+ "opencode-plugin",
7
+ "opencode",
8
+ "memory",
9
+ "context",
10
+ "compaction"
11
+ ],
12
+ "license": "MIT",
13
+ "author": {
14
+ "name": "vasilvestre",
15
+ "url": "https://github.com/vasilvestre"
16
+ },
17
+ "homepage": "https://github.com/vasilvestre/opencode-dream-memory",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/vasilvestre/opencode-dream-memory.git"
21
+ },
22
+ "type": "module",
23
+ "main": "./.opencode/plugins/dream-memory.js",
24
+ "exports": {
25
+ ".": "./.opencode/plugins/dream-memory.js",
26
+ "./plugin": "./.opencode/plugins/dream-memory.js"
27
+ },
28
+ "scripts": {
29
+ "test": "node scripts/check-plugin.mjs && sh scripts/check-corpus.sh"
30
+ },
31
+ "files": [
32
+ ".opencode/",
33
+ "dream/",
34
+ "install.sh",
35
+ "LICENSE"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }