@gavin-hu/my-pi-agent 0.11.4 → 0.13.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/README.md +38 -26
- package/README.zh-CN.md +33 -23
- package/extensions/bytefly/README.md +381 -225
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +52 -6
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +30 -2
- package/extensions/bytefly/footer/types.ts +12 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +15 -4
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +293 -48
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +87 -0
- package/extensions/bytefly/update/register.ts +158 -0
- package/extensions/bytefly/update/state.ts +40 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +24 -14
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +14 -5
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +45 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +56 -14
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +83 -36
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +84 -19
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +91 -2
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +246 -0
- package/extensions/plan/runtime.ts +73 -24
- package/extensions/plan/steps.ts +60 -39
- package/extensions/plan/todos.ts +29 -60
- package/extensions/plan/tools.ts +188 -244
- package/extensions/plan/tui.ts +18 -31
- package/extensions/plan/types.ts +12 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +7 -2
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +106 -41
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -3
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +46 -16
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +20 -21
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +14 -3
- package/extensions/web-access/text.ts +50 -11
- package/extensions/wechat/README.md +5 -4
- package/extensions/wechat/tools.ts +0 -2
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +12 -9
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +340 -46
- package/lib/todo-state.ts +62 -0
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/bytefly.schema.json +0 -785
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -39,9 +39,15 @@ How the `memory` call and its result look in the transcript — the call line as
|
|
|
39
39
|
- Re-reads each store when its file changes on disk (compared by mtime and size),
|
|
40
40
|
so an external edit lands in the same session without a restart.
|
|
41
41
|
- Exposes `/memory`: in the TUI it opens a picker to add, edit, or forget a
|
|
42
|
-
single note,
|
|
43
|
-
`/memory path`, `edit`, and `clear` print the file paths,
|
|
44
|
-
editor, and clear a scope
|
|
42
|
+
single note, merge two notes into one, move a note to the other scope, filter
|
|
43
|
+
by scope, and reload; elsewhere it lists the store. `/memory path`, `edit`, and `clear` print the file paths,
|
|
44
|
+
open the multi-line editor, and clear a scope; `/memory doctor` reports store
|
|
45
|
+
health.
|
|
46
|
+
- Reports store health with `/memory doctor`: note counts and file bytes per
|
|
47
|
+
scope, how much of the `maxInjectBytes` budget the injection uses and which
|
|
48
|
+
scope overflowed it, and the near-duplicate pairs the conservative key already
|
|
49
|
+
considers one fact. It is read-only — the report names the pair and the picker
|
|
50
|
+
merges it.
|
|
45
51
|
- Loads and writes the project store only when the project is trusted, sanitizes
|
|
46
52
|
every note to one terminal-safe line, and writes atomically.
|
|
47
53
|
|
|
@@ -91,24 +97,49 @@ or stem both survive.
|
|
|
91
97
|
|
|
92
98
|
| Command | What it does |
|
|
93
99
|
|---|---|
|
|
94
|
-
| `/memory` | In the TUI, open the picker (add, edit, or
|
|
100
|
+
| `/memory` | In the TUI, open the picker (add, edit, forget, merge, or move notes; `s`/Tab filters by scope; `r` reloads). Other modes notify the stored notes. |
|
|
95
101
|
| `/memory list` | Notify the stored notes and counts. |
|
|
96
102
|
| `/memory path` | Notify the two file paths and whether the project store is trusted. |
|
|
97
103
|
| `/memory reload` | Re-read the stores from disk and report what changed. |
|
|
104
|
+
| `/memory doctor` | Report counts, file bytes, the injection budget with the notes it omits, and near-duplicate pairs. Read-only. |
|
|
98
105
|
| `/memory edit [project\|global]` | Open the multi-line editor prefilled with the file and save the result. |
|
|
99
106
|
| `/memory clear [project\|global]` | Clear a scope after a confirmation. |
|
|
100
107
|
|
|
101
108
|
Bulk deletion lives only in the command, behind a confirm — the model cannot wipe
|
|
102
109
|
memory in one call.
|
|
103
110
|
|
|
111
|
+
### Picker keys
|
|
112
|
+
|
|
113
|
+
| Key | Action |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `Enter` / `e` | Edit the focused note in the shared inline editor. |
|
|
116
|
+
| `a` | Add a note to the active scope. |
|
|
117
|
+
| `d` | Forget the focused note after a `y`/`N` confirm. |
|
|
118
|
+
| `m` | Mark the focused note as a merge source; `m` on a second note in the same scope opens the editor pre-filled with `"<first>; <second>"`. `m` on the marked note unmarks it. |
|
|
119
|
+
| `p` | Move the focused note to the other scope: project → global, or global → project. No confirmation; `p` again moves it back, and a move into an untrusted project is refused. |
|
|
120
|
+
| `s` / `Tab` | Cycle the scope filter. |
|
|
121
|
+
| `r` | Re-read the stores. |
|
|
122
|
+
| `Esc` | Cancel the mark, the editor, or the screen, in that order. |
|
|
123
|
+
|
|
124
|
+
A merge is one write: the store replaces the first note in place and drops the
|
|
125
|
+
second, so a cancelled or failed merge leaves both notes as they were. Two notes
|
|
126
|
+
in different scopes are refused — the stores are separate files.
|
|
127
|
+
|
|
128
|
+
A move is two writes across those files, ordered target first: the note is added
|
|
129
|
+
to the destination and only then removed from the source. If the second write
|
|
130
|
+
fails the note is in both scopes — a duplicate `/memory doctor` reports and `m`
|
|
131
|
+
merges — rather than gone. A note the destination already holds is not
|
|
132
|
+
duplicated, and the move still removes it from the source.
|
|
133
|
+
|
|
104
134
|
## Behaviour by mode
|
|
105
135
|
|
|
106
136
|
The tool and the context injection work in every mode (`tui`, `rpc`, `json`,
|
|
107
|
-
`print`)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
`print`), and `/memory doctor` reports through `notify` everywhere. The picker
|
|
138
|
+
opened by a bare `/memory` requires `ctx.mode === "tui"`, and `/memory edit` and
|
|
139
|
+
`/memory clear` require `ctx.hasUI`; in JSON and print sessions the command
|
|
140
|
+
reports that the interactive form needs a TUI session. Memory itself is external
|
|
141
|
+
state, so it is not reconstructed from the session tree and tree navigation does
|
|
142
|
+
not change it.
|
|
112
143
|
|
|
113
144
|
## Configuration
|
|
114
145
|
|
|
@@ -152,10 +183,11 @@ skill. Mitigations:
|
|
|
152
183
|
- The near-duplicate guard on `add` is deliberately conservative: it folds
|
|
153
184
|
case, whitespace runs, and trailing sentence punctuation only, never word
|
|
154
185
|
order, stemming, or internal punctuation. Two paraphrases can therefore still
|
|
155
|
-
both be stored;
|
|
186
|
+
both be stored; `/memory doctor` reports the pairs the key does consider equal,
|
|
187
|
+
and `m` in the picker merges them under wording you write.
|
|
156
188
|
- `update` replaces every exact match of `text`; a replacement that already
|
|
157
189
|
exists under a different note can leave a duplicate. Forget the old note first
|
|
158
|
-
when consolidating.
|
|
190
|
+
when consolidating, or merge the pair in the picker afterward.
|
|
159
191
|
- Only one note per line; multi-line notes are collapsed to a single line.
|
|
160
192
|
|
|
161
193
|
## Non-goals
|
|
@@ -173,13 +205,22 @@ when consolidating.
|
|
|
173
205
|
|---|---|
|
|
174
206
|
| Registration | The default export registers the `memory` tool, the `/memory` command, and four event listeners; nothing long-lived. |
|
|
175
207
|
| Events | `session_start` loads config and both stores; `before_agent_start` injects the `[MEMORY]` message; `context` keeps only the newest injection; `session_shutdown` clears the cache. The runtime refreshes both stores from their file stamps whenever the notes are read. |
|
|
176
|
-
| Tool |
|
|
208
|
+
| Tool | direct exposure (the default), `executionMode: "sequential"`, `annotations` and `outputSchema` as above. |
|
|
177
209
|
| State | External markdown, **not** branch state: the tool result's `details`/`structuredContent` mirror the call but the notes are read from disk and survive independently of `/resume` and `/tree`. |
|
|
178
210
|
| UI | `ctx.ui.notify`, `editor`, and `confirm`, guarded by `ctx.hasUI`, plus the `/memory` picker (`ctx.ui.custom`) in the TUI. |
|
|
179
211
|
| Plan mode | Blocked — `plan`'s read-only policy allows only `readOnlyHint` tools, so memory cannot be mutated while planning; recall still works. |
|
|
180
212
|
|
|
181
213
|
## Design notes
|
|
182
214
|
|
|
215
|
+
- **Health reads the injection it describes.** `injectionPlan` is the same
|
|
216
|
+
cap arithmetic `buildInjection` joins into the context body, so the doctor
|
|
217
|
+
cannot claim notes fit while recall drops them; `buildInjection` is a join over
|
|
218
|
+
that plan, which is why the report needed no second budget model.
|
|
219
|
+
- **A merge is one transform.** `applyMerge` replaces the first note and drops
|
|
220
|
+
the second inside the same read-modify-write the other mutations use, so a
|
|
221
|
+
cancelled or failed merge cannot leave one note gone and the other not. The
|
|
222
|
+
editor is pre-filled with both notes, but the wording saved is the user's — the
|
|
223
|
+
duplicate rule stays conservative and nothing is folded automatically.
|
|
183
224
|
- **External storage, deliberately.** `goal`/`todo` keep state in tool-result
|
|
184
225
|
`details` because it follows the active branch. Memory is the opposite: it must
|
|
185
226
|
outlive the branch and be editable outside Pi, so it is plain files read at
|
|
@@ -207,11 +248,12 @@ when consolidating.
|
|
|
207
248
|
| `types.ts` | `MemoryScope`, `MemoryAction`, `MemoryDetails`, the context type. |
|
|
208
249
|
| `config.ts` | Defaults and the `memory` section of `bytefly.json` loading/clamping. |
|
|
209
250
|
| `schema.ts` | TypeBox params/result and pure validation of action, scope, and text. |
|
|
210
|
-
| `store.ts` | Paths, parse/read, file stamps, add/update/forget/clear transforms, atomic queued write. |
|
|
251
|
+
| `store.ts` | Paths, parse/read, file stamps, add/update/forget/merge/clear transforms, atomic queued write. |
|
|
252
|
+
| `doctor.ts` | The health report (pure): counts, file bytes, the injection plan's omissions, and near-duplicate pairs. |
|
|
211
253
|
| `runtime.ts` | Session view of both stores, stamp-based refresh, trust gating, mutations. |
|
|
212
254
|
| `format.ts` | Pure call/result/notice text and the bounded injection body. |
|
|
213
255
|
| `errors.ts` | `messageOf`, the shared error-text extraction for the tool and command. |
|
|
214
|
-
| `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
|
|
256
|
+
| `tui.ts` | The `/memory` picker component (note list, inline editor, merge mark, scope move, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
|
|
215
257
|
| `tools.ts` | `registerTool("memory", …)`. |
|
|
216
258
|
| `commands.ts` | `registerCommand("memory", …)`. |
|
|
217
259
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
14
14
|
import { messageOf } from "./errors.ts";
|
|
15
|
+
import { formatHealth, memoryHealth } from "./doctor.ts";
|
|
15
16
|
import { formatNotice, scopeLabel } from "./format.ts";
|
|
16
17
|
import type { MemoryRuntime } from "./runtime.ts";
|
|
17
18
|
import { MemoryPickerComponent } from "./tui.ts";
|
|
@@ -30,10 +31,10 @@ function notify(ctx: ExtensionCommandContext, message: string, kind: "info" | "w
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** `/memory` subcommands and the scope argument accepted by `edit`/`clear`. */
|
|
33
|
-
const MEMORY_SUBCOMMANDS = ["list", "path", "reload", "edit", "clear"];
|
|
34
|
+
const MEMORY_SUBCOMMANDS = ["list", "path", "reload", "doctor", "edit", "clear"];
|
|
34
35
|
|
|
35
36
|
/** Usage line shared by the command description and the unknown-subcommand warning. */
|
|
36
|
-
const MEMORY_USAGE = "/memory [list | path | reload | edit [project|global] | clear [project|global]]";
|
|
37
|
+
const MEMORY_USAGE = "/memory [list | path | reload | doctor | edit [project|global] | clear [project|global]]";
|
|
37
38
|
|
|
38
39
|
/** Subcommand on the first token, scope on the second for `edit`/`clear`. */
|
|
39
40
|
function completeMemoryArg(prefix: string): Array<{ value: string; label: string }> | null {
|
|
@@ -74,6 +75,8 @@ async function openPicker(pi: ExtensionAPI, runtime: MemoryRuntime, ctx: Extensi
|
|
|
74
75
|
add: (scope, text) => runtime.add(scope, text),
|
|
75
76
|
update: (scope, from, to) => runtime.update(scope, from, to),
|
|
76
77
|
forget: (scope, entry) => runtime.forget(scope, entry),
|
|
78
|
+
merge: (scope, first, second, merged) => runtime.merge(scope, first, second, merged),
|
|
79
|
+
move: (from, to, text) => runtime.move(from, to, text),
|
|
77
80
|
},
|
|
78
81
|
() => {
|
|
79
82
|
stopTimer();
|
|
@@ -140,6 +143,21 @@ export function registerCommands(pi: ExtensionAPI, runtime: MemoryRuntime): void
|
|
|
140
143
|
);
|
|
141
144
|
return;
|
|
142
145
|
}
|
|
146
|
+
case "doctor": {
|
|
147
|
+
// Read-only: the report names what to fix, and `m` in the picker merges a pair.
|
|
148
|
+
notify(
|
|
149
|
+
ctx,
|
|
150
|
+
formatHealth(
|
|
151
|
+
memoryHealth({
|
|
152
|
+
entries: runtime.entries(),
|
|
153
|
+
sizes: runtime.sizes(),
|
|
154
|
+
trusted: runtime.trusted(),
|
|
155
|
+
config: runtime.config(),
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
143
161
|
case "edit": {
|
|
144
162
|
if (!ctx.hasUI) {
|
|
145
163
|
notify(ctx, "Editing memory requires an interactive session.", "warning");
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory health: what the two stores hold, and whether recall is already
|
|
3
|
+
* dropping notes (pure).
|
|
4
|
+
*
|
|
5
|
+
* The report answers the questions a user cannot see from the notes alone: how
|
|
6
|
+
* many notes and bytes each store carries, how much of the `maxInjectBytes`
|
|
7
|
+
* budget the injection uses and which scope overflowed it, and which notes the
|
|
8
|
+
* conservative `duplicateKey` already considers the same fact. It never changes
|
|
9
|
+
* anything — consolidation is the picker's `m` merge, and the doctor only names
|
|
10
|
+
* the pair to merge.
|
|
11
|
+
*
|
|
12
|
+
* Byte counts are the store file's size on disk (what a user can reason about
|
|
13
|
+
* and compare with the cap), plus the injected body's size; both are exact UTF-8
|
|
14
|
+
* bytes, not a rounded display.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { clip } from "../../lib/tui/columns.ts";
|
|
18
|
+
import type { MemoryConfig } from "./config.ts";
|
|
19
|
+
import { injectionPlan, type MemoryEntries } from "./format.ts";
|
|
20
|
+
import { duplicateKey } from "./store.ts";
|
|
21
|
+
import { MEMORY_SCOPES, type MemoryScope } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** Longest note text a duplicate line shows. */
|
|
24
|
+
const PAIR_NOTE_WIDTH = 60;
|
|
25
|
+
/** Label column for the report's rows. */
|
|
26
|
+
const LABEL_WIDTH = 11;
|
|
27
|
+
|
|
28
|
+
/** One store's counts. */
|
|
29
|
+
export interface ScopeHealth {
|
|
30
|
+
notes: number;
|
|
31
|
+
/** File size in bytes; `undefined` when the store was not read. */
|
|
32
|
+
bytes?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Two notes the comparison key treats as one fact, in stored order. */
|
|
36
|
+
export interface DuplicatePair {
|
|
37
|
+
scope: MemoryScope;
|
|
38
|
+
/** The note `add` would have kept. */
|
|
39
|
+
kept: string;
|
|
40
|
+
/** The note `add` would have refused as a near-duplicate. */
|
|
41
|
+
duplicate: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MemoryHealth {
|
|
45
|
+
trusted: boolean;
|
|
46
|
+
inject: boolean;
|
|
47
|
+
/** The `maxInjectBytes` cap in effect. */
|
|
48
|
+
cap: number;
|
|
49
|
+
project: ScopeHealth;
|
|
50
|
+
global: ScopeHealth;
|
|
51
|
+
/** Present while injection is on: the body's bytes and what did not fit. */
|
|
52
|
+
injection?: { bytes: number; omitted: Record<MemoryScope, number> };
|
|
53
|
+
duplicates: DuplicatePair[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HealthInput {
|
|
57
|
+
entries: MemoryEntries;
|
|
58
|
+
sizes: Record<MemoryScope, number | undefined>;
|
|
59
|
+
trusted: boolean;
|
|
60
|
+
config: MemoryConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every near-duplicate pair, computed with the store's own comparison key. */
|
|
64
|
+
export function duplicatePairs(entries: MemoryEntries): DuplicatePair[] {
|
|
65
|
+
const pairs: DuplicatePair[] = [];
|
|
66
|
+
for (const scope of MEMORY_SCOPES) {
|
|
67
|
+
const kept = new Map<string, string>();
|
|
68
|
+
for (const note of entries[scope]) {
|
|
69
|
+
const key = duplicateKey(note);
|
|
70
|
+
const first = kept.get(key);
|
|
71
|
+
if (first === undefined) kept.set(key, note);
|
|
72
|
+
else pairs.push({ scope, kept: first, duplicate: note });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return pairs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Derive the report from the current stores and config. */
|
|
79
|
+
export function memoryHealth({ entries, sizes, trusted, config }: HealthInput): MemoryHealth {
|
|
80
|
+
const scope = (name: MemoryScope): ScopeHealth => {
|
|
81
|
+
const size = sizes[name];
|
|
82
|
+
return size === undefined ? { notes: entries[name].length } : { notes: entries[name].length, bytes: size };
|
|
83
|
+
};
|
|
84
|
+
const plan = config.inject ? injectionPlan(entries, config.maxInjectBytes) : undefined;
|
|
85
|
+
return {
|
|
86
|
+
trusted,
|
|
87
|
+
inject: config.inject,
|
|
88
|
+
cap: config.maxInjectBytes,
|
|
89
|
+
project: scope("project"),
|
|
90
|
+
global: scope("global"),
|
|
91
|
+
...(plan ? { injection: { bytes: Buffer.byteLength(plan.lines.join("\n"), "utf-8"), omitted: plan.omitted } } : {}),
|
|
92
|
+
duplicates: duplicatePairs(entries),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function row(label: string, text: string): string {
|
|
97
|
+
return ` ${label.padEnd(LABEL_WIDTH)}${text}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `3 notes · 412 B`, or the reason a store was not read. */
|
|
101
|
+
function scopeText(health: MemoryHealth, name: MemoryScope): string {
|
|
102
|
+
const scope = health[name];
|
|
103
|
+
if (name === "project" && !health.trusted) return "unavailable (untrusted project)";
|
|
104
|
+
const notes = `${scope.notes} note${scope.notes === 1 ? "" : "s"}`;
|
|
105
|
+
if (scope.bytes === undefined) return scope.notes === 0 ? notes : `${notes} · size unknown`;
|
|
106
|
+
return `${notes} · ${scope.bytes} B`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function injectionText(health: MemoryHealth): string {
|
|
110
|
+
if (!health.inject) return "off (memory.inject is false)";
|
|
111
|
+
if (!health.injection) return "nothing to inject";
|
|
112
|
+
const { bytes, omitted } = health.injection;
|
|
113
|
+
const missing = omitted.project + omitted.global;
|
|
114
|
+
if (missing === 0) return `${bytes} B of ${health.cap} B · all notes fit`;
|
|
115
|
+
const scopes = MEMORY_SCOPES.filter((name) => omitted[name] > 0).map((name) => `${name} ${omitted[name]}`);
|
|
116
|
+
return `${bytes} B of ${health.cap} B · ${missing} omitted (${scopes.join(", ")})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pairLine(pair: DuplicatePair): string {
|
|
120
|
+
const kept = clip(pair.kept, PAIR_NOTE_WIDTH);
|
|
121
|
+
const duplicate = clip(pair.duplicate, PAIR_NOTE_WIDTH);
|
|
122
|
+
return ` ${pair.scope} "${kept}" ≈ "${duplicate}"`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The `/memory doctor` notice: plain text, one row per fact. */
|
|
126
|
+
export function formatHealth(health: MemoryHealth): string {
|
|
127
|
+
const count = health.duplicates.length;
|
|
128
|
+
const duplicates = count === 0 ? "none" : `${count} pair${count === 1 ? "" : "s"} (merge with m in /memory)`;
|
|
129
|
+
return [
|
|
130
|
+
"Memory health",
|
|
131
|
+
row("project", scopeText(health, "project")),
|
|
132
|
+
row("global", scopeText(health, "global")),
|
|
133
|
+
row("injection", injectionText(health)),
|
|
134
|
+
row("duplicates", duplicates),
|
|
135
|
+
...health.duplicates.map(pairLine),
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
@@ -104,13 +104,21 @@ export function formatNotice(entries: MemoryEntries): string {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* The hidden `[MEMORY]` context body,
|
|
107
|
+
* The hidden `[MEMORY]` context body split into its lines, plus what did not fit.
|
|
108
108
|
*
|
|
109
109
|
* Sections are emitted project-then-global. When the notes do not fit under
|
|
110
110
|
* `maxBytes`, the remainder is replaced by one `(+N more)` tail so the model
|
|
111
|
-
* knows to call the tool for the rest.
|
|
111
|
+
* knows to call the tool for the rest. `omitted` says which store overflowed, for
|
|
112
|
+
* the health report; `buildInjection` only needs the lines.
|
|
112
113
|
*/
|
|
113
|
-
export
|
|
114
|
+
export interface InjectionPlan {
|
|
115
|
+
lines: string[];
|
|
116
|
+
/** Notes that did not fit, per scope. */
|
|
117
|
+
omitted: Record<MemoryScope, number>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Plan the injected body, or `undefined` when nothing is stored. */
|
|
121
|
+
export function injectionPlan(entries: MemoryEntries, maxBytes: number): InjectionPlan | undefined {
|
|
114
122
|
const sections: Array<[MemoryScope, string[]]> = [];
|
|
115
123
|
if (entries.project.length > 0) sections.push(["project", entries.project]);
|
|
116
124
|
if (entries.global.length > 0) sections.push(["global", entries.global]);
|
|
@@ -119,7 +127,7 @@ export function buildInjection(entries: MemoryEntries, maxBytes: number): string
|
|
|
119
127
|
const lines = [MEMORY_CONTEXT_MARKER, INTRO];
|
|
120
128
|
let used = lines.reduce((total, line) => total + bytes(line) + 1, 0);
|
|
121
129
|
const limit = Math.max(0, maxBytes - TAIL_RESERVE);
|
|
122
|
-
|
|
130
|
+
const omitted: Record<MemoryScope, number> = { project: 0, global: 0 };
|
|
123
131
|
|
|
124
132
|
for (const [scope, list] of sections) {
|
|
125
133
|
const header = `${scope}:`;
|
|
@@ -127,7 +135,7 @@ export function buildInjection(entries: MemoryEntries, maxBytes: number): string
|
|
|
127
135
|
for (const entry of list) {
|
|
128
136
|
const line = `- ${entry}`;
|
|
129
137
|
if (used + bytes(header) + 1 + bytes(line) + 1 > limit) {
|
|
130
|
-
omitted += 1;
|
|
138
|
+
omitted[scope] += 1;
|
|
131
139
|
continue;
|
|
132
140
|
}
|
|
133
141
|
rendered.push(line);
|
|
@@ -138,6 +146,15 @@ export function buildInjection(entries: MemoryEntries, maxBytes: number): string
|
|
|
138
146
|
used += bytes(header) + 1;
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const missing = omitted.project + omitted.global;
|
|
150
|
+
if (missing > 0) lines.push(`- (+${missing} more; use the memory tool)`);
|
|
151
|
+
return { lines, omitted };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The hidden `[MEMORY]` context body, or `undefined` when nothing is stored.
|
|
156
|
+
* Bounded by `maxBytes`, with one tail naming what did not fit.
|
|
157
|
+
*/
|
|
158
|
+
export function buildInjection(entries: MemoryEntries, maxBytes: number): string | undefined {
|
|
159
|
+
return injectionPlan(entries, maxBytes)?.lines.join("\n");
|
|
143
160
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
applyAdd,
|
|
16
16
|
applyClear,
|
|
17
17
|
applyForget,
|
|
18
|
+
applyMerge,
|
|
18
19
|
applyUpdate,
|
|
19
20
|
memoryFileStamp,
|
|
20
21
|
mutateMemoryFile,
|
|
@@ -40,6 +41,13 @@ interface AddResult {
|
|
|
40
41
|
existing?: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Outcome of a scope move: whether the source lost the note, and a target match. */
|
|
45
|
+
export interface MoveResult {
|
|
46
|
+
moved: boolean;
|
|
47
|
+
/** The note the target store already had, when it matched as a near-duplicate. */
|
|
48
|
+
existing?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
export interface MemoryRuntime {
|
|
44
52
|
/** Resolve paths and load notes for a session; returns a warning message, if any. */
|
|
45
53
|
load(cwd: string, trusted: boolean): Promise<string | undefined>;
|
|
@@ -61,6 +69,16 @@ export interface MemoryRuntime {
|
|
|
61
69
|
update(scope: MemoryScope, from: string, to: string): Promise<boolean>;
|
|
62
70
|
forget(scope: MemoryScope, entry: string): Promise<boolean>;
|
|
63
71
|
clear(scope: MemoryScope): Promise<boolean>;
|
|
72
|
+
/** File size of each store in bytes; `undefined` when it was not read. */
|
|
73
|
+
sizes(): Record<MemoryScope, number | undefined>;
|
|
74
|
+
/** Merge two notes in `scope` into one, in a single write. */
|
|
75
|
+
merge(scope: MemoryScope, first: string, second: string, merged: string): Promise<boolean>;
|
|
76
|
+
/**
|
|
77
|
+
* Move a note from one scope to the other: written to the target first, then
|
|
78
|
+
* removed from the source, so a failure between the two leaves a duplicate
|
|
79
|
+
* rather than losing the note.
|
|
80
|
+
*/
|
|
81
|
+
move(from: MemoryScope, to: MemoryScope, entry: string): Promise<MoveResult>;
|
|
64
82
|
/** Drop cached state on shutdown. */
|
|
65
83
|
reset(): void;
|
|
66
84
|
}
|
|
@@ -150,6 +168,14 @@ export function createMemoryRuntime(pi: Pick<ExtensionAPI, "exec">): MemoryRunti
|
|
|
150
168
|
},
|
|
151
169
|
config: () => config,
|
|
152
170
|
paths: () => paths,
|
|
171
|
+
sizes: () => {
|
|
172
|
+
// A store we may not read reports no size, the same way `refresh` refuses
|
|
173
|
+
// to cache a stamp for it.
|
|
174
|
+
return {
|
|
175
|
+
project: trusted ? stampOf("project")?.size : undefined,
|
|
176
|
+
global: stampOf("global")?.size,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
153
179
|
trusted: () => trusted,
|
|
154
180
|
entries: () => {
|
|
155
181
|
refresh();
|
|
@@ -188,6 +214,29 @@ export function createMemoryRuntime(pi: Pick<ExtensionAPI, "exec">): MemoryRunti
|
|
|
188
214
|
commit(scope, result.entries);
|
|
189
215
|
return result.changed;
|
|
190
216
|
},
|
|
217
|
+
async merge(scope, first, second, merged) {
|
|
218
|
+
requireTrusted(scope);
|
|
219
|
+
const result = await mutateMemoryFile(pathFor(scope), (content) => applyMerge(content, first, second, merged));
|
|
220
|
+
commit(scope, result.entries);
|
|
221
|
+
return result.changed;
|
|
222
|
+
},
|
|
223
|
+
async move(from, to, entry) {
|
|
224
|
+
if (from === to) throw new Error(`The note is already in ${scopeLabel(from)} memory.`);
|
|
225
|
+
// Refuse before any write: an untrusted target, or a note that is not in the
|
|
226
|
+
// source, must leave both stores exactly as they were.
|
|
227
|
+
requireTrusted(to);
|
|
228
|
+
// `entries()` refreshes both stores from disk first, so the check is against
|
|
229
|
+
// what is on disk now, not the cache a previous call left behind.
|
|
230
|
+
if (!this.entries()[from].includes(entry)) {
|
|
231
|
+
throw new Error(`No ${scopeLabel(from)} memory note matches "${entry}".`);
|
|
232
|
+
}
|
|
233
|
+
// Target first: the one failure possible across two files then leaves the note
|
|
234
|
+
// in both scopes, where `/memory doctor` reports it and `m` merges it, instead
|
|
235
|
+
// of deleting the only copy.
|
|
236
|
+
const added = await this.add(to, entry);
|
|
237
|
+
const moved = await this.forget(from, entry);
|
|
238
|
+
return added.existing === undefined ? { moved } : { moved, existing: added.existing };
|
|
239
|
+
},
|
|
191
240
|
reset: () => {
|
|
192
241
|
paths = undefined;
|
|
193
242
|
config = DEFAULT_MEMORY_CONFIG;
|
|
@@ -180,6 +180,37 @@ export function applyUpdate(content: string, from: string, to: string): FileChan
|
|
|
180
180
|
return changed ? { content: lines.join("\n"), changed: true } : { content, changed: false };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Merge two notes into one: replace `first` in place with `merged` and drop
|
|
185
|
+
* `second`, so the pair becomes a single bullet where the first one stood. One
|
|
186
|
+
* transform, so the caller's write is atomic; a missing note or two identical
|
|
187
|
+
* names changes nothing.
|
|
188
|
+
*/
|
|
189
|
+
export function applyMerge(content: string, first: string, second: string, merged: string): FileChange {
|
|
190
|
+
if (first === second) return { content, changed: false };
|
|
191
|
+
const lines = content.split(/\r?\n/);
|
|
192
|
+
const notes = lines.map((line) => {
|
|
193
|
+
const match = BULLET_PARTS.exec(line);
|
|
194
|
+
return match ? sanitizeLine(match[2]) : undefined;
|
|
195
|
+
});
|
|
196
|
+
if (!notes.includes(first) || !notes.includes(second)) return { content, changed: false };
|
|
197
|
+
|
|
198
|
+
let changed = false;
|
|
199
|
+
const out: string[] = [];
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const match = BULLET_PARTS.exec(line);
|
|
202
|
+
if (match && sanitizeLine(match[2]) === first) {
|
|
203
|
+
out.push(`${match[1]}${merged}${match[3]}`);
|
|
204
|
+
changed = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (match && sanitizeLine(match[2]) === second) continue;
|
|
208
|
+
out.push(line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return changed ? { content: endWithNewline(out.join("\n")), changed: true } : { content, changed: false };
|
|
212
|
+
}
|
|
213
|
+
|
|
183
214
|
/** Remove every note, keeping the header and any prose. */
|
|
184
215
|
export function applyClear(content: string): FileChange {
|
|
185
216
|
let changed = false;
|