@oh-my-pi/pi-coding-agent 15.11.1 → 15.11.3
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/CHANGELOG.md +36 -1
- package/dist/cli.js +643 -627
- package/dist/types/config/settings-schema.d.ts +36 -0
- package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
- package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
- package/dist/types/extensibility/extensions/types.d.ts +2 -2
- package/dist/types/extensibility/hooks/types.d.ts +8 -4
- package/dist/types/irc/bus.d.ts +15 -2
- package/dist/types/lsp/format-options.d.ts +32 -0
- package/dist/types/mnemopi/state.d.ts +29 -1
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +17 -3
- package/dist/types/slash-commands/available-commands.d.ts +34 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/attach.d.ts +4 -4
- package/dist/types/tools/browser/registry.d.ts +1 -0
- package/dist/types/tools/irc.d.ts +3 -2
- package/dist/types/tools/path-utils.d.ts +5 -5
- package/dist/types/utils/git.d.ts +1 -1
- package/package.json +11 -11
- package/src/config/settings-schema.ts +40 -0
- package/src/exec/bash-executor.ts +21 -6
- package/src/extensibility/custom-commands/loader.ts +3 -1
- package/src/extensibility/custom-commands/types.ts +6 -3
- package/src/extensibility/custom-tools/loader.ts +4 -7
- package/src/extensibility/custom-tools/types.ts +8 -4
- package/src/extensibility/extensions/loader.ts +2 -1
- package/src/extensibility/extensions/types.ts +2 -2
- package/src/extensibility/hooks/loader.ts +3 -1
- package/src/extensibility/hooks/types.ts +8 -4
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/irc/bus.ts +14 -3
- package/src/lsp/clients/lsp-linter-client.ts +2 -10
- package/src/lsp/defaults.json +6 -0
- package/src/lsp/format-options.ts +119 -0
- package/src/lsp/index.ts +2 -10
- package/src/lsp/render.ts +2 -28
- package/src/memories/index.ts +2 -0
- package/src/mnemopi/backend.ts +4 -8
- package/src/mnemopi/state.ts +42 -3
- package/src/modes/acp/acp-agent.ts +4 -67
- package/src/modes/components/plan-review-overlay.ts +32 -3
- package/src/modes/controllers/streaming-reveal.ts +16 -8
- package/src/modes/interactive-mode.ts +54 -2
- package/src/modes/rpc/rpc-client.ts +32 -0
- package/src/modes/rpc/rpc-mode.ts +82 -7
- package/src/modes/rpc/rpc-types.ts +23 -0
- package/src/modes/theme/theme.ts +7 -7
- package/src/modes/utils/ui-helpers.ts +13 -4
- package/src/prompts/memories/consolidation_system.md +4 -0
- package/src/prompts/system/irc-autoreply.md +6 -0
- package/src/prompts/system/irc-incoming.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/session/agent-session.ts +96 -7
- package/src/slash-commands/available-commands.ts +105 -0
- package/src/tools/bash.ts +5 -1
- package/src/tools/browser/attach.ts +26 -7
- package/src/tools/browser/registry.ts +11 -1
- package/src/tools/irc.ts +16 -4
- package/src/tools/job.ts +7 -3
- package/src/tools/path-utils.ts +56 -25
- package/src/tools/search.ts +11 -0
- package/src/utils/git.ts +7 -2
package/src/irc/bus.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* AgentLifecycleManager, idle agents are woken with a real turn, and busy
|
|
8
8
|
* agents receive the message as a non-interrupting aside at the next step
|
|
9
9
|
* boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
|
|
10
|
-
* the recipient, observed via `wait
|
|
10
|
+
* the recipient, observed via `wait` — with one exception: when the sender
|
|
11
|
+
* awaits a reply and the recipient is mid-turn with async execution
|
|
12
|
+
* disabled, the recipient session generates an ephemeral side-channel
|
|
13
|
+
* auto-reply (it may be blocked in a synchronous task spawn whose batch
|
|
14
|
+
* includes the sender, so a real turn could never happen in time).
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
@@ -80,8 +84,15 @@ export class IrcBus {
|
|
|
80
84
|
* context, so buffering it too would double-deliver via a later
|
|
81
85
|
* `wait`/`inbox` and inflate unread counts. Only a failed live hand-off
|
|
82
86
|
* is buffered for the recipient to drain later.
|
|
87
|
+
*
|
|
88
|
+
* `opts.expectsReply` marks sends whose caller is blocked on an answer
|
|
89
|
+
* (`send await:true`). It is forwarded to the recipient session so a
|
|
90
|
+
* mid-turn recipient that cannot reach a step boundary (async execution
|
|
91
|
+
* disabled — e.g. blocked in a synchronous task spawn awaiting the
|
|
92
|
+
* sender's own batch) can generate an ephemeral side-channel auto-reply
|
|
93
|
+
* instead of stranding the sender until timeout.
|
|
83
94
|
*/
|
|
84
|
-
async send(msg: Omit<IrcMessage, "id" | "ts"
|
|
95
|
+
async send(msg: Omit<IrcMessage, "id" | "ts">, opts?: { expectsReply?: boolean }): Promise<IrcDeliveryReceipt> {
|
|
85
96
|
const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
|
|
86
97
|
const ref = this.#registry.get(message.to);
|
|
87
98
|
if (!ref || ref.status === "aborted") {
|
|
@@ -118,7 +129,7 @@ export class IrcBus {
|
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
try {
|
|
121
|
-
const delivery = await session.deliverIrcMessage(message);
|
|
132
|
+
const delivery = await session.deliverIrcMessage(message, opts);
|
|
122
133
|
this.#relayToMainUi(message);
|
|
123
134
|
return { to: message.to, outcome: revived ? "revived" : delivery };
|
|
124
135
|
} catch (error) {
|
|
@@ -4,18 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getOrCreateClient, notifySaved, sendRequest, syncContent } from "../../lsp/client";
|
|
6
6
|
import { applyTextEditsToString } from "../../lsp/edits";
|
|
7
|
+
import { resolveFormatOptions } from "../../lsp/format-options";
|
|
7
8
|
import type { Diagnostic, LinterClient, LspClient, ServerConfig, TextEdit } from "../../lsp/types";
|
|
8
9
|
import { fileToUri } from "../../lsp/utils";
|
|
9
10
|
|
|
10
|
-
/** Default formatting options for LSP */
|
|
11
|
-
const DEFAULT_FORMAT_OPTIONS = {
|
|
12
|
-
tabSize: 3,
|
|
13
|
-
insertSpaces: true,
|
|
14
|
-
trimTrailingWhitespace: true,
|
|
15
|
-
insertFinalNewline: true,
|
|
16
|
-
trimFinalNewlines: true,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
11
|
/**
|
|
20
12
|
* LSP-based linter client implementation.
|
|
21
13
|
* Wraps the existing LSP client infrastructure.
|
|
@@ -56,7 +48,7 @@ export class LspLinterClient implements LinterClient {
|
|
|
56
48
|
// Request formatting
|
|
57
49
|
const edits = (await sendRequest(client, "textDocument/formatting", {
|
|
58
50
|
textDocument: { uri },
|
|
59
|
-
options:
|
|
51
|
+
options: resolveFormatOptions(filePath, content),
|
|
60
52
|
})) as TextEdit[] | null;
|
|
61
53
|
|
|
62
54
|
if (!edits || edits.length === 0) {
|
package/src/lsp/defaults.json
CHANGED
|
@@ -248,6 +248,12 @@
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
},
|
|
251
|
+
"expert": {
|
|
252
|
+
"command": "expert",
|
|
253
|
+
"args": ["--stdio"],
|
|
254
|
+
"fileTypes": [".ex", ".exs", ".heex", ".eex"],
|
|
255
|
+
"rootMarkers": ["mix.exs", "mix.lock"]
|
|
256
|
+
},
|
|
251
257
|
"erlangls": {
|
|
252
258
|
"command": "erlang_ls",
|
|
253
259
|
"args": [],
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-file LSP `FormattingOptions` resolution.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the historical hardcoded `{ tabSize: 3, insertSpaces: true }` default
|
|
5
|
+
* that fed every `textDocument/formatting` request — it silently re-indented
|
|
6
|
+
* 2-space YAML (and any LSP-formatted file) on every write/edit (issue #2329).
|
|
7
|
+
*
|
|
8
|
+
* Precedence, highest to lowest:
|
|
9
|
+
* 1. `.editorconfig` in the file's chain (`indent_style`, `indent_size`, `tab_width`).
|
|
10
|
+
* 2. Indent detected from the file content the agent is about to write.
|
|
11
|
+
* 3. Hardcoded fallback — 2 spaces, matching the dominant convention for YAML,
|
|
12
|
+
* JSON, JS/TS, Python (PEP 8 is 4 but most LSP servers honour their own
|
|
13
|
+
* defaults when ours don't disagree), and most config formats. The previous
|
|
14
|
+
* `3` default was an unusual stride that actively damaged every file with
|
|
15
|
+
* a 2/4-space convention.
|
|
16
|
+
*/
|
|
17
|
+
import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils";
|
|
18
|
+
|
|
19
|
+
/** Subset of the LSP `FormattingOptions` we send. */
|
|
20
|
+
export interface LspFormattingOptions {
|
|
21
|
+
tabSize: number;
|
|
22
|
+
insertSpaces: boolean;
|
|
23
|
+
trimTrailingWhitespace: boolean;
|
|
24
|
+
insertFinalNewline: boolean;
|
|
25
|
+
trimFinalNewlines: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sensible fallback when neither `.editorconfig` nor file content pins the indent. */
|
|
29
|
+
const FALLBACK_TAB_SIZE = 2;
|
|
30
|
+
const FALLBACK_INSERT_SPACES = true;
|
|
31
|
+
|
|
32
|
+
/** Static flags we always pass — these have no per-file analogue and match common formatter expectations. */
|
|
33
|
+
const TRIM_OPTIONS = {
|
|
34
|
+
trimTrailingWhitespace: true,
|
|
35
|
+
insertFinalNewline: true,
|
|
36
|
+
trimFinalNewlines: true,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
interface DetectedIndent {
|
|
40
|
+
tabSize?: number;
|
|
41
|
+
insertSpaces?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sniff `insertSpaces` and the indent unit from `content`.
|
|
46
|
+
*
|
|
47
|
+
* Walks the buffer once: the first indented line decides spaces vs tabs; for
|
|
48
|
+
* space indents, the GCD of all space-indent widths gives the stride (so a
|
|
49
|
+
* 2/4/6 file reports `2`, a 4/8 file reports `4`). Returns `undefined` for any
|
|
50
|
+
* field the content does not pin so a higher-precedence override (editorconfig)
|
|
51
|
+
* can win without being overwritten by sniffing noise.
|
|
52
|
+
*/
|
|
53
|
+
export function detectIndentFromContent(content: string): DetectedIndent {
|
|
54
|
+
if (content.length === 0) return {};
|
|
55
|
+
|
|
56
|
+
let insertSpaces: boolean | undefined;
|
|
57
|
+
let unit = 0;
|
|
58
|
+
|
|
59
|
+
// Split is the cheapest reliable line walk on arbitrary text; the
|
|
60
|
+
// per-line regex matches are O(leading whitespace) so total cost is
|
|
61
|
+
// linear in the file's indented prefix bytes.
|
|
62
|
+
for (const line of content.split("\n")) {
|
|
63
|
+
// Skip blank/whitespace-only lines — they carry no indent signal.
|
|
64
|
+
if (line.length === 0 || line.trim().length === 0) continue;
|
|
65
|
+
|
|
66
|
+
const first = line[0];
|
|
67
|
+
if (first !== " " && first !== "\t") continue;
|
|
68
|
+
|
|
69
|
+
if (insertSpaces === undefined) {
|
|
70
|
+
insertSpaces = first === " ";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tab-indented file: the unit is one tab per level; tabSize is a
|
|
74
|
+
// display concern, leave it to caller defaults / editorconfig.
|
|
75
|
+
if (first === "\t") continue;
|
|
76
|
+
|
|
77
|
+
// Space-indented: count the leading spaces (stop at first tab to avoid
|
|
78
|
+
// mixing). GCD across non-zero widths converges on the stride.
|
|
79
|
+
let n = 0;
|
|
80
|
+
while (n < line.length && line[n] === " ") n++;
|
|
81
|
+
if (n === 0) continue;
|
|
82
|
+
unit = unit === 0 ? n : gcd(unit, n);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result: DetectedIndent = {};
|
|
86
|
+
if (insertSpaces !== undefined) result.insertSpaces = insertSpaces;
|
|
87
|
+
if (unit > 0 && insertSpaces === true) result.tabSize = unit;
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gcd(a: number, b: number): number {
|
|
92
|
+
let x = a;
|
|
93
|
+
let y = b;
|
|
94
|
+
while (y !== 0) {
|
|
95
|
+
const t = y;
|
|
96
|
+
y = x % y;
|
|
97
|
+
x = t;
|
|
98
|
+
}
|
|
99
|
+
return x;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the `FormattingOptions` payload for a `textDocument/formatting` request
|
|
104
|
+
* targeting `filePath` with `content`.
|
|
105
|
+
*
|
|
106
|
+
* The two fields that actually affect on-disk bytes (`tabSize`, `insertSpaces`)
|
|
107
|
+
* are layered: editorconfig wins, then content sniffing, then the fallback.
|
|
108
|
+
* Trim/final-newline flags are static.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveFormatOptions(filePath: string, content: string): LspFormattingOptions {
|
|
111
|
+
const fromConfig = getEditorConfigFormatting(filePath);
|
|
112
|
+
const detected = detectIndentFromContent(content);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
tabSize: fromConfig.tabSize ?? detected.tabSize ?? FALLBACK_TAB_SIZE,
|
|
116
|
+
insertSpaces: fromConfig.insertSpaces ?? detected.insertSpaces ?? FALLBACK_INSERT_SPACES,
|
|
117
|
+
...TRIM_OPTIONS,
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/lsp/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
flattenWorkspaceTextEdits,
|
|
40
40
|
rangesOverlap,
|
|
41
41
|
} from "./edits";
|
|
42
|
+
import { resolveFormatOptions } from "./format-options";
|
|
42
43
|
import { detectLspmux } from "./lspmux";
|
|
43
44
|
import {
|
|
44
45
|
type CodeAction,
|
|
@@ -779,15 +780,6 @@ export enum FileFormatResult {
|
|
|
779
780
|
FORMATTED = "formatted",
|
|
780
781
|
}
|
|
781
782
|
|
|
782
|
-
/** Default formatting options for LSP */
|
|
783
|
-
const DEFAULT_FORMAT_OPTIONS = {
|
|
784
|
-
tabSize: 3,
|
|
785
|
-
insertSpaces: true,
|
|
786
|
-
trimTrailingWhitespace: true,
|
|
787
|
-
insertFinalNewline: true,
|
|
788
|
-
trimFinalNewlines: true,
|
|
789
|
-
};
|
|
790
|
-
|
|
791
783
|
/**
|
|
792
784
|
* Format content using LSP or custom linter client.
|
|
793
785
|
*
|
|
@@ -834,7 +826,7 @@ async function formatContent(
|
|
|
834
826
|
"textDocument/formatting",
|
|
835
827
|
{
|
|
836
828
|
textDocument: { uri },
|
|
837
|
-
options:
|
|
829
|
+
options: resolveFormatOptions(absolutePath, content),
|
|
838
830
|
},
|
|
839
831
|
signal,
|
|
840
832
|
)) as TextEdit[] | null;
|
package/src/lsp/render.ts
CHANGED
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
* - Collapsible/expandable views
|
|
9
9
|
*/
|
|
10
10
|
import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
|
|
11
|
-
import { type HighlightColors, highlightCode as nativeHighlightCode, supportsLanguage } from "@oh-my-pi/pi-natives";
|
|
12
11
|
import { type Component, Text } from "@oh-my-pi/pi-tui";
|
|
13
|
-
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
|
|
12
|
+
import { getLanguageFromPath, highlightCode as highlightThemeCode, type Theme } from "../modes/theme/theme";
|
|
14
13
|
import {
|
|
15
14
|
formatExpandHint,
|
|
16
15
|
formatMoreItems,
|
|
@@ -219,7 +218,7 @@ function renderHover(
|
|
|
219
218
|
const beforeCode = fullText.slice(0, codeStart).trimEnd();
|
|
220
219
|
const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim();
|
|
221
220
|
|
|
222
|
-
const codeLines =
|
|
221
|
+
const codeLines = highlightThemeCode(code, lang, theme);
|
|
223
222
|
const icon = theme.styledSymbol("status.info", "accent");
|
|
224
223
|
const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : "";
|
|
225
224
|
|
|
@@ -274,31 +273,6 @@ function renderHover(
|
|
|
274
273
|
return output.split("\n");
|
|
275
274
|
}
|
|
276
275
|
|
|
277
|
-
/**
|
|
278
|
-
* Syntax highlight code using native highlighter.
|
|
279
|
-
*/
|
|
280
|
-
function highlightCode(codeText: string, language: string, theme: Theme): string[] {
|
|
281
|
-
const validLang = language && supportsLanguage(language) ? language : undefined;
|
|
282
|
-
try {
|
|
283
|
-
const colors: HighlightColors = {
|
|
284
|
-
comment: theme.getFgAnsi("syntaxComment"),
|
|
285
|
-
keyword: theme.getFgAnsi("syntaxKeyword"),
|
|
286
|
-
function: theme.getFgAnsi("syntaxFunction"),
|
|
287
|
-
variable: theme.getFgAnsi("syntaxVariable"),
|
|
288
|
-
string: theme.getFgAnsi("syntaxString"),
|
|
289
|
-
number: theme.getFgAnsi("syntaxNumber"),
|
|
290
|
-
type: theme.getFgAnsi("syntaxType"),
|
|
291
|
-
operator: theme.getFgAnsi("syntaxOperator"),
|
|
292
|
-
punctuation: theme.getFgAnsi("syntaxPunctuation"),
|
|
293
|
-
inserted: theme.getFgAnsi("toolDiffAdded"),
|
|
294
|
-
deleted: theme.getFgAnsi("toolDiffRemoved"),
|
|
295
|
-
};
|
|
296
|
-
return nativeHighlightCode(codeText, validLang, colors).split("\n");
|
|
297
|
-
} catch {
|
|
298
|
-
return codeText.split("\n");
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
276
|
// =============================================================================
|
|
303
277
|
// Diagnostics Rendering
|
|
304
278
|
// =============================================================================
|
package/src/memories/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ModelRegistry } from "../config/model-registry";
|
|
|
11
11
|
import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
|
|
12
12
|
import type { Settings } from "../config/settings";
|
|
13
13
|
import consolidationTemplate from "../prompts/memories/consolidation.md" with { type: "text" };
|
|
14
|
+
import consolidationSystemTemplate from "../prompts/memories/consolidation_system.md" with { type: "text" };
|
|
14
15
|
import readPathTemplate from "../prompts/memories/read-path.md" with { type: "text" };
|
|
15
16
|
import stageOneInputTemplate from "../prompts/memories/stage_one_input.md" with { type: "text" };
|
|
16
17
|
import stageOneSystemTemplate from "../prompts/memories/stage_one_system.md" with { type: "text" };
|
|
@@ -752,6 +753,7 @@ async function runConsolidationModel(options: {
|
|
|
752
753
|
const response = await completeSimple(
|
|
753
754
|
model,
|
|
754
755
|
{
|
|
756
|
+
systemPrompt: [consolidationSystemTemplate],
|
|
755
757
|
messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
|
|
756
758
|
},
|
|
757
759
|
{
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -82,7 +82,7 @@ export const mnemopiBackend: MemoryBackend = {
|
|
|
82
82
|
hasRecalledForFirstTurn: true,
|
|
83
83
|
}),
|
|
84
84
|
);
|
|
85
|
-
previous?.dispose();
|
|
85
|
+
await previous?.dispose();
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -91,7 +91,7 @@ export const mnemopiBackend: MemoryBackend = {
|
|
|
91
91
|
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
|
92
92
|
const state = new MnemopiSessionState({ sessionId, config, session });
|
|
93
93
|
const previous = setMnemopiSessionState(session, state);
|
|
94
|
-
previous?.dispose();
|
|
94
|
+
await previous?.dispose();
|
|
95
95
|
state.attachSessionListeners();
|
|
96
96
|
} catch (error) {
|
|
97
97
|
logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
|
|
@@ -115,7 +115,7 @@ export const mnemopiBackend: MemoryBackend = {
|
|
|
115
115
|
|
|
116
116
|
async clear(agentDir, _cwd, session): Promise<void> {
|
|
117
117
|
const previous = session ? setMnemopiSessionState(session, undefined) : undefined;
|
|
118
|
-
previous?.dispose();
|
|
118
|
+
await previous?.dispose({ consolidate: false });
|
|
119
119
|
const config = previous?.config ?? (session ? loadMnemopiConfig(session.settings, agentDir) : undefined);
|
|
120
120
|
if (!config) return;
|
|
121
121
|
await loadMnemopiCore();
|
|
@@ -136,11 +136,7 @@ export const mnemopiBackend: MemoryBackend = {
|
|
|
136
136
|
state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
|
137
137
|
setMnemopiSessionState(session, state);
|
|
138
138
|
}
|
|
139
|
-
await state?.
|
|
140
|
-
// Drain the background fact extraction scheduled by the final retain
|
|
141
|
-
// before the process can exit, otherwise the last turn's facts are lost.
|
|
142
|
-
await state?.memory.flushExtractions();
|
|
143
|
-
state?.memory.sleepAllSessions(false);
|
|
139
|
+
await state?.consolidate();
|
|
144
140
|
} catch (error) {
|
|
145
141
|
logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
|
|
146
142
|
}
|
package/src/mnemopi/state.ts
CHANGED
|
@@ -370,12 +370,51 @@ export class MnemopiSessionState {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Drain in-flight fact extraction and run beam consolidation on every owned
|
|
375
|
+
* bank, after capturing the current transcript. Mirrors the manual
|
|
376
|
+
* `/memory enqueue` slash command, but stops short of closing the DBs so
|
|
377
|
+
* callers can keep using the state. {@link dispose} composes this with the
|
|
378
|
+
* close step so normal session shutdown promotes working memory to
|
|
379
|
+
* episodic/gists/graph automatically (see issue #2320).
|
|
380
|
+
*
|
|
381
|
+
* Aliased subagent states share `scoped` (and therefore the actual SQLite
|
|
382
|
+
* banks) with their parent. `consolidate()` deliberately does NOT
|
|
383
|
+
* short-circuit on `aliasOf`: `forceRetainCurrentSession` already guards
|
|
384
|
+
* itself, and an explicit `/memory enqueue` invoked from within a subagent
|
|
385
|
+
* still needs to flush extractions and sleep the parent's shared banks —
|
|
386
|
+
* otherwise enqueue would report success while leaving the subagent's
|
|
387
|
+
* retained memories unconsolidated until the parent eventually shuts down
|
|
388
|
+
* (PR #2327 review).
|
|
389
|
+
*/
|
|
390
|
+
async consolidate(): Promise<void> {
|
|
391
|
+
await this.forceRetainCurrentSession();
|
|
392
|
+
for (const memory of this.scoped.owned) {
|
|
393
|
+
await memory.flushExtractions();
|
|
394
|
+
memory.sleepAllSessions(false);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Release the per-session resources. Defaults to running {@link consolidate}
|
|
400
|
+
* before closing handles so normal session shutdown promotes working memory
|
|
401
|
+
* into long-term storage. Callers that are about to delete the DB files —
|
|
402
|
+
* e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
|
|
403
|
+
* extraction/sleep pass, since spending tokens on memories that will be
|
|
404
|
+
* wiped on the next line is wasted work (PR #2327 review).
|
|
405
|
+
*/
|
|
406
|
+
async dispose(options: { consolidate?: boolean } = {}): Promise<void> {
|
|
374
407
|
this.unsubscribe?.();
|
|
375
408
|
this.unsubscribe = undefined;
|
|
376
|
-
if (
|
|
377
|
-
|
|
409
|
+
if (this.aliasOf) return;
|
|
410
|
+
if (options.consolidate !== false) {
|
|
411
|
+
try {
|
|
412
|
+
await this.consolidate();
|
|
413
|
+
} catch (error) {
|
|
414
|
+
logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
|
|
415
|
+
}
|
|
378
416
|
}
|
|
417
|
+
for (const memory of this.scoped.owned) memory.close();
|
|
379
418
|
}
|
|
380
419
|
}
|
|
381
420
|
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
} from "../../extensibility/extensions";
|
|
57
57
|
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
|
|
58
58
|
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
|
|
59
|
-
import { buildSkillPromptMessage
|
|
59
|
+
import { buildSkillPromptMessage } from "../../extensibility/skills";
|
|
60
60
|
import { loadSlashCommands } from "../../extensibility/slash-commands";
|
|
61
61
|
import { resolveLocalUrlToPath } from "../../internal-urls";
|
|
62
62
|
import { MCPManager } from "../../mcp/manager";
|
|
@@ -71,12 +71,8 @@ import {
|
|
|
71
71
|
type SessionInfo as StoredSessionInfo,
|
|
72
72
|
type UsageStatistics,
|
|
73
73
|
} from "../../session/session-manager";
|
|
74
|
-
import {
|
|
75
|
-
|
|
76
|
-
ACP_BUILTIN_SLASH_COMMANDS,
|
|
77
|
-
executeAcpBuiltinSlashCommand,
|
|
78
|
-
isAcpBuiltinShadowedName,
|
|
79
|
-
} from "../../slash-commands/acp-builtins";
|
|
74
|
+
import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
|
|
75
|
+
import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands";
|
|
80
76
|
import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
|
|
81
77
|
import { normalizeLocalScheme } from "../../tools/path-utils";
|
|
82
78
|
import { runResolveInvocation } from "../../tools/resolve";
|
|
@@ -1662,66 +1658,7 @@ export class AcpAgent implements Agent {
|
|
|
1662
1658
|
}
|
|
1663
1659
|
|
|
1664
1660
|
async #buildAvailableCommands(session: AgentSession): Promise<AvailableCommand[]> {
|
|
1665
|
-
|
|
1666
|
-
const seenNames = new Set<string>();
|
|
1667
|
-
const appendCommand = (command: AvailableCommand): void => {
|
|
1668
|
-
if (seenNames.has(command.name)) {
|
|
1669
|
-
return;
|
|
1670
|
-
}
|
|
1671
|
-
seenNames.add(command.name);
|
|
1672
|
-
commands.push(command);
|
|
1673
|
-
};
|
|
1674
|
-
|
|
1675
|
-
// Advertise in the order dispatch resolves them (mirrors AgentSession
|
|
1676
|
-
// dispatch: builtins → skills → extensions → custom TS → file-based).
|
|
1677
|
-
// `appendCommand` dedupes by name so earlier entries win; extension
|
|
1678
|
-
// commands therefore correctly shadow custom TS commands of the same
|
|
1679
|
-
// name, matching the runtime behaviour of #tryExecuteExtensionCommand
|
|
1680
|
-
// running before #tryExecuteCustomCommand.
|
|
1681
|
-
for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
|
|
1682
|
-
appendCommand(command);
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
if (session.skillsSettings?.enableSkillCommands) {
|
|
1686
|
-
for (const skill of session.skills) {
|
|
1687
|
-
appendCommand({
|
|
1688
|
-
name: getSkillSlashCommandName(skill),
|
|
1689
|
-
description: skill.description || `Run ${skill.name} skill`,
|
|
1690
|
-
input: { hint: "arguments" },
|
|
1691
|
-
});
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) {
|
|
1696
|
-
// Reserved-set filtering in getRegisteredCommands only covers exact
|
|
1697
|
-
// names; colon-namespaced names whose prefix is a builtin (e.g.
|
|
1698
|
-
// `model:foo`) would still dispatch to the builtin in ACP.
|
|
1699
|
-
if (isAcpBuiltinShadowedName(command.name)) {
|
|
1700
|
-
continue;
|
|
1701
|
-
}
|
|
1702
|
-
appendCommand({
|
|
1703
|
-
name: command.name,
|
|
1704
|
-
description: command.description ?? "(extension command)",
|
|
1705
|
-
input: { hint: "arguments" },
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
for (const command of session.customCommands) {
|
|
1710
|
-
appendCommand({
|
|
1711
|
-
name: command.command.name,
|
|
1712
|
-
description: command.command.description,
|
|
1713
|
-
input: { hint: "arguments" },
|
|
1714
|
-
});
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
for (const command of await loadSlashCommands({ cwd: session.sessionManager.getCwd() })) {
|
|
1718
|
-
appendCommand({
|
|
1719
|
-
name: command.name,
|
|
1720
|
-
description: command.description,
|
|
1721
|
-
});
|
|
1722
|
-
}
|
|
1723
|
-
|
|
1724
|
-
return commands;
|
|
1661
|
+
return toAcpAvailableCommands(await buildAvailableSlashCommands(session));
|
|
1725
1662
|
}
|
|
1726
1663
|
|
|
1727
1664
|
#toSessionInfo(session: StoredSessionInfo): SessionInfo {
|
|
@@ -83,6 +83,8 @@ export interface PlanReviewOverlayCallbacks {
|
|
|
83
83
|
onCancel: () => void;
|
|
84
84
|
/** Invoked when the external-editor key is pressed (overlay stays open). */
|
|
85
85
|
onExternalEditor?: () => void;
|
|
86
|
+
/** Invoked when the external-editor key edits the active annotation draft. */
|
|
87
|
+
onAnnotationExternalEditor?: (draft: string, commit: (text: string | null) => void) => void;
|
|
86
88
|
/** Invoked with the new full plan text after an in-overlay delete/undo. */
|
|
87
89
|
onPlanEdited?: (content: string) => void;
|
|
88
90
|
/** Invoked with the Refine feedback markdown whenever annotations change. */
|
|
@@ -282,6 +284,12 @@ export class PlanReviewOverlay implements Component {
|
|
|
282
284
|
handleInput(keyData: string): void {
|
|
283
285
|
if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
|
|
284
286
|
if (this.#annotating) {
|
|
287
|
+
if (this.callbacks.onAnnotationExternalEditor && matchesAppExternalEditor(keyData)) {
|
|
288
|
+
this.callbacks.onAnnotationExternalEditor(this.#input.getValue(), text => {
|
|
289
|
+
if (text !== null) this.#submitAnnotation(text);
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
285
293
|
this.#input.handleInput(keyData);
|
|
286
294
|
return;
|
|
287
295
|
}
|
|
@@ -603,11 +611,23 @@ export class PlanReviewOverlay implements Component {
|
|
|
603
611
|
}
|
|
604
612
|
for (const section of annotated) {
|
|
605
613
|
feedback += `\n## ${section.title}\n`;
|
|
606
|
-
for (const note of section.annotations) feedback +=
|
|
614
|
+
for (const note of section.annotations) feedback += this.#formatAnnotationFeedback(note);
|
|
607
615
|
}
|
|
608
616
|
this.callbacks.onFeedbackChange?.(feedback);
|
|
609
617
|
}
|
|
610
618
|
|
|
619
|
+
#formatAnnotationFeedback(note: string): string {
|
|
620
|
+
if (!note.includes("\n")) return `- ${note}\n`;
|
|
621
|
+
const fence = this.#markdownFenceFor(note);
|
|
622
|
+
return `${fence}md\n${note}\n${fence}\n`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
#markdownFenceFor(text: string): string {
|
|
626
|
+
let fence = "```";
|
|
627
|
+
while (text.includes(fence)) fence += "`";
|
|
628
|
+
return fence;
|
|
629
|
+
}
|
|
630
|
+
|
|
611
631
|
#renderSliderLines(): string[] {
|
|
612
632
|
const slider = this.#slider;
|
|
613
633
|
if (!slider) return [];
|
|
@@ -676,7 +696,14 @@ export class PlanReviewOverlay implements Component {
|
|
|
676
696
|
if (section.level >= 1 && section.annotations.length > 0 && rendered.length > 0) {
|
|
677
697
|
lines.push(rendered[0]!);
|
|
678
698
|
for (const note of section.annotations) {
|
|
679
|
-
|
|
699
|
+
const noteLines = note.split(/\r?\n/);
|
|
700
|
+
for (let j = 0; j < noteLines.length; j++) {
|
|
701
|
+
const prefix =
|
|
702
|
+
j === 0
|
|
703
|
+
? `${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}`
|
|
704
|
+
: `${theme.fg("warning", "▎ ")}${theme.fg("dim", " ")}`;
|
|
705
|
+
lines.push(`${prefix}${theme.fg("accent", noteLines[j] ?? "")}`);
|
|
706
|
+
}
|
|
680
707
|
}
|
|
681
708
|
for (let k = 1; k < rendered.length; k++) lines.push(rendered[k]!);
|
|
682
709
|
} else {
|
|
@@ -749,7 +776,9 @@ export class PlanReviewOverlay implements Component {
|
|
|
749
776
|
const section = this.#sections[this.#toc[this.#tocCursor]!];
|
|
750
777
|
const title = section?.title ?? "";
|
|
751
778
|
const caption = `${theme.fg("dim", "Annotate")} ${theme.fg("accent", `‹${title}›`)}`;
|
|
752
|
-
|
|
779
|
+
const hintParts = ["enter save", "esc cancel"];
|
|
780
|
+
if (this.#externalEditorLabel) hintParts.push(`${this.#externalEditorLabel} editor`);
|
|
781
|
+
return [caption, this.#input.render(innerWidth)[0] ?? "", theme.fg("dim", hintParts.join(" · "))];
|
|
753
782
|
}
|
|
754
783
|
return [theme.fg("dim", this.#buildHelp())];
|
|
755
784
|
}
|
|
@@ -163,7 +163,7 @@ export class StreamingRevealController {
|
|
|
163
163
|
this.#hideThinkingBlock = this.#getHideThinkingBlock();
|
|
164
164
|
this.#smoothStreaming = this.#getSmoothStreaming();
|
|
165
165
|
if (!this.#smoothStreaming) {
|
|
166
|
-
component.updateContent(message);
|
|
166
|
+
component.updateContent(message, { transient: true });
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
169
169
|
const total = this.#visibleUnits(message);
|
|
@@ -171,10 +171,12 @@ export class StreamingRevealController {
|
|
|
171
171
|
// A tool call is a transcript-order boundary: finish any leading
|
|
172
172
|
// assistant text before EventController renders the separate tool card.
|
|
173
173
|
this.#revealed = total;
|
|
174
|
-
component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf)
|
|
174
|
+
component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
|
|
175
|
+
transient: true,
|
|
176
|
+
});
|
|
175
177
|
return;
|
|
176
178
|
}
|
|
177
|
-
this.#renderCurrent(
|
|
179
|
+
this.#renderCurrent();
|
|
178
180
|
this.#syncTimer(total);
|
|
179
181
|
}
|
|
180
182
|
|
|
@@ -182,7 +184,7 @@ export class StreamingRevealController {
|
|
|
182
184
|
this.#target = message;
|
|
183
185
|
if (!this.#component) return;
|
|
184
186
|
if (!this.#smoothStreaming) {
|
|
185
|
-
this.#component.updateContent(message);
|
|
187
|
+
this.#component.updateContent(message, { transient: true });
|
|
186
188
|
return;
|
|
187
189
|
}
|
|
188
190
|
const total = this.#visibleUnits(message);
|
|
@@ -193,13 +195,16 @@ export class StreamingRevealController {
|
|
|
193
195
|
this.#stopTimer();
|
|
194
196
|
this.#component.updateContent(
|
|
195
197
|
buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf),
|
|
198
|
+
{
|
|
199
|
+
transient: true,
|
|
200
|
+
},
|
|
196
201
|
);
|
|
197
202
|
return;
|
|
198
203
|
}
|
|
199
204
|
if (this.#revealed > total) {
|
|
200
205
|
this.#revealed = total;
|
|
201
206
|
}
|
|
202
|
-
this.#renderCurrent(
|
|
207
|
+
this.#renderCurrent();
|
|
203
208
|
this.#syncTimer(total);
|
|
204
209
|
}
|
|
205
210
|
|
|
@@ -225,11 +230,14 @@ export class StreamingRevealController {
|
|
|
225
230
|
return total;
|
|
226
231
|
}
|
|
227
232
|
|
|
228
|
-
#renderCurrent(
|
|
233
|
+
#renderCurrent(): void {
|
|
229
234
|
if (!this.#target || !this.#component) return;
|
|
235
|
+
// Every controller render is an in-flight streaming snapshot, even when
|
|
236
|
+
// smooth reveal has temporarily caught up to the current target. The
|
|
237
|
+
// message_end handler performs the only stable non-transient render.
|
|
230
238
|
this.#component.updateContent(
|
|
231
239
|
buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock, this.#countOf),
|
|
232
|
-
{ transient:
|
|
240
|
+
{ transient: true },
|
|
233
241
|
);
|
|
234
242
|
}
|
|
235
243
|
|
|
@@ -269,7 +277,7 @@ export class StreamingRevealController {
|
|
|
269
277
|
}
|
|
270
278
|
this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
|
|
271
279
|
component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
|
|
272
|
-
transient:
|
|
280
|
+
transient: true,
|
|
273
281
|
});
|
|
274
282
|
this.#requestRender();
|
|
275
283
|
if (this.#revealed >= total) {
|