Merge remote-tracking branch 'origin/main' into feat/telegram-channel

# Conflicts:
#	apps/desktop/electron/electron-env.d.ts
#	apps/desktop/electron/ipc/index.ts
#	apps/desktop/electron/preload.ts
#	apps/desktop/src/App.tsx
#	apps/desktop/src/pages/layout.tsx
#	src/agent/async-agent.ts
#	src/agent/runner.ts
#	src/hub/hub.ts
This commit is contained in:
Naiyuan Qing 2026-02-09 13:44:08 +08:00
commit 23905daaa1
85 changed files with 7368 additions and 470 deletions

View file

@ -1,5 +1,4 @@
import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { v7 as uuidv7 } from "uuid";
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
import type { MulticaEvent } from "./events.js";
@ -85,6 +84,10 @@ export class Agent {
private readonly stderr: NodeJS.WritableStream;
private initialized = false;
// Internal run state
private _internalRun = false;
private _runMutex: Promise<void> = Promise.resolve();
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
@ -353,7 +356,49 @@ export class Agent {
}
}
async run(prompt: string, images?: ImageContent[]): Promise<AgentRunResult> {
async run(prompt: string): Promise<AgentRunResult> {
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
return this.withRunMutex(() => this._run(prompt));
}
/**
* Run a prompt as an internal turn.
* Messages are persisted with `internal: true` and rolled back from
* in-memory state after the turn completes, so they do not pollute
* the main conversation context.
*/
async runInternal(prompt: string): Promise<AgentRunResult> {
return this.withRunMutex(async () => {
const messageCountBefore = this.agent.state.messages.length;
this._internalRun = true;
try {
const result = await this._run(prompt);
return result;
} finally {
this._internalRun = false;
// Roll back internal messages from in-memory state
const current = this.agent.state.messages;
if (current.length > messageCountBefore) {
this.agent.replaceMessages(current.slice(0, messageCountBefore));
}
}
});
}
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
// Chain on the mutex so only one run executes at a time
const prev = this._runMutex;
let resolve: () => void;
this._runMutex = new Promise<void>((r) => { resolve = r; });
await prev;
try {
return await fn();
} finally {
resolve!();
}
}
private async _run(prompt: string): Promise<AgentRunResult> {
await this.ensureInitialized();
this.output.state.lastAssistantText = "";
@ -363,7 +408,7 @@ export class Agent {
// Loop to exhaust all candidate profiles on rotatable errors
while (true) {
try {
await this.agent.prompt(prompt, images);
await this.agent.prompt(prompt);
break; // success — exit loop
} catch (error) {
lastError = error;
@ -443,8 +488,10 @@ export class Agent {
private handleSessionEvent(event: AgentEvent) {
if (event.type === "message_end") {
const message = event.message as AgentMessage;
this.session.saveMessage(message);
if (message.role === "assistant") {
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
// Skip compaction during internal runs — internal messages will be
// rolled back from memory afterwards, so compacting now would be incorrect.
if (message.role === "assistant" && !this._internalRun) {
void this.maybeCompact();
}
}
@ -511,6 +558,40 @@ export class Agent {
return this.agent.state.tools?.map(t => t.name) ?? [];
}
/** Whether the agent is currently executing an internal run */
get isInternalRun(): boolean {
return this._internalRun;
}
/**
* Persist a synthetic assistant message into both in-memory state and session JSONL.
* Used after an internal run to keep the LLM summary visible in future turns
* while the internal prompt stays hidden.
*/
persistAssistantSummary(text: string): void {
const model = this.agent.state.model;
const message = {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: model?.api ?? "openai-completions",
provider: model?.provider ?? "internal",
model: model?.id ?? "unknown",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
this.agent.appendMessage(message);
this.session.saveMessage(message);
}
/** Ensure session messages are loaded from disk (idempotent) */
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
@ -522,11 +603,19 @@ export class Agent {
this.initialized = true;
}
/** Get all messages from the current session */
/** Get all messages from the current session (in-memory state) */
getMessages(): AgentMessage[] {
return this.agent.state.messages.slice();
}
/**
* Load messages from session storage with filtering.
* By default, internal messages are excluded.
*/
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
return this.session.loadMessages(options);
}
/**
* Get all skills with their eligibility status.
* Returns empty array if skills are disabled.
@ -596,6 +685,27 @@ export class Agent {
return this.profile?.getProfile()?.id;
}
/**
* Get profile directory path, if profile is enabled.
*/
getProfileDir(): string | undefined {
return this.profile?.getProfileDir();
}
/**
* Get heartbeat configuration from profile config.
*/
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
return this.profile?.getHeartbeatConfig();
}
/**
* Get agent display name from profile config.
*/
@ -771,6 +881,7 @@ export class Agent {
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},
profileDir: this.profile!.getProfileDir(),