@oh-my-pi/pi-coding-agent 15.11.2 → 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 +9 -0
- package/dist/cli.js +28 -27
- package/dist/types/lsp/format-options.d.ts +32 -0
- package/dist/types/mnemopi/state.d.ts +29 -1
- package/dist/types/tools/path-utils.d.ts +5 -1
- package/dist/types/utils/git.d.ts +1 -1
- package/package.json +11 -11
- package/src/lsp/clients/lsp-linter-client.ts +2 -10
- package/src/lsp/format-options.ts +119 -0
- package/src/lsp/index.ts +2 -10
- package/src/mnemopi/backend.ts +4 -8
- package/src/mnemopi/state.ts +42 -3
- package/src/modes/interactive-mode.ts +22 -2
- package/src/session/agent-session.ts +1 -1
- package/src/tools/path-utils.ts +34 -10
- package/src/tools/search.ts +11 -0
- package/src/utils/git.ts +7 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Subset of the LSP `FormattingOptions` we send. */
|
|
2
|
+
export interface LspFormattingOptions {
|
|
3
|
+
tabSize: number;
|
|
4
|
+
insertSpaces: boolean;
|
|
5
|
+
trimTrailingWhitespace: boolean;
|
|
6
|
+
insertFinalNewline: boolean;
|
|
7
|
+
trimFinalNewlines: boolean;
|
|
8
|
+
}
|
|
9
|
+
interface DetectedIndent {
|
|
10
|
+
tabSize?: number;
|
|
11
|
+
insertSpaces?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Sniff `insertSpaces` and the indent unit from `content`.
|
|
15
|
+
*
|
|
16
|
+
* Walks the buffer once: the first indented line decides spaces vs tabs; for
|
|
17
|
+
* space indents, the GCD of all space-indent widths gives the stride (so a
|
|
18
|
+
* 2/4/6 file reports `2`, a 4/8 file reports `4`). Returns `undefined` for any
|
|
19
|
+
* field the content does not pin so a higher-precedence override (editorconfig)
|
|
20
|
+
* can win without being overwritten by sniffing noise.
|
|
21
|
+
*/
|
|
22
|
+
export declare function detectIndentFromContent(content: string): DetectedIndent;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the `FormattingOptions` payload for a `textDocument/formatting` request
|
|
25
|
+
* targeting `filePath` with `content`.
|
|
26
|
+
*
|
|
27
|
+
* The two fields that actually affect on-disk bytes (`tabSize`, `insertSpaces`)
|
|
28
|
+
* are layered: editorconfig wins, then content sniffing, then the fallback.
|
|
29
|
+
* Trim/final-newline flags are static.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveFormatOptions(filePath: string, content: string): LspFormattingOptions;
|
|
32
|
+
export {};
|
|
@@ -75,7 +75,35 @@ export declare class MnemopiSessionState {
|
|
|
75
75
|
}>, sourceId: string): Promise<void>;
|
|
76
76
|
attachSessionListeners(): void;
|
|
77
77
|
maybeRecallOnAgentStart(): Promise<void>;
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Drain in-flight fact extraction and run beam consolidation on every owned
|
|
80
|
+
* bank, after capturing the current transcript. Mirrors the manual
|
|
81
|
+
* `/memory enqueue` slash command, but stops short of closing the DBs so
|
|
82
|
+
* callers can keep using the state. {@link dispose} composes this with the
|
|
83
|
+
* close step so normal session shutdown promotes working memory to
|
|
84
|
+
* episodic/gists/graph automatically (see issue #2320).
|
|
85
|
+
*
|
|
86
|
+
* Aliased subagent states share `scoped` (and therefore the actual SQLite
|
|
87
|
+
* banks) with their parent. `consolidate()` deliberately does NOT
|
|
88
|
+
* short-circuit on `aliasOf`: `forceRetainCurrentSession` already guards
|
|
89
|
+
* itself, and an explicit `/memory enqueue` invoked from within a subagent
|
|
90
|
+
* still needs to flush extractions and sleep the parent's shared banks —
|
|
91
|
+
* otherwise enqueue would report success while leaving the subagent's
|
|
92
|
+
* retained memories unconsolidated until the parent eventually shuts down
|
|
93
|
+
* (PR #2327 review).
|
|
94
|
+
*/
|
|
95
|
+
consolidate(): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Release the per-session resources. Defaults to running {@link consolidate}
|
|
98
|
+
* before closing handles so normal session shutdown promotes working memory
|
|
99
|
+
* into long-term storage. Callers that are about to delete the DB files —
|
|
100
|
+
* e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
|
|
101
|
+
* extraction/sleep pass, since spending tokens on memories that will be
|
|
102
|
+
* wiped on the next line is wasted work (PR #2327 review).
|
|
103
|
+
*/
|
|
104
|
+
dispose(options?: {
|
|
105
|
+
consolidate?: boolean;
|
|
106
|
+
}): Promise<void>;
|
|
79
107
|
}
|
|
80
108
|
export declare function getMnemopiScopedDbPaths(config: MnemopiBackendConfig): readonly string[];
|
|
81
109
|
export declare function getMnemopiScopedBanks(config: MnemopiBackendConfig): readonly string[];
|
|
@@ -135,7 +135,7 @@ export declare function parseSearchPath(filePath: string): ParsedSearchPath;
|
|
|
135
135
|
export declare function parseSearchPathPreferringLiteral(filePath: string, cwd: string): Promise<ParsedSearchPath>;
|
|
136
136
|
export declare function parseFindPattern(pattern: string): ParsedFindPattern;
|
|
137
137
|
export declare function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): string | undefined;
|
|
138
|
-
export declare function resolveExplicitSearchPaths(pathItems: string[], cwd: string, suffixGlob?: string): Promise<ResolvedMultiSearchPath | undefined>;
|
|
138
|
+
export declare function resolveExplicitSearchPaths(pathItems: string[], cwd: string, suffixGlob?: string, fanOutFileItems?: boolean): Promise<ResolvedMultiSearchPath | undefined>;
|
|
139
139
|
export declare function resolveExplicitFindPatterns(patternItems: string[], cwd: string): Promise<ResolvedMultiFindPattern | undefined>;
|
|
140
140
|
/**
|
|
141
141
|
* Result of partitioning a list of user-supplied paths/globs into entries whose
|
|
@@ -176,6 +176,10 @@ export interface ToolScopeOptions {
|
|
|
176
176
|
trackImmutableSources?: boolean;
|
|
177
177
|
/** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
|
|
178
178
|
surfaceExactFilePaths?: boolean;
|
|
179
|
+
/** Fan plain-file entries out into per-target scans instead of folding them
|
|
180
|
+
* into a directory walk's glob union (search-only: the caller must dedupe
|
|
181
|
+
* matches from overlapping targets). */
|
|
182
|
+
fanOutFileTargets?: boolean;
|
|
179
183
|
/** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
|
|
180
184
|
multipathStatHint?: string;
|
|
181
185
|
/** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
|
|
@@ -177,7 +177,7 @@ export declare const stage: {
|
|
|
177
177
|
};
|
|
178
178
|
/** Create a commit with the given message (passed via stdin). */
|
|
179
179
|
export declare function commit(cwd: string, message: string, options?: CommitOptions): Promise<GitCommandResult>;
|
|
180
|
-
/** Push the current branch. */
|
|
180
|
+
/** Push the current branch (branch-scoped: never follows tags). */
|
|
181
181
|
export declare function push(cwd: string, options?: PushOptions): Promise<void>;
|
|
182
182
|
/** Checkout a ref. */
|
|
183
183
|
export declare function checkout(cwd: string, ref: string, signal?: AbortSignal): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "15.11.
|
|
4
|
+
"version": "15.11.3",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,16 +47,16 @@
|
|
|
47
47
|
"@agentclientprotocol/sdk": "0.22.1",
|
|
48
48
|
"@babel/parser": "^7.29.7",
|
|
49
49
|
"@mozilla/readability": "^0.6.0",
|
|
50
|
-
"@oh-my-pi/hashline": "15.11.
|
|
51
|
-
"@oh-my-pi/omp-stats": "15.11.
|
|
52
|
-
"@oh-my-pi/pi-agent-core": "15.11.
|
|
53
|
-
"@oh-my-pi/pi-ai": "15.11.
|
|
54
|
-
"@oh-my-pi/pi-catalog": "15.11.
|
|
55
|
-
"@oh-my-pi/pi-mnemopi": "15.11.
|
|
56
|
-
"@oh-my-pi/pi-natives": "15.11.
|
|
57
|
-
"@oh-my-pi/pi-tui": "15.11.
|
|
58
|
-
"@oh-my-pi/pi-utils": "15.11.
|
|
59
|
-
"@oh-my-pi/snapcompact": "15.11.
|
|
50
|
+
"@oh-my-pi/hashline": "15.11.3",
|
|
51
|
+
"@oh-my-pi/omp-stats": "15.11.3",
|
|
52
|
+
"@oh-my-pi/pi-agent-core": "15.11.3",
|
|
53
|
+
"@oh-my-pi/pi-ai": "15.11.3",
|
|
54
|
+
"@oh-my-pi/pi-catalog": "15.11.3",
|
|
55
|
+
"@oh-my-pi/pi-mnemopi": "15.11.3",
|
|
56
|
+
"@oh-my-pi/pi-natives": "15.11.3",
|
|
57
|
+
"@oh-my-pi/pi-tui": "15.11.3",
|
|
58
|
+
"@oh-my-pi/pi-utils": "15.11.3",
|
|
59
|
+
"@oh-my-pi/snapcompact": "15.11.3",
|
|
60
60
|
"@opentelemetry/api": "^1.9.1",
|
|
61
61
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
62
62
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
|
@@ -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) {
|
|
@@ -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/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
|
|
|
@@ -14,7 +14,14 @@ import {
|
|
|
14
14
|
import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
|
|
15
15
|
import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
|
|
16
16
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
Component,
|
|
19
|
+
EditorTheme,
|
|
20
|
+
LoaderMessageColorFn,
|
|
21
|
+
NativeScrollbackLiveRegion,
|
|
22
|
+
OverlayHandle,
|
|
23
|
+
SlashCommand,
|
|
24
|
+
} from "@oh-my-pi/pi-tui";
|
|
18
25
|
import {
|
|
19
26
|
Container,
|
|
20
27
|
clearRenderCache,
|
|
@@ -257,6 +264,19 @@ export interface InteractiveModeOptions {
|
|
|
257
264
|
initialMessages?: string[];
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Hosts the working loader and transient status rows. While anything is
|
|
269
|
+
* mounted, every row is live: report a seam at 0 so the engine never commits
|
|
270
|
+
* a still-animating loader to native scrollback (stale `Working…` rows would
|
|
271
|
+
* otherwise pile up above the live one). The transcript's own seam, when
|
|
272
|
+
* present, sits higher and wins (topmost-seam merge in TUI.render).
|
|
273
|
+
*/
|
|
274
|
+
class StatusContainer extends Container implements NativeScrollbackLiveRegion {
|
|
275
|
+
getNativeScrollbackLiveRegionStart(): number | undefined {
|
|
276
|
+
return this.children.length > 0 ? 0 : undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
260
280
|
export class InteractiveMode implements InteractiveModeContext {
|
|
261
281
|
session: AgentSession;
|
|
262
282
|
sessionManager: SessionManager;
|
|
@@ -418,7 +438,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
418
438
|
setTerminalTextSizing(settings.get("tui.textSizing") && TERMINAL.textSizing);
|
|
419
439
|
this.chatContainer = new TranscriptContainer();
|
|
420
440
|
this.pendingMessagesContainer = new Container();
|
|
421
|
-
this.statusContainer = new
|
|
441
|
+
this.statusContainer = new StatusContainer();
|
|
422
442
|
this.todoContainer = new Container();
|
|
423
443
|
this.btwContainer = new Container();
|
|
424
444
|
this.omfgContainer = new Container();
|
|
@@ -3213,7 +3213,7 @@ export class AgentSession {
|
|
|
3213
3213
|
this.setHindsightSessionState(undefined);
|
|
3214
3214
|
hindsightState?.dispose();
|
|
3215
3215
|
const mnemopiState = setMnemopiSessionState(this, undefined);
|
|
3216
|
-
mnemopiState?.dispose();
|
|
3216
|
+
await mnemopiState?.dispose();
|
|
3217
3217
|
this.#disconnectFromAgent();
|
|
3218
3218
|
if (this.#unsubscribeAppendOnly) {
|
|
3219
3219
|
this.#unsubscribeAppendOnly();
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -729,6 +729,7 @@ async function resolveSearchPathItems(
|
|
|
729
729
|
pathItems: string[],
|
|
730
730
|
cwd: string,
|
|
731
731
|
suffixGlob?: string,
|
|
732
|
+
fanOutFileItems = false,
|
|
732
733
|
): Promise<ResolvedMultiSearchPath | undefined> {
|
|
733
734
|
if (pathItems.length < 1) {
|
|
734
735
|
return undefined;
|
|
@@ -760,14 +761,27 @@ async function resolveSearchPathItems(
|
|
|
760
761
|
}
|
|
761
762
|
return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
|
|
762
763
|
});
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
764
|
+
// A single walk rooted at the common ancestor is only safe when that
|
|
765
|
+
// ancestor is itself one of the requested scopes (e.g. `.` + `src/foo.ts`):
|
|
766
|
+
// the walk then covers exactly what the caller asked for. When the common
|
|
767
|
+
// ancestor is an unrequested parent (`.` + `~/.gitconfig` → `$HOME`, or
|
|
768
|
+
// disjoint trees → `/`), a collapsed walk traverses every unrelated sibling
|
|
769
|
+
// under it — fan out into per-item targets so each scan stays bounded to a
|
|
770
|
+
// requested path.
|
|
771
|
+
const commonIsRequestedScope = parsedItems.some(item => item.absoluteBasePath === commonBasePath);
|
|
772
|
+
// Walkers prune `.git` unconditionally and honor gitignore, so a plain-file
|
|
773
|
+
// item folded into a directory walk's glob union (`.` + `.git/config`) can
|
|
774
|
+
// silently never match. Callers that dedupe overlapping results opt in via
|
|
775
|
+
// `fanOutFileItems` to get explicit file targets, which bypass the walker.
|
|
776
|
+
const demotesFileItem =
|
|
777
|
+
fanOutFileItems && !allExactFiles && parsedItems.some(item => !item.parsedPath.glob && item.stat.isFile());
|
|
778
|
+
const targets =
|
|
779
|
+
parsedItems.length > 1 && (!commonIsRequestedScope || demotesFileItem)
|
|
780
|
+
? parsedItems.map(item => ({
|
|
781
|
+
basePath: item.absoluteBasePath,
|
|
782
|
+
glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob,
|
|
783
|
+
}))
|
|
784
|
+
: undefined;
|
|
771
785
|
|
|
772
786
|
return {
|
|
773
787
|
basePath: commonBasePath,
|
|
@@ -782,8 +796,9 @@ export async function resolveExplicitSearchPaths(
|
|
|
782
796
|
pathItems: string[],
|
|
783
797
|
cwd: string,
|
|
784
798
|
suffixGlob?: string,
|
|
799
|
+
fanOutFileItems = false,
|
|
785
800
|
): Promise<ResolvedMultiSearchPath | undefined> {
|
|
786
|
-
return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
|
|
801
|
+
return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob, fanOutFileItems);
|
|
787
802
|
}
|
|
788
803
|
|
|
789
804
|
async function resolveFindPatternItems(
|
|
@@ -928,6 +943,10 @@ export interface ToolScopeOptions {
|
|
|
928
943
|
trackImmutableSources?: boolean;
|
|
929
944
|
/** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
|
|
930
945
|
surfaceExactFilePaths?: boolean;
|
|
946
|
+
/** Fan plain-file entries out into per-target scans instead of folding them
|
|
947
|
+
* into a directory walk's glob union (search-only: the caller must dedupe
|
|
948
|
+
* matches from overlapping targets). */
|
|
949
|
+
fanOutFileTargets?: boolean;
|
|
931
950
|
/** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
|
|
932
951
|
multipathStatHint?: string;
|
|
933
952
|
/** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
|
|
@@ -1024,7 +1043,12 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
|
|
|
1024
1043
|
globFilter = parsedPath.glob;
|
|
1025
1044
|
scopePath = formatPathRelativeToCwd(searchPath, cwd);
|
|
1026
1045
|
} else {
|
|
1027
|
-
const multiSearchPath = await resolveExplicitSearchPaths(
|
|
1046
|
+
const multiSearchPath = await resolveExplicitSearchPaths(
|
|
1047
|
+
effectivePaths,
|
|
1048
|
+
cwd,
|
|
1049
|
+
undefined,
|
|
1050
|
+
opts.fanOutFileTargets === true,
|
|
1051
|
+
);
|
|
1028
1052
|
if (!multiSearchPath) {
|
|
1029
1053
|
throw new ToolError("`paths` must contain at least one path or glob");
|
|
1030
1054
|
}
|
package/src/tools/search.ts
CHANGED
|
@@ -795,6 +795,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
795
795
|
internalUrlAction: "search",
|
|
796
796
|
trackImmutableSources: true,
|
|
797
797
|
surfaceExactFilePaths: true,
|
|
798
|
+
fanOutFileTargets: true,
|
|
798
799
|
multipathStatHint: " (`paths` entries must each exist relative to cwd)",
|
|
799
800
|
settings: this.session.settings,
|
|
800
801
|
signal,
|
|
@@ -863,6 +864,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
863
864
|
if (searchablePaths.length > 0) {
|
|
864
865
|
if (exactFilePaths || multiTargets) {
|
|
865
866
|
const matches: GrepMatch[] = [];
|
|
867
|
+
const seenMatchKeys = new Set<string>();
|
|
866
868
|
let limitReached = false;
|
|
867
869
|
let totalMatches = 0;
|
|
868
870
|
let filesSearched = 0;
|
|
@@ -900,6 +902,15 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
900
902
|
filesSearched += targetResult.filesSearched;
|
|
901
903
|
for (const match of targetResult.matches) {
|
|
902
904
|
const absolute = path.resolve(target.basePath, match.path);
|
|
905
|
+
// Overlapping targets (a directory plus a file nested
|
|
906
|
+
// inside it) surface the same physical line twice;
|
|
907
|
+
// keep the first occurrence.
|
|
908
|
+
const matchKey = `${absolute}\0${match.lineNumber}`;
|
|
909
|
+
if (seenMatchKeys.has(matchKey)) {
|
|
910
|
+
totalMatches = Math.max(0, totalMatches - 1);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
seenMatchKeys.add(matchKey);
|
|
903
914
|
const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
|
|
904
915
|
matches.push({ ...match, path: rebased });
|
|
905
916
|
}
|
package/src/utils/git.ts
CHANGED
|
@@ -1126,9 +1126,14 @@ export async function commit(cwd: string, message: string, options: CommitOption
|
|
|
1126
1126
|
return runChecked(cwd, args, { signal: options.signal, stdin: message });
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
|
-
/** Push the current branch. */
|
|
1129
|
+
/** Push the current branch (branch-scoped: never follows tags). */
|
|
1130
1130
|
export async function push(cwd: string, options: PushOptions = {}): Promise<void> {
|
|
1131
|
-
|
|
1131
|
+
// `--no-follow-tags` overrides a user's `push.followTags = true`, which
|
|
1132
|
+
// would otherwise ride every reachable annotated tag along with the
|
|
1133
|
+
// branch — rejected refs ("permission denied") on remotes the user
|
|
1134
|
+
// cannot tag (e.g. PR-head forks), failing the call after the branch
|
|
1135
|
+
// itself already updated. Tool pushes push exactly the named refspec.
|
|
1136
|
+
const args = ["push", "--no-follow-tags"];
|
|
1132
1137
|
if (options.forceWithLease) args.push("--force-with-lease");
|
|
1133
1138
|
if (options.remote) args.push(options.remote);
|
|
1134
1139
|
if (options.refspec) args.push(options.refspec);
|