pi-invisible-continue 0.3.2 → 0.3.4
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 +48 -59
- package/continue.ts +31 -154
- package/package.json +11 -12
- package/src/index.ts +18 -17
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Invisible session continuation for [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
6
|
|
|
7
|
-
_Resume the agentic loop without the LLM seeing
|
|
7
|
+
_Resume the agentic loop without the LLM seeing a new prompt._
|
|
8
8
|
|
|
9
9
|
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
10
|
[](./LICENSE)
|
|
@@ -15,66 +15,66 @@ _Resume the agentic loop without the LLM seeing any new prompt at all._
|
|
|
15
15
|
|
|
16
16
|
## The Problem
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Most “continue” extensions send user text to the LLM:
|
|
19
19
|
|
|
20
20
|
| Package | What the LLM sees |
|
|
21
21
|
|---------|-------------------|
|
|
22
|
-
| `pi-continue` |
|
|
23
|
-
| `pi-hodor` | `"continue"`
|
|
24
|
-
| `pi-auto-continue` | `"continue"`
|
|
25
|
-
| `pi-retry` | `"Continue"` on max-tokens, or hidden custom trigger on errors |
|
|
22
|
+
| `pi-continue` | A full handoff document |
|
|
23
|
+
| `pi-hodor` | `"continue"` |
|
|
24
|
+
| `pi-auto-continue` | `"continue"` |
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
---
|
|
26
|
+
That text can influence the next response or be interpreted as a new task.
|
|
30
27
|
|
|
31
28
|
## The Solution
|
|
32
29
|
|
|
33
|
-
`pi-invisible-continue`
|
|
30
|
+
`pi-invisible-continue` starts a normal Pi session turn with a hidden custom marker. A `context` hook removes that marker before provider serialization, so the LLM receives no new prompt text.
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
- The LLM receives **the exact same message list it had before**
|
|
37
|
-
- No new text, no handoff, no pollution, no session artifact
|
|
38
|
-
- Nothing in `convertToLlm`'s path — no filtering needed
|
|
32
|
+
The continuation deliberately goes through `AgentSession`:
|
|
39
33
|
|
|
40
|
-
|
|
34
|
+
- Pi’s session-level busy state remains accurate.
|
|
35
|
+
- A concurrent prompt is queued through the native follow-up path rather than racing it.
|
|
36
|
+
- Auto-retry, compaction, abort, and `agent_settled` behavior remain intact.
|
|
37
|
+
- The marker is hidden from the TUI and filtered from every LLM request.
|
|
41
38
|
|
|
42
39
|
## How It Works
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
`/continue` calls:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
pi.sendMessage(
|
|
45
|
+
{
|
|
46
|
+
customType: "pi-invisible-continue:resume",
|
|
47
|
+
content: [],
|
|
48
|
+
display: false,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
triggerTurn: true,
|
|
52
|
+
deliverAs: "followUp",
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Pi starts the turn immediately when idle or queues it when another turn is active. Before the provider request, the extension removes its custom marker from `event.messages`.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
### Why not `Agent.prompt([])`?
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Calling the low-level agent directly bypasses `AgentSession._runAgentPrompt()`. Pi can then report the session as idle while `Agent.activeRun` is already processing. A user prompt may be routed as a fresh prompt and fail with:
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
> Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Using `sendMessage` keeps one authoritative lifecycle and avoids that split-brain busy state.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
### Session artifact
|
|
68
|
+
|
|
69
|
+
The session journal contains one hidden custom entry per continuation. It has no display content and is removed before every provider call, but it is still bookkeeping in the saved session. This is the correctness trade-off until Pi exposes a public session-level resume API that adds no entry.
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
## Usage
|
|
70
72
|
|
|
71
73
|
| Command | What it does |
|
|
72
74
|
|---------|-------------|
|
|
73
|
-
| `/continue` |
|
|
74
|
-
| `/continue status` | Show
|
|
75
|
-
| `/continue help` | Show
|
|
76
|
-
|
|
77
|
-
---
|
|
75
|
+
| `/continue` | Start or queue an invisible continuation. |
|
|
76
|
+
| `/continue status` | Show session idle state and the last assistant text. |
|
|
77
|
+
| `/continue help` | Show the command reference. |
|
|
78
78
|
|
|
79
79
|
## Installation
|
|
80
80
|
|
|
@@ -88,7 +88,7 @@ Or install from GitHub:
|
|
|
88
88
|
pi install https://github.com/monotykamary/pi-invisible-continue
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
Or
|
|
91
|
+
Or add it to `~/.pi/agent/settings.json`:
|
|
92
92
|
|
|
93
93
|
```json
|
|
94
94
|
{
|
|
@@ -98,36 +98,25 @@ Or in `~/.pi/agent/settings.json`:
|
|
|
98
98
|
}
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
Then `/reload` or restart
|
|
101
|
+
Then run `/reload` or restart Pi.
|
|
102
102
|
|
|
103
|
-
For
|
|
103
|
+
For a one-off test:
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
106
|
pi -e ./continue.ts
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
## Comparison with Other Packages
|
|
109
|
+
## Comparison
|
|
112
110
|
|
|
113
111
|
| Feature | pi-invisible-continue | pi-continue | pi-hodor | pi-auto-continue |
|
|
114
112
|
|---------|----------------------|-------------|----------|-------------------|
|
|
115
|
-
| LLM sees new user text |
|
|
116
|
-
|
|
|
117
|
-
|
|
|
118
|
-
|
|
|
119
|
-
|
|
|
120
|
-
| Complexity | Prototype patch + 1 call | 49 files, multi-stage | 2 files, config-driven | 1 file, loop-based |
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## About the Hack
|
|
125
|
-
|
|
126
|
-
The extension uses the public `@earendil-works/pi-agent-core` package (which pi's extension loader resolves to the same module instance used internally) to import `Agent` and monkey-patch `Agent.prototype.prompt`. This captures the live `Agent` instance when pi first calls `agent.prompt()` during normal operation.
|
|
127
|
-
|
|
128
|
-
Then `/continue` calls `agent.prompt([])` — an empty prompt array. The `runAgentLoop` function spreads the prompts into the context messages, so with an empty array, nothing is added. The loop starts from the unmodified context snapshot and the LLM continues naturally.
|
|
113
|
+
| LLM sees new user text | No | Handoff doc | `"continue"` | `"continue"` |
|
|
114
|
+
| Visible transcript entry | No | Yes | Yes | Yes |
|
|
115
|
+
| Hidden session bookkeeping | One custom entry | Compaction + user entry | User entry | User entry |
|
|
116
|
+
| Native session lifecycle | Yes | Yes | Yes | Yes |
|
|
117
|
+
| Auto-triggered | No | On compaction | On error/length | On `agent_end` |
|
|
129
118
|
|
|
130
|
-
|
|
119
|
+
The desired upstream primitive is a public `AgentSession.resume()` operation that preserves native lifecycle behavior without adding a marker. Until then, a filtered custom message is safer than bypassing the session.
|
|
131
120
|
|
|
132
121
|
## License
|
|
133
122
|
|
package/continue.ts
CHANGED
|
@@ -1,144 +1,43 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Agent } from "@earendil-works/pi-agent-core";
|
|
3
2
|
import {
|
|
4
3
|
CONTINUE_COMMAND_DESCRIPTION,
|
|
4
|
+
INVISIBLE_CONTINUE_CUSTOM_TYPE,
|
|
5
5
|
getLastAssistantMessageText,
|
|
6
|
+
isInvisibleContinueMarker,
|
|
6
7
|
} from "./src/index.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - Monkey-patch Agent.prototype.subscribe to capture the Agent instance
|
|
13
|
-
* - /continue calls agent.prompt([]) directly, starting a fresh agent loop
|
|
14
|
-
* with an empty prompt — no message is injected into context at all
|
|
15
|
-
* - The LLM receives the exact same message list it had before
|
|
16
|
-
* - No session JSONL artifact, no convertToLlm involvement, no filter needed
|
|
17
|
-
*
|
|
18
|
-
* This bypasses AgentSession._runAgentPrompt, so auto-compaction is not
|
|
19
|
-
* triggered after a manual /continue. Auto-retry is not a concern — pi-retry
|
|
20
|
-
* covers that gap via its agent_end handler, which still fires because the
|
|
21
|
-
* agent's processEvents propagates to AgentSession's subscriber normally.
|
|
22
|
-
*
|
|
23
|
-
* Module resolution:
|
|
24
|
-
* The Agent class is imported from @earendil-works/pi-agent-core.
|
|
25
|
-
* This package does NOT list pi-agent-core as a devDependency — there is
|
|
26
|
-
* no local node_modules copy. When jiti loads this extension, its alias
|
|
27
|
-
* system rewrites the import specifier to pi's bundled copy, so the
|
|
28
|
-
* subscribe/continue patches apply to the SAME Agent class that
|
|
29
|
-
* AgentSession uses. Previously, a local node_modules/@earendil-works/pi-agent-core
|
|
30
|
-
* caused jiti to resolve the import to a different class — patching the
|
|
31
|
-
* wrong prototype and leaving _agent permanently null.
|
|
10
|
+
* Resume through AgentSession instead of calling Agent.prompt([]) directly.
|
|
11
|
+
* The hidden custom message starts or queues a canonical session turn, while
|
|
12
|
+
* the context hook removes it before provider serialization.
|
|
32
13
|
*/
|
|
14
|
+
export default function invisibleContinueExtension(pi: ExtensionAPI) {
|
|
15
|
+
pi.on("context", (event) => {
|
|
16
|
+
const messages = event.messages.filter((message) => !isInvisibleContinueMarker(message));
|
|
17
|
+
if (messages.length !== event.messages.length) return { messages };
|
|
18
|
+
});
|
|
33
19
|
|
|
34
|
-
// Capture the live Agent instance when AgentSession subscribes to it.
|
|
35
|
-
// subscribe() is called during AgentSession construction — fires on both
|
|
36
|
-
// fresh sessions and session resumes, unlike prompt().
|
|
37
|
-
//
|
|
38
|
-
// Chain the previous patch (if pi-retry or pi-vcc already patched it)
|
|
39
|
-
// so all extensions can coexist.
|
|
40
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
-
let _agent: Agent | null = null;
|
|
42
|
-
const _origSubscribe = Agent.prototype.subscribe as (this: Agent, ...args: any[]) => any;
|
|
43
|
-
Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
|
|
44
|
-
_agent = this;
|
|
45
|
-
return _origSubscribe.apply(this, args);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Mutex: only one invisible continue may be in-flight at a time.
|
|
49
|
-
// Without this, concurrent /continue (or /continue during pi-retry/pi-vcc
|
|
50
|
-
// auto-continuation) race through waitForIdle() and both call prompt([]),
|
|
51
|
-
// producing "Agent is already processing".
|
|
52
|
-
let _continueInProgress = false;
|
|
53
|
-
|
|
54
|
-
// Timestamp of the last completed invisible continue.
|
|
55
|
-
// Used to avoid double continuation when continue() unblocks after
|
|
56
|
-
// triggerInvisibleContinue just ran.
|
|
57
|
-
let _lastInvisibleContinueTime = 0;
|
|
58
|
-
|
|
59
|
-
// Monkey-patch continue() so the session's built-in loop cooperates with
|
|
60
|
-
// our mutex AND can convert the "Cannot continue from assistant" error
|
|
61
|
-
// into a prompt([]) call when the agent was mid-task.
|
|
62
|
-
// Without this, the session's continue() would throw when the last message
|
|
63
|
-
// is an assistant (common after compaction), and the agent loop would die
|
|
64
|
-
// leaving mid-task work unfinished.
|
|
65
|
-
//
|
|
66
|
-
// When continue() throws "Cannot continue from message role: assistant":
|
|
67
|
-
// - stopReason "stop" → agent finished cleanly, don't continue
|
|
68
|
-
// - stopReason "aborted" → user cancelled, don't continue
|
|
69
|
-
// - stopReason "error" → pi-retry handles errors, don't race it
|
|
70
|
-
// - stopReason "toolUse" or "length" → mid-task, fall back to prompt([])
|
|
71
|
-
//
|
|
72
|
-
// Chains the previous patch (pi-retry, pi-vcc) so all mutexes are respected.
|
|
73
|
-
const _origContinue = Agent.prototype.continue;
|
|
74
|
-
Agent.prototype.continue = function (this: Agent) {
|
|
75
|
-
const self = this;
|
|
76
|
-
return (async (): Promise<void> => {
|
|
77
|
-
while (_continueInProgress) {
|
|
78
|
-
await new Promise(r => setTimeout(r, 10));
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
await _origContinue.call(self);
|
|
82
|
-
} catch (e: any) {
|
|
83
|
-
const msg = e?.message ?? '';
|
|
84
|
-
if (msg.includes('Cannot continue from message role') ||
|
|
85
|
-
msg.includes('Cannot continue from an assistant message')) {
|
|
86
|
-
// Check stopReason — only continue if the agent was mid-task
|
|
87
|
-
const lastMsg = self.state.messages[self.state.messages.length - 1];
|
|
88
|
-
if (lastMsg?.role === 'assistant' &&
|
|
89
|
-
lastMsg.stopReason !== 'stop' &&
|
|
90
|
-
lastMsg.stopReason !== 'aborted' &&
|
|
91
|
-
lastMsg.stopReason !== 'error') {
|
|
92
|
-
// Agent was mid-task — fall back to prompt([])
|
|
93
|
-
// Guard: if an invisible continue just completed, don't double-run
|
|
94
|
-
if (!_continueInProgress && Date.now() - _lastInvisibleContinueTime > 500) {
|
|
95
|
-
_continueInProgress = true;
|
|
96
|
-
try {
|
|
97
|
-
await self.prompt([]);
|
|
98
|
-
} catch {
|
|
99
|
-
// Agent already processing or other transient error
|
|
100
|
-
} finally {
|
|
101
|
-
_continueInProgress = false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
// For stop/aborted/error: return void, the session loop exits naturally
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (msg.includes('Agent is already processing')) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
throw e;
|
|
112
|
-
}
|
|
113
|
-
})();
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
export default function (pi: ExtensionAPI) {
|
|
117
20
|
pi.registerCommand("continue", {
|
|
118
21
|
description: CONTINUE_COMMAND_DESCRIPTION,
|
|
119
22
|
handler: async (args, ctx) => {
|
|
120
|
-
|
|
23
|
+
runContinueCommand(pi, ctx, args);
|
|
121
24
|
},
|
|
122
25
|
});
|
|
123
|
-
|
|
124
|
-
pi.on("session_start", () => {
|
|
125
|
-
_continueInProgress = false;
|
|
126
|
-
_lastInvisibleContinueTime = 0;
|
|
127
|
-
});
|
|
128
26
|
}
|
|
129
27
|
|
|
130
|
-
|
|
28
|
+
function runContinueCommand(
|
|
29
|
+
pi: ExtensionAPI,
|
|
131
30
|
ctx: ExtensionCommandContext,
|
|
132
31
|
args: string,
|
|
133
|
-
):
|
|
134
|
-
|
|
32
|
+
): void {
|
|
33
|
+
const subcommand = args.trim().toLowerCase();
|
|
34
|
+
|
|
35
|
+
if (subcommand === "status") {
|
|
135
36
|
const last = getLastAssistantMessageText(ctx.sessionManager.getEntries());
|
|
136
|
-
const idle = ctx.isIdle();
|
|
137
37
|
ctx.ui.notify(
|
|
138
38
|
[
|
|
139
39
|
"pi-invisible-continue status:",
|
|
140
|
-
` Agent idle: ${
|
|
141
|
-
` Captured agent: ${_agent ? "yes" : "no"}`,
|
|
40
|
+
` Agent idle: ${ctx.isIdle() ? "yes" : "no"}`,
|
|
142
41
|
` Last assistant: ${last ?? "(none)"}`.slice(0, 120),
|
|
143
42
|
].join("\n"),
|
|
144
43
|
"info",
|
|
@@ -146,7 +45,7 @@ async function runContinueCommand(
|
|
|
146
45
|
return;
|
|
147
46
|
}
|
|
148
47
|
|
|
149
|
-
if (
|
|
48
|
+
if (subcommand === "help") {
|
|
150
49
|
ctx.ui.notify(
|
|
151
50
|
[
|
|
152
51
|
"pi-invisible-continue /continue Resume loop invisibly",
|
|
@@ -158,38 +57,16 @@ async function runContinueCommand(
|
|
|
158
57
|
return;
|
|
159
58
|
}
|
|
160
59
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Guard: if pi-retry or pi-vcc already has an invisible continue in-flight,
|
|
174
|
-
// skip — their prompt([]) will resume the loop.
|
|
175
|
-
if (_continueInProgress) {
|
|
176
|
-
ctx.ui.notify(
|
|
177
|
-
"pi-invisible-continue: Another invisible continue is already in progress.",
|
|
178
|
-
"info",
|
|
179
|
-
);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
_continueInProgress = true;
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
await _agent.waitForIdle();
|
|
186
|
-
try {
|
|
187
|
-
await _agent.prompt([]);
|
|
188
|
-
} catch {
|
|
189
|
-
// Agent is already processing — something else is driving.
|
|
190
|
-
}
|
|
191
|
-
} finally {
|
|
192
|
-
_continueInProgress = false;
|
|
193
|
-
_lastInvisibleContinueTime = Date.now();
|
|
194
|
-
}
|
|
60
|
+
pi.sendMessage(
|
|
61
|
+
{
|
|
62
|
+
customType: INVISIBLE_CONTINUE_CUSTOM_TYPE,
|
|
63
|
+
content: [],
|
|
64
|
+
display: false,
|
|
65
|
+
details: undefined,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
triggerTurn: true,
|
|
69
|
+
deliverAs: "followUp",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
195
72
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-invisible-continue",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Invisible session continuation for pi
|
|
3
|
+
"version": "0.3.4",
|
|
4
|
+
"description": "Invisible session continuation for pi — resume the agentic loop without sending ANY prompt the LLM can see",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
7
7
|
"license": "MIT",
|
|
@@ -30,16 +30,8 @@
|
|
|
30
30
|
"src/",
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"test": "vitest run",
|
|
35
|
-
"test:watch": "vitest",
|
|
36
|
-
"test:coverage": "vitest run --coverage",
|
|
37
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
-
"lint:dead": "knip --no-gitignore"
|
|
39
|
-
},
|
|
40
33
|
"devDependencies": {
|
|
41
|
-
"@earendil-works/pi-agent
|
|
42
|
-
"@earendil-works/pi-coding-agent": "0.79.4",
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.79.8",
|
|
43
35
|
"@types/node": "25.9.1",
|
|
44
36
|
"@vitest/coverage-v8": "4.1.7",
|
|
45
37
|
"knip": "6.14.1",
|
|
@@ -55,5 +47,12 @@
|
|
|
55
47
|
"brace-expansion": "5.0.6",
|
|
56
48
|
"protobufjs": "8.4.0",
|
|
57
49
|
"ws": "8.20.1"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"test:watch": "vitest",
|
|
54
|
+
"test:coverage": "vitest run --coverage",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"lint:dead": "knip --no-gitignore"
|
|
58
57
|
}
|
|
59
|
-
}
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared constants and utilities for pi-invisible-continue.
|
|
3
|
-
*
|
|
4
|
-
* The extension is small enough that heavy shared logic is unnecessary.
|
|
5
|
-
* The command description and a session introspection helper are the only exports.
|
|
6
|
-
*/
|
|
1
|
+
/** Shared constants and utilities for pi-invisible-continue. */
|
|
7
2
|
|
|
8
|
-
/** Description shown in the / commands list. */
|
|
9
3
|
export const CONTINUE_COMMAND_DESCRIPTION =
|
|
10
4
|
"Resume the agentic loop without sending a prompt the LLM can read";
|
|
11
5
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
export const INVISIBLE_CONTINUE_CUSTOM_TYPE = "pi-invisible-continue:resume";
|
|
7
|
+
|
|
8
|
+
export function isInvisibleContinueMarker(message: unknown): boolean {
|
|
9
|
+
if (!message || typeof message !== "object") return false;
|
|
10
|
+
const candidate = message as { role?: unknown; customType?: unknown };
|
|
11
|
+
return (
|
|
12
|
+
candidate.role === "custom" &&
|
|
13
|
+
candidate.customType === INVISIBLE_CONTINUE_CUSTOM_TYPE
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
16
17
|
export function getLastAssistantMessageText(
|
|
17
18
|
entries: ReadonlyArray<{ type: string; message?: { role?: string; content?: unknown } }>,
|
|
18
19
|
): string | undefined {
|
|
@@ -21,17 +22,17 @@ export function getLastAssistantMessageText(
|
|
|
21
22
|
if (
|
|
22
23
|
entry.type === "message" &&
|
|
23
24
|
entry.message?.role === "assistant" &&
|
|
24
|
-
entry.message
|
|
25
|
+
entry.message.content
|
|
25
26
|
) {
|
|
26
27
|
const content = entry.message.content;
|
|
27
28
|
if (typeof content === "string") return content;
|
|
28
29
|
if (Array.isArray(content)) {
|
|
29
30
|
const textBlocks = content.filter(
|
|
30
|
-
(block:
|
|
31
|
-
typeof block
|
|
32
|
-
block
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
(block: unknown): block is { type: "text"; text: string } => {
|
|
32
|
+
if (!block || typeof block !== "object") return false;
|
|
33
|
+
const candidate = block as { type?: unknown; text?: unknown };
|
|
34
|
+
return candidate.type === "text" && typeof candidate.text === "string";
|
|
35
|
+
},
|
|
35
36
|
);
|
|
36
37
|
if (textBlocks.length === 0) return undefined;
|
|
37
38
|
return textBlocks.map((block) => block.text).join("\n");
|