@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.18
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 +49 -0
- package/dist/cli.js +16646 -16608
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/cli/usage-cli.d.ts +14 -0
- package/dist/types/commands/token.d.ts +9 -0
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +7 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +34 -15
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
- package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/thinking.d.ts +8 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +9 -16
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/usage-cli.ts +40 -5
- package/src/commands/token.ts +54 -0
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +40 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +240 -90
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/extensions/extension-dashboard.ts +221 -165
- package/src/modes/components/extensions/extension-list.ts +66 -33
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +23 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +193 -49
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/parallel.ts +6 -1
- package/src/thinking.ts +9 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/utils/tools-manager.ts +67 -10
|
@@ -11,6 +11,8 @@ import * as os from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
|
|
13
13
|
import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
|
|
14
|
+
import { normalizePluginRuntimeConfig } from "../runtime-config";
|
|
15
|
+
import type { PluginRuntimeConfig } from "../types";
|
|
14
16
|
|
|
15
17
|
import { cachePlugin } from "./cache";
|
|
16
18
|
import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
|
|
@@ -38,6 +40,16 @@ import type {
|
|
|
38
40
|
} from "./types";
|
|
39
41
|
import { buildPluginId, parsePluginId } from "./types";
|
|
40
42
|
|
|
43
|
+
const RUNTIME_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
44
|
+
const MAX_RUNTIME_PACKAGE_NAME_LENGTH = 214;
|
|
45
|
+
|
|
46
|
+
function assertRuntimePackageName(name: string): string {
|
|
47
|
+
if (name.length > MAX_RUNTIME_PACKAGE_NAME_LENGTH || !RUNTIME_PACKAGE_NAME_RE.test(name)) {
|
|
48
|
+
throw new Error(`Invalid marketplace plugin package name: ${JSON.stringify(name)}`);
|
|
49
|
+
}
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
// ── Options ──────────────────────────────────────────────────────────────────
|
|
42
54
|
|
|
43
55
|
export interface MarketplaceManagerOptions {
|
|
@@ -298,6 +310,9 @@ export class MarketplaceManager {
|
|
|
298
310
|
}
|
|
299
311
|
}
|
|
300
312
|
|
|
313
|
+
const packageName = await this.#resolvePluginPackageName(cachePath, name);
|
|
314
|
+
const previousPackageNames = await this.#resolveInstalledPackageNames(existing ?? [], name);
|
|
315
|
+
|
|
301
316
|
// Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
|
|
302
317
|
if (existing && existing.length > 0) {
|
|
303
318
|
// Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
|
|
@@ -337,6 +352,13 @@ export class MarketplaceManager {
|
|
|
337
352
|
const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
|
|
338
353
|
await writeInstalledPluginsRegistry(registryPath, newInstReg);
|
|
339
354
|
|
|
355
|
+
for (const previousPackageName of previousPackageNames) {
|
|
356
|
+
if (previousPackageName !== packageName) {
|
|
357
|
+
await this.#removeRuntimePlugin(scope, previousPackageName);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
await this.#registerRuntimePlugin(scope, packageName, cachePath, version, wasDisabled ? false : undefined);
|
|
361
|
+
|
|
340
362
|
this.#clearCache();
|
|
341
363
|
|
|
342
364
|
logger.debug("Plugin installed", { pluginId, version, cachePath });
|
|
@@ -434,6 +456,7 @@ export class MarketplaceManager {
|
|
|
434
456
|
const targetEntries = targetScope === "project" ? projectEntries! : userEntries!;
|
|
435
457
|
const targetReg = targetScope === "project" ? projectReg : userReg;
|
|
436
458
|
const registryPath = this.#registryPath(targetScope);
|
|
459
|
+
const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name);
|
|
437
460
|
|
|
438
461
|
const updatedReg = removeInstalledPlugin(targetReg, pluginId);
|
|
439
462
|
await writeInstalledPluginsRegistry(registryPath, updatedReg);
|
|
@@ -453,6 +476,10 @@ export class MarketplaceManager {
|
|
|
453
476
|
}
|
|
454
477
|
}
|
|
455
478
|
|
|
479
|
+
for (const packageName of packageNames) {
|
|
480
|
+
await this.#removeRuntimePlugin(targetScope, packageName);
|
|
481
|
+
}
|
|
482
|
+
|
|
456
483
|
this.#clearCache();
|
|
457
484
|
|
|
458
485
|
logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
|
|
@@ -539,6 +566,12 @@ export class MarketplaceManager {
|
|
|
539
566
|
};
|
|
540
567
|
await writeInstalledPluginsRegistry(registryPath, updated);
|
|
541
568
|
|
|
569
|
+
const fallbackName = parsePluginId(pluginId)?.name ?? pluginId;
|
|
570
|
+
const packageNames = await this.#resolveInstalledPackageNames(entries, fallbackName);
|
|
571
|
+
for (const packageName of packageNames) {
|
|
572
|
+
await this.#setRuntimePluginEnabled(targetScope, packageName, enabled);
|
|
573
|
+
}
|
|
574
|
+
|
|
542
575
|
this.#clearCache();
|
|
543
576
|
|
|
544
577
|
logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
|
|
@@ -701,6 +734,107 @@ export class MarketplaceManager {
|
|
|
701
734
|
|
|
702
735
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
703
736
|
|
|
737
|
+
#runtimeRoot(scope: "user" | "project"): string {
|
|
738
|
+
return path.dirname(this.#registryPath(scope));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
#nodeModulesPath(scope: "user" | "project"): string {
|
|
742
|
+
return path.join(this.#runtimeRoot(scope), "node_modules");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
#runtimeLockPath(scope: "user" | "project"): string {
|
|
746
|
+
return path.join(this.#runtimeRoot(scope), "omp-plugins.lock.json");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async #loadRuntimeConfig(scope: "user" | "project"): Promise<PluginRuntimeConfig> {
|
|
750
|
+
try {
|
|
751
|
+
return normalizePluginRuntimeConfig(await Bun.file(this.#runtimeLockPath(scope)).json());
|
|
752
|
+
} catch (err) {
|
|
753
|
+
if (isEnoent(err)) return normalizePluginRuntimeConfig({});
|
|
754
|
+
logger.warn("Failed to load marketplace plugin runtime config", {
|
|
755
|
+
path: this.#runtimeLockPath(scope),
|
|
756
|
+
error: String(err),
|
|
757
|
+
});
|
|
758
|
+
return normalizePluginRuntimeConfig({});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async #writeRuntimeConfig(scope: "user" | "project", config: PluginRuntimeConfig): Promise<void> {
|
|
763
|
+
await Bun.write(this.#runtimeLockPath(scope), JSON.stringify(config, null, 2));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async #resolvePluginPackageName(installPath: string, fallbackName: string): Promise<string> {
|
|
767
|
+
try {
|
|
768
|
+
const pkg: { name?: unknown } = await Bun.file(path.join(installPath, "package.json")).json();
|
|
769
|
+
const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName;
|
|
770
|
+
return assertRuntimePackageName(name);
|
|
771
|
+
} catch (err) {
|
|
772
|
+
if (isEnoent(err)) return assertRuntimePackageName(fallbackName);
|
|
773
|
+
throw err;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
#runtimePackagePath(scope: "user" | "project", packageName: string): string {
|
|
778
|
+
const nodeModules = path.resolve(this.#nodeModulesPath(scope));
|
|
779
|
+
const linkPath = path.resolve(nodeModules, assertRuntimePackageName(packageName));
|
|
780
|
+
const relative = path.relative(nodeModules, linkPath);
|
|
781
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
782
|
+
throw new Error(`Marketplace plugin package path escapes node_modules: ${JSON.stringify(packageName)}`);
|
|
783
|
+
}
|
|
784
|
+
return linkPath;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async #resolveInstalledPackageNames(
|
|
788
|
+
entries: readonly InstalledPluginEntry[],
|
|
789
|
+
fallbackName: string,
|
|
790
|
+
): Promise<Set<string>> {
|
|
791
|
+
const packageNames = new Set<string>();
|
|
792
|
+
for (const entry of entries) {
|
|
793
|
+
packageNames.add(await this.#resolvePluginPackageName(entry.installPath, fallbackName));
|
|
794
|
+
}
|
|
795
|
+
return packageNames;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async #registerRuntimePlugin(
|
|
799
|
+
scope: "user" | "project",
|
|
800
|
+
packageName: string,
|
|
801
|
+
cachePath: string,
|
|
802
|
+
version: string,
|
|
803
|
+
enabled: boolean | undefined,
|
|
804
|
+
): Promise<void> {
|
|
805
|
+
const linkPath = this.#runtimePackagePath(scope, packageName);
|
|
806
|
+
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
|
807
|
+
await fs.rm(linkPath, { recursive: true, force: true });
|
|
808
|
+
await fs.symlink(cachePath, linkPath, process.platform === "win32" ? "junction" : "dir");
|
|
809
|
+
|
|
810
|
+
const config = await this.#loadRuntimeConfig(scope);
|
|
811
|
+
const previous = config.plugins[packageName];
|
|
812
|
+
config.plugins[packageName] = {
|
|
813
|
+
version,
|
|
814
|
+
enabledFeatures: previous?.enabledFeatures ?? null,
|
|
815
|
+
enabled: enabled ?? previous?.enabled ?? true,
|
|
816
|
+
};
|
|
817
|
+
await this.#writeRuntimeConfig(scope, config);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async #removeRuntimePlugin(scope: "user" | "project", packageName: string): Promise<void> {
|
|
821
|
+
await fs.rm(this.#runtimePackagePath(scope, packageName), { recursive: true, force: true });
|
|
822
|
+
|
|
823
|
+
const config = await this.#loadRuntimeConfig(scope);
|
|
824
|
+
delete config.plugins[packageName];
|
|
825
|
+
delete config.settings[packageName];
|
|
826
|
+
await this.#writeRuntimeConfig(scope, config);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async #setRuntimePluginEnabled(scope: "user" | "project", packageName: string, enabled: boolean): Promise<void> {
|
|
830
|
+
const config = await this.#loadRuntimeConfig(scope);
|
|
831
|
+
const previous = config.plugins[packageName];
|
|
832
|
+
if (!previous) return;
|
|
833
|
+
|
|
834
|
+
config.plugins[packageName] = { ...previous, enabled };
|
|
835
|
+
await this.#writeRuntimeConfig(scope, config);
|
|
836
|
+
}
|
|
837
|
+
|
|
704
838
|
#registryPath(scope: "user" | "project"): string {
|
|
705
839
|
if (scope === "project") {
|
|
706
840
|
if (!this.#opts.projectInstalledRegistryPath) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const HASHLINE_FILE_PREFIX = "¶";
|
|
2
|
+
const HASHLINE_FILE_TAG_RE = /#[0-9a-fA-F]{4}$/u;
|
|
3
|
+
|
|
4
|
+
function stringField(input: Record<string, unknown>, key: string): string | undefined {
|
|
5
|
+
const value = input[key];
|
|
6
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeHashlineHeaderPath(body: string): string | undefined {
|
|
10
|
+
const trimmed = body.trim();
|
|
11
|
+
if (trimmed.length === 0) return undefined;
|
|
12
|
+
const hashStart = HASHLINE_FILE_TAG_RE.exec(trimmed)?.index;
|
|
13
|
+
const rawPath = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
|
|
14
|
+
if (rawPath.length < 2) return rawPath.length > 0 ? rawPath : undefined;
|
|
15
|
+
const first = rawPath[0];
|
|
16
|
+
const last = rawPath[rawPath.length - 1];
|
|
17
|
+
if ((first === '"' || first === "'") && first === last) return rawPath.slice(1, -1);
|
|
18
|
+
return rawPath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function extractHashlinePaths(input: string): string[] {
|
|
22
|
+
const paths: string[] = [];
|
|
23
|
+
const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
|
|
24
|
+
for (const rawLine of stripped.split("\n")) {
|
|
25
|
+
const line = rawLine.replace(/\r$/, "").trimStart();
|
|
26
|
+
if (!line.startsWith(HASHLINE_FILE_PREFIX)) continue;
|
|
27
|
+
let prefixEnd = 0;
|
|
28
|
+
while (prefixEnd < line.length && line[prefixEnd] === HASHLINE_FILE_PREFIX) prefixEnd++;
|
|
29
|
+
const path = normalizeHashlineHeaderPath(line.slice(prefixEnd));
|
|
30
|
+
if (path) paths.push(path);
|
|
31
|
+
}
|
|
32
|
+
return paths;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Adds derived compatibility fields to tool event input without changing tool execution parameters. */
|
|
36
|
+
export function normalizeToolEventInput(toolName: string, input: Record<string, unknown>): Record<string, unknown> {
|
|
37
|
+
if (toolName !== "edit" || stringField(input, "path")) return input;
|
|
38
|
+
|
|
39
|
+
// Hashline edit mode: the only authoritative target list is the parsed
|
|
40
|
+
// `¶PATH#TAG` headers inside `input`/`_input`. Trusting a passthrough
|
|
41
|
+
// `_path` here would let a model-supplied field override the real edit
|
|
42
|
+
// target and bypass extension gates that allowlist by path.
|
|
43
|
+
const rawInput = stringField(input, "input") ?? stringField(input, "_input");
|
|
44
|
+
if (rawInput !== undefined) {
|
|
45
|
+
const hashlinePaths = extractHashlinePaths(rawInput);
|
|
46
|
+
if (hashlinePaths.length === 0) return input;
|
|
47
|
+
if (hashlinePaths.length === 1) return { ...input, path: hashlinePaths[0], paths: hashlinePaths };
|
|
48
|
+
return { ...input, paths: hashlinePaths };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Replace/patch modes: `path` is the real parameter; some hosts forward
|
|
52
|
+
// it as `_path` after schema normalization, so propagate it for gates.
|
|
53
|
+
const directPath = stringField(input, "_path");
|
|
54
|
+
if (directPath) return { ...input, path: directPath };
|
|
55
|
+
|
|
56
|
+
return input;
|
|
57
|
+
}
|
package/src/hindsight/content.ts
CHANGED
|
@@ -189,6 +189,22 @@ export interface RetentionTranscript {
|
|
|
189
189
|
* Messages are tag-stripped before framing to break the recall→retain loop.
|
|
190
190
|
* Returns `{ transcript: null }` when nothing meaningful survives.
|
|
191
191
|
*/
|
|
192
|
+
function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
|
|
193
|
+
const parts: string[] = [];
|
|
194
|
+
for (const msg of messages) {
|
|
195
|
+
const content = stripMemoryTags(msg.content).trim();
|
|
196
|
+
if (!hasSubstantiveContent(content)) continue;
|
|
197
|
+
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (parts.length === 0) return { transcript: null, messageCount: 0 };
|
|
201
|
+
|
|
202
|
+
const transcript = parts.join("\n\n");
|
|
203
|
+
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
|
|
204
|
+
|
|
205
|
+
return { transcript, messageCount: parts.length };
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
export function prepareRetentionTranscript(
|
|
193
209
|
messages: HindsightMessage[],
|
|
194
210
|
retainFullWindow = false,
|
|
@@ -210,17 +226,10 @@ export function prepareRetentionTranscript(
|
|
|
210
226
|
targetMessages = messages.slice(lastUserIdx);
|
|
211
227
|
}
|
|
212
228
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const content = stripMemoryTags(msg.content).trim();
|
|
216
|
-
if (!hasSubstantiveContent(content)) continue;
|
|
217
|
-
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (parts.length === 0) return { transcript: null, messageCount: 0 };
|
|
221
|
-
|
|
222
|
-
const transcript = parts.join("\n\n");
|
|
223
|
-
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
|
|
229
|
+
return formatRetentionMessages(targetMessages);
|
|
230
|
+
}
|
|
224
231
|
|
|
225
|
-
|
|
232
|
+
/** Format only user-authored messages for memory fact/entity extraction. */
|
|
233
|
+
export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
|
|
234
|
+
return formatRetentionMessages(messages.filter(message => message.role === "user"));
|
|
226
235
|
}
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -58,6 +58,31 @@ function normalizeToolArgs(value: unknown): MCPToolArgs {
|
|
|
58
58
|
return value as MCPToolArgs;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function isUnusedOptionalPlaceholder(value: unknown): boolean {
|
|
62
|
+
return (
|
|
63
|
+
value === undefined ||
|
|
64
|
+
value === "" ||
|
|
65
|
+
(typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
|
|
70
|
+
const properties = inputSchema.properties;
|
|
71
|
+
if (!properties) return args;
|
|
72
|
+
|
|
73
|
+
let cleaned: MCPToolArgs | undefined;
|
|
74
|
+
const required = new Set(inputSchema.required ?? []);
|
|
75
|
+
for (const [key, value] of Object.entries(args)) {
|
|
76
|
+
if (required.has(key) || !Object.hasOwn(properties, key) || !isUnusedOptionalPlaceholder(value)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
cleaned ??= { ...args };
|
|
80
|
+
delete cleaned[key];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return cleaned ?? args;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
/** Details included in MCP tool results for rendering */
|
|
62
87
|
export interface MCPToolDetails {
|
|
63
88
|
/** Server name */
|
|
@@ -261,7 +286,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
261
286
|
signal?: AbortSignal,
|
|
262
287
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
263
288
|
throwIfAborted(signal);
|
|
264
|
-
const args = normalizeToolArgs(params);
|
|
289
|
+
const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
|
|
265
290
|
const provider = this.connection._source?.provider;
|
|
266
291
|
const providerName = this.connection._source?.providerName;
|
|
267
292
|
|
|
@@ -360,7 +385,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
360
385
|
signal?: AbortSignal,
|
|
361
386
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
362
387
|
throwIfAborted(signal);
|
|
363
|
-
const args = normalizeToolArgs(params);
|
|
388
|
+
const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
|
|
364
389
|
const provider = this.#fallbackProvider;
|
|
365
390
|
const providerName = this.#fallbackProviderName;
|
|
366
391
|
|
package/src/mnemopi/state.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
composeRecallQuery,
|
|
10
10
|
formatCurrentTime,
|
|
11
11
|
prepareRetentionTranscript,
|
|
12
|
+
prepareUserRetentionTranscript,
|
|
12
13
|
truncateRecallQuery,
|
|
13
14
|
} from "../hindsight/content";
|
|
14
15
|
import { extractMessages } from "../hindsight/transcript";
|
|
@@ -352,6 +353,7 @@ export class MnemopiSessionState {
|
|
|
352
353
|
async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
|
|
353
354
|
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
|
|
354
355
|
if (!transcript) return;
|
|
356
|
+
const { transcript: extractText } = prepareUserRetentionTranscript(messages);
|
|
355
357
|
this.rememberInScope(transcript, {
|
|
356
358
|
source: "coding-agent-transcript",
|
|
357
359
|
importance: 0.65,
|
|
@@ -362,8 +364,9 @@ export class MnemopiSessionState {
|
|
|
362
364
|
cwd: this.session.sessionManager.getCwd(),
|
|
363
365
|
},
|
|
364
366
|
scope: "bank",
|
|
365
|
-
extract:
|
|
366
|
-
extractEntities:
|
|
367
|
+
extract: extractText !== null,
|
|
368
|
+
extractEntities: extractText !== null,
|
|
369
|
+
extractText,
|
|
367
370
|
veracity: "unknown",
|
|
368
371
|
memoryType: "episode",
|
|
369
372
|
});
|
|
@@ -7,16 +7,11 @@
|
|
|
7
7
|
* compositing into the live transcript's scrollback. It renders a parked
|
|
8
8
|
* subagent / advisor / collab-guest transcript that has no live in-view session.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Local agents re-read the whole session file whenever its size or mtime changes
|
|
17
|
-
* (covering SessionManager's in-place rewrites, not just appends). Collab guests
|
|
18
|
-
* keep the incremental byte cursor the host's capped `readTranscript` requires
|
|
19
|
-
* and rebuild components from the accumulated entries.
|
|
10
|
+
* Local transcripts tail append-only growth: unchanged file identity plus stable
|
|
11
|
+
* sentinels means only newly appended JSONL is parsed and rendered. Rewrites,
|
|
12
|
+
* truncation, rotation, or sentinel drift fall back to a full rebuild so changed
|
|
13
|
+
* historical entries cannot leave stale components behind. Collab guests use the
|
|
14
|
+
* same append path over the host's byte-capped transcript reads.
|
|
20
15
|
*/
|
|
21
16
|
import * as fs from "node:fs";
|
|
22
17
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
@@ -64,6 +59,56 @@ export interface AgentTranscriptViewerDeps {
|
|
|
64
59
|
/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
|
|
65
60
|
const POLL_MS = 250;
|
|
66
61
|
|
|
62
|
+
const SENTINEL_BYTES = 4096;
|
|
63
|
+
|
|
64
|
+
interface LocalTranscriptSentinel {
|
|
65
|
+
offset: number;
|
|
66
|
+
bytes: Buffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface LocalTranscriptState {
|
|
70
|
+
path: string;
|
|
71
|
+
dev: number;
|
|
72
|
+
ino: number;
|
|
73
|
+
size: number;
|
|
74
|
+
mtimeMs: number;
|
|
75
|
+
offset: number;
|
|
76
|
+
pending: string;
|
|
77
|
+
sentinels: LocalTranscriptSentinel[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFileRangeSync(file: string, offset: number, length: number): Buffer {
|
|
81
|
+
if (length <= 0) return Buffer.alloc(0);
|
|
82
|
+
const fd = fs.openSync(file, "r");
|
|
83
|
+
try {
|
|
84
|
+
const buffer = Buffer.alloc(length);
|
|
85
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
|
|
86
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
87
|
+
} finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sentinelOffsets(size: number): number[] {
|
|
93
|
+
if (size <= 0) return [];
|
|
94
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
95
|
+
return [...new Set([0, Math.max(0, Math.floor((size - length) / 2)), Math.max(0, size - length)])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sentinelsFromBuffer(buffer: Buffer): LocalTranscriptSentinel[] {
|
|
99
|
+
const size = buffer.byteLength;
|
|
100
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
101
|
+
return sentinelOffsets(size).map(offset => ({
|
|
102
|
+
offset,
|
|
103
|
+
bytes: Buffer.from(buffer.subarray(offset, offset + length)),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sentinelsFromFile(file: string, size: number): LocalTranscriptSentinel[] {
|
|
108
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
109
|
+
return sentinelOffsets(size).map(offset => ({ offset, bytes: readFileRangeSync(file, offset, length) }));
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
function statusBadge(status: AgentStatus): string {
|
|
68
113
|
switch (status) {
|
|
69
114
|
case "running":
|
|
@@ -85,10 +130,9 @@ export class AgentTranscriptViewer implements Component {
|
|
|
85
130
|
#notice: string | undefined;
|
|
86
131
|
#expanded = false;
|
|
87
132
|
|
|
88
|
-
|
|
89
|
-
#
|
|
133
|
+
#localState: LocalTranscriptState | undefined;
|
|
134
|
+
#localUnavailable = "";
|
|
90
135
|
// Remote transcript state (incremental; the host caps each read).
|
|
91
|
-
#remoteEntries: SessionMessageEntry[] = [];
|
|
92
136
|
#remoteBytes = 0;
|
|
93
137
|
#remoteFetchInFlight = false;
|
|
94
138
|
#remoteToken = 0;
|
|
@@ -145,7 +189,7 @@ export class AgentTranscriptViewer implements Component {
|
|
|
145
189
|
// Transcript loading
|
|
146
190
|
// ========================================================================
|
|
147
191
|
|
|
148
|
-
/**
|
|
192
|
+
/** Refresh the transcript from a local file or remote host. */
|
|
149
193
|
#refresh(): void {
|
|
150
194
|
if (this.#disposed) return;
|
|
151
195
|
if (this.deps.remote) {
|
|
@@ -154,39 +198,132 @@ export class AgentTranscriptViewer implements Component {
|
|
|
154
198
|
}
|
|
155
199
|
const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
|
|
156
200
|
if (!sessionFile) {
|
|
157
|
-
|
|
158
|
-
this.#lastSignature = "none";
|
|
159
|
-
this.#rebuild([]);
|
|
160
|
-
}
|
|
201
|
+
this.#clearLocal("none");
|
|
161
202
|
return;
|
|
162
203
|
}
|
|
163
|
-
let
|
|
204
|
+
let stat: fs.Stats;
|
|
164
205
|
try {
|
|
165
|
-
|
|
166
|
-
// Include the path: a different file with the same size/mtime must not alias.
|
|
167
|
-
signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
|
|
206
|
+
stat = fs.statSync(sessionFile);
|
|
168
207
|
} catch {
|
|
169
|
-
|
|
170
|
-
// clear stale content once instead of freezing on it forever.
|
|
171
|
-
if (this.#lastSignature !== "missing") {
|
|
172
|
-
this.#lastSignature = "missing";
|
|
173
|
-
this.#model = undefined;
|
|
174
|
-
this.#rebuild([]);
|
|
175
|
-
}
|
|
208
|
+
this.#clearLocal("missing");
|
|
176
209
|
return;
|
|
177
210
|
}
|
|
178
|
-
|
|
179
|
-
|
|
211
|
+
const state = this.#localState;
|
|
212
|
+
if (state && this.#canAppendLocal(sessionFile, stat, state)) {
|
|
213
|
+
if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return;
|
|
214
|
+
if (stat.size > state.size) {
|
|
215
|
+
this.#appendLocal(sessionFile, stat, state);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#clearLocal(reason: string): void {
|
|
223
|
+
if (!this.#localState && this.#localUnavailable === reason) return;
|
|
224
|
+
this.#localState = undefined;
|
|
225
|
+
this.#localUnavailable = reason;
|
|
226
|
+
this.#model = undefined;
|
|
227
|
+
this.#rebuild([]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#canAppendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): boolean {
|
|
231
|
+
if (state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.size)
|
|
232
|
+
return false;
|
|
233
|
+
for (const sentinel of state.sentinels) {
|
|
234
|
+
let current: Buffer;
|
|
235
|
+
try {
|
|
236
|
+
current = readFileRangeSync(sessionFile, sentinel.offset, sentinel.bytes.byteLength);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// The file can be unlinked/rotated between statSync and this read.
|
|
239
|
+
// Treat as not-appendable so #refresh falls back to a guarded full load.
|
|
240
|
+
logger.debug("transcript viewer: sentinel read failed", { err: String(err) });
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!current.equals(sentinel.bytes)) return false;
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#loadLocalFull(sessionFile: string, stat: fs.Stats): void {
|
|
249
|
+
let data: Buffer;
|
|
180
250
|
try {
|
|
181
|
-
|
|
251
|
+
data = fs.readFileSync(sessionFile);
|
|
182
252
|
} catch (err) {
|
|
183
|
-
// Leave #
|
|
253
|
+
// Leave #localState unchanged so a transient read error retries next poll.
|
|
184
254
|
logger.debug("transcript viewer: read failed", { err: String(err) });
|
|
185
255
|
return;
|
|
186
256
|
}
|
|
187
|
-
this
|
|
257
|
+
// The file may have grown between the earlier `statSync` and this read.
|
|
258
|
+
// Anchor the tail cursor to what we actually consumed so the next poll's
|
|
259
|
+
// `#appendLocal` never re-renders bytes already in the rebuilt transcript;
|
|
260
|
+
// re-stat for mtime/identity so the post-read clock matches what's on disk.
|
|
261
|
+
let post: fs.Stats;
|
|
262
|
+
try {
|
|
263
|
+
post = fs.statSync(sessionFile);
|
|
264
|
+
} catch {
|
|
265
|
+
post = stat;
|
|
266
|
+
}
|
|
267
|
+
// A reader that opens the file mid-append sees a trailing partial line
|
|
268
|
+
// (no terminating newline). Carry those bytes as `pending` so the next
|
|
269
|
+
// poll's `#appendLocal` joins them with the completion bytes instead of
|
|
270
|
+
// parsing a headless line fragment and dropping the entry.
|
|
271
|
+
const text = data.toString("utf-8");
|
|
272
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
273
|
+
const complete = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
|
|
274
|
+
const pending = lastNewline >= 0 ? text.slice(lastNewline + 1) : text;
|
|
275
|
+
this.#localUnavailable = "";
|
|
276
|
+
this.#localState = {
|
|
277
|
+
path: sessionFile,
|
|
278
|
+
dev: post.dev,
|
|
279
|
+
ino: post.ino,
|
|
280
|
+
size: data.byteLength,
|
|
281
|
+
mtimeMs: post.mtimeMs,
|
|
282
|
+
offset: data.byteLength,
|
|
283
|
+
pending,
|
|
284
|
+
sentinels: sentinelsFromBuffer(data),
|
|
285
|
+
};
|
|
188
286
|
this.#model = undefined;
|
|
189
|
-
this.#rebuild(this.#extractMessages(parseSessionEntries(
|
|
287
|
+
this.#rebuild(this.#extractMessages(parseSessionEntries(complete)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
|
|
291
|
+
let chunk: string;
|
|
292
|
+
try {
|
|
293
|
+
chunk = readFileRangeSync(sessionFile, state.offset, stat.size - state.offset).toString("utf-8");
|
|
294
|
+
} catch (err) {
|
|
295
|
+
logger.debug("transcript viewer: tail read failed", { err: String(err) });
|
|
296
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const combined = state.pending + chunk;
|
|
300
|
+
const lastNewline = combined.lastIndexOf("\n");
|
|
301
|
+
const complete = lastNewline >= 0 ? combined.slice(0, lastNewline + 1) : "";
|
|
302
|
+
const previousModel = this.#model;
|
|
303
|
+
const parsed = complete ? this.#extractMessages(parseSessionEntries(complete)) : [];
|
|
304
|
+
let sentinels: LocalTranscriptSentinel[];
|
|
305
|
+
try {
|
|
306
|
+
sentinels = sentinelsFromFile(sessionFile, stat.size);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// File unlinked/rotated mid-poll: fall back to a guarded full reload
|
|
309
|
+
// instead of letting the open escape the poll timer.
|
|
310
|
+
logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
|
|
311
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.#localState = {
|
|
315
|
+
...state,
|
|
316
|
+
size: stat.size,
|
|
317
|
+
mtimeMs: stat.mtimeMs,
|
|
318
|
+
offset: stat.size,
|
|
319
|
+
pending: lastNewline >= 0 ? combined.slice(lastNewline + 1) : combined,
|
|
320
|
+
sentinels,
|
|
321
|
+
};
|
|
322
|
+
if (parsed.length > 0) {
|
|
323
|
+
this.#append(parsed);
|
|
324
|
+
} else if (this.#model !== previousModel) {
|
|
325
|
+
this.deps.requestRender();
|
|
326
|
+
}
|
|
190
327
|
}
|
|
191
328
|
|
|
192
329
|
#fetchRemote(): void {
|
|
@@ -209,9 +346,13 @@ export class AgentTranscriptViewer implements Component {
|
|
|
209
346
|
return;
|
|
210
347
|
}
|
|
211
348
|
if (result.newSize < fromByte) {
|
|
212
|
-
// Host transcript rotated/truncated —
|
|
349
|
+
// Host transcript rotated/truncated — drop the stale rendered rows
|
|
350
|
+
// before restarting; otherwise the post-rotation fetch would stack
|
|
351
|
+
// new content under the pre-rotation history.
|
|
213
352
|
this.#remoteBytes = 0;
|
|
214
|
-
this.#
|
|
353
|
+
this.#hasRemoteData = false;
|
|
354
|
+
this.#model = undefined;
|
|
355
|
+
this.#rebuild([]);
|
|
215
356
|
this.#fetchRemote();
|
|
216
357
|
return;
|
|
217
358
|
}
|
|
@@ -222,10 +363,14 @@ export class AgentTranscriptViewer implements Component {
|
|
|
222
363
|
if (lastNewline >= 0) {
|
|
223
364
|
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
224
365
|
this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
366
|
+
const previousModel = this.#model;
|
|
225
367
|
const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
|
|
226
368
|
if (parsed.length > 0) {
|
|
227
|
-
this.#
|
|
228
|
-
|
|
369
|
+
this.#append(parsed);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (this.#model !== previousModel) {
|
|
373
|
+
this.deps.requestRender();
|
|
229
374
|
return;
|
|
230
375
|
}
|
|
231
376
|
}
|
|
@@ -257,6 +402,11 @@ export class AgentTranscriptViewer implements Component {
|
|
|
257
402
|
this.deps.requestRender();
|
|
258
403
|
}
|
|
259
404
|
|
|
405
|
+
#append(entries: SessionMessageEntry[]): void {
|
|
406
|
+
this.#builder.append(entries);
|
|
407
|
+
this.deps.requestRender();
|
|
408
|
+
}
|
|
409
|
+
|
|
260
410
|
// ========================================================================
|
|
261
411
|
// Input
|
|
262
412
|
// ========================================================================
|
|
@@ -455,8 +605,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
455
605
|
}
|
|
456
606
|
|
|
457
607
|
#placeholder(): string {
|
|
458
|
-
if (this.deps.remote
|
|
459
|
-
|
|
608
|
+
if (this.deps.remote) {
|
|
609
|
+
if (this.#remoteUnavailable) return "Transcript lives on the host — not available.";
|
|
610
|
+
return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…";
|
|
611
|
+
}
|
|
460
612
|
if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
|
|
461
613
|
return "No messages yet.";
|
|
462
614
|
}
|