@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.
Files changed (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. 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, filter by scope, and reload; elsewhere it lists the store.
43
- `/memory path`, `edit`, and `clear` print the file paths, open the multi-line
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 forget a note; `s`/Tab filters by scope; `r` reloads). Other modes notify the stored notes. |
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`). The picker opened by a bare `/memory` requires `ctx.mode === "tui"`, and
108
- `/memory edit` and `/memory clear` require `ctx.hasUI`; in JSON and print sessions
109
- the command reports that the interactive form needs a TUI session. Memory itself is
110
- external state, so it is not reconstructed from the session tree and tree
111
- navigation does not change it.
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; use `update` to consolidate them.
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 | `exposure: "direct"`, `executionMode: "sequential"`, `annotations` and `outputSchema` as above. |
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, or `undefined` when nothing is stored.
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 function buildInjection(entries: MemoryEntries, maxBytes: number): string | undefined {
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
- let omitted = 0;
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
- if (omitted > 0) lines.push(`- (+${omitted} more; use the memory tool)`);
142
- return lines.join("\n");
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;