@kingcrab/pi-imessage 0.0.41 → 0.0.43
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 +23 -1
- package/dist/agent.d.ts +57 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +372 -51
- package/dist/agent.js.map +1 -1
- package/dist/main.js +34 -5
- package/dist/main.js.map +1 -1
- package/dist/memory.d.ts +53 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +196 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-health.d.ts +11 -0
- package/dist/model-health.d.ts.map +1 -0
- package/dist/model-health.js +69 -0
- package/dist/model-health.js.map +1 -0
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +5 -1
- package/dist/pipeline.js.map +1 -1
- package/dist/reminders.d.ts +42 -0
- package/dist/reminders.d.ts.map +1 -0
- package/dist/reminders.js +207 -0
- package/dist/reminders.js.map +1 -0
- package/dist/send.d.ts +6 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +166 -13
- package/dist/send.js.map +1 -1
- package/dist/store.js +1 -1
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/web/index.d.ts +4 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +75 -7
- package/dist/web/index.js.map +1 -1
- package/package.json +6 -17
package/dist/agent.js
CHANGED
|
@@ -12,8 +12,151 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import {
|
|
15
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
16
|
+
import { DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, createAgentSession, defineTool, getAgentDir, } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { listMemoryNamespaces, loadMemoryNamespaces, readCoreMemory, saveMemory, searchMemory } from "./memory.js";
|
|
18
|
+
// ── Config & Types ────────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Abort a prompt only after this much continuous inactivity. Any agent event —
|
|
21
|
+
* including streamed model output and tool start/end events — refreshes it.
|
|
22
|
+
*/
|
|
23
|
+
export const AGENT_IDLE_TIMEOUT_MS = Number.parseInt(process.env.AGENT_IDLE_TIMEOUT_MS || "120000", 10);
|
|
24
|
+
/** Absolute safety ceiling that activity cannot extend. */
|
|
25
|
+
export const AGENT_MAX_PROMPT_DURATION_MS = Number.parseInt(process.env.AGENT_MAX_PROMPT_DURATION_MS || "1800000", 10);
|
|
26
|
+
export const AGENT_COMPACT_TIMEOUT_MS = Number.parseInt(process.env.AGENT_COMPACT_TIMEOUT_MS || "60000", 10);
|
|
27
|
+
export const AUTO_COMPACT_CONTEXT_RATIO = Number.parseFloat(process.env.AGENT_AUTO_COMPACT_RATIO || "0.7");
|
|
28
|
+
export const AUTO_COMPACT_FALLBACK_TOKENS = 100_000;
|
|
29
|
+
/**
|
|
30
|
+
* Compact relative to the selected model's context window instead of using a
|
|
31
|
+
* fixed low threshold. AGENT_AUTO_COMPACT_TOKENS remains an explicit override.
|
|
32
|
+
*/
|
|
33
|
+
export function getAutoCompactTokenThreshold(contextWindow, explicitTokens = process.env.AGENT_AUTO_COMPACT_TOKENS || "") {
|
|
34
|
+
const explicit = Number.parseInt(explicitTokens, 10);
|
|
35
|
+
if (Number.isFinite(explicit) && explicit > 0)
|
|
36
|
+
return explicit;
|
|
37
|
+
if (typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || contextWindow <= 0) {
|
|
38
|
+
return AUTO_COMPACT_FALLBACK_TOKENS;
|
|
39
|
+
}
|
|
40
|
+
const ratio = Number.isFinite(AUTO_COMPACT_CONTEXT_RATIO) && AUTO_COMPACT_CONTEXT_RATIO > 0 && AUTO_COMPACT_CONTEXT_RATIO < 1
|
|
41
|
+
? AUTO_COMPACT_CONTEXT_RATIO
|
|
42
|
+
: 0.7;
|
|
43
|
+
return Math.floor(contextWindow * ratio);
|
|
44
|
+
}
|
|
45
|
+
const FAST_OPENAI_CODEX_MODELS = /^gpt-5\.6-(?:sol|terra|luna)$/;
|
|
46
|
+
/** Enable OpenAI priority processing without enabling user-discovered extensions. */
|
|
47
|
+
export function openAiCodexFastExtension(pi) {
|
|
48
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
49
|
+
if (ctx.model?.provider !== "openai-codex" || !FAST_OPENAI_CODEX_MODELS.test(ctx.model.id)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof event.payload !== "object" || event.payload === null || Array.isArray(event.payload)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
return { ...event.payload, service_tier: "priority" };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Stop model work without injecting a synthetic user message into the transcript.
|
|
60
|
+
*
|
|
61
|
+
* AgentSession.steer("stop") is not an abort API: it queues a literal user
|
|
62
|
+
* message. If the current operation fails before consuming that queue (for
|
|
63
|
+
* example, compaction fails), the stale "stop" can be delivered with a later
|
|
64
|
+
* real chat message and misattributed to that sender.
|
|
65
|
+
*/
|
|
66
|
+
export async function clearAndAbortSession(session) {
|
|
67
|
+
session.clearQueue();
|
|
68
|
+
await session.abort();
|
|
69
|
+
}
|
|
16
70
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* Race an async operation against a wall-clock timeout.
|
|
73
|
+
*
|
|
74
|
+
* If `operation()` settles first, its result (or rejection) is passed through
|
|
75
|
+
* and the timer is cleared. If the timeout fires first, `onTimeout()` is invoked
|
|
76
|
+
* (best-effort — e.g. to abort the underlying request) and the returned promise
|
|
77
|
+
* rejects with a timeout Error. A failure inside `onTimeout()` never masks the
|
|
78
|
+
* timeout rejection.
|
|
79
|
+
*
|
|
80
|
+
* Note: this does not cancel `operation()` itself — cancellation must happen via
|
|
81
|
+
* `onTimeout` (the SDK exposes `AgentSession.abort()`). The timeout rejects
|
|
82
|
+
* immediately without waiting for cancellation, because a stuck abort must not
|
|
83
|
+
* keep the caller blocked.
|
|
84
|
+
*/
|
|
85
|
+
export async function runWithTimeout(operation, onTimeout, timeoutMs) {
|
|
86
|
+
let timer;
|
|
87
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
88
|
+
timer = setTimeout(() => {
|
|
89
|
+
try {
|
|
90
|
+
void Promise.resolve(onTimeout()).catch(() => { });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A cleanup failure must never suppress the timeout.
|
|
94
|
+
}
|
|
95
|
+
reject(new Error(`operation timed out after ${timeoutMs}ms`));
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
return await Promise.race([operation(), timeout]);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (timer)
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Race an operation against a sliding inactivity timeout and a separate hard
|
|
108
|
+
* duration ceiling. Calling `markActivity()` refreshes only the idle timer.
|
|
109
|
+
*/
|
|
110
|
+
export async function runWithActivityTimeout(operation, onTimeout, idleTimeoutMs, maxDurationMs) {
|
|
111
|
+
let idleTimer;
|
|
112
|
+
let maxTimer;
|
|
113
|
+
let settled = false;
|
|
114
|
+
let rejectTimeout = () => { };
|
|
115
|
+
const clearTimers = () => {
|
|
116
|
+
if (idleTimer)
|
|
117
|
+
clearTimeout(idleTimer);
|
|
118
|
+
if (maxTimer)
|
|
119
|
+
clearTimeout(maxTimer);
|
|
120
|
+
};
|
|
121
|
+
const fireTimeout = (kind, timeoutMs) => {
|
|
122
|
+
if (settled)
|
|
123
|
+
return;
|
|
124
|
+
settled = true;
|
|
125
|
+
clearTimers();
|
|
126
|
+
try {
|
|
127
|
+
void Promise.resolve(onTimeout(kind)).catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// A cleanup failure must never suppress the timeout.
|
|
131
|
+
}
|
|
132
|
+
const message = kind === "idle"
|
|
133
|
+
? `operation idle timed out after ${timeoutMs}ms`
|
|
134
|
+
: `operation exceeded maximum duration of ${timeoutMs}ms`;
|
|
135
|
+
rejectTimeout(new Error(message));
|
|
136
|
+
};
|
|
137
|
+
const markActivity = () => {
|
|
138
|
+
if (settled)
|
|
139
|
+
return;
|
|
140
|
+
if (idleTimer)
|
|
141
|
+
clearTimeout(idleTimer);
|
|
142
|
+
idleTimer = setTimeout(() => fireTimeout("idle", idleTimeoutMs), idleTimeoutMs);
|
|
143
|
+
};
|
|
144
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
145
|
+
rejectTimeout = reject;
|
|
146
|
+
markActivity();
|
|
147
|
+
if (maxDurationMs > 0) {
|
|
148
|
+
maxTimer = setTimeout(() => fireTimeout("max_duration", maxDurationMs), maxDurationMs);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const operationPromise = Promise.resolve().then(() => operation(markActivity));
|
|
152
|
+
try {
|
|
153
|
+
return await Promise.race([operationPromise, timeout]);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimers();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
17
160
|
/**
|
|
18
161
|
* Format a prompt with sender/chat context prefix.
|
|
19
162
|
*
|
|
@@ -57,19 +200,6 @@ function readFileIfExists(path) {
|
|
|
57
200
|
return undefined;
|
|
58
201
|
}
|
|
59
202
|
}
|
|
60
|
-
/** Read global and chat-specific MEMORY.md files, returning combined content. */
|
|
61
|
-
function getMemory(workingDir, chatDir) {
|
|
62
|
-
const parts = [];
|
|
63
|
-
const global = readFileIfExists(join(workingDir, "MEMORY.md"));
|
|
64
|
-
if (global)
|
|
65
|
-
parts.push(`### Global Memory\n${global}`);
|
|
66
|
-
if (chatDir) {
|
|
67
|
-
const chat = readFileIfExists(join(chatDir, "MEMORY.md"));
|
|
68
|
-
if (chat)
|
|
69
|
-
parts.push(`### Chat Memory\n${chat}`);
|
|
70
|
-
}
|
|
71
|
-
return parts.length > 0 ? parts.join("\n\n") : "(no memory yet)";
|
|
72
|
-
}
|
|
73
203
|
function getCustomPrompt(workingDir, chatDir) {
|
|
74
204
|
const parts = [];
|
|
75
205
|
const global = readFileIfExists(join(workingDir, "SYSTEM.md"));
|
|
@@ -83,7 +213,10 @@ function getCustomPrompt(workingDir, chatDir) {
|
|
|
83
213
|
return parts.join("\n\n");
|
|
84
214
|
}
|
|
85
215
|
function buildSystemPrompt(workingDir, chatDir) {
|
|
86
|
-
const
|
|
216
|
+
const coreMemory = readCoreMemory(workingDir);
|
|
217
|
+
const namespaces = listMemoryNamespaces(workingDir)
|
|
218
|
+
.map((item) => `${item.namespace} (${item.active} active)`)
|
|
219
|
+
.join(", ");
|
|
87
220
|
const customPrompt = getCustomPrompt(workingDir, chatDir);
|
|
88
221
|
return `You are the user's best friend communicating via iMessage. Be concise. No emojis.
|
|
89
222
|
|
|
@@ -99,11 +232,11 @@ You are running directly on the host machine.
|
|
|
99
232
|
## Workspace Layout
|
|
100
233
|
${workingDir}/
|
|
101
234
|
├── settings.json # Bot configuration (see below)
|
|
102
|
-
├── MEMORY.md #
|
|
235
|
+
├── MEMORY.md # Legacy memory archive; do not write new entries
|
|
103
236
|
├── SYSTEM.md # System configuration log
|
|
104
|
-
├── skills/
|
|
237
|
+
├── skills/file-memory/ # Structured memory store and CLI
|
|
105
238
|
└── <chatId>/ # Each iMessage chat gets a directory
|
|
106
|
-
├── MEMORY.md #
|
|
239
|
+
├── MEMORY.md # Legacy chat memory archive
|
|
107
240
|
├── context.jsonl # LLM context (session persistence)
|
|
108
241
|
├── log.jsonl # Message history
|
|
109
242
|
├── attachments/ # User-shared files
|
|
@@ -111,18 +244,23 @@ ${workingDir}/
|
|
|
111
244
|
└── skills/ # Chat-specific tools
|
|
112
245
|
|
|
113
246
|
## Memory
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
247
|
+
Structured memory is the only active read/write memory. Legacy MEMORY.md files are read-only archives.
|
|
248
|
+
|
|
249
|
+
Read:
|
|
250
|
+
- Use the typed load_memory tool when prior context may help. You choose one or more relevant namespaces from meaning and conversation context, not fixed keywords.
|
|
251
|
+
- load_memory returns every active record in each selected namespace. A namespace is injected only once per session; later calls return only newly added records.
|
|
252
|
+
- Available namespaces: ${namespaces || "(none yet)"}
|
|
253
|
+
- Use search_memory only to find a specific old record, especially before a correction. Do not use it as automatic per-record retrieval.
|
|
254
|
+
- Treat retrieved memory as context, not as a substitute for the current message or verified facts.
|
|
117
255
|
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
256
|
+
Write:
|
|
257
|
+
- When you learn something important and durable, call save_memory with concise atomic text, namespace, kind, subjects, factual event date when known, and a specific source.
|
|
258
|
+
- Do not store every message, guesses, short-lived states, secrets, or duplicate facts.
|
|
259
|
+
- For corrections, find the old ID and set supersedes_id. Never silently overwrite history.
|
|
260
|
+
- Do not edit JSONL or legacy MEMORY.md directly.
|
|
123
261
|
|
|
124
|
-
###
|
|
125
|
-
${
|
|
262
|
+
### Core Memory
|
|
263
|
+
${coreMemory}
|
|
126
264
|
|
|
127
265
|
## System Configuration Log
|
|
128
266
|
Maintain ${workingDir}/SYSTEM.md to log all environment modifications:
|
|
@@ -133,14 +271,18 @@ Maintain ${workingDir}/SYSTEM.md to log all environment modifications:
|
|
|
133
271
|
|
|
134
272
|
Update this file whenever you modify the environment.
|
|
135
273
|
|
|
136
|
-
## Messaging API
|
|
137
|
-
A local HTTP server runs at http://localhost:7750 with
|
|
274
|
+
## Messaging and Reminder API
|
|
275
|
+
A local HTTP server runs at http://localhost:7750 with endpoints for sending messages and scheduling reminders:
|
|
138
276
|
|
|
139
|
-
### POST /send — send a message directly
|
|
277
|
+
### POST /send — send a message or local file attachment directly
|
|
140
278
|
\`\`\`bash
|
|
141
279
|
curl -X POST http://localhost:7750/send \\
|
|
142
280
|
-H "Content-Type: application/json" \\
|
|
143
281
|
-d '{"chatGuid":"<chatGuid>","text":"hello"}'
|
|
282
|
+
|
|
283
|
+
curl -X POST http://localhost:7750/send \\
|
|
284
|
+
-H "Content-Type: application/json" \\
|
|
285
|
+
-d '{"chatGuid":"<chatGuid>","filePath":"/path/to/image.png"}'
|
|
144
286
|
\`\`\`
|
|
145
287
|
|
|
146
288
|
### POST /prompt — send a prompt to the agent, send the agent's reply to the chat
|
|
@@ -154,8 +296,21 @@ curl -X POST http://localhost:7750/prompt \\
|
|
|
154
296
|
If the agent is already processing a message for this chat, the prompt is queued (followUp)
|
|
155
297
|
and will run after the current processing finishes.
|
|
156
298
|
|
|
157
|
-
|
|
158
|
-
|
|
299
|
+
### POST /reminders — schedule a persistent one-time reminder
|
|
300
|
+
Use this instead of creating a one-off script or crontab entry. \`scheduledAt\` must be
|
|
301
|
+
ISO 8601 with an explicit timezone. The reminder survives service restarts, catches up
|
|
302
|
+
after downtime, retries transient send failures, and supports an optional idempotency key.
|
|
303
|
+
\`\`\`bash
|
|
304
|
+
curl -X POST http://localhost:7750/reminders \\
|
|
305
|
+
-H "Content-Type: application/json" \\
|
|
306
|
+
-d '{"chatGuid":"<chatGuid>","text":"check the oven","scheduledAt":"2026-08-08T21:30:00+08:00","idempotencyKey":"check-oven-2026-08-08"}'
|
|
307
|
+
|
|
308
|
+
curl 'http://localhost:7750/reminders?status=pending'
|
|
309
|
+
curl -X DELETE http://localhost:7750/reminders/<reminderId>
|
|
310
|
+
\`\`\`
|
|
311
|
+
|
|
312
|
+
Use system crontab (\`crontab -e\`) only for recurring messages or tasks.
|
|
313
|
+
Example recurring crontab entries:
|
|
159
314
|
\`\`\`
|
|
160
315
|
# Send a static message every morning at 9:00
|
|
161
316
|
0 9 * * * curl -s -X POST http://localhost:7750/send -H "Content-Type: application/json" -d '{"chatGuid":"iMessage;-;+1234567890","text":"good morning"}'
|
|
@@ -212,43 +367,147 @@ function extractToolResultText(result) {
|
|
|
212
367
|
function extractToolLabel(toolName, args) {
|
|
213
368
|
if (toolName === "bash" && typeof args.command === "string")
|
|
214
369
|
return args.command;
|
|
370
|
+
if (toolName === "load_memory" && Array.isArray(args.namespaces))
|
|
371
|
+
return `memory: ${args.namespaces.join(", ")}`;
|
|
215
372
|
if (typeof args.path === "string")
|
|
216
373
|
return `${toolName}: ${args.path}`;
|
|
217
374
|
if (typeof args.label === "string")
|
|
218
375
|
return args.label;
|
|
219
376
|
return toolName;
|
|
220
377
|
}
|
|
378
|
+
function createMemoryExtension(workingDir, loadedIds) {
|
|
379
|
+
return (pi) => {
|
|
380
|
+
pi.registerTool(defineTool({
|
|
381
|
+
name: "load_memory",
|
|
382
|
+
label: "Load Memory",
|
|
383
|
+
description: "Load all active records from one or more semantically relevant namespaces. Select namespaces yourself from the conversation. Repeated calls return only records not already loaded in this session.",
|
|
384
|
+
parameters: Type.Object({
|
|
385
|
+
namespaces: Type.Array(Type.String(), { minItems: 1 }),
|
|
386
|
+
}),
|
|
387
|
+
async execute(_toolCallId, params) {
|
|
388
|
+
const records = loadMemoryNamespaces(workingDir, params.namespaces);
|
|
389
|
+
const delta = records.filter((item) => !loadedIds.has(item.id));
|
|
390
|
+
for (const item of delta)
|
|
391
|
+
loadedIds.add(item.id);
|
|
392
|
+
return {
|
|
393
|
+
content: [
|
|
394
|
+
{
|
|
395
|
+
type: "text",
|
|
396
|
+
text: JSON.stringify({
|
|
397
|
+
namespaces: params.namespaces,
|
|
398
|
+
records: delta,
|
|
399
|
+
already_loaded: records.length - delta.length,
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
details: { namespaces: params.namespaces, loaded: delta.length },
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
}));
|
|
407
|
+
pi.registerTool(defineTool({
|
|
408
|
+
name: "search_memory",
|
|
409
|
+
label: "Search Memory",
|
|
410
|
+
description: "Find a specific active memory record, primarily to locate its ID before saving a correction.",
|
|
411
|
+
parameters: Type.Object({
|
|
412
|
+
query: Type.String(),
|
|
413
|
+
namespaces: Type.Optional(Type.Array(Type.String())),
|
|
414
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })),
|
|
415
|
+
}),
|
|
416
|
+
async execute(_toolCallId, params) {
|
|
417
|
+
const records = searchMemory(workingDir, params.query, {
|
|
418
|
+
namespaces: params.namespaces,
|
|
419
|
+
limit: params.limit,
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: "text", text: JSON.stringify({ records }) }],
|
|
423
|
+
details: { matches: records.length },
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
}));
|
|
427
|
+
pi.registerTool(defineTool({
|
|
428
|
+
name: "save_memory",
|
|
429
|
+
label: "Save Memory",
|
|
430
|
+
description: "Validate and append one durable atomic record to the structured memory source of truth.",
|
|
431
|
+
parameters: Type.Object({
|
|
432
|
+
text: Type.String(),
|
|
433
|
+
namespace: Type.String(),
|
|
434
|
+
kind: Type.Union([
|
|
435
|
+
Type.Literal("fact"),
|
|
436
|
+
Type.Literal("event"),
|
|
437
|
+
Type.Literal("preference"),
|
|
438
|
+
Type.Literal("procedure"),
|
|
439
|
+
]),
|
|
440
|
+
subjects: Type.Array(Type.String()),
|
|
441
|
+
event_time: Type.Union([Type.String(), Type.Null()]),
|
|
442
|
+
source: Type.String(),
|
|
443
|
+
importance: Type.Number({ minimum: 0, maximum: 1 }),
|
|
444
|
+
confidence: Type.Number({ minimum: 0, maximum: 1 }),
|
|
445
|
+
supersedes_id: Type.Optional(Type.String()),
|
|
446
|
+
}),
|
|
447
|
+
async execute(_toolCallId, params) {
|
|
448
|
+
const result = await saveMemory(workingDir, params);
|
|
449
|
+
loadedIds.add(result.item.id);
|
|
450
|
+
return {
|
|
451
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
452
|
+
details: { added: result.added, id: result.item.id },
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
}));
|
|
456
|
+
};
|
|
457
|
+
}
|
|
221
458
|
// ── Agent Manager ─────────────────────────────────────────────────────────────
|
|
222
459
|
export async function createAgentManager(config) {
|
|
223
460
|
const { workingDir } = config;
|
|
224
461
|
const sessionMap = new Map();
|
|
462
|
+
let activePrompts = 0;
|
|
463
|
+
let lastAgentActivityAt = null;
|
|
225
464
|
const agentDir = getAgentDir();
|
|
226
|
-
const
|
|
465
|
+
const modelRuntime = await ModelRuntime.create({
|
|
466
|
+
authPath: join(agentDir, "auth.json"),
|
|
467
|
+
modelsPath: join(agentDir, "models.json"),
|
|
468
|
+
});
|
|
227
469
|
/** Create a new AgentSession for a chat, persisted to context.jsonl. */
|
|
228
470
|
async function createSession(chatGuid) {
|
|
229
471
|
const chatDir = join(workingDir, sanitizeChatGuid(chatGuid));
|
|
230
472
|
mkdirSync(chatDir, { recursive: true });
|
|
231
473
|
const sessionManager = SessionManager.open(join(chatDir, "context.jsonl"), chatDir);
|
|
474
|
+
const loadedMemoryIds = new Set();
|
|
475
|
+
// Force SSE for pi-imessage. Large Codex contexts frequently exceed the
|
|
476
|
+
// WebSocket frame limit or leave an auto-selected socket half-open.
|
|
477
|
+
const settingsManager = SettingsManager.create(workingDir, agentDir);
|
|
478
|
+
settingsManager.applyOverrides({ transport: "sse" });
|
|
232
479
|
// Per-chat resource loader so the system prompt can reference chatDir.
|
|
233
480
|
const resourceLoader = new DefaultResourceLoader({
|
|
234
481
|
cwd: workingDir,
|
|
235
482
|
agentDir,
|
|
483
|
+
settingsManager,
|
|
236
484
|
systemPrompt: buildSystemPrompt(workingDir, chatDir),
|
|
485
|
+
// Keep extension discovery disabled, but install this one controlled
|
|
486
|
+
// inline hook so Codex 5.6 fast mode still applies to iMessage.
|
|
487
|
+
extensionFactories: [
|
|
488
|
+
{ name: "openai-codex-fast", factory: openAiCodexFastExtension },
|
|
489
|
+
{ name: "structured-memory", factory: createMemoryExtension(workingDir, loadedMemoryIds) },
|
|
490
|
+
],
|
|
237
491
|
noExtensions: true,
|
|
238
492
|
noSkills: true,
|
|
239
493
|
noPromptTemplates: true,
|
|
240
494
|
noThemes: true,
|
|
241
495
|
});
|
|
242
496
|
await resourceLoader.reload();
|
|
497
|
+
const extensionErrors = resourceLoader.getExtensions().errors;
|
|
498
|
+
if (extensionErrors.length > 0) {
|
|
499
|
+
throw new Error(`Failed to enable Codex fast mode: ${extensionErrors.map((item) => item.error).join("; ")}`);
|
|
500
|
+
}
|
|
243
501
|
const { session } = await createAgentSession({
|
|
244
502
|
cwd: workingDir,
|
|
245
503
|
agentDir,
|
|
246
|
-
|
|
504
|
+
modelRuntime,
|
|
247
505
|
sessionManager,
|
|
506
|
+
settingsManager,
|
|
248
507
|
resourceLoader,
|
|
249
508
|
});
|
|
250
509
|
const modelLabel = session.model ? `${session.model.provider}/${session.model.id}` : "default";
|
|
251
|
-
console.log(`[agent] session created: ${chatGuid} model=${modelLabel}`);
|
|
510
|
+
console.log(`[agent] session created: ${chatGuid} model=${modelLabel} transport=sse sol_fast=priority`);
|
|
252
511
|
const entry = { session, chatGuid, chain: Promise.resolve() };
|
|
253
512
|
sessionMap.set(chatGuid, entry);
|
|
254
513
|
return entry;
|
|
@@ -275,12 +534,26 @@ export async function createAgentManager(config) {
|
|
|
275
534
|
const queueWaitMs = promptStart - queuedAt;
|
|
276
535
|
console.log(`[agent] prompt start: ${chatGuid} model=${modelLabel} queue_ms=${queueWaitMs} ` +
|
|
277
536
|
`chars=${promptText.length} images=${msg.images.length} "${promptText.substring(0, 60)}"`);
|
|
537
|
+
activePrompts += 1;
|
|
538
|
+
lastAgentActivityAt = Date.now();
|
|
278
539
|
// Subscribe for this prompt's lifetime, routing events through handler
|
|
279
540
|
let replyChain = Promise.resolve();
|
|
280
541
|
let firstAssistantStartMs = null;
|
|
281
542
|
let assistantDurationMs = null;
|
|
282
543
|
const pendingTools = new Map();
|
|
544
|
+
let markActivity = () => { };
|
|
545
|
+
const queueReply = (reply) => {
|
|
546
|
+
replyChain = replyChain
|
|
547
|
+
.then(() => handler(reply))
|
|
548
|
+
.catch((error) => {
|
|
549
|
+
console.error(`[agent] reply handler error: ${chatGuid}`, error);
|
|
550
|
+
});
|
|
551
|
+
};
|
|
283
552
|
const unsubscribe = session.subscribe((event) => {
|
|
553
|
+
// Every session event proves the run is alive. This includes streamed
|
|
554
|
+
// message updates as well as tool lifecycle events.
|
|
555
|
+
lastAgentActivityAt = Date.now();
|
|
556
|
+
markActivity();
|
|
284
557
|
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
285
558
|
firstAssistantStartMs ??= Date.now() - promptStart;
|
|
286
559
|
console.log(`[agent] message start: ${chatGuid} role=assistant first_token_ms=${Date.now() - promptStart}`);
|
|
@@ -294,15 +567,18 @@ export async function createAgentManager(config) {
|
|
|
294
567
|
` first_token_ms=${firstAssistantStartMs ?? "n/a"} generation_ms=${assistantDurationMs ?? "n/a"}` +
|
|
295
568
|
` chars=${text?.length ?? 0} text="${(text ?? "(empty)").substring(0, 60)}"`);
|
|
296
569
|
if (text) {
|
|
297
|
-
|
|
570
|
+
queueReply({ kind: "assistant", text });
|
|
298
571
|
}
|
|
299
572
|
}
|
|
300
573
|
else if (event.type === "tool_execution_start") {
|
|
301
574
|
const toolArgs = event.args;
|
|
302
575
|
const label = extractToolLabel(event.toolName, toolArgs);
|
|
303
|
-
|
|
576
|
+
const hidden = ["load_memory", "search_memory", "save_memory"].includes(event.toolName) ||
|
|
577
|
+
(event.toolName === "bash" && label.includes("skills/file-memory/memory_cli.py"));
|
|
578
|
+
pendingTools.set(event.toolCallId, { toolName: event.toolName, startTime: Date.now(), hidden });
|
|
304
579
|
console.log(`[agent] tool start: ${chatGuid} → ${label}`);
|
|
305
|
-
|
|
580
|
+
if (!hidden)
|
|
581
|
+
queueReply({ kind: "tool_start", label });
|
|
306
582
|
}
|
|
307
583
|
else if (event.type === "tool_execution_end") {
|
|
308
584
|
const resultText = extractToolResultText(event.result);
|
|
@@ -312,27 +588,65 @@ export async function createAgentManager(config) {
|
|
|
312
588
|
const symbol = event.isError ? "✗" : "✓";
|
|
313
589
|
console.log(`[agent] tool end: ${chatGuid} ${symbol} ${event.toolName} (${duration}s)` +
|
|
314
590
|
` result="${resultText.substring(0, 60)}"`);
|
|
315
|
-
|
|
591
|
+
if (!pending?.hidden) {
|
|
592
|
+
queueReply({ kind: "tool_end", toolName: event.toolName, symbol, duration, result: resultText });
|
|
593
|
+
}
|
|
316
594
|
}
|
|
317
595
|
});
|
|
318
596
|
try {
|
|
319
|
-
await
|
|
597
|
+
await runWithActivityTimeout(async (activity) => {
|
|
598
|
+
markActivity = activity;
|
|
599
|
+
activity();
|
|
600
|
+
await session.prompt(promptText, { images, streamingBehavior: options?.streamingBehavior });
|
|
601
|
+
}, (kind) => {
|
|
602
|
+
const timeoutMs = kind === "idle" ? AGENT_IDLE_TIMEOUT_MS : AGENT_MAX_PROMPT_DURATION_MS;
|
|
603
|
+
const label = kind === "idle" ? "idle timeout" : "maximum duration exceeded";
|
|
604
|
+
console.error(`[agent] prompt ${label}: ${chatGuid} after ${timeoutMs}ms — detaching session and aborting in background`);
|
|
605
|
+
if (sessionMap.get(chatGuid) === entry)
|
|
606
|
+
sessionMap.delete(chatGuid);
|
|
607
|
+
void clearAndAbortSession(session).catch((error) => {
|
|
608
|
+
console.error(`[agent] background abort failed: ${chatGuid}`, error);
|
|
609
|
+
});
|
|
610
|
+
}, AGENT_IDLE_TIMEOUT_MS, AGENT_MAX_PROMPT_DURATION_MS);
|
|
320
611
|
const sessionEnd = Date.now();
|
|
321
612
|
await replyChain;
|
|
322
613
|
const replyEnd = Date.now();
|
|
323
614
|
console.log(`[agent] prompt end: ${chatGuid} total_ms=${replyEnd - promptStart} session_ms=${sessionEnd - promptStart} ` +
|
|
324
615
|
`handler_ms=${replyEnd - sessionEnd} first_token_ms=${firstAssistantStartMs ?? "n/a"} ` +
|
|
325
616
|
`generation_ms=${assistantDurationMs ?? "n/a"}`);
|
|
617
|
+
// Never make the current user wait for preventive compaction. Reply
|
|
618
|
+
// first, mark the prompt complete, then compact before the next queued
|
|
619
|
+
// prompt only when the model-relative threshold has been crossed.
|
|
620
|
+
const contextUsage = session.getContextUsage();
|
|
621
|
+
const threshold = getAutoCompactTokenThreshold(session.model?.contextWindow);
|
|
622
|
+
if (typeof contextUsage?.tokens === "number" && contextUsage.tokens >= threshold) {
|
|
623
|
+
console.log(`[agent] post-reply compact start: ${chatGuid} tokens=${contextUsage.tokens} threshold=${threshold} ` +
|
|
624
|
+
`context_window=${session.model?.contextWindow ?? "unknown"}`);
|
|
625
|
+
try {
|
|
626
|
+
const result = await runWithTimeout(() => session.compact(), () => {
|
|
627
|
+
console.error(`[agent] post-reply compact timeout: ${chatGuid} after ${AGENT_COMPACT_TIMEOUT_MS}ms`);
|
|
628
|
+
if (sessionMap.get(chatGuid) === entry)
|
|
629
|
+
sessionMap.delete(chatGuid);
|
|
630
|
+
void clearAndAbortSession(session).catch(() => { });
|
|
631
|
+
}, AGENT_COMPACT_TIMEOUT_MS);
|
|
632
|
+
console.log(`[agent] post-reply compact end: ${chatGuid} tokens_before=${result.tokensBefore}`);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
636
|
+
console.log(`[agent] post-reply compact skipped: ${chatGuid} ${message}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
326
639
|
}
|
|
327
640
|
catch (error) {
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
641
|
+
// Never inject a steering message here. steer("stop") queues literal
|
|
642
|
+
// user text, which can survive a failed compaction and contaminate the
|
|
643
|
+
// next prompt. Abort and clear pending queues instead.
|
|
644
|
+
void clearAndAbortSession(session).catch(() => { });
|
|
332
645
|
throw error;
|
|
333
646
|
}
|
|
334
647
|
finally {
|
|
335
648
|
unsubscribe();
|
|
649
|
+
activePrompts = Math.max(0, activePrompts - 1);
|
|
336
650
|
}
|
|
337
651
|
}
|
|
338
652
|
/** Abort the in-progress agent run for a chat. No-op if no session or not running. */
|
|
@@ -342,8 +656,8 @@ export async function createAgentManager(config) {
|
|
|
342
656
|
console.log(`[agent] stop: no active session for ${chatGuid}`);
|
|
343
657
|
return;
|
|
344
658
|
}
|
|
345
|
-
await entry.session
|
|
346
|
-
console.log(`[agent] stop
|
|
659
|
+
await clearAndAbortSession(entry.session);
|
|
660
|
+
console.log(`[agent] stop aborted: ${chatGuid}`);
|
|
347
661
|
}
|
|
348
662
|
/** Compact the session context, reducing token usage while preserving a summary. */
|
|
349
663
|
async function compact(chatGuid, customInstructions) {
|
|
@@ -413,7 +727,7 @@ export async function createAgentManager(config) {
|
|
|
413
727
|
* Switch the requesting session to the current default model from settings.
|
|
414
728
|
*
|
|
415
729
|
* Mirrors pi TUI's /model command flow:
|
|
416
|
-
* 1.
|
|
730
|
+
* 1. modelRuntime.refresh() — reload models from disk
|
|
417
731
|
* 2. Resolve model from settings (TUI uses manual selection instead)
|
|
418
732
|
* 3. session.setModel() — updates agent + context.jsonl + settings.json
|
|
419
733
|
*
|
|
@@ -421,11 +735,11 @@ export async function createAgentManager(config) {
|
|
|
421
735
|
* handleModelCommand() → getModelCandidates() → session.setModel()
|
|
422
736
|
*/
|
|
423
737
|
async function reload(chatGuid) {
|
|
424
|
-
|
|
738
|
+
await modelRuntime.refresh();
|
|
425
739
|
const settings = SettingsManager.create(workingDir, agentDir);
|
|
426
740
|
const provider = settings.getDefaultProvider();
|
|
427
741
|
const modelId = settings.getDefaultModel();
|
|
428
|
-
const newModel = provider && modelId ?
|
|
742
|
+
const newModel = provider && modelId ? modelRuntime.getModel(provider, modelId) : undefined;
|
|
429
743
|
if (!newModel) {
|
|
430
744
|
console.log("[agent] reload: no default model in settings");
|
|
431
745
|
return;
|
|
@@ -442,7 +756,14 @@ export async function createAgentManager(config) {
|
|
|
442
756
|
}
|
|
443
757
|
console.log(`[agent] reloaded: ${chatGuid} switched to ${provider}/${modelId} thinkingLevel=${thinkingLevel ?? "unchanged"}`);
|
|
444
758
|
}
|
|
445
|
-
|
|
759
|
+
function getRuntimeStatus() {
|
|
760
|
+
return {
|
|
761
|
+
activePrompts,
|
|
762
|
+
sessions: sessionMap.size,
|
|
763
|
+
lastAgentActivityAt: lastAgentActivityAt === null ? null : new Date(lastAgentActivityAt).toISOString(),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return { processMessage, newSession, getSessionStatus, getRuntimeStatus, reload, stop, compact };
|
|
446
767
|
}
|
|
447
768
|
/** Format a token count as a compact string: 0, 1.2k, 5.9k, 12k, 1.8M, etc. */
|
|
448
769
|
function formatTokenCount(tokens) {
|