@seangle/pi-remember 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ Breaking replacement of the standalone 0.2.0 implementation, now maintained in
6
+ [Esensats/agents-remember](https://github.com/Esensats/agents-remember).
7
+ The npm name and install command are unchanged.
8
+
9
+ ### Changed
10
+
11
+ - Shared host-independent `@seangle/remember-core@0.1.0` and explicit
12
+ `@seangle/remember-migrators@0.1.0` dependencies.
13
+ - Workspace memory defaults to `.agents/memory`, shared with compatible adapters,
14
+ instead of `.pi/memory`. Global memory remains Pi-owned.
15
+ - Published plain ESM JavaScript instead of runtime TypeScript.
16
+ - Snapshot rebuilt before each run in Pi's named `remember` prompt section;
17
+ unchanged bytes remain stable and external changes appear on the next run.
18
+ - Environment configuration uses `REMEMBER_*`. In particular,
19
+ `PI_REMEMBER_DIR` becomes `REMEMBER_GLOBAL_DIR`.
20
+ - New timestamps use minute-precision UTC; existing valid timestamps are retained.
21
+
22
+ ### Migration
23
+
24
+ - New `memory_migrate` tool and `/memory migrate` command.
25
+ - Migration is explicit, supports recognized legacy version-1 stores, preserves
26
+ current fact/note IDs and paths, and reports skipped invalid/duplicate content.
27
+ - Local migration leaves the legacy source intact and refuses existing targets.
28
+ - Global legacy stores require explicit in-place migration with source and target
29
+ set to the global directory.
30
+ - Old recovery history and migration artifacts are not converted. They remain
31
+ at the source; in-place adoption leaves old files present but does not promise
32
+ compatibility. Back up stores and stop all writers before migrating.
33
+ - Populated unmarked stores are refused, not silently adopted. Upgrade pre-v1
34
+ formats with the legacy 0.2.0 implementation first.
35
+
36
+ ### Removed
37
+
38
+ - Opt-in automatic exit-summary model calls and `PI_REMEMBER_EXIT_SUMMARY*`
39
+ configuration. `/memory update` remains available for explicit review.
40
+
41
+ See [README.md](README.md) for upgrade instructions and concurrency limits.
42
+
43
+ ## 0.2.0 and earlier
44
+
45
+ Standalone implementation and historical changes:
46
+ [Esensats/pi-remember](https://github.com/Esensats/pi-remember).
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,190 @@
1
- # Temporary Holding Version
1
+ # @seangle/pi-remember
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
+ Durable, plain-file memory for the [Pi coding agent](https://pi.dev/): curated
4
+ facts, Markdown notes, lexical search, stable memo IDs, recovery, and a
5
+ byte-stable session snapshot. Save knowledge that was expensive to obtain and
6
+ will matter again—not transcripts, todos, or temporary debugging state.
7
+
8
+ This is the Pi adapter for
9
+ [agents-remember](https://github.com/Esensats/agents-remember). Starting with
10
+ **0.3.0**, it replaces the standalone implementation while retaining the same
11
+ npm package name. The unscoped `pi-remember` package is unrelated.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pi install npm:@seangle/pi-remember
17
+ ```
18
+
19
+ Restart Pi or run `/reload`. Requires Node.js **>=22.15** and Pi host APIs tested
20
+ with **`@earendil-works/pi-coding-agent@1.4.0`**. Published code is compiled ESM
21
+ JavaScript; users do not need a build step. Host SDK packages are peer dependencies
22
+ provided by Pi, not bundled copies.
23
+
24
+ If you already install this npm package, use:
25
+
26
+ ```sh
27
+ pi update npm:@seangle/pi-remember
28
+ ```
29
+
30
+ A pinned version stays pinned; change its installation specification to upgrade.
31
+ Remove any manually installed or local-path copy first so two extensions do not
32
+ register the same `memory_*` tools. Keep memory stores: they live separately.
33
+ **Read the migration section before upgrading from 0.2.x.**
34
+
35
+ ## Stores and context
36
+
37
+ | Scope | Default path |
38
+ |---|---|
39
+ | Local | `<workspace>/.agents/memory`—shared with compatible agent adapters |
40
+ | Global | `~/.pi/agent/memory`—Pi-owned |
41
+
42
+ Facts live in `MEMORY.md`; notes live in `notes/<date>-<slug>.md` with validated
43
+ `description`, `memory-id`, `created-date`, and `last-modified-date` frontmatter.
44
+ The `remember.json` marker records store format version 1. Updates and deletions
45
+ produce recovery records for conflict-aware undo.
46
+
47
+ Facts and a derived note catalogue enter the named `remember` system-prompt
48
+ section. Note bodies remain on disk until read or searched. The snapshot is
49
+ rebuilt before each run; Pi patches the transcript only when its bytes change.
50
+ Each scope has a default 3,000-character budget, with middle truncation for large
51
+ stores. Read/search before assuming the snapshot is complete.
52
+
53
+ Memory is agent-writable background data, **not trusted instructions**. Do not
54
+ store secrets. Add `.agents/memory/` to a workspace's `.gitignore` if memory should
55
+ remain private; recovery records can contain removed information too.
56
+
57
+ ## Tools and command
58
+
59
+ | Tool | Purpose | Default scope |
60
+ |---|---|---|
61
+ | `memory_init` | Explicitly initialize local memory in an existing workspace | local |
62
+ | `memory_write` | Append one durable fact, at most 300 characters | local |
63
+ | `memory_store` | Store a Markdown note; `title` means its short description | local |
64
+ | `memory_read` | Read facts/catalogue, filter, or fetch a memo by ID/path | both |
65
+ | `memory_search` | Ranked lexical search over facts and note bodies | both |
66
+ | `memory_update` | Replace one memo's content, preserving identity and path | local |
67
+ | `memory_forget` | Recoverably remove matches by text/description/path/metadata | local |
68
+ | `memory_restore` | Undo a deletion or update with its recovery ID | both |
69
+ | `memory_status` | Inspect paths, counts, configuration, and snapshot size | both |
70
+ | `memory_migrate` | Explicitly migrate a supported legacy store | explicit paths or local defaults |
71
+
72
+ Local reads and writes require an initialized store. Global memory needs no
73
+ explicit initialization. Optional `root` selects another existing workspace's
74
+ local memory for that call; it does not change the session workspace or global
75
+ memory. A memo ID identifies knowledge; a recovery ID identifies an undo record.
76
+ Read before updating and preserve unrelated content.
77
+
78
+ Search is lexical, with exact tokens, field weighting, phrase/coverage boosts,
79
+ and one hit per note. No embeddings, database, model calls, or persistent search
80
+ index are involved. Tool output is bounded and truncation is reported.
81
+
82
+ - `/memory` or `/memory status`: show status without starting an agent turn.
83
+ - `/memory migrate`: migrate the current workspace's recognized legacy store.
84
+ - `/memory update`: ask the current agent to review durable knowledge, using a
85
+ follow-up turn rather than interrupting ongoing work. This can incur normal
86
+ model usage; it is not an automatic exit summarizer.
87
+
88
+ ## Upgrading from 0.2.x
89
+
90
+ **0.3.0 is a breaking release.** The extension keeps its npm name but now uses
91
+ shared core/migrator packages and a different local-store location.
92
+
93
+ 1. Back up important stores and stop other memory writers.
94
+ 2. Upgrade the extension and rename any configured environment variables below.
95
+ 3. For each workspace, inspect `memory_migrate({dryRun: true})`. With user
96
+ approval, run `/memory migrate` or `memory_migrate({})`.
97
+ 4. Review skipped files/warnings and verify facts/notes before relying on the new
98
+ store. Do not delete the source just because migration completed.
99
+
100
+ The supported source is a legacy **version-1** store with `pi-remember.json`.
101
+ If you still have an older/unmarked legacy format, first upgrade it with the
102
+ legacy 0.2.0 extension. The new adapter does not silently adopt populated,
103
+ unmarked stores or automatically convert old formats on reads.
104
+
105
+ ### Workspace memory
106
+
107
+ Migration copies current facts and valid notes from `.pi/memory` to
108
+ `.agents/memory`, preserving memo IDs and note paths and leaving the source
109
+ intact. Invalid notes and duplicate IDs are skipped and reported. Existing
110
+ target stores are refused, not merged.
111
+
112
+ **Recovery history and old migration backups are not copied.** Keep the source
113
+ if you need them. Do not mix old and new writers: the separate local stores will
114
+ diverge, and legacy writers do not understand the new format marker.
115
+
116
+ ### Global memory
117
+
118
+ The global path stays `~/.pi/agent/memory`, but an existing legacy store still
119
+ needs an explicit, one-time in-place migration. `/memory migrate` handles local
120
+ memory, not global memory. Use explicit tool arguments (adjust paths if configured):
121
+
122
+ ```js
123
+ memory_migrate({
124
+ source: '~/.pi/agent/memory',
125
+ target: '~/.pi/agent/memory',
126
+ dryRun: true,
127
+ })
128
+ // Only after approval and reviewing the plan:
129
+ memory_migrate({
130
+ source: '~/.pi/agent/memory',
131
+ target: '~/.pi/agent/memory',
132
+ })
133
+ ```
134
+
135
+ A recognized in-place store receives only `remember.json`; its existing files
136
+ are left in place. Old recovery files are not converted or guaranteed compatible
137
+ with the new engine. Do not use the old extension against this store afterward.
138
+
139
+ ### Other changes
140
+
141
+ - `PI_REMEMBER_DIR` becomes `REMEMBER_GLOBAL_DIR`.
142
+ - `PI_REMEMBER_INJECT_CHARS_GLOBAL` and `_LOCAL` become
143
+ `REMEMBER_INJECT_CHARS_GLOBAL` and `_LOCAL`.
144
+ - The old opt-in `PI_REMEMBER_EXIT_SUMMARY*` feature is **not included**. Use
145
+ `/memory update` for an explicit review instead.
146
+ - New timestamps use UTC minute precision; existing valid timestamps remain
147
+ readable and are preserved.
148
+
149
+ ## Configuration
150
+
151
+ | Variable | Default |
152
+ |---|---|
153
+ | `REMEMBER_DIR_NAME` | `.agents` (workspace configuration directory, not a store path) |
154
+ | `REMEMBER_GLOBAL_DIR` | `~/.pi/agent/memory` |
155
+ | `REMEMBER_INJECT_CHARS_GLOBAL` | `3000` |
156
+ | `REMEMBER_INJECT_CHARS_LOCAL` | `3000` |
157
+ | `REMEMBER_AUTO_INJECT` | `1` |
158
+
159
+ Budgets must be positive integers; invalid/nonpositive values fall back to the
160
+ default. Set `REMEMBER_AUTO_INJECT=0` to disable injection, not the tools.
161
+ Configuration loads when the extension loads; restart or `/reload` after changes.
162
+
163
+ ## Concurrency and safety
164
+
165
+ Pi's mutation queues serialize cooperating operations within one process, not
166
+ across Pi/DSH processes or editors. Avoid simultaneous writes to a shared store,
167
+ including during migration. Path checks are not a sandbox against hostile
168
+ concurrent filesystem changes. Stored paths, credentials, or remembered facts
169
+ never grant permission for remote access.
170
+
171
+ ## Local development
172
+
173
+ ```sh
174
+ git clone git@github.com:Esensats/agents-remember.git
175
+ cd agents-remember
176
+ pnpm install
177
+ npm run check
178
+ npm run test:packed
179
+ pi install ./packages/pi
180
+ ```
181
+
182
+ Remove an existing npm installation before installing the local path. Local
183
+ packages load in place; rebuild with `npm run build` and run `/reload` after edits.
184
+ Tests use temporary stores, not live memory. `test:packed` additionally installs
185
+ only release tarballs outside the monorepo and reruns the adapter integration
186
+ suite against emitted JavaScript.
187
+
188
+ ## License
189
+
190
+ Apache-2.0. Copyright 2026 Esensats.
@@ -0,0 +1,56 @@
1
+
2
+
3
+ /**
4
+ * Incremental review prompt, kept from pi-remember so `/memory update` behaves
5
+ * the same. It asks for evidence-based edits, not a rewrite of the collection.
6
+ */
7
+ export const MEMORY_UPDATE_PROMPT = `Please review durable memory in light of this conversation and update it only where needed. This is an incremental memory reminder, not a request to overhaul or rewrite the memory collection.
8
+
9
+ - Use the available conversation context (including any compaction summary) to identify durable knowledge that will help in future sessions: established decisions, preferences, hard-won lessons, and confirmed project/server setup or operational details. Save important knowledge you have not already saved.
10
+ - Read the current memory with memory_read and use memory_search for relevant topics; read related notes as needed before changing anything. The injected snapshot may be incomplete. Avoid duplicates and preserve unrelated or still-correct information. Do not scan or rewrite every note just for completeness.
11
+ - Correct or remove information only when this conversation provides evidence that it is outdated or wrong; age alone or not mentioning it is not evidence. Make the smallest targeted changes. Use memory_forget with a specific identifying string for obsolete facts or notes (deletions are recoverable), memory_write for one-line facts, and memory_store for new detailed notes. For memos with IDs, read by ID and use memory_update to edit the relevant file in place instead of forgetting/recreating facts or creating redundant replacement notes; preserve identity, description and unrelated content. Notes carry their own frontmatter; supply only the revised Markdown body, including any body heading worth keeping. Store migration is automatic; do not manually recreate an index.
12
+ - Default to local memory in the current workdir (a shared .agents/memory store that other harnesses may read too), including server-specific knowledge when working in a context directory such as ~/ctx/server-ABC. When the user requests memory in another workspace, use its root on each tool call, including reads and recovery; root is the workspace directory, not its .agents directory. Use memory_status to inspect a store before initialization, and memory_init only when you intend to create local memory in that existing directory; do not initialize a workspace merely to make a search succeed. Global memory is only for user/machine-wide facts, not particular servers, software, repos, or configs. Keep MEMORY.md for one-line facts only; put detailed knowledge in standalone notes with concise descriptions and chosen filename slugs. The date is prepended automatically; descriptions are injected from frontmatter, not duplicated in MEMORY.md.
13
+ - Do not store secrets, credentials, temporary task state, todos, or a transcript/session recap. Work from already available evidence; this command does not authorize remote access or imply that past remote-access permission carries into future sessions.
14
+ - If nothing useful needs changing, leave memory untouched. Briefly report what you added, corrected, or removed, or say that no updates were needed.`;
15
+
16
+
17
+
18
+
19
+
20
+
21
+ const COMPLETIONS = [
22
+ { value: "status", label: "status", description: "Show memory store locations, counts, and snapshot size" },
23
+ { value: "migrate", label: "migrate", description: "Migrate a legacy .pi/memory store into the shared store" },
24
+ { value: "update", label: "update", description: "Review this conversation and update memory only if needed" },
25
+ ];
26
+
27
+ export function registerMemoryCommand(
28
+ pi ,
29
+ actions ,
30
+ ) {
31
+ pi.registerCommand("memory", {
32
+ description: "Show memory status, migrate a legacy store, or review and update memory",
33
+ getArgumentCompletions: (prefix) => {
34
+ const value = prefix.trim().toLowerCase();
35
+ const matches = COMPLETIONS.filter((item) => item.value.startsWith(value));
36
+ return matches.length > 0 ? matches : null;
37
+ },
38
+ handler: async (args, ctx) => {
39
+ const subcommand = args.trim().toLowerCase();
40
+ if (subcommand === "update") {
41
+ // Start a normal turn when idle; otherwise wait for the ongoing work.
42
+ pi.sendUserMessage(MEMORY_UPDATE_PROMPT, { deliverAs: "followUp" });
43
+ return;
44
+ }
45
+ if (subcommand === "migrate") {
46
+ ctx.ui.notify(await actions.migrate(ctx), "info");
47
+ return;
48
+ }
49
+ if (subcommand === "" || subcommand === "status") {
50
+ ctx.ui.notify(await actions.status(ctx), "info");
51
+ return;
52
+ }
53
+ ctx.ui.notify("Usage: /memory [status|migrate|update]", "warning");
54
+ },
55
+ });
56
+ }
@@ -0,0 +1,57 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { resolveStores, } from "@seangle/remember-core";
4
+
5
+ /**
6
+ * Deployment policy for the Pi adapter.
7
+ *
8
+ * The workspace store is shared with the DeepSeek Harness (`.agents/memory`), so
9
+ * both harnesses read and write the same facts and notes. The global store stays
10
+ * in Pi's home: machine-wide memory is harness-owned, not a shared file.
11
+ */
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+ function truthy(value ) {
21
+ return value !== undefined && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
22
+ }
23
+
24
+ function intEnv(env , name , fallback ) {
25
+ const raw = env[name]?.trim();
26
+ if (!raw) return fallback;
27
+ const value = Number.parseInt(raw, 10);
28
+ return Number.isFinite(value) && value > 0 ? value : fallback;
29
+ }
30
+
31
+ /** Expand a leading `~`; core's workspace resolver does the same for `root`. */
32
+ export function expandHome(input ) {
33
+ if (input === "~") return homedir();
34
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
35
+ return input;
36
+ }
37
+
38
+ export function loadConfig(env = process.env) {
39
+ const globalDir = env.REMEMBER_GLOBAL_DIR?.trim();
40
+ return {
41
+ dirName: env.REMEMBER_DIR_NAME?.trim() || ".agents",
42
+ globalDir: globalDir ? expandHome(globalDir) : join(homedir(), ".pi", "agent", "memory"),
43
+ injectCharsGlobal: intEnv(env, "REMEMBER_INJECT_CHARS_GLOBAL", 3000),
44
+ injectCharsLocal: intEnv(env, "REMEMBER_INJECT_CHARS_LOCAL", 3000),
45
+ autoInject: env.REMEMBER_AUTO_INJECT === undefined ? true : truthy(env.REMEMBER_AUTO_INJECT),
46
+ };
47
+ }
48
+
49
+ /** Resolve both stores for one workspace root; nothing is created by resolution. */
50
+ export function storesFor(cwd , config ) {
51
+ return resolveStores(cwd, config.globalDir, config.dirName);
52
+ }
53
+
54
+ /** Default legacy source for a workspace: pi-remember's own layout. */
55
+ export function legacySource(cwd ) {
56
+ return join(cwd, ".pi", "memory");
57
+ }
package/dist/index.js ADDED
@@ -0,0 +1,124 @@
1
+ import { basename } from "node:path";
2
+
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { ensureStoreVersion, readStoreVersion } from "@seangle/remember-core";
5
+ import { migrate } from "@seangle/remember-migrators";
6
+ import { registerMemoryCommand } from "./command.js";
7
+ import { loadConfig, storesFor, } from "./context.js";
8
+ import { detectForWorkspace, formatReport, snapshotFor, statusText, toolSpecs } from "./tools.js";
9
+
10
+ /** System-prompt section name; Pi wraps it in a matching <remember> tag. */
11
+ const SNAPSHOT_SECTION = "remember";
12
+
13
+ /**
14
+ * Offer a migration once per workspace and session when a legacy store exists
15
+ * and the target is not initialized. The model asks the user; nothing is
16
+ * written without consent.
17
+ */
18
+ async function migrationNotice(cwd , config ) {
19
+ try {
20
+ const detection = await detectForWorkspace(cwd, config);
21
+ if (!detection) return "";
22
+ const targetVersion = await readStoreVersion({ dir: detection.target });
23
+ if (targetVersion !== null) return "";
24
+ const where = detection.source === detection.target
25
+ ? `at ${detection.source}, which is missing only remember.json`
26
+ : `at ${detection.source}; no remember store exists at ${detection.target}`;
27
+ return `<remember-migration>\nLegacy memory detected: ${detection.description} ${where}.\nAsk the user whether to migrate. If they agree, call memory_migrate (dryRun first if unsure). If they decline, continue without it and do not ask again this session.\n</remember-migration>`;
28
+ } catch {
29
+ // Detection is advisory; never block injection on it.
30
+ return "";
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Mount the memory engine: ten model-facing tools, a `/memory` command, and one
36
+ * system-prompt section rebuilt at each run start. Pi patches the section only
37
+ * when its bytes change, so the snapshot stays cache-stable between runs.
38
+ */
39
+ export default function (pi ) {
40
+ const config = loadConfig();
41
+ let noticeWorkspace = null;
42
+
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ noticeWorkspace = null;
45
+ // Adopt a store that already exists without a marker (e.g. a copied tree),
46
+ // and surface an invalid version instead of injecting nothing silently.
47
+ const stores = storesFor(ctx.cwd, config);
48
+ for (const scope of ["global", "local"] ) {
49
+ try {
50
+ await withFileMutationQueue(stores[scope].memoryFile, () => ensureStoreVersion(stores[scope]));
51
+ } catch (error) {
52
+ if (ctx.hasUI) ctx.ui.notify(`pi-remember: ${scope} memory unavailable: ${String(error)}`, "warning");
53
+ }
54
+ }
55
+ });
56
+
57
+ pi.on("before_agent_start", async (event, ctx) => {
58
+ if (!config.autoInject) {
59
+ delete event.systemPromptOptions.sections[SNAPSHOT_SECTION];
60
+ return;
61
+ }
62
+ const stores = storesFor(ctx.cwd, config);
63
+ const snapshot = await snapshotFor(stores, config, basename(ctx.cwd));
64
+
65
+ let notice = "";
66
+ if (noticeWorkspace !== ctx.cwd) {
67
+ notice = await migrationNotice(ctx.cwd, config);
68
+ if (notice) noticeWorkspace = ctx.cwd;
69
+ }
70
+
71
+ const text = [notice, snapshot.text].filter((part) => part.trim().length > 0).join("\n\n");
72
+ if (text) event.systemPromptOptions.sections[SNAPSHOT_SECTION] = text;
73
+ else delete event.systemPromptOptions.sections[SNAPSHOT_SECTION];
74
+ });
75
+
76
+ for (const spec of toolSpecs({ config })) {
77
+ pi.registerTool({
78
+ name: spec.name,
79
+ label: spec.label,
80
+ description: spec.description,
81
+ promptSnippet: spec.promptSnippet,
82
+ promptGuidelines: spec.promptGuidelines,
83
+ parameters: spec.parameters,
84
+ async execute(
85
+ _toolCallId ,
86
+ params ,
87
+ signal ,
88
+ _onUpdate ,
89
+ toolCtx ,
90
+ ) {
91
+ try {
92
+ const result = await (spec.execute )(
93
+ params, { cwd: toolCtx.cwd, signal },
94
+ );
95
+ return {
96
+ content: [{ type: "text" , text: result.text }],
97
+ details: {},
98
+ ...(result.isError ? { isError: true } : {}),
99
+ };
100
+ } catch (error) {
101
+ return {
102
+ content: [{ type: "text" , text: `remember: ${(error ).message}` }],
103
+ details: {},
104
+ isError: true,
105
+ };
106
+ }
107
+ },
108
+ });
109
+ }
110
+
111
+ registerMemoryCommand(pi, {
112
+ status: async (ctx) => statusText(storesFor(ctx.cwd, config), config, ctx.cwd),
113
+ migrate: async (ctx) => {
114
+ const detection = await detectForWorkspace(ctx.cwd, config);
115
+ if (!detection) return `No legacy memory store found for ${ctx.cwd}.`;
116
+ try {
117
+ const report = await migrate({ source: detection.source, target: detection.target });
118
+ return formatReport(report);
119
+ } catch (error) {
120
+ return `Migration refused: ${(error ).message}`;
121
+ }
122
+ },
123
+ });
124
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,552 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { StringEnum } from "@earendil-works/pi-ai";
5
+ import { truncateTail, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ appendEntry,
8
+ assertCurrentStoreVersion,
9
+ buildSnapshot,
10
+ entryId,
11
+ ensureStoreVersion,
12
+ findSimilar,
13
+ forgetEntries,
14
+ initializeMemory,
15
+ parseEntries,
16
+ readCatalogue,
17
+ readMemo,
18
+ resolveNotePath,
19
+ resolveWorkspaceStores,
20
+ restoreEntry,
21
+ searchStores,
22
+ statMemory,
23
+ updateMemo,
24
+ validNoteSlug,
25
+ workspaceAccessError,
26
+ writeNote,
27
+
28
+
29
+
30
+
31
+ } from "@seangle/remember-core";
32
+ import { detectMigrations, migrate, } from "@seangle/remember-migrators";
33
+ import { Type } from "typebox";
34
+ import { expandHome, legacySource, storesFor, } from "./context.js";
35
+
36
+ const READ_CAP = { maxBytes: 20_000, maxLines: 400 };
37
+ const NOTE_CAP = { maxBytes: 30_000, maxLines: 600 };
38
+ const MAX_FACT = 300;
39
+ const MAX_DESCRIPTION = 300;
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+ const ROOT_PARAMETER = Type.Optional(Type.String({
58
+ minLength: 1,
59
+ description: "Workspace directory for local memory (not its .agents directory). Defaults to the session working directory; accepts absolute, relative-to-cwd, and ~/ paths. Ignored for global-only operations.",
60
+ }));
61
+
62
+ const SCOPE_WRITE = Type.Optional(StringEnum(["local", "global"] , {
63
+ description: "local = this workspace; global = user/machine facts. Defaults to local.",
64
+ }));
65
+
66
+ const SCOPE_READ = Type.Optional(StringEnum(["local", "global", "both"] , {
67
+ description: "Which stores to read. Defaults to both.",
68
+ }));
69
+
70
+ const WORKSPACE_GUIDANCE =
71
+ " Optional root selects another workspace's local memory; global memory is unchanged. Local memory requires memory_init first.";
72
+
73
+ function resolveScopes(param ) {
74
+ if (param === "global") return ["global"];
75
+ if (param === "local") return ["local"];
76
+ return ["global", "local"];
77
+ }
78
+
79
+ function formatTruncated(t ) {
80
+ return t.truncated ? `${t.content}\n[truncated: showing ${t.outputLines}/${t.totalLines} lines]` : t.content;
81
+ }
82
+
83
+ function formatEntry(entry ) {
84
+ const id = entryId(entry);
85
+ return `${id ? `[id=${id}] ` : ""}${entry.line}`;
86
+ }
87
+
88
+ function restoreHint(recoveryId , scope , root ) {
89
+ const args = { recoveryId, scope, ...(scope === "local" && root !== undefined ? { root } : {}) };
90
+ return `Undo with memory_restore(${JSON.stringify(args)}).`;
91
+ }
92
+
93
+ function failure(message ) {
94
+ return { text: message, isError: true };
95
+ }
96
+
97
+ /**
98
+ * Find a legacy store this workspace could migrate. Prefers `.pi/memory`, and
99
+ * also recognizes an in-place store (a copied tree missing only the marker).
100
+ */
101
+ export async function detectForWorkspace(cwd , config ) {
102
+ const target = join(cwd, config.dirName, "memory");
103
+ const source = legacySource(cwd);
104
+ const candidates = await detectMigrations(source, target);
105
+ if (candidates.length > 0) return candidates[0];
106
+ const inPlace = await detectMigrations(target, target);
107
+ return inPlace[0] ?? null;
108
+ }
109
+
110
+ export function formatReport(report ) {
111
+ const lines = [
112
+ `${report.dryRun ? "Dry run" : "Migrated"} ${report.plan?.inPlace ? "in place" : `from ${report.source} to ${report.target}`}: ${report.facts} fact(s), ${report.notes} note(s).`,
113
+ ];
114
+ if (report.wrote.length > 0) lines.push(`Wrote: ${report.wrote.join(", ")}`);
115
+ if (report.skipped.length > 0) lines.push(`Skipped:\n${report.skipped.map((item) => `- ${item.rel}: ${item.reason}`).join("\n")}`);
116
+ if (report.warnings.length > 0) lines.push(`Warnings:\n${report.warnings.map((warning) => `- ${warning}`).join("\n")}`);
117
+ return lines.join("\n");
118
+ }
119
+
120
+ export async function statusText(stores , config , cwd ) {
121
+ const lines = [];
122
+ for (const scope of ["global", "local"] ) {
123
+ const stat = await statMemory(stores[scope]);
124
+ lines.push(stat.exists
125
+ ? `${scope}: ${stat.path} — ${stat.entries} facts, ${stat.notesCount} notes, ${stat.bytes} bytes, ${stat.recoveryCount} recovery file(s)`
126
+ : `${scope}: ${stat.path} — no memory yet${scope === "local" ? "; run memory_init first" : ""}`);
127
+ }
128
+ const snapshot = await snapshotFor(stores, config, basename(cwd));
129
+ lines.push(`injected snapshot (current session: ${cwd}): ${snapshot.text.length} chars (global ${snapshot.globalChars}, local ${snapshot.localChars})`);
130
+ lines.push(`config: dirName=${config.dirName}, autoInject=${config.autoInject ? "on" : "off"}, snapshot budgets global/local ${config.injectCharsGlobal}/${config.injectCharsLocal}`);
131
+ return lines.join("\n");
132
+ }
133
+
134
+ /** Build the snapshot from both stores; never throws on a missing or invalid store. */
135
+ export async function snapshotFor(
136
+ stores ,
137
+ config ,
138
+ projectName ,
139
+ ) {
140
+ const read = async (store ) => {
141
+ try {
142
+ await assertCurrentStoreVersion(store);
143
+ return await readCatalogue(store);
144
+ } catch {
145
+ return "";
146
+ }
147
+ };
148
+ const [globalText, localText] = await Promise.all([read(stores.global), read(stores.local)]);
149
+ return buildSnapshot(globalText, localText, projectName, {
150
+ maxGlobalChars: config.injectCharsGlobal,
151
+ maxLocalChars: config.injectCharsLocal,
152
+ // Pi wraps a named system-prompt section in <remember> itself.
153
+ wrap: false,
154
+ });
155
+ }
156
+
157
+ /** Map the model-facing tools onto the host-agnostic engine. */
158
+ export function toolSpecs(ctx ) {
159
+ const queue = (path, operation) => withFileMutationQueue(path, operation);
160
+ const memoryDir = join("<workspace>", ctx.config.dirName, "memory");
161
+
162
+ /** Resolve stores for one call without changing the session's injected memory. */
163
+ const targetStores = (root , scopes , exec ) =>
164
+ scopes.includes("local")
165
+ ? resolveWorkspaceStores(exec.cwd, ctx.config.globalDir, ctx.config.dirName, root)
166
+ : { status: "resolved" , root: exec.cwd, stores: storesFor(exec.cwd, ctx.config) };
167
+
168
+ /** Validate after acquiring the queue, mark a new store, then run the mutation. */
169
+ const mutate = async (
170
+ stores ,
171
+ scope ,
172
+ operation ,
173
+ requireInit = true,
174
+ ) => {
175
+ return queue(stores[scope].memoryFile, async () => {
176
+ const error = workspaceAccessError(stores, [scope], requireInit);
177
+ if (error) return { status: "error" , message: error };
178
+ try {
179
+ // Mark a brand-new store before the first write, otherwise the
180
+ // post-write check sees content without a marker and refuses it.
181
+ await ensureStoreVersion(stores[scope], { create: true });
182
+ } catch (cause) {
183
+ return { status: "error" , message: String(cause) };
184
+ }
185
+ const value = await operation();
186
+ await ensureStoreVersion(stores[scope]);
187
+ return { status: "ok" , value };
188
+ });
189
+ };
190
+
191
+ /** Reads of other workspaces validate and migrate lazily; missing stores stay missing. */
192
+ const prepareReads = async (stores , scopes ) => {
193
+ for (const scope of scopes) {
194
+ const error = workspaceAccessError(stores, [scope]);
195
+ if (error) return error;
196
+ try {
197
+ await queue(stores[scope].memoryFile, () => assertCurrentStoreVersion(stores[scope]));
198
+ } catch (cause) {
199
+ return String(cause);
200
+ }
201
+ }
202
+ return null;
203
+ };
204
+
205
+ /** Similar entries in the other scope of this call, not another workspace. */
206
+ const similarInOtherScope = async (stores , store , content ) => {
207
+ const other = store.scope === "local" ? stores.global : stores.local;
208
+ try {
209
+ return findSimilar(parseEntries(await readCatalogue(other)), content);
210
+ } catch {
211
+ return [];
212
+ }
213
+ };
214
+
215
+ const similarWarning = (similar , scope ) =>
216
+ similar.length > 0
217
+ ? `\nNote: similar entry in ${scope === "local" ? "global" : "local"} memory: ${similar[0].line}\nKeep knowledge in ONE scope — memory_forget the copy that does not belong.`
218
+ : "";
219
+
220
+ return [
221
+ {
222
+ name: "memory_init",
223
+ label: "Memory Init",
224
+ description:
225
+ `Explicitly initialize local memory in an existing workspace. Optional root defaults to the session working directory. Creates ${memoryDir}/MEMORY.md only if missing; never overwrites existing memory or creates the workspace itself. Global memory needs no initialization.`,
226
+ promptSnippet: "Initialize local memory in an existing workspace",
227
+ promptGuidelines: [
228
+ "Use root to access local memory in another workspace. Initialize it with memory_init only when you intend to create a memory store there; do not initialize merely to make a search succeed.",
229
+ ],
230
+ parameters: Type.Object({ root: ROOT_PARAMETER }),
231
+ async execute(args , exec ) {
232
+ const target = targetStores(args.root, ["local"], exec);
233
+ if (target.status === "error") return failure(target.message);
234
+ const store = target.stores.local;
235
+ const mutation = await mutate(target.stores, "local", () => initializeMemory(store), false);
236
+ if (mutation.status === "error") return failure(mutation.message);
237
+ const result = mutation.value;
238
+ if (result.status === "error") return failure(result.message);
239
+ return {
240
+ text: result.status === "initialized"
241
+ ? `Initialized local memory: ${store.memoryFile}`
242
+ : `Local memory already initialized: ${store.memoryFile}. Existing content preserved.`,
243
+ };
244
+ },
245
+ },
246
+ {
247
+ name: "memory_write",
248
+ label: "Memory Write",
249
+ description:
250
+ `Append exactly ONE concise, durable fact to memory. Default scope 'local' (this workspace, ${ctx.config.dirName}/memory/MEMORY.md). ` +
251
+ "Use scope 'global' ONLY for machine/user-wide facts (identity, OS, global tool preferences). " +
252
+ "Store only knowledge that was expensive to obtain and will plausibly matter in a FUTURE session: stable preferences, decisions, lessons, environment quirks. " +
253
+ "NEVER store temporary session state: current task, todos, in-progress work, debugging state, 'user asked me to X'. " +
254
+ "One line only, max 300 chars. For longer content use memory_store, which writes a standalone note." +
255
+ WORKSPACE_GUIDANCE,
256
+ promptSnippet: "Persist one durable fact to memory (project-local or global)",
257
+ promptGuidelines: [
258
+ "Use memory_write sparingly — only for facts a future session will plausibly need. When in doubt, don't write.",
259
+ "global scope is reserved for facts about the user or the machine; facts about specific software, repos, or configs stay local to the workdir where they were learned.",
260
+ "Do not record session state (what you're doing now, todos, next steps) — pi's own session files persist that.",
261
+ "Keep MEMORY.md for one-line facts only. Notes carry their own metadata; their descriptions are automatically included in session context.",
262
+ "Use memory_search to recall stored knowledge before re-deriving it or asking the user.",
263
+ ],
264
+ parameters: Type.Object({
265
+ content: Type.String({ description: "Single fact to remember (one line, max 300 chars)." }),
266
+ scope: SCOPE_WRITE,
267
+ root: ROOT_PARAMETER,
268
+ }),
269
+ async execute(args , exec ) {
270
+ if (args.content.length > MAX_FACT) {
271
+ return failure(`Entry too long for the index (${args.content.length} chars, max ${MAX_FACT}). Use memory_store for detailed knowledge — it writes a notes file and keeps MEMORY.md lean.`);
272
+ }
273
+ const scope = args.scope ?? "local";
274
+ const target = targetStores(args.root, [scope], exec);
275
+ if (target.status === "error") return failure(target.message);
276
+ const store = target.stores[scope];
277
+ const mutation = await mutate(target.stores, scope, () => appendEntry(store, args.content));
278
+ if (mutation.status === "error") return failure(mutation.message);
279
+ const result = mutation.value;
280
+ if (result.duplicate) {
281
+ return { text: `Duplicate — already in ${scope} memory:\n${(result.existing ?? []).map((entry) => `- ${formatEntry(entry)}`).join("\n")}` };
282
+ }
283
+ if (!result.line) return { text: "Empty content — nothing saved." };
284
+ const similar = await similarInOtherScope(target.stores, store, result.line);
285
+ return { text: `Saved to ${scope} memory (${store.memoryFile}). Memo ID: ${result.id}.${similarWarning(similar, scope)}` };
286
+ },
287
+ },
288
+ {
289
+ name: "memory_store",
290
+ label: "Memory Store Note",
291
+ description:
292
+ "Store detailed knowledge as a standalone Markdown note with description, ID and timestamps in frontmatter. " +
293
+ "Only note descriptions/IDs/paths enter context; bodies are read on demand via memory_read. " +
294
+ "Filenames always prepend the UTC date; no Markdown heading is added. Default scope 'local'. " +
295
+ "Existing/duplicate entries are returned without creating or overwriting a note; use memory_update to revise an indexed memo." +
296
+ WORKSPACE_GUIDANCE,
297
+ promptSnippet: "Store detailed knowledge as a dated, self-describing note",
298
+ promptGuidelines: [
299
+ "Use memory_store instead of memory_write whenever knowledge needs more than one line. MEMORY.md holds only short facts; notes are discovered from frontmatter.",
300
+ "When recalling, prefer memory_search to find notes, then memory_read(path=...) to fetch the full note.",
301
+ ],
302
+ parameters: Type.Object({
303
+ title: Type.String({ description: "Short note description stored in frontmatter and injected into context (max 300 chars). Not a heading." }),
304
+ content: Type.String({ description: "Markdown body only; no frontmatter. Include your own heading if useful." }),
305
+ slug: Type.Optional(Type.String({
306
+ minLength: 1,
307
+ maxLength: 80,
308
+ description: "Concise filename slug: letters/digits separated by hyphens, no date/path/extension. UTC date is prepended.",
309
+ })),
310
+ scope: SCOPE_WRITE,
311
+ root: ROOT_PARAMETER,
312
+ }),
313
+ async execute(args , exec ) {
314
+ if (!args.title.trim() || /[\x00-\x1f\x7f]/.test(args.title)) return failure("Description must be a nonempty single line.");
315
+ if (!args.content.trim()) return failure("Empty note body — nothing saved.");
316
+ if (args.slug !== undefined && !validNoteSlug(args.slug)) return failure("Invalid slug: use 1–80 letters/digits separated by hyphens, without date, path or extension.");
317
+ if (args.title.length > MAX_DESCRIPTION) return failure(`Title too long (${args.title.length} chars, max ${MAX_DESCRIPTION}).`);
318
+ const scope = args.scope ?? "local";
319
+ const target = targetStores(args.root, [scope], exec);
320
+ if (target.status === "error") return failure(target.message);
321
+ const store = target.stores[scope];
322
+ const mutation = await mutate(target.stores, scope, () => writeNote(store, args.title, args.content, args.slug));
323
+ if (mutation.status === "error") return failure(mutation.message);
324
+ const result = mutation.value;
325
+ if (result.duplicate) {
326
+ return { text: `Already indexed in ${scope} memory; no note created or overwritten:\n${(result.existing ?? []).map((entry) => `- ${formatEntry(entry)}`).join("\n")}\nRead the existing memo and use memory_update to revise it.` };
327
+ }
328
+ const similar = await similarInOtherScope(target.stores, store, result.indexLine);
329
+ return {
330
+ text: `Stored note: ${result.noteRel}\nMemory file: ${store.memoryFile}\nMemo ID: ${result.id}\nDescription: ${result.indexLine}\nMetadata is stored in the note, not duplicated in MEMORY.md.${similarWarning(similar, scope)}`,
331
+ };
332
+ },
333
+ },
334
+ {
335
+ name: "memory_update",
336
+ label: "Memory Update",
337
+ description:
338
+ "Update ONE existing memo by its exact eight-character metadata ID, preserving ID, creation date, note path and description. " +
339
+ "Default scope 'local'. Read the memo first. For facts, content replaces the fact (one line, max 300 chars); " +
340
+ "for notes, content replaces the full Markdown body excluding frontmatter. Blank content is rejected; identical content is a no-op. " +
341
+ "Actual changes return a recovery ID for memory_restore." +
342
+ WORKSPACE_GUIDANCE,
343
+ promptSnippet: "Revise one memo in place by its ID",
344
+ parameters: Type.Object({
345
+ id: Type.String({ pattern: "^[a-fA-F0-9]{8}$", description: "Exact memo ID from index metadata or a tool result (not a recovery ID)." }),
346
+ content: Type.String({ description: "Replacement fact or full Markdown note body; omit frontmatter." }),
347
+ scope: SCOPE_WRITE,
348
+ root: ROOT_PARAMETER,
349
+ }),
350
+ async execute(args , exec ) {
351
+ const scope = args.scope ?? "local";
352
+ const target = targetStores(args.root, [scope], exec);
353
+ if (target.status === "error") return failure(target.message);
354
+ const store = target.stores[scope];
355
+ const mutation = await mutate(target.stores, scope, () => updateMemo(store, args.id, args.content, queue));
356
+ if (mutation.status === "error") return failure(mutation.message);
357
+ const result = mutation.value;
358
+ if (result.status === "error") return failure(result.message);
359
+ return {
360
+ text: result.status === "unchanged"
361
+ ? `Memo ${args.id} unchanged; nothing written.`
362
+ : `Updated memo ${args.id} in ${scope} memory (${store.dir}). ID, creation date and note reference preserved; note last-modified-date updated.\nRecovery ID: ${result.recoveryId}\n${restoreHint(result.recoveryId, scope, args.root)}`,
363
+ };
364
+ },
365
+ },
366
+ {
367
+ name: "memory_read",
368
+ label: "Memory Read",
369
+ description:
370
+ "Read memory contents. Default: one-line facts plus a derived note catalogue for scope 'both' (global + local). " +
371
+ "Optional query filters index entries (case-insensitive substring). Optional path reads an existing note file. " +
372
+ "Optional id retrieves exactly one indexed memo, including note contents. Do not combine id with query/path. " +
373
+ "Ambiguous IDs/paths require an explicit scope; no guessing." +
374
+ WORKSPACE_GUIDANCE,
375
+ promptSnippet: "Read memory facts, the note catalogue, or one memo by ID/path",
376
+ parameters: Type.Object({
377
+ id: Type.Optional(Type.String({ pattern: "^[a-fA-F0-9]{8}$", description: "Exact memo ID; returns the index entry and linked note contents." })),
378
+ query: Type.Optional(Type.String({ description: "Case-insensitive substring filter over catalogue lines." })),
379
+ path: Type.Optional(Type.String({ description: "Note path to read, e.g. 'notes/2026-08-27-foo.md'." })),
380
+ scope: SCOPE_READ,
381
+ root: ROOT_PARAMETER,
382
+ }),
383
+ async execute(args , exec ) {
384
+ const scopes = resolveScopes(args.scope);
385
+ const target = targetStores(args.root, scopes, exec);
386
+ if (target.status === "error") return failure(target.message);
387
+ const preparationError = await prepareReads(target.stores, scopes);
388
+ if (preparationError) return failure(preparationError);
389
+
390
+ if (args.id !== undefined) {
391
+ if (args.query !== undefined || args.path !== undefined) return failure("Specify id alone, or use query/path without id.");
392
+ const memo = await readMemo(target.stores, args.id, scopes);
393
+ if (memo.status === "error") return failure(memo.message);
394
+ const out = `## ${memo.store.scope} memo ${args.id} — ${memo.store.memoryFile}\n\n${memo.entry.meta ?? ""}\n${memo.entry.line}` +
395
+ (memo.noteText !== undefined ? `\n\n## Note: ${memo.noteRel}\n\n${memo.noteText}` : "");
396
+ return { text: formatTruncated(truncateTail(out, NOTE_CAP)) };
397
+ }
398
+
399
+ if (args.path !== undefined) {
400
+ const resolved = resolveNotePath(target.stores, args.path, scopes);
401
+ if (resolved.status === "error") return failure(resolved.message);
402
+ const text = await readFile(resolved.absPath, "utf8");
403
+ return { text: formatTruncated(truncateTail(`## ${resolved.store.scope} note: ${resolved.rel}\n\n${text}`, NOTE_CAP)) };
404
+ }
405
+
406
+ const sections = [];
407
+ for (const scope of scopes) {
408
+ const store = target.stores[scope];
409
+ const text = await readCatalogue(store);
410
+ let body = text;
411
+ if (args.query) {
412
+ const q = args.query.toLowerCase();
413
+ body = parseEntries(text)
414
+ .filter((entry) => entry.line.toLowerCase().includes(q) || (entry.meta ?? "").toLowerCase().includes(q))
415
+ .map((entry) => `${entry.meta ? `${entry.meta}\n` : ""}${entry.line}`)
416
+ .join("\n");
417
+ }
418
+ const label = scope === "local" ? `local (${basename(target.root)})` : "global";
419
+ sections.push(`## ${label} memory — ${store.memoryFile}${existsSync(store.memoryFile) ? "" : " (missing)"}\n\n${body.trim() || "(empty)"}`);
420
+ }
421
+ return { text: formatTruncated(truncateTail(sections.join("\n\n"), READ_CAP)) };
422
+ },
423
+ },
424
+ {
425
+ name: "memory_search",
426
+ label: "Memory Search",
427
+ description:
428
+ "Search facts and note bodies with weighted lexical ranking, exact tokens, phrase/coverage boosts and matching-passage counts. Descriptions and tags weigh more; one result per note. Default scope 'both'." +
429
+ WORKSPACE_GUIDANCE,
430
+ promptSnippet: "Search memory facts and note bodies",
431
+ parameters: Type.Object({
432
+ query: Type.String({ description: "Search terms." }),
433
+ scope: SCOPE_READ,
434
+ root: ROOT_PARAMETER,
435
+ }),
436
+ async execute(args , exec ) {
437
+ const scopes = resolveScopes(args.scope);
438
+ const target = targetStores(args.root, scopes, exec);
439
+ if (target.status === "error") return failure(target.message);
440
+ const preparationError = await prepareReads(target.stores, scopes);
441
+ if (preparationError) return failure(preparationError);
442
+ const hits = await searchStores(target.stores, args.query, scopes, 20);
443
+ if (hits.length === 0) return { text: "No matches in memory." };
444
+ const lines = hits.map((hit) =>
445
+ `[${hit.scope}/${hit.file}] ${hit.id ? `[id=${hit.id}] ` : ""}${hit.line}${hit.matchLines > 1 ? ` (+${hit.matchLines - 1} more matching lines)` : ""}`,
446
+ );
447
+ return { text: formatTruncated(truncateTail(lines.join("\n"), READ_CAP)) };
448
+ },
449
+ },
450
+ {
451
+ name: "memory_forget",
452
+ label: "Memory Forget",
453
+ description:
454
+ "Delete facts or notes whose description, path or metadata contains the given text (case-insensitive); note bodies are not matched. Removes note files with recoverable snapshots. Returns a recovery ID for memory_restore. Default scope 'local'." +
455
+ WORKSPACE_GUIDANCE,
456
+ promptSnippet: "Forget facts or notes matching a string (recoverable)",
457
+ parameters: Type.Object({
458
+ text: Type.String({ description: "Text that identifies entries to delete." }),
459
+ scope: SCOPE_WRITE,
460
+ root: ROOT_PARAMETER,
461
+ }),
462
+ async execute(args , exec ) {
463
+ const scope = args.scope ?? "local";
464
+ const target = targetStores(args.root, [scope], exec);
465
+ if (target.status === "error") return failure(target.message);
466
+ const store = target.stores[scope];
467
+ const mutation = await mutate(target.stores, scope, () => forgetEntries(store, args.text, queue));
468
+ if (mutation.status === "error") return failure(mutation.message);
469
+ const result = mutation.value;
470
+ if (result.recoveryId === null) return { text: `No entries matching "${args.text}" in ${scope} memory.` };
471
+ return { text: `Removed ${result.removed.length} entr(y/ies) from ${scope} memory (${store.memoryFile}). Recovery id: ${result.recoveryId}\n${restoreHint(result.recoveryId, scope, args.root)}` };
472
+ },
473
+ },
474
+ {
475
+ name: "memory_restore",
476
+ label: "Memory Restore",
477
+ description:
478
+ "Restore entries deleted by memory_forget or undo a memory_update using its recovery ID. Refuses to overwrite later edits; conflicts retain the recovery record. Default scope 'both'; use the same root as the original change." +
479
+ WORKSPACE_GUIDANCE,
480
+ promptSnippet: "Undo a forget or update with its recovery ID",
481
+ parameters: Type.Object({
482
+ recoveryId: Type.String({ description: "Recovery id returned by memory_forget or memory_update." }),
483
+ scope: SCOPE_READ,
484
+ root: ROOT_PARAMETER,
485
+ }),
486
+ async execute(args , exec ) {
487
+ const scopes = resolveScopes(args.scope);
488
+ const target = targetStores(args.root, scopes, exec);
489
+ if (target.status === "error") return failure(target.message);
490
+ const result = await restoreEntry(
491
+ target.stores,
492
+ args.recoveryId,
493
+ queue,
494
+ scopes,
495
+ (store) => workspaceAccessError(target.stores, [store.scope]),
496
+ ensureStoreVersion,
497
+ );
498
+ if (!result) return { text: `No recovery record found for id ${args.recoveryId}.` };
499
+ if (result.status === "error") return failure(result.message);
500
+ return {
501
+ text: `Restored ${result.entries.length} entr(y/ies) to ${result.scope} memory:\n${result.entries.map((entry) => `- ${entry.line}`).join("\n")}`,
502
+ };
503
+ },
504
+ },
505
+ {
506
+ name: "memory_status",
507
+ label: "Memory Status",
508
+ description:
509
+ "Show memory file locations, sizes, entry counts, and current session's injected snapshot size. Optional root selects another existing workspace. Works before memory_init and never creates files.",
510
+ promptSnippet: "Show memory store locations, counts, and snapshot size",
511
+ parameters: Type.Object({ root: ROOT_PARAMETER }),
512
+ async execute(args , exec ) {
513
+ const target = targetStores(args.root, ["local"], exec);
514
+ if (target.status === "error") return failure(target.message);
515
+ return { text: await statusText(target.stores, ctx.config, target.root) };
516
+ },
517
+ },
518
+ {
519
+ name: "memory_migrate",
520
+ label: "Memory Migrate",
521
+ description:
522
+ "Migrate a legacy plain-file memory store into this workspace's shared store. Detects a pi-remember v1 store at <workspace>/.pi/memory (or an in-place copy missing only the marker). " +
523
+ "Copies current facts and notes only; recovery records, migration backups and other history are left behind. " +
524
+ "Never overwrites an initialized target. Use dryRun first when unsure, and ask the user before migrating.",
525
+ promptSnippet: "Migrate a legacy pi-remember store into this workspace",
526
+ parameters: Type.Object({
527
+ source: Type.Optional(Type.String({ description: "Legacy store directory. Defaults to <workspace>/.pi/memory." })),
528
+ target: Type.Optional(Type.String({ description: `Destination store directory. Defaults to <workspace>/${ctx.config.dirName}/memory.` })),
529
+ dryRun: Type.Optional(Type.Boolean({ description: "Plan and report without writing anything." })),
530
+ root: ROOT_PARAMETER,
531
+ }),
532
+ async execute(args , exec ) {
533
+ const cwd = args.root?.trim() ? resolve(exec.cwd, expandHome(args.root.trim())) : exec.cwd;
534
+ const explicit = Boolean(args.source?.trim() || args.target?.trim());
535
+ let source = args.source?.trim() ? resolve(cwd, expandHome(args.source.trim())) : legacySource(cwd);
536
+ let target = args.target?.trim() ? resolve(cwd, expandHome(args.target.trim())) : join(cwd, ctx.config.dirName, "memory");
537
+ if (!explicit) {
538
+ const detection = await detectForWorkspace(cwd, ctx.config);
539
+ if (!detection) return { text: `No legacy memory store found for ${cwd} (looked for ${legacySource(cwd)} and an in-place marker in ${target}).` };
540
+ source = detection.source;
541
+ target = detection.target;
542
+ }
543
+ try {
544
+ const report = await migrate({ source, target, dryRun: args.dryRun === true });
545
+ return { text: formatReport(report) };
546
+ } catch (error) {
547
+ return failure(`Migration refused: ${(error ).message}`);
548
+ }
549
+ },
550
+ },
551
+ ];
552
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@seangle/pi-remember",
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": "Portable plain-file memory for the Pi coding agent: facts, notes, stable memo IDs, lexical search, and a session snapshot.",
5
+ "license": "Apache-2.0",
6
+ "author": "Esensats",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Esensats/agents-remember.git",
10
+ "directory": "packages/pi"
11
+ },
12
+ "homepage": "https://github.com/Esensats/agents-remember/tree/main/packages/pi#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Esensats/agents-remember/issues"
15
+ },
16
+ "keywords": ["pi-package", "pi-extension", "memory", "persistent-memory"],
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "type": "module",
22
+ "pi": {
23
+ "extensions": [
24
+ "./dist/index.js"
25
+ ]
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "CHANGELOG.md",
31
+ "LICENSE"
32
+ ],
33
+ "dependencies": {
34
+ "@seangle/remember-core": "^0.1.0",
35
+ "@seangle/remember-migrators": "^0.1.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "typebox": "*"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.15.0"
44
+ },
45
+ "scripts": {
46
+ "prepack": "node ../../scripts/build.mjs",
47
+ "build": "node ../../scripts/build.mjs",
48
+ "test": "node --test test/*.test.ts"
49
+ }
50
+ }