@pimote/pimote 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -17
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
- package/client/build/_app/immutable/assets/2.D9fiCd8W.css +1 -0
- package/client/build/_app/immutable/chunks/{BTSGQ0LP.js → B8lQCytv.js} +1 -1
- package/client/build/_app/immutable/chunks/BNqgidwO.js +5 -0
- package/client/build/_app/immutable/chunks/D26i4pYm.js +1 -0
- package/client/build/_app/immutable/chunks/D_Fpgknp.js +1 -0
- package/client/build/_app/immutable/chunks/DoVhjU85.js +1 -0
- package/client/build/_app/immutable/chunks/DzqbY2XU.js +1 -0
- package/client/build/_app/immutable/chunks/{L5t1qIFa.js → uZO1iyJZ.js} +2 -2
- package/client/build/_app/immutable/entry/app.DO-zgzyy.js +2 -0
- package/client/build/_app/immutable/entry/start.BZlrOH0-.js +1 -0
- package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +10 -0
- package/client/build/_app/immutable/nodes/1.B2l9JGRO.js +1 -0
- package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +8 -8
- package/package.json +9 -5
- package/patches/{@mariozechner+pi-coding-agent+0.65.0.patch → @mariozechner+pi-coding-agent+0.67.6.patch} +4 -4
- package/server/dist/auto-drain-on-abort.js +49 -0
- package/server/dist/config.js +21 -0
- package/server/dist/extension-ui-bridge.js +14 -1
- package/server/dist/folder-index.js +8 -4
- package/server/dist/git-branch.js +32 -0
- package/server/dist/index.js +31 -1
- package/server/dist/message-mapper.js +99 -4
- package/server/dist/server.js +5 -2
- package/server/dist/session-manager.js +99 -6
- package/server/dist/voice/fsm/actions.js +6 -0
- package/server/dist/voice/fsm/events.js +7 -0
- package/server/dist/voice/fsm/reducer.js +74 -0
- package/server/dist/voice/fsm/reducers/lifecycle.js +146 -0
- package/server/dist/voice/fsm/reducers/streaming.js +220 -0
- package/server/dist/voice/fsm/reducers/walkback.js +73 -0
- package/server/dist/voice/fsm/state.js +21 -0
- package/server/dist/voice/fsm/text-extractor.js +128 -0
- package/server/dist/voice/index.js +319 -0
- package/server/dist/voice/interpreter-prompt.js +115 -0
- package/server/dist/voice/speechmux-client.js +153 -0
- package/server/dist/voice/state-machine.js +7 -0
- package/server/dist/voice/wait-for-idle.js +67 -0
- package/server/dist/voice/walk-back.js +198 -0
- package/server/dist/voice-orchestrator-boot.js +90 -0
- package/server/dist/voice-orchestrator.js +91 -0
- package/server/dist/ws-handler.js +340 -36
- package/shared/dist/index.d.ts +1 -0
- package/shared/dist/index.js +2 -0
- package/shared/dist/protocol.d.ts +614 -0
- package/shared/dist/protocol.js +30 -0
- package/client/build/_app/immutable/assets/0.Cj7UL9cq.css +0 -2
- package/client/build/_app/immutable/assets/2.CIRqqeIr.css +0 -1
- package/client/build/_app/immutable/chunks/BEKHoMUP.js +0 -1
- package/client/build/_app/immutable/chunks/CfQ6Egqh.js +0 -1
- package/client/build/_app/immutable/chunks/DQ-KfPq0.js +0 -1
- package/client/build/_app/immutable/chunks/DfA0ecbz.js +0 -1
- package/client/build/_app/immutable/chunks/Dnh9Emns.js +0 -5
- package/client/build/_app/immutable/entry/app.j0V4R67V.js +0 -2
- package/client/build/_app/immutable/entry/start.wkfo4Ebw.js +0 -1
- package/client/build/_app/immutable/nodes/0.CUipL_P7.js +0 -5
- package/client/build/_app/immutable/nodes/1.ex7ejMby.js +0 -1
- package/client/build/_app/immutable/nodes/2.165oQG9Z.js +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pimote/pimote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Web client and embedded server for pi with multi-session browser access, streaming, and extension UI support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
"bin/pimote.js",
|
|
30
30
|
"client/build/**",
|
|
31
31
|
"server/dist/**/*.js",
|
|
32
|
+
"shared/dist/**/*.js",
|
|
33
|
+
"shared/dist/**/*.d.ts",
|
|
32
34
|
"scripts/postinstall-patches.mjs",
|
|
33
|
-
"patches/@mariozechner+pi-coding-agent+0.
|
|
35
|
+
"patches/@mariozechner+pi-coding-agent+0.67.6.patch",
|
|
34
36
|
"README.md",
|
|
35
37
|
"LICENSE"
|
|
36
38
|
],
|
|
@@ -38,13 +40,13 @@
|
|
|
38
40
|
"node": ">=22.0.0"
|
|
39
41
|
},
|
|
40
42
|
"workspaces": [
|
|
41
|
-
"shared",
|
|
42
43
|
"server",
|
|
43
44
|
"client",
|
|
44
45
|
"packages/panels"
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
|
-
"build": "npm run build
|
|
48
|
+
"build": "npm run build:shared && npm run build --workspace=@pimote/server && npm run build --workspace=client",
|
|
49
|
+
"build:shared": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('shared/dist', { recursive: true, force: true }); rmSync('shared/tsconfig.tsbuildinfo', { force: true });\" && tsc -b shared --force",
|
|
48
50
|
"start": "node ./bin/pimote.js start",
|
|
49
51
|
"format": "prettier --write .",
|
|
50
52
|
"format:check": "prettier --check .",
|
|
@@ -73,7 +75,9 @@
|
|
|
73
75
|
},
|
|
74
76
|
"dependencies": {
|
|
75
77
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
|
76
|
-
"@mariozechner/pi-coding-agent": "0.
|
|
78
|
+
"@mariozechner/pi-coding-agent": "0.67.6",
|
|
79
|
+
"@sinclair/typebox": "^0.34.49",
|
|
80
|
+
"@streamparser/json": "^0.0.22",
|
|
77
81
|
"patch-package": "^8.0.1",
|
|
78
82
|
"web-push": "^3.6.7",
|
|
79
83
|
"ws": "^8.20.0"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js
|
|
2
|
-
index
|
|
2
|
+
index fcb3466..20ee304 100644
|
|
3
3
|
--- a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js
|
|
4
4
|
+++ b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -75,9 +75,6 @@ export class AgentSessionRuntime {
|
|
6
6
|
this.session.dispose();
|
|
7
7
|
}
|
|
8
8
|
apply(result) {
|
|
@@ -12,9 +12,9 @@ index 9ab786e..6f89829 100644
|
|
|
12
12
|
this._session = result.session;
|
|
13
13
|
this._services = result.services;
|
|
14
14
|
this._diagnostics = result.diagnostics;
|
|
15
|
-
@@ -
|
|
16
|
-
*/
|
|
15
|
+
@@ -227,9 +224,6 @@ export class AgentSessionRuntime {
|
|
17
16
|
export async function createAgentSessionRuntime(createRuntime, options) {
|
|
17
|
+
assertSessionCwdExists(options.sessionManager, options.cwd);
|
|
18
18
|
const result = await createRuntime(options);
|
|
19
19
|
- if (process.cwd() !== result.services.cwd) {
|
|
20
20
|
- process.chdir(result.services.cwd);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Auto-drain queued steering / follow-up messages after an aborted run.
|
|
2
|
+
//
|
|
3
|
+
// pi-agent-core's `runLoop` exits without polling the steering queue when
|
|
4
|
+
// an abort signal fires mid-stream (see `agent-loop.js` — the
|
|
5
|
+
// `stopReason === 'aborted'` branch returns immediately, before the
|
|
6
|
+
// trailing `getSteeringMessages` poll). Any messages queued before / during
|
|
7
|
+
// the abort would otherwise sit in the queue until something else calls
|
|
8
|
+
// `agent.prompt()` or `agent.continue()`.
|
|
9
|
+
//
|
|
10
|
+
// In pimote this surfaces as silently-dropped user messages whenever a
|
|
11
|
+
// queued steer races with an abort — most visibly during voice-mode
|
|
12
|
+
// barge-in, but also for typed-mode users who queue a follow-up while the
|
|
13
|
+
// agent is streaming and then hit the abort button.
|
|
14
|
+
//
|
|
15
|
+
// The fix is to call `agent.continue()` after the run settles: from the
|
|
16
|
+
// pi-agent-core source, `continue()` explicitly drains the steering queue
|
|
17
|
+
// (and falls back to follow-up if steering is empty) and replays the
|
|
18
|
+
// drained messages as a fresh prompt.
|
|
19
|
+
/**
|
|
20
|
+
* If `lastMessage` is the aborted-assistant synthetic that pi appends on
|
|
21
|
+
* abort and the session has queued messages, wait for the current run to
|
|
22
|
+
* settle and then drain the queue via `agent.continue()`.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent: after `continue()` runs, the queue is empty so a re-entry
|
|
25
|
+
* (e.g. another agent_end) becomes a no-op. Errors from `continue()`
|
|
26
|
+
* (notably "Agent is already processing" when something else races us to
|
|
27
|
+
* a new prompt) are swallowed via `onError` — losing the auto-drain in
|
|
28
|
+
* that case is acceptable because the racing prompt itself will drain
|
|
29
|
+
* the queue inside `runLoop`'s initial `getSteeringMessages` poll.
|
|
30
|
+
*/
|
|
31
|
+
export async function autoDrainOnAbort(session, lastMessage, onError = (err) => console.warn('[pimote] auto-drain after abort failed', err)) {
|
|
32
|
+
if (!lastMessage || lastMessage.stopReason !== 'aborted') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
// The `agent_end` listener fires before `finishRun()` clears the
|
|
37
|
+
// run's `activeRun` reference. `waitForIdle()` resolves on the same
|
|
38
|
+
// promise that `finishRun` resolves, so awaiting it parks us until
|
|
39
|
+
// `agent.continue()` is safe to call without throwing
|
|
40
|
+
// "Agent is already processing".
|
|
41
|
+
await session.agent.waitForIdle();
|
|
42
|
+
if (session.pendingMessageCount === 0)
|
|
43
|
+
return;
|
|
44
|
+
await session.agent.continue();
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
onError(err);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/server/dist/config.js
CHANGED
|
@@ -48,11 +48,32 @@ export async function loadConfig() {
|
|
|
48
48
|
defaultProvider: typeof obj.defaultProvider === 'string' ? obj.defaultProvider : undefined,
|
|
49
49
|
defaultModel: typeof obj.defaultModel === 'string' ? obj.defaultModel : undefined,
|
|
50
50
|
defaultThinkingLevel: typeof obj.defaultThinkingLevel === 'string' ? obj.defaultThinkingLevel : undefined,
|
|
51
|
+
defaultInterpreterModel: parseModelRef(obj.defaultInterpreterModel),
|
|
52
|
+
defaultWorkerModel: parseModelRef(obj.defaultWorkerModel),
|
|
53
|
+
voice: parseVoiceConfig(obj.voice),
|
|
51
54
|
vapidPublicKey: typeof obj.vapidPublicKey === 'string' ? obj.vapidPublicKey : undefined,
|
|
52
55
|
vapidPrivateKey: typeof obj.vapidPrivateKey === 'string' ? obj.vapidPrivateKey : undefined,
|
|
53
56
|
vapidEmail: typeof obj.vapidEmail === 'string' ? obj.vapidEmail : undefined,
|
|
54
57
|
};
|
|
55
58
|
}
|
|
59
|
+
function parseModelRef(v) {
|
|
60
|
+
if (!v || typeof v !== 'object')
|
|
61
|
+
return undefined;
|
|
62
|
+
const o = v;
|
|
63
|
+
if (typeof o.provider !== 'string' || typeof o.modelId !== 'string')
|
|
64
|
+
return undefined;
|
|
65
|
+
return { provider: o.provider, modelId: o.modelId };
|
|
66
|
+
}
|
|
67
|
+
function parseVoiceConfig(v) {
|
|
68
|
+
if (!v || typeof v !== 'object')
|
|
69
|
+
return undefined;
|
|
70
|
+
const o = v;
|
|
71
|
+
return {
|
|
72
|
+
speechmuxBinary: typeof o.speechmuxBinary === 'string' ? o.speechmuxBinary : undefined,
|
|
73
|
+
speechmuxSignalUrl: typeof o.speechmuxSignalUrl === 'string' ? o.speechmuxSignalUrl : undefined,
|
|
74
|
+
speechmuxLlmWsUrl: typeof o.speechmuxLlmWsUrl === 'string' ? o.speechmuxLlmWsUrl : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
56
77
|
export async function ensureVapidKeys(config) {
|
|
57
78
|
if (config.vapidPublicKey && config.vapidPrivateKey) {
|
|
58
79
|
return config;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UI_BRIDGE_DISABLED_IN_VOICE_MODE } from '../../shared/dist/index.js';
|
|
1
2
|
import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
|
|
2
3
|
/**
|
|
3
4
|
* Creates an ExtensionUIContext implementation that bridges pi extension UI calls
|
|
@@ -12,7 +13,11 @@ import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
|
|
|
12
13
|
* is updated and the bridge automatically routes to the new connection.
|
|
13
14
|
* Pending UI promises survive reconnects and are replayed to the new client.
|
|
14
15
|
*/
|
|
15
|
-
export function createExtensionUIBridge(slot, pushNotificationService) {
|
|
16
|
+
export function createExtensionUIBridge(slot, pushNotificationService, options) {
|
|
17
|
+
const isVoice = () => options?.isVoiceModeActive?.() ?? false;
|
|
18
|
+
function voiceDisabledError() {
|
|
19
|
+
return new Error(UI_BRIDGE_DISABLED_IN_VOICE_MODE);
|
|
20
|
+
}
|
|
16
21
|
function notifyInteraction(method, fields) {
|
|
17
22
|
if (!pushNotificationService)
|
|
18
23
|
return;
|
|
@@ -72,24 +77,32 @@ export function createExtensionUIBridge(slot, pushNotificationService) {
|
|
|
72
77
|
const ui = {
|
|
73
78
|
// ---- Dialog methods (send + wait for response) ----
|
|
74
79
|
async select(title, options, opts) {
|
|
80
|
+
if (isVoice())
|
|
81
|
+
throw voiceDisabledError();
|
|
75
82
|
const requestId = crypto.randomUUID();
|
|
76
83
|
const event = sendRequest(requestId, { method: 'select', title, options });
|
|
77
84
|
notifyInteraction('select', { title, options });
|
|
78
85
|
return dialogWithTimeout(requestId, event, opts, undefined);
|
|
79
86
|
},
|
|
80
87
|
async confirm(title, message, opts) {
|
|
88
|
+
if (isVoice())
|
|
89
|
+
throw voiceDisabledError();
|
|
81
90
|
const requestId = crypto.randomUUID();
|
|
82
91
|
const event = sendRequest(requestId, { method: 'confirm', title, message });
|
|
83
92
|
notifyInteraction('confirm', { title, message });
|
|
84
93
|
return dialogWithTimeout(requestId, event, opts, false);
|
|
85
94
|
},
|
|
86
95
|
async input(title, placeholder, opts) {
|
|
96
|
+
if (isVoice())
|
|
97
|
+
throw voiceDisabledError();
|
|
87
98
|
const requestId = crypto.randomUUID();
|
|
88
99
|
const event = sendRequest(requestId, { method: 'input', title, placeholder });
|
|
89
100
|
notifyInteraction('input', { title });
|
|
90
101
|
return dialogWithTimeout(requestId, event, opts, undefined);
|
|
91
102
|
},
|
|
92
103
|
async editor(title, prefill) {
|
|
104
|
+
if (isVoice())
|
|
105
|
+
throw voiceDisabledError();
|
|
93
106
|
const requestId = crypto.randomUUID();
|
|
94
107
|
const event = sendRequest(requestId, { method: 'editor', title, prefill });
|
|
95
108
|
notifyInteraction('editor', { title });
|
|
@@ -7,9 +7,13 @@ const PROJECT_MARKERS = ['.git', 'package.json'];
|
|
|
7
7
|
* Scans configured root directories for project folders and lists their sessions.
|
|
8
8
|
*/
|
|
9
9
|
export class FolderIndex {
|
|
10
|
-
|
|
11
|
-
constructor(
|
|
12
|
-
this.
|
|
10
|
+
_roots;
|
|
11
|
+
constructor(_roots) {
|
|
12
|
+
this._roots = _roots;
|
|
13
|
+
}
|
|
14
|
+
/** Returns the configured root directories. */
|
|
15
|
+
get roots() {
|
|
16
|
+
return this._roots;
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
15
19
|
* Scan all roots one level deep for project directories.
|
|
@@ -17,7 +21,7 @@ export class FolderIndex {
|
|
|
17
21
|
*/
|
|
18
22
|
async scan() {
|
|
19
23
|
const folders = [];
|
|
20
|
-
for (const root of this.
|
|
24
|
+
for (const root of this._roots) {
|
|
21
25
|
let entries;
|
|
22
26
|
try {
|
|
23
27
|
entries = await readdir(root);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
/** Resolve the current git branch for a directory. Returns null if not a git repo or detached. */
|
|
3
|
+
export function getGitBranch(cwd) {
|
|
4
|
+
// Guard against inherited Git env vars forcing resolution to another repo.
|
|
5
|
+
const env = { ...process.env };
|
|
6
|
+
delete env.GIT_DIR;
|
|
7
|
+
delete env.GIT_WORK_TREE;
|
|
8
|
+
const runGit = (args) => {
|
|
9
|
+
try {
|
|
10
|
+
const value = execFileSync('git', args, {
|
|
11
|
+
cwd,
|
|
12
|
+
env,
|
|
13
|
+
encoding: 'utf-8',
|
|
14
|
+
timeout: 2000,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
16
|
+
}).trim();
|
|
17
|
+
return value || null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
// Best signal for the checked-out branch (works with linked worktrees).
|
|
24
|
+
const current = runGit(['branch', '--show-current']);
|
|
25
|
+
if (current)
|
|
26
|
+
return current;
|
|
27
|
+
// Fallback for older Git versions / unusual setups.
|
|
28
|
+
const abbrevRef = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
29
|
+
if (!abbrevRef || abbrevRef === 'HEAD')
|
|
30
|
+
return null;
|
|
31
|
+
return abbrevRef;
|
|
32
|
+
}
|
package/server/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { PushNotificationService } from './push-notification.js';
|
|
|
8
8
|
import { FilePushSubscriptionStore, WebPushSender, migratePushSubscriptionStore } from './push-infrastructure.js';
|
|
9
9
|
import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH } from './paths.js';
|
|
10
10
|
import { FileSessionMetadataStore } from './session-metadata.js';
|
|
11
|
+
import { buildVoiceOrchestrator } from './voice-orchestrator-boot.js';
|
|
11
12
|
export async function main(options = {}) {
|
|
12
13
|
let config = await loadConfig();
|
|
13
14
|
config = await ensureVapidKeys(config);
|
|
@@ -23,7 +24,35 @@ export async function main(options = {}) {
|
|
|
23
24
|
const sessionMetadataStore = new FileSessionMetadataStore(PIMOTE_SESSION_METADATA_PATH);
|
|
24
25
|
await sessionMetadataStore.initialize();
|
|
25
26
|
const sessionManager = new PimoteSessionManager(config, pushNotificationService);
|
|
26
|
-
|
|
27
|
+
// Build the voice orchestrator before createServer so each WsHandler can be
|
|
28
|
+
// handed a reference. The orchestrator needs a client-registry lookup, but
|
|
29
|
+
// the real registry is created inside createServer below — so we hand it a
|
|
30
|
+
// small forwarding shim whose backing map is swapped in after createServer
|
|
31
|
+
// returns (see review finding 6: previously a Proxy-over-Map).
|
|
32
|
+
const clientRegistryRef = { current: new Map() };
|
|
33
|
+
const voiceBoot = buildVoiceOrchestrator({
|
|
34
|
+
config,
|
|
35
|
+
sessionManager,
|
|
36
|
+
clientRegistry: {
|
|
37
|
+
get: (clientId) => clientRegistryRef.current.get(clientId),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceBoot.orchestrator);
|
|
41
|
+
clientRegistryRef.current = server.clientRegistry;
|
|
42
|
+
// Tear down orchestrator bookkeeping when a session is being closed (idle
|
|
43
|
+
// reap, explicit close). Emits call_ended{server_ended} to the owner.
|
|
44
|
+
sessionManager.onBeforeSessionClose = async (sessionId) => {
|
|
45
|
+
if (!voiceBoot.orchestrator.isCallActive(sessionId))
|
|
46
|
+
return;
|
|
47
|
+
const slot = sessionManager.getSlot(sessionId);
|
|
48
|
+
const ownerClientId = slot?.connection?.connectedClientId;
|
|
49
|
+
await voiceBoot.orchestrator.endCall({ sessionId, reason: 'server_ended' });
|
|
50
|
+
if (ownerClientId) {
|
|
51
|
+
const handler = server.clientRegistry.get(ownerClientId);
|
|
52
|
+
handler?.sendCallEndedEvent(sessionId, 'server_ended');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
await voiceBoot.orchestrator.start();
|
|
27
56
|
// Start idle session reaping with client connectivity check
|
|
28
57
|
sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
|
|
29
58
|
await server.start(port);
|
|
@@ -36,6 +65,7 @@ export async function main(options = {}) {
|
|
|
36
65
|
// Graceful shutdown
|
|
37
66
|
const shutdown = async () => {
|
|
38
67
|
console.log('\n[pimote] Shutting down...');
|
|
68
|
+
await voiceBoot.shutdown();
|
|
39
69
|
await sessionManager.dispose();
|
|
40
70
|
await server.close();
|
|
41
71
|
process.exit(0);
|
|
@@ -6,6 +6,87 @@
|
|
|
6
6
|
export function mapAgentMessages(messages) {
|
|
7
7
|
return messages.map(mapAgentMessage);
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Extract entry IDs from branch entries in the same order that
|
|
11
|
+
* buildSessionContext produces messages. This mirrors the SDK's
|
|
12
|
+
* compaction/branch-summary logic so IDs can be zipped 1:1 with
|
|
13
|
+
* the mapped PimoteAgentMessage array.
|
|
14
|
+
*/
|
|
15
|
+
export function extractMessageEntryIds(branch) {
|
|
16
|
+
// Find the last compaction entry on the path
|
|
17
|
+
let compaction = null;
|
|
18
|
+
for (const entry of branch) {
|
|
19
|
+
if (entry.type === 'compaction')
|
|
20
|
+
compaction = entry;
|
|
21
|
+
}
|
|
22
|
+
const ids = [];
|
|
23
|
+
const appendId = (entry) => {
|
|
24
|
+
if (entry.type === 'message') {
|
|
25
|
+
ids.push(entry.id);
|
|
26
|
+
}
|
|
27
|
+
else if (entry.type === 'custom_message') {
|
|
28
|
+
ids.push(entry.id);
|
|
29
|
+
}
|
|
30
|
+
else if (entry.type === 'branch_summary' && entry.summary) {
|
|
31
|
+
ids.push(entry.id);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
if (compaction) {
|
|
35
|
+
// Compaction summary message maps to the compaction entry
|
|
36
|
+
ids.push(compaction.id);
|
|
37
|
+
const compactionIdx = branch.findIndex((e) => e.type === 'compaction' && e.id === compaction.id);
|
|
38
|
+
// Kept messages before the compaction entry
|
|
39
|
+
let foundFirstKept = false;
|
|
40
|
+
for (let i = 0; i < compactionIdx; i++) {
|
|
41
|
+
if (branch[i].id === compaction.firstKeptEntryId)
|
|
42
|
+
foundFirstKept = true;
|
|
43
|
+
if (foundFirstKept)
|
|
44
|
+
appendId(branch[i]);
|
|
45
|
+
}
|
|
46
|
+
// Messages after the compaction entry
|
|
47
|
+
for (let i = compactionIdx + 1; i < branch.length; i++) {
|
|
48
|
+
appendId(branch[i]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
for (const entry of branch) {
|
|
53
|
+
appendId(entry);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ids;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* True when the message is pi-agent-core's synthetic aborted placeholder
|
|
60
|
+
* (pushed into agent.state.messages on session.abort() but never persisted
|
|
61
|
+
* via message_end). Identifying these lets entryId alignment skip over them.
|
|
62
|
+
*/
|
|
63
|
+
export function isAbortedPlaceholderMessage(msg) {
|
|
64
|
+
if (msg.role !== 'assistant')
|
|
65
|
+
return false;
|
|
66
|
+
if (msg.aborted !== true)
|
|
67
|
+
return false;
|
|
68
|
+
return msg.content.every((c) => c.type === 'text' && !c.text);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Apply entry IDs from the session manager onto mapped messages.
|
|
72
|
+
*
|
|
73
|
+
* Subtle alignment: `messages` comes from `agent.state.messages`, which
|
|
74
|
+
* includes pi-agent-core's synthetic aborted placeholders (abort pushes an
|
|
75
|
+
* empty assistant into state but never persists an entry for it).
|
|
76
|
+
* `entryIds` comes from persisted session entries, which do NOT include
|
|
77
|
+
* those placeholders. We walk the messages and skip aborted placeholders
|
|
78
|
+
* so the persisted IDs land on the correct real messages.
|
|
79
|
+
*/
|
|
80
|
+
export function applyEntryIds(messages, entryIds) {
|
|
81
|
+
let idIdx = 0;
|
|
82
|
+
for (let i = 0; i < messages.length; i++) {
|
|
83
|
+
if (isAbortedPlaceholderMessage(messages[i]))
|
|
84
|
+
continue;
|
|
85
|
+
if (idIdx >= entryIds.length)
|
|
86
|
+
break;
|
|
87
|
+
messages[i].entryId = entryIds[idIdx++];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
9
90
|
export function mapAgentMessage(msg) {
|
|
10
91
|
const role = msg.role ?? 'unknown';
|
|
11
92
|
const content = [];
|
|
@@ -46,13 +127,17 @@ export function mapAgentMessage(msg) {
|
|
|
46
127
|
console.warn('[message-mapper] Content value:', msg.content);
|
|
47
128
|
content.push({ type: 'text', text: `[Unexpected content type: ${typeof msg.content}]` });
|
|
48
129
|
}
|
|
49
|
-
//
|
|
50
|
-
|
|
130
|
+
// Aborted assistant turns are a real signal in voice mode (every barge-in
|
|
131
|
+
// produces one via pi-agent-core's handleRunFailure) and shouldn't be
|
|
132
|
+
// confused with malformed messages. Log the empty-content warning only
|
|
133
|
+
// when it's NOT an expected aborted turn.
|
|
134
|
+
const aborted = role === 'assistant' && msg.stopReason === 'aborted';
|
|
135
|
+
if (content.length === 0 && !aborted) {
|
|
51
136
|
console.warn('[message-mapper] Empty content array for message:', { role, content: msg.content });
|
|
52
137
|
}
|
|
53
138
|
// Handle custom messages — preserve customType and display flag for the client
|
|
54
139
|
if (role === 'custom') {
|
|
55
|
-
return { role, content, customType: msg.customType, display: msg.display ?? true };
|
|
140
|
+
return { role, content, entryId: msg.id, customType: msg.customType, display: msg.display ?? true };
|
|
56
141
|
}
|
|
57
142
|
// Handle tool result messages
|
|
58
143
|
if (role === 'toolResult') {
|
|
@@ -72,9 +157,19 @@ export function mapAgentMessage(msg) {
|
|
|
72
157
|
toolCallId: msg.toolCallId,
|
|
73
158
|
toolName: msg.toolName,
|
|
74
159
|
result: resultContent.length > 0 ? resultContent[0].text : undefined,
|
|
160
|
+
isError: msg.isError || undefined,
|
|
75
161
|
},
|
|
76
162
|
],
|
|
163
|
+
entryId: msg.id,
|
|
77
164
|
};
|
|
78
165
|
}
|
|
79
|
-
|
|
166
|
+
// Note: msg.id is typically undefined for standard SDK messages (UserMessage,
|
|
167
|
+
// AssistantMessage, ToolResultMessage). Entry IDs are applied separately via
|
|
168
|
+
// applyEntryIds() using the session manager's branch entries.
|
|
169
|
+
return {
|
|
170
|
+
role,
|
|
171
|
+
content,
|
|
172
|
+
...(msg.id ? { entryId: msg.id } : {}),
|
|
173
|
+
...(aborted ? { aborted: true } : {}),
|
|
174
|
+
};
|
|
80
175
|
}
|
package/server/dist/server.js
CHANGED
|
@@ -79,7 +79,7 @@ async function serveFallback(res) {
|
|
|
79
79
|
res.end(JSON.stringify({ error: 'not found' }));
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore) {
|
|
82
|
+
export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator) {
|
|
83
83
|
const clientVersion = await loadClientVersion();
|
|
84
84
|
if (clientVersion) {
|
|
85
85
|
console.log(`[pimote] Client build version: ${clientVersion}`);
|
|
@@ -121,6 +121,9 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
|
|
|
121
121
|
sessionManager.onSessionClosed = (sessionId, folderPath) => {
|
|
122
122
|
WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
|
|
123
123
|
};
|
|
124
|
+
sessionManager.onGitBranchChange = (sessionId, folderPath) => {
|
|
125
|
+
WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
|
|
126
|
+
};
|
|
124
127
|
const wss = new WebSocketServer({ noServer: true });
|
|
125
128
|
const clientRegistry = new Map();
|
|
126
129
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
@@ -154,7 +157,7 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
|
|
|
154
157
|
// The close handler skips cleanup when the registry already points to a
|
|
155
158
|
// different handler, so this is the only place it runs.
|
|
156
159
|
const existing = clientRegistry.get(clientId);
|
|
157
|
-
const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry);
|
|
160
|
+
const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator);
|
|
158
161
|
clientRegistry.set(clientId, handler);
|
|
159
162
|
if (existing) {
|
|
160
163
|
existing.cleanup();
|