@gavin-hu/my-pi-agent 0.12.0 → 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 (153) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +139 -67
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +36 -6
  7. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  8. package/extensions/bytefly/footer/config.ts +7 -1
  9. package/extensions/bytefly/footer/delegation.ts +100 -0
  10. package/extensions/bytefly/footer/register.ts +13 -2
  11. package/extensions/bytefly/footer/render/lines.ts +2 -0
  12. package/extensions/bytefly/footer/render/segments.ts +27 -0
  13. package/extensions/bytefly/footer/types.ts +11 -0
  14. package/extensions/bytefly/index.ts +4 -3
  15. package/extensions/bytefly/notify/config.ts +2 -0
  16. package/extensions/bytefly/notify/format.ts +19 -1
  17. package/extensions/bytefly/notify/register.ts +37 -13
  18. package/extensions/bytefly/notify/types.ts +2 -0
  19. package/extensions/bytefly/panel/commands.ts +34 -13
  20. package/extensions/bytefly/panel/settings.ts +98 -16
  21. package/extensions/bytefly/update/cache.ts +73 -0
  22. package/extensions/bytefly/update/format.ts +65 -10
  23. package/extensions/bytefly/update/register.ts +99 -13
  24. package/extensions/bytefly/update/state.ts +13 -1
  25. package/extensions/document/README.md +22 -12
  26. package/extensions/document/config.ts +4 -0
  27. package/extensions/document/deadline.ts +61 -0
  28. package/extensions/document/tool.ts +13 -2
  29. package/extensions/file-browse/README.md +12 -8
  30. package/extensions/file-browse/client.ts +6 -7
  31. package/extensions/file-browse/files.ts +0 -4
  32. package/extensions/file-browse/git.ts +9 -10
  33. package/extensions/file-browse/html.ts +17 -7
  34. package/extensions/file-browse/router.ts +24 -15
  35. package/extensions/goal/README.md +7 -1
  36. package/extensions/goal/schema.ts +6 -2
  37. package/extensions/goal/types.ts +6 -2
  38. package/extensions/job/README.md +16 -12
  39. package/extensions/job/runtime.ts +23 -6
  40. package/extensions/job/status.ts +4 -2
  41. package/extensions/memory/README.md +55 -13
  42. package/extensions/memory/commands.ts +20 -2
  43. package/extensions/memory/doctor.ts +137 -0
  44. package/extensions/memory/format.ts +24 -7
  45. package/extensions/memory/runtime.ts +49 -0
  46. package/extensions/memory/store.ts +31 -0
  47. package/extensions/memory/tui.ts +136 -12
  48. package/extensions/plan/README.md +60 -29
  49. package/extensions/plan/commands.ts +67 -8
  50. package/extensions/plan/index.ts +35 -11
  51. package/extensions/plan/plan-index.ts +234 -0
  52. package/extensions/plan/plan-text.ts +95 -0
  53. package/extensions/plan/plans.ts +90 -1
  54. package/extensions/plan/review.ts +12 -9
  55. package/extensions/plan/runtime.ts +72 -27
  56. package/extensions/plan/steps.ts +35 -25
  57. package/extensions/plan/todos.ts +24 -2
  58. package/extensions/plan/tools.ts +124 -24
  59. package/extensions/plan/tui.ts +7 -28
  60. package/extensions/plan/types.ts +9 -0
  61. package/extensions/plan/web-page.ts +2 -2
  62. package/extensions/plan/web.ts +12 -11
  63. package/extensions/rewind/README.md +28 -8
  64. package/extensions/rewind/commands.ts +1 -1
  65. package/extensions/rewind/format.ts +4 -4
  66. package/extensions/rewind/git.ts +56 -18
  67. package/extensions/rewind/index.ts +3 -5
  68. package/extensions/rewind/path-picker.ts +198 -0
  69. package/extensions/rewind/paths.ts +128 -0
  70. package/extensions/rewind/restore.ts +69 -28
  71. package/extensions/rewind/rewind.ts +80 -3
  72. package/extensions/rewind/runtime.ts +13 -5
  73. package/extensions/rewind/snapshot-run.ts +5 -2
  74. package/extensions/rewind/types.ts +2 -0
  75. package/extensions/subagent/README.md +7 -1
  76. package/extensions/subagent/accounting.ts +34 -0
  77. package/extensions/subagent/index.ts +23 -1
  78. package/extensions/subagent/stream.ts +2 -0
  79. package/extensions/subagent/types.ts +2 -0
  80. package/extensions/watch/README.md +8 -3
  81. package/extensions/watch/index.ts +19 -0
  82. package/extensions/web-access/README.md +43 -13
  83. package/extensions/web-access/charset.ts +58 -0
  84. package/extensions/web-access/fetch/find.ts +11 -7
  85. package/extensions/web-access/fetch/format.ts +64 -18
  86. package/extensions/web-access/fetch/page.ts +5 -1
  87. package/extensions/web-access/fetch/tool.ts +19 -18
  88. package/extensions/web-access/http.ts +49 -8
  89. package/extensions/web-access/search/facts.ts +38 -0
  90. package/extensions/web-access/search/tool.ts +13 -0
  91. package/extensions/web-access/text.ts +50 -11
  92. package/extensions/worktree/README.md +74 -15
  93. package/extensions/worktree/config.ts +11 -0
  94. package/extensions/worktree/exit.ts +35 -3
  95. package/extensions/worktree/gaps.ts +74 -0
  96. package/extensions/worktree/guard.ts +50 -4
  97. package/extensions/worktree/handoff.ts +55 -0
  98. package/extensions/worktree/index.ts +12 -2
  99. package/extensions/worktree/runtime.ts +20 -0
  100. package/extensions/worktree/status.ts +15 -2
  101. package/extensions/worktree/tools.ts +3 -2
  102. package/lib/README.md +10 -8
  103. package/lib/events.ts +86 -1
  104. package/lib/goal-state.ts +95 -0
  105. package/lib/requirements.ts +282 -62
  106. package/lib/tui/README.md +91 -0
  107. package/lib/tui/glyphs.ts +1 -0
  108. package/lib/web/layout.ts +12 -2
  109. package/package.json +5 -3
  110. package/themes/bytefly-dark.json +1 -1
  111. package/assets/chrome.png +0 -0
  112. package/assets/file-browse.png +0 -0
  113. package/assets/job.png +0 -0
  114. package/assets/plan-review.png +0 -0
  115. package/assets/question.png +0 -0
  116. package/assets/rewind.png +0 -0
  117. package/assets/todo.png +0 -0
  118. package/assets/tool-renderings.png +0 -0
  119. package/assets/tools/ask_user_question-cancelled.png +0 -0
  120. package/assets/tools/ask_user_question.png +0 -0
  121. package/assets/tools/bash.png +0 -0
  122. package/assets/tools/edit.png +0 -0
  123. package/assets/tools/enter_plan_mode.png +0 -0
  124. package/assets/tools/enter_worktree.png +0 -0
  125. package/assets/tools/exit_plan_mode.png +0 -0
  126. package/assets/tools/exit_worktree.png +0 -0
  127. package/assets/tools/find.png +0 -0
  128. package/assets/tools/goal.png +0 -0
  129. package/assets/tools/grep.png +0 -0
  130. package/assets/tools/job.png +0 -0
  131. package/assets/tools/list_worktrees.png +0 -0
  132. package/assets/tools/ls.png +0 -0
  133. package/assets/tools/memory-long-subject.png +0 -0
  134. package/assets/tools/memory.png +0 -0
  135. package/assets/tools/notice.png +0 -0
  136. package/assets/tools/open_path.png +0 -0
  137. package/assets/tools/prune_worktrees.png +0 -0
  138. package/assets/tools/read.png +0 -0
  139. package/assets/tools/read_doc-running.png +0 -0
  140. package/assets/tools/read_doc.png +0 -0
  141. package/assets/tools/run_doctor.png +0 -0
  142. package/assets/tools/send_wechat.png +0 -0
  143. package/assets/tools/set_plan_steps.png +0 -0
  144. package/assets/tools/subagent.png +0 -0
  145. package/assets/tools/todo.png +0 -0
  146. package/assets/tools/turn-rule.png +0 -0
  147. package/assets/tools/user-message.png +0 -0
  148. package/assets/tools/watch.png +0 -0
  149. package/assets/tools/web_fetch-rejected.png +0 -0
  150. package/assets/tools/web_fetch.png +0 -0
  151. package/assets/tools/web_search.png +0 -0
  152. package/assets/tools/write.png +0 -0
  153. package/assets/tools/write_plan.png +0 -0
@@ -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;
@@ -7,6 +7,12 @@
7
7
  * without the component owning any authoritative state. Navigation and scroll
8
8
  * math come from `lib/tui/cursor.ts` and the screen chrome from `lib/tui/chrome.ts`;
9
9
  * the inline note editor is the shared `Editor` from `@earendil-works/pi-tui`.
10
+ *
11
+ * `m` marks one note as a merge source and `m` on a second note in the same
12
+ * scope opens the editor, pre-filled with both notes so the user writes the
13
+ * merged wording. The merge is one write: the store replaces the first note and
14
+ * drops the second. `p` moves the focused note to the other scope, which is two
15
+ * writes ordered target-first so a failure leaves a duplicate, never a loss.
10
16
  */
11
17
 
12
18
  import type { Theme } from "@earendil-works/pi-coding-agent";
@@ -53,6 +59,10 @@ export interface MemoryPickerCallbacks {
53
59
  add(scope: MemoryScope, text: string): Promise<{ changed: boolean; existing?: string }>;
54
60
  update(scope: MemoryScope, from: string, to: string): Promise<boolean>;
55
61
  forget(scope: MemoryScope, entry: string): Promise<boolean>;
62
+ /** Merge two notes in `scope` into one, in a single write. */
63
+ merge(scope: MemoryScope, first: string, second: string, merged: string): Promise<boolean>;
64
+ /** Move a note to the other scope: written there first, then removed here. */
65
+ move(from: MemoryScope, to: MemoryScope, entry: string): Promise<{ moved: boolean; existing?: string }>;
56
66
  }
57
67
 
58
68
  /** One note in the flat, filtered list. */
@@ -72,11 +82,15 @@ export class MemoryPickerComponent implements Component {
72
82
  private readonly editor: Editor;
73
83
 
74
84
  private filter: MemoryFilter = "all";
75
- private mode: "list" | "edit" | "add" = "list";
85
+ private mode: "list" | "edit" | "add" | "merge" = "list";
76
86
  /** Scope the inline editor writes to. */
77
87
  private editorScope: MemoryScope = "project";
78
88
  /** Note being edited, or undefined while adding. */
79
89
  private editing: Note | undefined;
90
+ /** The two notes a merge editor will combine. */
91
+ private mergePair: { first: Note; second: Note } | undefined;
92
+ /** The note marked with `m`, awaiting a second `m` to merge into it. */
93
+ private mergeSource: Note | undefined;
80
94
  /** Note awaiting a y/N forget confirmation. */
81
95
  private confirm: Note | undefined;
82
96
  /** Transient feedback shown in place of the hint. */
@@ -161,15 +175,55 @@ export class MemoryPickerComponent implements Component {
161
175
  }
162
176
  this.mode = "edit";
163
177
  this.editing = note;
178
+ this.mergePair = undefined;
164
179
  this.editorScope = note.scope;
165
180
  this.editor.setText(note.text);
166
181
  this.syncEditorFocus();
167
182
  this.requestRender();
168
183
  }
169
184
 
185
+ /**
186
+ * `m`: mark the focused note, unmark it, or open the merge editor for a marked
187
+ * pair. The mark survives navigation so the second note can be found.
188
+ */
189
+ private toggleMerge(note: Note | undefined): void {
190
+ if (!note) return;
191
+ const source = this.mergeSource;
192
+ if (source && noteKey(source) === noteKey(note)) {
193
+ this.mergeSource = undefined;
194
+ this.requestRender();
195
+ return;
196
+ }
197
+ if (source) {
198
+ this.startMerge(source, note);
199
+ return;
200
+ }
201
+ this.mergeSource = note;
202
+ this.notice = undefined;
203
+ this.requestRender();
204
+ }
205
+
206
+ /** Open the editor for a marked pair, pre-filled with both notes to rewrite. */
207
+ private startMerge(first: Note, second: Note): void {
208
+ if (first.scope !== second.scope) {
209
+ this.notice = "A merge combines two notes in the same scope.";
210
+ this.requestRender();
211
+ return;
212
+ }
213
+ this.mode = "merge";
214
+ this.editing = undefined;
215
+ this.mergePair = { first, second };
216
+ this.editorScope = first.scope;
217
+ // The user writes the merged wording; the seed only saves retyping both notes.
218
+ this.editor.setText(`${first.text}; ${second.text}`.slice(0, MAX_NOTE_CHARS));
219
+ this.syncEditorFocus();
220
+ this.requestRender();
221
+ }
222
+
170
223
  private startAdd(): void {
171
224
  this.mode = "add";
172
225
  this.editing = undefined;
226
+ this.mergePair = undefined;
173
227
  this.editorScope = this.activeScope();
174
228
  this.editor.setText("");
175
229
  this.syncEditorFocus();
@@ -179,6 +233,8 @@ export class MemoryPickerComponent implements Component {
179
233
  private cancelEditor(): void {
180
234
  this.mode = "list";
181
235
  this.editing = undefined;
236
+ this.mergePair = undefined;
237
+ this.mergeSource = undefined;
182
238
  this.syncEditorFocus();
183
239
  this.notice = undefined;
184
240
  this.requestRender();
@@ -188,8 +244,11 @@ export class MemoryPickerComponent implements Component {
188
244
  const text = sanitizeLine(value).trim();
189
245
  const editing = this.editing;
190
246
  const scope = this.editorScope;
247
+ const pair = this.mergePair;
191
248
  this.mode = "list";
192
249
  this.editing = undefined;
250
+ this.mergePair = undefined;
251
+ this.mergeSource = undefined;
193
252
  this.syncEditorFocus();
194
253
 
195
254
  if (text.length === 0) {
@@ -204,6 +263,12 @@ export class MemoryPickerComponent implements Component {
204
263
  }
205
264
 
206
265
  this.run(async () => {
266
+ if (pair) {
267
+ await this.callbacks.merge(pair.first.scope, pair.first.text, pair.second.text, text);
268
+ this.notice = `Merged 2 ${scopeLabel(pair.first.scope)} notes.`;
269
+ this.selectedKey = noteKey({ scope: pair.first.scope, text });
270
+ return;
271
+ }
207
272
  if (editing) {
208
273
  if (text === editing.text) {
209
274
  this.notice = "Unchanged.";
@@ -236,6 +301,30 @@ export class MemoryPickerComponent implements Component {
236
301
  });
237
302
  }
238
303
 
304
+ /** `p`: move the focused note to the other scope, target first. */
305
+ private moveNote(note: Note | undefined): void {
306
+ if (!note) return;
307
+ const to: MemoryScope = note.scope === "project" ? "global" : "project";
308
+ if (to === "project" && !this.callbacks.trusted()) {
309
+ this.notice = "Project memory is unavailable for an untrusted project.";
310
+ this.requestRender();
311
+ return;
312
+ }
313
+ const from = note.scope;
314
+ const text = note.text;
315
+ this.run(async () => {
316
+ const result = await this.callbacks.move(from, to, text);
317
+ if (!result.moved) {
318
+ this.notice = `Added to ${scopeLabel(to)}; the ${scopeLabel(from)} note was already gone.`;
319
+ } else if (result.existing !== undefined) {
320
+ this.notice = `Moved to ${scopeLabel(to)} (it was already there).`;
321
+ } else {
322
+ this.notice = `Moved to ${scopeLabel(to)}.`;
323
+ }
324
+ this.selectedKey = noteKey({ scope: to, text });
325
+ });
326
+ }
327
+
239
328
  private reload(): void {
240
329
  this.notice = this.callbacks.refresh() ? "Reloaded memory." : "Memory is already up to date.";
241
330
  this.requestRender();
@@ -248,11 +337,21 @@ export class MemoryPickerComponent implements Component {
248
337
  .catch((error: unknown) => {
249
338
  this.notice = `Memory not saved: ${messageOf(error)}`;
250
339
  })
251
- .then(() => this.requestRender());
340
+ .then(() => {
341
+ this.dropStaleMergeSource();
342
+ this.requestRender();
343
+ });
344
+ }
345
+
346
+ /** A merge source that a mutation removed is no longer a valid target. */
347
+ private dropStaleMergeSource(): void {
348
+ const source = this.mergeSource;
349
+ if (!source) return;
350
+ if (!this.notes().some((note) => noteKey(note) === noteKey(source))) this.mergeSource = undefined;
252
351
  }
253
352
 
254
353
  private syncEditorFocus(): void {
255
- this.editor.focused = this.componentFocused && (this.mode === "edit" || this.mode === "add");
354
+ this.editor.focused = this.componentFocused && this.mode !== "list";
256
355
  }
257
356
 
258
357
  private handleConfirm(data: string): void {
@@ -270,7 +369,7 @@ export class MemoryPickerComponent implements Component {
270
369
  }
271
370
 
272
371
  handleInput(data: string): void {
273
- if (this.mode === "edit" || this.mode === "add") {
372
+ if (this.mode !== "list") {
274
373
  if (isCloseKey(data) || matchesKey(data, Key.escape)) {
275
374
  this.cancelEditor();
276
375
  return;
@@ -284,6 +383,12 @@ export class MemoryPickerComponent implements Component {
284
383
  return;
285
384
  }
286
385
  if (isCloseKey(data) || data === "q") {
386
+ // With a mark set, the first Esc cancels the merge instead of closing the screen.
387
+ if (this.mergeSource) {
388
+ this.mergeSource = undefined;
389
+ this.requestRender();
390
+ return;
391
+ }
287
392
  this.onClose();
288
393
  return;
289
394
  }
@@ -308,6 +413,8 @@ export class MemoryPickerComponent implements Component {
308
413
  if (matchesKey(data, Key.tab) || data === "s") this.cycleFilter();
309
414
  else if (matchesKey(data, Key.enter) || data === "e") this.startEdit(focused);
310
415
  else if (data === "a") this.startAdd();
416
+ else if (data === "m") this.toggleMerge(focused);
417
+ else if (data === "p") this.moveNote(focused);
311
418
  else if (data === "d") {
312
419
  if (!focused) return;
313
420
  this.confirm = focused;
@@ -355,9 +462,11 @@ export class MemoryPickerComponent implements Component {
355
462
  /** One note row: selection marker, scope tag, and the sanitized note text. */
356
463
  private noteRow(note: Note, selected: boolean, width: number): string {
357
464
  const tag = `${paint(this.theme, "meta", scopeLabel(note.scope))} ${paint(this.theme, "faint", "·")}`;
465
+ const source = this.mergeSource;
466
+ const marked = source !== undefined && noteKey(source) === noteKey(note);
358
467
  return listRow(this.theme, width, {
359
468
  selected,
360
- leading: tag,
469
+ leading: marked ? `${tag} ${paint(this.theme, "warn", "merge")}` : tag,
361
470
  label: sanitizeLine(note.text),
362
471
  labelRole: selected ? "accent" : "label",
363
472
  });
@@ -372,17 +481,20 @@ export class MemoryPickerComponent implements Component {
372
481
  }
373
482
 
374
483
  private renderEditor(width: number): string[] {
375
- const verb = this.mode === "edit" ? "Edit" : "Add";
484
+ const verb = this.mode === "edit" ? "Edit" : this.mode === "merge" ? "Merge" : "Add";
376
485
  const scope = scopeLabel(this.editorScope);
377
486
  const pad = width > 1 ? " " : "";
487
+ const summary =
488
+ this.mode === "edit"
489
+ ? "Editing the focused note"
490
+ : this.mode === "merge"
491
+ ? `Merging 2 ${scope} notes into one`
492
+ : `New ${scope} note`;
378
493
  const body: string[] = [];
379
494
  for (const line of this.editor.render(Math.max(1, width - 2))) body.push(`${pad}${line}`);
380
495
  return dockListFrame({
381
496
  header: titleRule(this.theme, width, `Memory · ${verb} ${scope}`),
382
- summary: clip(
383
- ` ${paint(this.theme, "meta", this.mode === "edit" ? "Editing the focused note" : `New ${scope} note`)}`,
384
- width,
385
- ),
497
+ summary: clip(` ${paint(this.theme, "meta", summary)}`, width),
386
498
  body,
387
499
  hint: screenHint(this.theme, width, ["Enter save", "Esc cancel"]),
388
500
  });
@@ -391,7 +503,7 @@ export class MemoryPickerComponent implements Component {
391
503
  render(width: number): string[] {
392
504
  const w = Math.max(1, width);
393
505
  this.syncEditorFocus();
394
- if (this.mode === "edit" || this.mode === "add") return this.renderEditor(w);
506
+ if (this.mode !== "list") return this.renderEditor(w);
395
507
 
396
508
  const notes = this.notes();
397
509
  this.cursor.visible = fitRows(this.viewportRowsSource, notes.length, CHROME, SCREEN_DEFAULT_ROWS);
@@ -411,7 +523,19 @@ export class MemoryPickerComponent implements Component {
411
523
  ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
412
524
  : this.notice !== undefined
413
525
  ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
414
- : screenHint(this.theme, w, ["Esc close", "Enter/e edit", "a add", "d forget", "s/Tab scope", "↑/↓", "g/G"]);
526
+ : this.mergeSource
527
+ ? screenHint(this.theme, w, ["m merge into the focused note", "Esc cancel merge", "↑/↓"])
528
+ : screenHint(this.theme, w, [
529
+ "Esc close",
530
+ "Enter/e edit",
531
+ "a add",
532
+ "d forget",
533
+ "m merge",
534
+ "p move",
535
+ "s/Tab scope",
536
+ "↑/↓",
537
+ "g/G",
538
+ ]);
415
539
 
416
540
  return dockListFrame({
417
541
  header: titleRule(this.theme, w, "Memory"),