@gavin-hu/my-pi-agent 0.12.0 → 0.13.1
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 +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +162 -70
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +38 -6
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +16 -0
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +46 -4
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +8 -5
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +52 -14
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +129 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- 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/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/format.ts +4 -7
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- 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 +60 -29
- package/extensions/plan/commands.ts +67 -8
- 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 +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- 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 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +19 -8
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/index.ts +19 -0
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/web-access/README.md +43 -13
- 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 +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +81 -16
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +55 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +14 -2
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +25 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +14 -10
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +361 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/tui/tool.ts +6 -2
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- 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
package/extensions/job/README.md
CHANGED
|
@@ -38,10 +38,11 @@ How the `job` call and its result look in the transcript — the call line as a
|
|
|
38
38
|
killing, and clearing.
|
|
39
39
|
- Reports finished jobs to the model at the start of the next turn, or — for
|
|
40
40
|
jobs started with `wake: true` — by triggering one turn.
|
|
41
|
-
- Kills session-owned jobs on shutdown so no process is left behind;
|
|
42
|
-
job with `detached: true` to keep it running and reattach next session.
|
|
43
|
-
detached
|
|
44
|
-
logging after this process ends.
|
|
41
|
+
- Kills session-owned jobs on shutdown so no process is left behind; on POSIX,
|
|
42
|
+
start a job with `detached: true` to keep it running and reattach next session.
|
|
43
|
+
A detached job points its own output straight at its log file, so it keeps
|
|
44
|
+
logging after this process ends. Windows has neither a process group to leave
|
|
45
|
+
nor a shell `EXIT` trap, so it refuses the detach instead of faking one.
|
|
45
46
|
|
|
46
47
|
## Tool
|
|
47
48
|
|
|
@@ -53,7 +54,7 @@ How the `job` call and its result look in the transcript — the call line as a
|
|
|
53
54
|
| `cwd` | Working directory (`start`); defaults to the session cwd. |
|
|
54
55
|
| `label` | Short display label (`start`); defaults to the command. |
|
|
55
56
|
| `wake` | Wake the agent with one triggered turn when it finishes (`start`). |
|
|
56
|
-
| `detached` | Leave it running when the session ends (`start`). |
|
|
57
|
+
| `detached` | Leave it running when the session ends (`start`); POSIX only, refused on Windows. |
|
|
57
58
|
| `stdin` | Give the job a stdin pipe so `send` can write to it (`start`); not with `detached`. |
|
|
58
59
|
| `id` | Job id such as `j1` (`status`/`logs`/`send`/`kill`/`wait`/`clear`). |
|
|
59
60
|
| `lines` | Log lines to return (`logs`, 1–2000). |
|
|
@@ -191,7 +192,7 @@ document, project wins):
|
|
|
191
192
|
| `enabled` | `true` | Track jobs at all. |
|
|
192
193
|
| `showStatus` | `true` | Show the `▸ N` / `✗ N` status chips. |
|
|
193
194
|
| `wakeOnFinish` | `false` | Wake the agent when any job finishes. |
|
|
194
|
-
| `detachedByDefault` | `false` | Leave jobs running when the session ends. |
|
|
195
|
+
| `detachedByDefault` | `false` | Leave jobs running when the session ends; inert on Windows. |
|
|
195
196
|
| `maxJobs` | `20` | Finished jobs kept before the oldest are pruned. |
|
|
196
197
|
| `maxLogLines` | `100` | Log lines returned by `logs`. |
|
|
197
198
|
| `maxLogBytes` | `8388608` | Bytes a log segment may reach before it rotates to `<id>.log.1`; `0` keeps logs unbounded. |
|
|
@@ -214,8 +215,8 @@ rewritten.
|
|
|
214
215
|
- **`unknown` still has no exit code on some paths.** A POSIX job is wrapped in
|
|
215
216
|
an `EXIT` trap that writes `$?` to a per-job `.status` file, so a job
|
|
216
217
|
reattached after its session died reports its real exit code. `exec cmd`
|
|
217
|
-
replaces the shell and bypasses the trap, and Windows has no equivalent,
|
|
218
|
-
those
|
|
218
|
+
replaces the shell and bypasses the trap, and a Windows job has no equivalent,
|
|
219
|
+
so those settle as `unknown`.
|
|
219
220
|
- **Another session's jobs are shown, but not managed here.** Records are merged
|
|
220
221
|
from every session file so ids stay unique and a peer's job is not reaped, and
|
|
221
222
|
`list`/`/jobs` show them tagged `peer` (a dead session's records are adopted
|
|
@@ -236,10 +237,13 @@ rewritten.
|
|
|
236
237
|
keep appending to the moved inode, or lose everything. Its log therefore grows
|
|
237
238
|
without the `maxLogBytes` cap; `logs` still reads a bounded tail, and the file
|
|
238
239
|
can be emptied by hand.
|
|
239
|
-
- **Windows
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
- **Windows cannot detach a job.** There is no process group to leave and no
|
|
241
|
+
shell `EXIT` trap, so `detached: true` is refused with the three alternatives
|
|
242
|
+
(`detached: false`, WSL, a `subagent`) rather than starting a job that would
|
|
243
|
+
die with the session while claiming to outlive it. `job.detachedByDefault` is
|
|
244
|
+
inert there instead of failing every start with a config set on another
|
|
245
|
+
machine, and `doctor` names both facts. Tree-kill is still `taskkill /T`, and a
|
|
246
|
+
job reported only after its process is gone settles as `unknown`.
|
|
243
247
|
|
|
244
248
|
## Non-goals
|
|
245
249
|
|
package/extensions/job/format.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
17
16
|
import { formatDuration } from "../../lib/format.ts";
|
|
17
|
+
import { clip, LABEL_WIDTH, labelLine } from "../../lib/tui/columns.ts";
|
|
18
18
|
import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
19
19
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
20
20
|
import type { JobRecord, JobStatus } from "./types.ts";
|
|
@@ -22,9 +22,6 @@ import type { JobRecord, JobStatus } from "./types.ts";
|
|
|
22
22
|
/** Longest sanitized log line kept for display; longer lines are clipped. */
|
|
23
23
|
const MAX_LOG_LINE = 200;
|
|
24
24
|
|
|
25
|
-
/** Longest label shown in the transcript or `/jobs` screen. */
|
|
26
|
-
const MAX_DISPLAY_LABEL = 48;
|
|
27
|
-
|
|
28
25
|
/** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
|
|
29
26
|
const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
30
27
|
const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
@@ -111,7 +108,7 @@ function statusWord(job: JobRecord): string {
|
|
|
111
108
|
|
|
112
109
|
/** A label clipped to one safe display line. */
|
|
113
110
|
export function clipLabel(text: string): string {
|
|
114
|
-
return
|
|
111
|
+
return labelLine(text, sanitizeLogLine);
|
|
115
112
|
}
|
|
116
113
|
|
|
117
114
|
/** A job's label (falling back to its command) clipped to one safe display line. */
|
|
@@ -180,7 +177,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
|
|
|
180
177
|
case "start": {
|
|
181
178
|
const command = typeof args.command === "string" ? sanitizeLogLine(args.command.trim()) : "";
|
|
182
179
|
if (!command) return argsComplete ? "start" : "start …";
|
|
183
|
-
const preview =
|
|
180
|
+
const preview = clip(command, LABEL_WIDTH);
|
|
184
181
|
return `start → ${preview}`;
|
|
185
182
|
}
|
|
186
183
|
case "kill":
|
|
@@ -188,7 +185,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
|
|
|
188
185
|
case "send": {
|
|
189
186
|
if (!target) return "send";
|
|
190
187
|
const input = typeof args.input === "string" ? sanitizeLogLine(args.input) : "";
|
|
191
|
-
const preview = input ?
|
|
188
|
+
const preview = input ? clip(input, LABEL_WIDTH) : "";
|
|
192
189
|
const parts = [preview, args.eof === true ? "EOF" : ""].filter(Boolean).join(" + ");
|
|
193
190
|
return parts ? `send ${target} → ${parts}` : `send ${target}`;
|
|
194
191
|
}
|
|
@@ -127,6 +127,8 @@ export interface RuntimeOptions {
|
|
|
127
127
|
killTree?: KillTreeFn;
|
|
128
128
|
startToken?: StartTokenFn;
|
|
129
129
|
now?: () => number;
|
|
130
|
+
/** Platform seam: whether this is Windows. Defaults to the running platform. */
|
|
131
|
+
isWindows?: () => boolean;
|
|
130
132
|
/** Fixed config, for tests; production loads it per cwd. */
|
|
131
133
|
config?: JobsConfig;
|
|
132
134
|
}
|
|
@@ -202,6 +204,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
202
204
|
const killTree = options.killTree ?? defaultKillTree;
|
|
203
205
|
const startToken = options.startToken ?? defaultStartToken;
|
|
204
206
|
const now = options.now ?? (() => Date.now());
|
|
207
|
+
const isWindows = options.isWindows ?? (() => process.platform === "win32");
|
|
205
208
|
|
|
206
209
|
const jobs = new Map<string, Job>();
|
|
207
210
|
const handles = new Map<string, Handle>();
|
|
@@ -717,7 +720,20 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
717
720
|
if (!config.enabled) throw new Error("jobs are disabled.");
|
|
718
721
|
ui.attach(ctx);
|
|
719
722
|
const wantsStdin = startOptions.stdin === true;
|
|
720
|
-
|
|
723
|
+
// Windows has no process group to leave and no shell `EXIT` trap to record an
|
|
724
|
+
// exit code, so a job there cannot outlive the session: refusing an explicit
|
|
725
|
+
// detach is the honest answer, and it happens before any id is reserved.
|
|
726
|
+
// `detachedByDefault` stays inert instead of failing every start with a config
|
|
727
|
+
// the user set on another machine; `doctor` names that.
|
|
728
|
+
const windows = isWindows();
|
|
729
|
+
if (windows && startOptions.detached === true) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
"A detached job cannot outlive the session on Windows: there is no process group to leave " +
|
|
732
|
+
"and no shell EXIT trap to record its exit code. Start it with `detached: false`, " +
|
|
733
|
+
"run it under WSL, or move the work into a `subagent`.",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
const detached = windows ? false : (startOptions.detached ?? config.detachedByDefault);
|
|
721
737
|
// A detached job outlives this process, so a stdin pipe owned by this session
|
|
722
738
|
// would hand it EOF the moment the session ends; refuse instead of silently
|
|
723
739
|
// dropping the pipe (or the detach).
|
|
@@ -732,14 +748,15 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
732
748
|
// so a peer that advanced the shared counter cannot hand us a live job's id.
|
|
733
749
|
const id = store.nextId(jobs.keys());
|
|
734
750
|
const logPath = store.logPath(id);
|
|
735
|
-
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
const
|
|
751
|
+
// Only a POSIX shell has an `EXIT` trap to record a status, and only a detached
|
|
752
|
+
// job points its own output at the log so it keeps logging after this process
|
|
753
|
+
// ends (which on Windows is never: `detached` is false there).
|
|
754
|
+
const statusPath = windows ? null : store.statusPath(id);
|
|
755
|
+
const detachOutput = detached;
|
|
739
756
|
|
|
740
757
|
// Wrap the command so a POSIX shell records its exit status; the path travels
|
|
741
758
|
// in the environment so the shell never has to quote it.
|
|
742
|
-
const program = withExitTrap(startOptions.command,
|
|
759
|
+
const program = withExitTrap(startOptions.command, windows, detachOutput);
|
|
743
760
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
744
761
|
if (statusPath) env[STATUS_ENV] = statusPath;
|
|
745
762
|
if (detachOutput) env[LOG_ENV] = logPath;
|
package/extensions/job/status.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* shell never has to quote it). A later session reads that file when it finds a
|
|
8
8
|
* dead pid and reports `exited`/`failed` instead of `unknown`.
|
|
9
9
|
*
|
|
10
|
-
* Windows shells have no equivalent trap
|
|
11
|
-
*
|
|
10
|
+
* Windows shells have no equivalent trap. A Windows job is never detached (the
|
|
11
|
+
* runtime refuses it), so the missing trap is not a second limitation: a job
|
|
12
|
+
* reported only after its process is gone still settles as `unknown`, which
|
|
13
|
+
* `doctor` names.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
@@ -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
|
|
@@ -180,6 +212,15 @@ when consolidating.
|
|
|
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;
|