@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.
- package/README.md +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +139 -67
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +36 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/register.ts +13 -2
- 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 +4 -3
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +98 -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/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/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- 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 +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +10 -8
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +282 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +1 -0
- 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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory health: what the two stores hold, and whether recall is already
|
|
3
|
+
* dropping notes (pure).
|
|
4
|
+
*
|
|
5
|
+
* The report answers the questions a user cannot see from the notes alone: how
|
|
6
|
+
* many notes and bytes each store carries, how much of the `maxInjectBytes`
|
|
7
|
+
* budget the injection uses and which scope overflowed it, and which notes the
|
|
8
|
+
* conservative `duplicateKey` already considers the same fact. It never changes
|
|
9
|
+
* anything — consolidation is the picker's `m` merge, and the doctor only names
|
|
10
|
+
* the pair to merge.
|
|
11
|
+
*
|
|
12
|
+
* Byte counts are the store file's size on disk (what a user can reason about
|
|
13
|
+
* and compare with the cap), plus the injected body's size; both are exact UTF-8
|
|
14
|
+
* bytes, not a rounded display.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { clip } from "../../lib/tui/columns.ts";
|
|
18
|
+
import type { MemoryConfig } from "./config.ts";
|
|
19
|
+
import { injectionPlan, type MemoryEntries } from "./format.ts";
|
|
20
|
+
import { duplicateKey } from "./store.ts";
|
|
21
|
+
import { MEMORY_SCOPES, type MemoryScope } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** Longest note text a duplicate line shows. */
|
|
24
|
+
const PAIR_NOTE_WIDTH = 60;
|
|
25
|
+
/** Label column for the report's rows. */
|
|
26
|
+
const LABEL_WIDTH = 11;
|
|
27
|
+
|
|
28
|
+
/** One store's counts. */
|
|
29
|
+
export interface ScopeHealth {
|
|
30
|
+
notes: number;
|
|
31
|
+
/** File size in bytes; `undefined` when the store was not read. */
|
|
32
|
+
bytes?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Two notes the comparison key treats as one fact, in stored order. */
|
|
36
|
+
export interface DuplicatePair {
|
|
37
|
+
scope: MemoryScope;
|
|
38
|
+
/** The note `add` would have kept. */
|
|
39
|
+
kept: string;
|
|
40
|
+
/** The note `add` would have refused as a near-duplicate. */
|
|
41
|
+
duplicate: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MemoryHealth {
|
|
45
|
+
trusted: boolean;
|
|
46
|
+
inject: boolean;
|
|
47
|
+
/** The `maxInjectBytes` cap in effect. */
|
|
48
|
+
cap: number;
|
|
49
|
+
project: ScopeHealth;
|
|
50
|
+
global: ScopeHealth;
|
|
51
|
+
/** Present while injection is on: the body's bytes and what did not fit. */
|
|
52
|
+
injection?: { bytes: number; omitted: Record<MemoryScope, number> };
|
|
53
|
+
duplicates: DuplicatePair[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HealthInput {
|
|
57
|
+
entries: MemoryEntries;
|
|
58
|
+
sizes: Record<MemoryScope, number | undefined>;
|
|
59
|
+
trusted: boolean;
|
|
60
|
+
config: MemoryConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every near-duplicate pair, computed with the store's own comparison key. */
|
|
64
|
+
export function duplicatePairs(entries: MemoryEntries): DuplicatePair[] {
|
|
65
|
+
const pairs: DuplicatePair[] = [];
|
|
66
|
+
for (const scope of MEMORY_SCOPES) {
|
|
67
|
+
const kept = new Map<string, string>();
|
|
68
|
+
for (const note of entries[scope]) {
|
|
69
|
+
const key = duplicateKey(note);
|
|
70
|
+
const first = kept.get(key);
|
|
71
|
+
if (first === undefined) kept.set(key, note);
|
|
72
|
+
else pairs.push({ scope, kept: first, duplicate: note });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return pairs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Derive the report from the current stores and config. */
|
|
79
|
+
export function memoryHealth({ entries, sizes, trusted, config }: HealthInput): MemoryHealth {
|
|
80
|
+
const scope = (name: MemoryScope): ScopeHealth => {
|
|
81
|
+
const size = sizes[name];
|
|
82
|
+
return size === undefined ? { notes: entries[name].length } : { notes: entries[name].length, bytes: size };
|
|
83
|
+
};
|
|
84
|
+
const plan = config.inject ? injectionPlan(entries, config.maxInjectBytes) : undefined;
|
|
85
|
+
return {
|
|
86
|
+
trusted,
|
|
87
|
+
inject: config.inject,
|
|
88
|
+
cap: config.maxInjectBytes,
|
|
89
|
+
project: scope("project"),
|
|
90
|
+
global: scope("global"),
|
|
91
|
+
...(plan ? { injection: { bytes: Buffer.byteLength(plan.lines.join("\n"), "utf-8"), omitted: plan.omitted } } : {}),
|
|
92
|
+
duplicates: duplicatePairs(entries),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function row(label: string, text: string): string {
|
|
97
|
+
return ` ${label.padEnd(LABEL_WIDTH)}${text}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `3 notes · 412 B`, or the reason a store was not read. */
|
|
101
|
+
function scopeText(health: MemoryHealth, name: MemoryScope): string {
|
|
102
|
+
const scope = health[name];
|
|
103
|
+
if (name === "project" && !health.trusted) return "unavailable (untrusted project)";
|
|
104
|
+
const notes = `${scope.notes} note${scope.notes === 1 ? "" : "s"}`;
|
|
105
|
+
if (scope.bytes === undefined) return scope.notes === 0 ? notes : `${notes} · size unknown`;
|
|
106
|
+
return `${notes} · ${scope.bytes} B`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function injectionText(health: MemoryHealth): string {
|
|
110
|
+
if (!health.inject) return "off (memory.inject is false)";
|
|
111
|
+
if (!health.injection) return "nothing to inject";
|
|
112
|
+
const { bytes, omitted } = health.injection;
|
|
113
|
+
const missing = omitted.project + omitted.global;
|
|
114
|
+
if (missing === 0) return `${bytes} B of ${health.cap} B · all notes fit`;
|
|
115
|
+
const scopes = MEMORY_SCOPES.filter((name) => omitted[name] > 0).map((name) => `${name} ${omitted[name]}`);
|
|
116
|
+
return `${bytes} B of ${health.cap} B · ${missing} omitted (${scopes.join(", ")})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pairLine(pair: DuplicatePair): string {
|
|
120
|
+
const kept = clip(pair.kept, PAIR_NOTE_WIDTH);
|
|
121
|
+
const duplicate = clip(pair.duplicate, PAIR_NOTE_WIDTH);
|
|
122
|
+
return ` ${pair.scope} "${kept}" ≈ "${duplicate}"`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The `/memory doctor` notice: plain text, one row per fact. */
|
|
126
|
+
export function formatHealth(health: MemoryHealth): string {
|
|
127
|
+
const count = health.duplicates.length;
|
|
128
|
+
const duplicates = count === 0 ? "none" : `${count} pair${count === 1 ? "" : "s"} (merge with m in /memory)`;
|
|
129
|
+
return [
|
|
130
|
+
"Memory health",
|
|
131
|
+
row("project", scopeText(health, "project")),
|
|
132
|
+
row("global", scopeText(health, "global")),
|
|
133
|
+
row("injection", injectionText(health)),
|
|
134
|
+
row("duplicates", duplicates),
|
|
135
|
+
...health.duplicates.map(pairLine),
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
@@ -104,13 +104,21 @@ export function formatNotice(entries: MemoryEntries): string {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* The hidden `[MEMORY]` context body,
|
|
107
|
+
* The hidden `[MEMORY]` context body split into its lines, plus what did not fit.
|
|
108
108
|
*
|
|
109
109
|
* Sections are emitted project-then-global. When the notes do not fit under
|
|
110
110
|
* `maxBytes`, the remainder is replaced by one `(+N more)` tail so the model
|
|
111
|
-
* knows to call the tool for the rest.
|
|
111
|
+
* knows to call the tool for the rest. `omitted` says which store overflowed, for
|
|
112
|
+
* the health report; `buildInjection` only needs the lines.
|
|
112
113
|
*/
|
|
113
|
-
export
|
|
114
|
+
export interface InjectionPlan {
|
|
115
|
+
lines: string[];
|
|
116
|
+
/** Notes that did not fit, per scope. */
|
|
117
|
+
omitted: Record<MemoryScope, number>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Plan the injected body, or `undefined` when nothing is stored. */
|
|
121
|
+
export function injectionPlan(entries: MemoryEntries, maxBytes: number): InjectionPlan | undefined {
|
|
114
122
|
const sections: Array<[MemoryScope, string[]]> = [];
|
|
115
123
|
if (entries.project.length > 0) sections.push(["project", entries.project]);
|
|
116
124
|
if (entries.global.length > 0) sections.push(["global", entries.global]);
|
|
@@ -119,7 +127,7 @@ export function buildInjection(entries: MemoryEntries, maxBytes: number): string
|
|
|
119
127
|
const lines = [MEMORY_CONTEXT_MARKER, INTRO];
|
|
120
128
|
let used = lines.reduce((total, line) => total + bytes(line) + 1, 0);
|
|
121
129
|
const limit = Math.max(0, maxBytes - TAIL_RESERVE);
|
|
122
|
-
|
|
130
|
+
const omitted: Record<MemoryScope, number> = { project: 0, global: 0 };
|
|
123
131
|
|
|
124
132
|
for (const [scope, list] of sections) {
|
|
125
133
|
const header = `${scope}:`;
|
|
@@ -127,7 +135,7 @@ export function buildInjection(entries: MemoryEntries, maxBytes: number): string
|
|
|
127
135
|
for (const entry of list) {
|
|
128
136
|
const line = `- ${entry}`;
|
|
129
137
|
if (used + bytes(header) + 1 + bytes(line) + 1 > limit) {
|
|
130
|
-
omitted += 1;
|
|
138
|
+
omitted[scope] += 1;
|
|
131
139
|
continue;
|
|
132
140
|
}
|
|
133
141
|
rendered.push(line);
|
|
@@ -138,6 +146,15 @@ export function buildInjection(entries: MemoryEntries, maxBytes: number): string
|
|
|
138
146
|
used += bytes(header) + 1;
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const missing = omitted.project + omitted.global;
|
|
150
|
+
if (missing > 0) lines.push(`- (+${missing} more; use the memory tool)`);
|
|
151
|
+
return { lines, omitted };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The hidden `[MEMORY]` context body, or `undefined` when nothing is stored.
|
|
156
|
+
* Bounded by `maxBytes`, with one tail naming what did not fit.
|
|
157
|
+
*/
|
|
158
|
+
export function buildInjection(entries: MemoryEntries, maxBytes: number): string | undefined {
|
|
159
|
+
return injectionPlan(entries, maxBytes)?.lines.join("\n");
|
|
143
160
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
applyAdd,
|
|
16
16
|
applyClear,
|
|
17
17
|
applyForget,
|
|
18
|
+
applyMerge,
|
|
18
19
|
applyUpdate,
|
|
19
20
|
memoryFileStamp,
|
|
20
21
|
mutateMemoryFile,
|
|
@@ -40,6 +41,13 @@ interface AddResult {
|
|
|
40
41
|
existing?: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Outcome of a scope move: whether the source lost the note, and a target match. */
|
|
45
|
+
export interface MoveResult {
|
|
46
|
+
moved: boolean;
|
|
47
|
+
/** The note the target store already had, when it matched as a near-duplicate. */
|
|
48
|
+
existing?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
export interface MemoryRuntime {
|
|
44
52
|
/** Resolve paths and load notes for a session; returns a warning message, if any. */
|
|
45
53
|
load(cwd: string, trusted: boolean): Promise<string | undefined>;
|
|
@@ -61,6 +69,16 @@ export interface MemoryRuntime {
|
|
|
61
69
|
update(scope: MemoryScope, from: string, to: string): Promise<boolean>;
|
|
62
70
|
forget(scope: MemoryScope, entry: string): Promise<boolean>;
|
|
63
71
|
clear(scope: MemoryScope): Promise<boolean>;
|
|
72
|
+
/** File size of each store in bytes; `undefined` when it was not read. */
|
|
73
|
+
sizes(): Record<MemoryScope, number | undefined>;
|
|
74
|
+
/** Merge two notes in `scope` into one, in a single write. */
|
|
75
|
+
merge(scope: MemoryScope, first: string, second: string, merged: string): Promise<boolean>;
|
|
76
|
+
/**
|
|
77
|
+
* Move a note from one scope to the other: written to the target first, then
|
|
78
|
+
* removed from the source, so a failure between the two leaves a duplicate
|
|
79
|
+
* rather than losing the note.
|
|
80
|
+
*/
|
|
81
|
+
move(from: MemoryScope, to: MemoryScope, entry: string): Promise<MoveResult>;
|
|
64
82
|
/** Drop cached state on shutdown. */
|
|
65
83
|
reset(): void;
|
|
66
84
|
}
|
|
@@ -150,6 +168,14 @@ export function createMemoryRuntime(pi: Pick<ExtensionAPI, "exec">): MemoryRunti
|
|
|
150
168
|
},
|
|
151
169
|
config: () => config,
|
|
152
170
|
paths: () => paths,
|
|
171
|
+
sizes: () => {
|
|
172
|
+
// A store we may not read reports no size, the same way `refresh` refuses
|
|
173
|
+
// to cache a stamp for it.
|
|
174
|
+
return {
|
|
175
|
+
project: trusted ? stampOf("project")?.size : undefined,
|
|
176
|
+
global: stampOf("global")?.size,
|
|
177
|
+
};
|
|
178
|
+
},
|
|
153
179
|
trusted: () => trusted,
|
|
154
180
|
entries: () => {
|
|
155
181
|
refresh();
|
|
@@ -188,6 +214,29 @@ export function createMemoryRuntime(pi: Pick<ExtensionAPI, "exec">): MemoryRunti
|
|
|
188
214
|
commit(scope, result.entries);
|
|
189
215
|
return result.changed;
|
|
190
216
|
},
|
|
217
|
+
async merge(scope, first, second, merged) {
|
|
218
|
+
requireTrusted(scope);
|
|
219
|
+
const result = await mutateMemoryFile(pathFor(scope), (content) => applyMerge(content, first, second, merged));
|
|
220
|
+
commit(scope, result.entries);
|
|
221
|
+
return result.changed;
|
|
222
|
+
},
|
|
223
|
+
async move(from, to, entry) {
|
|
224
|
+
if (from === to) throw new Error(`The note is already in ${scopeLabel(from)} memory.`);
|
|
225
|
+
// Refuse before any write: an untrusted target, or a note that is not in the
|
|
226
|
+
// source, must leave both stores exactly as they were.
|
|
227
|
+
requireTrusted(to);
|
|
228
|
+
// `entries()` refreshes both stores from disk first, so the check is against
|
|
229
|
+
// what is on disk now, not the cache a previous call left behind.
|
|
230
|
+
if (!this.entries()[from].includes(entry)) {
|
|
231
|
+
throw new Error(`No ${scopeLabel(from)} memory note matches "${entry}".`);
|
|
232
|
+
}
|
|
233
|
+
// Target first: the one failure possible across two files then leaves the note
|
|
234
|
+
// in both scopes, where `/memory doctor` reports it and `m` merges it, instead
|
|
235
|
+
// of deleting the only copy.
|
|
236
|
+
const added = await this.add(to, entry);
|
|
237
|
+
const moved = await this.forget(from, entry);
|
|
238
|
+
return added.existing === undefined ? { moved } : { moved, existing: added.existing };
|
|
239
|
+
},
|
|
191
240
|
reset: () => {
|
|
192
241
|
paths = undefined;
|
|
193
242
|
config = DEFAULT_MEMORY_CONFIG;
|
|
@@ -180,6 +180,37 @@ export function applyUpdate(content: string, from: string, to: string): FileChan
|
|
|
180
180
|
return changed ? { content: lines.join("\n"), changed: true } : { content, changed: false };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Merge two notes into one: replace `first` in place with `merged` and drop
|
|
185
|
+
* `second`, so the pair becomes a single bullet where the first one stood. One
|
|
186
|
+
* transform, so the caller's write is atomic; a missing note or two identical
|
|
187
|
+
* names changes nothing.
|
|
188
|
+
*/
|
|
189
|
+
export function applyMerge(content: string, first: string, second: string, merged: string): FileChange {
|
|
190
|
+
if (first === second) return { content, changed: false };
|
|
191
|
+
const lines = content.split(/\r?\n/);
|
|
192
|
+
const notes = lines.map((line) => {
|
|
193
|
+
const match = BULLET_PARTS.exec(line);
|
|
194
|
+
return match ? sanitizeLine(match[2]) : undefined;
|
|
195
|
+
});
|
|
196
|
+
if (!notes.includes(first) || !notes.includes(second)) return { content, changed: false };
|
|
197
|
+
|
|
198
|
+
let changed = false;
|
|
199
|
+
const out: string[] = [];
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
const match = BULLET_PARTS.exec(line);
|
|
202
|
+
if (match && sanitizeLine(match[2]) === first) {
|
|
203
|
+
out.push(`${match[1]}${merged}${match[3]}`);
|
|
204
|
+
changed = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (match && sanitizeLine(match[2]) === second) continue;
|
|
208
|
+
out.push(line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return changed ? { content: endWithNewline(out.join("\n")), changed: true } : { content, changed: false };
|
|
212
|
+
}
|
|
213
|
+
|
|
183
214
|
/** Remove every note, keeping the header and any prose. */
|
|
184
215
|
export function applyClear(content: string): FileChange {
|
|
185
216
|
let changed = false;
|
package/extensions/memory/tui.ts
CHANGED
|
@@ -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(() =>
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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"),
|