@cjhyy/code-shell-pet 0.9.2 → 0.9.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/dist/bound-session-commands.d.ts +45 -0
- package/dist/bound-session-commands.js +54 -0
- package/dist/capability.js +18 -1
- package/dist/conversation-session-route.d.ts +115 -0
- package/dist/conversation-session-route.js +251 -0
- package/dist/conversation-session.d.ts +12 -0
- package/dist/conversation-session.js +12 -0
- package/dist/delegate-work.js +11 -2
- package/dist/host-actions.d.ts +8 -1
- package/dist/host-actions.js +21 -4
- package/dist/im-session-list.d.ts +64 -0
- package/dist/im-session-list.js +75 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/long-task.d.ts +8 -3
- package/dist/long-task.js +26 -2
- package/dist/profile.d.ts +1 -1
- package/dist/profile.js +21 -6
- package/dist/session-control.d.ts +4 -0
- package/dist/session-control.js +64 -0
- package/dist/session-index.js +20 -3
- package/dist/session-visit-receipt.d.ts +85 -0
- package/dist/session-visit-receipt.js +80 -0
- package/package.json +2 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a bound conversation that must never involve a model.
|
|
3
|
+
*
|
|
4
|
+
* Leaving a Session has to work when the model is down, the worker is wedged,
|
|
5
|
+
* or the bound Session itself is unreachable — otherwise a user can get stuck
|
|
6
|
+
* inside a Session with no way out. So /mimi is matched by exact string here
|
|
7
|
+
* and handled before anything else in the pipeline.
|
|
8
|
+
*
|
|
9
|
+
* Likewise, which assistant output reaches the chat is a fixed rule rather
|
|
10
|
+
* than a judgement call: a bound conversation shows the turn's final answer
|
|
11
|
+
* and the things the user must act on, never the intermediate stream.
|
|
12
|
+
*/
|
|
13
|
+
/** Commands recognized while a conversation is routed to a Work Session. */
|
|
14
|
+
export type BoundSessionCommand = "leave" | "status";
|
|
15
|
+
export declare function parseBoundSessionCommand(message: string): BoundSessionCommand | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Assistant output a bound conversation may forward.
|
|
18
|
+
*
|
|
19
|
+
* `final` is the turn's answer. `decision` is an approval or AskUser question,
|
|
20
|
+
* which must reach the user or the Session stalls invisibly. `terminal` is the
|
|
21
|
+
* end-of-Session notice. Everything else — partial text, tool arguments, raw
|
|
22
|
+
* tool results, token stream — stays in the Session.
|
|
23
|
+
*/
|
|
24
|
+
export type BoundReplyKind = "final" | "decision" | "terminal";
|
|
25
|
+
export interface BoundReplyCandidate {
|
|
26
|
+
kind: BoundReplyKind | "partial" | "tool" | "progress";
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function shouldForwardBoundReply(candidate: BoundReplyCandidate): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* A normal message must never be read as an approval. Permission decisions
|
|
32
|
+
* carry a signed one-time token; free text like "同意" is ordinary input that
|
|
33
|
+
* happens to look like consent, and treating it as consent would let a chat
|
|
34
|
+
* message authorize a destructive action.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isApprovalDecision(_message: string): false;
|
|
37
|
+
export interface StalePromptText {
|
|
38
|
+
title: string;
|
|
39
|
+
hint: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The one-time reminder shown when a long-quiet conversation is still bound,
|
|
43
|
+
* before the message is delivered to the Session.
|
|
44
|
+
*/
|
|
45
|
+
export declare function boundSessionStalePrompt(sessionTitle: string): StalePromptText;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of a bound conversation that must never involve a model.
|
|
3
|
+
*
|
|
4
|
+
* Leaving a Session has to work when the model is down, the worker is wedged,
|
|
5
|
+
* or the bound Session itself is unreachable — otherwise a user can get stuck
|
|
6
|
+
* inside a Session with no way out. So /mimi is matched by exact string here
|
|
7
|
+
* and handled before anything else in the pipeline.
|
|
8
|
+
*
|
|
9
|
+
* Likewise, which assistant output reaches the chat is a fixed rule rather
|
|
10
|
+
* than a judgement call: a bound conversation shows the turn's final answer
|
|
11
|
+
* and the things the user must act on, never the intermediate stream.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Exact matches only. A fuzzy match would eventually swallow a real message
|
|
15
|
+
* that merely mentions leaving, and losing user input into a control path is
|
|
16
|
+
* worse than making the user type the command exactly.
|
|
17
|
+
*/
|
|
18
|
+
const LEAVE_COMMANDS = new Set(["/mimi", "/session leave", "返回 mimi", "退出 session"]);
|
|
19
|
+
const STATUS_COMMANDS = new Set(["/session", "/session status"]);
|
|
20
|
+
export function parseBoundSessionCommand(message) {
|
|
21
|
+
const normalized = message.trim().toLowerCase().replace(/\s+/gu, " ");
|
|
22
|
+
if (LEAVE_COMMANDS.has(normalized))
|
|
23
|
+
return "leave";
|
|
24
|
+
if (STATUS_COMMANDS.has(normalized))
|
|
25
|
+
return "status";
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function shouldForwardBoundReply(candidate) {
|
|
29
|
+
if (candidate.kind !== "final" &&
|
|
30
|
+
candidate.kind !== "decision" &&
|
|
31
|
+
candidate.kind !== "terminal") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return candidate.text.trim().length > 0;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A normal message must never be read as an approval. Permission decisions
|
|
38
|
+
* carry a signed one-time token; free text like "同意" is ordinary input that
|
|
39
|
+
* happens to look like consent, and treating it as consent would let a chat
|
|
40
|
+
* message authorize a destructive action.
|
|
41
|
+
*/
|
|
42
|
+
export function isApprovalDecision(_message) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The one-time reminder shown when a long-quiet conversation is still bound,
|
|
47
|
+
* before the message is delivered to the Session.
|
|
48
|
+
*/
|
|
49
|
+
export function boundSessionStalePrompt(sessionTitle) {
|
|
50
|
+
return {
|
|
51
|
+
title: sessionTitle,
|
|
52
|
+
hint: `这条消息会发送到「${sessionTitle}」。发送 /mimi 可以退出。`,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/capability.js
CHANGED
|
@@ -15,7 +15,7 @@ import { mobileRemoteAvailability, mobileRemoteTool, mobileRemoteToolDef, } from
|
|
|
15
15
|
import { gatewayAvailability, gatewayTool, gatewayToolDef } from "./gateway.js";
|
|
16
16
|
import { sessionsAvailability, sessionsTool, sessionsToolDef } from "./sessions-tool.js";
|
|
17
17
|
import { currentTimeAvailability, currentTimeTool, currentTimeToolDef } from "./current-time.js";
|
|
18
|
-
import { manageSessionsAvailability, manageSessionsTool, manageSessionsToolDef, watchSessionAvailability, watchSessionTool, watchSessionToolDef, } from "./session-control.js";
|
|
18
|
+
import { bindConversationSessionAvailability, bindConversationSessionTool, bindConversationSessionToolDef, manageSessionsAvailability, manageSessionsTool, manageSessionsToolDef, watchSessionAvailability, watchSessionTool, watchSessionToolDef, } from "./session-control.js";
|
|
19
19
|
import { followUpsAvailability, followUpsTool, followUpsToolDef, manageFollowUpAvailability, manageFollowUpTool, manageFollowUpToolDef, } from "./follow-ups.js";
|
|
20
20
|
import { rewriteSendMessageDef, sendMessageAvailability, sendMessageTool, sendMessageToolDef, } from "./outbound-message.js";
|
|
21
21
|
import { controlLongTaskAvailability, controlLongTaskTool, controlLongTaskToolDef, gatewayReplyAvailability, gatewayReplyTool, gatewayReplyToolDef, memoryAvailability, memoryTool, memoryToolDef, rewriteGatewayReplyDef, } from "./host-actions.js";
|
|
@@ -264,6 +264,23 @@ function buildPetParts() {
|
|
|
264
264
|
availability: manageSessionsAvailability,
|
|
265
265
|
},
|
|
266
266
|
},
|
|
267
|
+
{
|
|
268
|
+
definition: {
|
|
269
|
+
...bindConversationSessionToolDef,
|
|
270
|
+
source: "builtin",
|
|
271
|
+
permissionDefault: "allow",
|
|
272
|
+
isReadOnly: false,
|
|
273
|
+
isConcurrencySafe: false,
|
|
274
|
+
},
|
|
275
|
+
execute: bindConversationSessionTool,
|
|
276
|
+
exposure: {
|
|
277
|
+
presetTags: ["harness-min", "general"],
|
|
278
|
+
defaultPermissionRules: [
|
|
279
|
+
{ tool: bindConversationSessionToolDef.name, decision: "allow" },
|
|
280
|
+
],
|
|
281
|
+
availability: bindConversationSessionAvailability,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
267
284
|
{
|
|
268
285
|
definition: {
|
|
269
286
|
...currentTimeToolDef,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable routing between one external IM conversation and one Work Session.
|
|
3
|
+
*
|
|
4
|
+
* Three relationships already pointed an IM conversation at a Work Session
|
|
5
|
+
* before this module existed: a DelegateWork launch (PetLongTask
|
|
6
|
+
* completionTarget), a WatchSession subscription, and — new — explicitly
|
|
7
|
+
* entering a Session so the chat talks to it directly. All three need the same
|
|
8
|
+
* things: existence checks, terminal reconciliation, restart recovery and
|
|
9
|
+
* fail-closed revocation. They are therefore one record with a `mode`, not
|
|
10
|
+
* three stores.
|
|
11
|
+
*
|
|
12
|
+
* `notify` means "send this conversation the Session's terminal outcome".
|
|
13
|
+
* `bound` means "this conversation currently IS the Session's chat". Entering
|
|
14
|
+
* upgrades notify → bound; leaving downgrades bound → notify rather than
|
|
15
|
+
* deleting, so a user who exits still receives the completion they were
|
|
16
|
+
* waiting for.
|
|
17
|
+
*
|
|
18
|
+
* This module is pure domain logic: no fs, no clock, no channel access. The
|
|
19
|
+
* host owns persistence (desktop conversation-session-route-store.ts) and
|
|
20
|
+
* every authorization decision.
|
|
21
|
+
*/
|
|
22
|
+
export declare const CONVERSATION_SESSION_ROUTE_SCHEMA_VERSION = 1;
|
|
23
|
+
export type ConversationSessionRouteMode = "notify" | "bound";
|
|
24
|
+
export type ConversationSessionRouteOrigin = "delegate" | "watch" | "enter";
|
|
25
|
+
export type ConversationSessionRouteStatus = "active" | "suspended";
|
|
26
|
+
/**
|
|
27
|
+
* Why a bound route stopped being usable. Every reason is terminal for the
|
|
28
|
+
* `bound` mode: the next inbound message goes back to Mimi rather than being
|
|
29
|
+
* delivered somewhere the user did not intend.
|
|
30
|
+
*/
|
|
31
|
+
export type ConversationSessionRouteSuspendReason = "session-missing" | "session-archived" | "workspace-missing" | "worktree-missing" | "authorization-revoked" | "session-terminal";
|
|
32
|
+
/** Why a bound route was downgraded back to notify. */
|
|
33
|
+
export type ConversationSessionRouteLeaveReason = "user" | "expired" | "terminal" | "suspended";
|
|
34
|
+
export interface ConversationSessionRoute {
|
|
35
|
+
schemaVersion: typeof CONVERSATION_SESSION_ROUTE_SCHEMA_VERSION;
|
|
36
|
+
id: string;
|
|
37
|
+
/**
|
|
38
|
+
* Stable conversation identity: `im:<channel>\0<target>\0<senderId>`. Built
|
|
39
|
+
* by the host (petChatRouteKey) and never by the model. A route key missing
|
|
40
|
+
* target or sender is not addressable and must be rejected, not guessed.
|
|
41
|
+
*/
|
|
42
|
+
routeKey: string;
|
|
43
|
+
channel: string;
|
|
44
|
+
target: string;
|
|
45
|
+
senderId: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
/** Display cache only. `sessionId` is the sole authority for identity. */
|
|
48
|
+
sessionTitle: string;
|
|
49
|
+
mode: ConversationSessionRouteMode;
|
|
50
|
+
origin: ConversationSessionRouteOrigin;
|
|
51
|
+
status: ConversationSessionRouteStatus;
|
|
52
|
+
suspendedReason?: ConversationSessionRouteSuspendReason;
|
|
53
|
+
revision: number;
|
|
54
|
+
createdAt: number;
|
|
55
|
+
updatedAt: number;
|
|
56
|
+
lastInboundAt?: number;
|
|
57
|
+
/** When a bound route auto-downgrades for inactivity. Unset for notify. */
|
|
58
|
+
expiresAt?: number;
|
|
59
|
+
/** Last time the user was told they are still inside a Session. */
|
|
60
|
+
stalePromptAt?: number;
|
|
61
|
+
}
|
|
62
|
+
/** A bound route with no inbound traffic for this long downgrades itself. */
|
|
63
|
+
export declare const BOUND_ROUTE_IDLE_EXPIRY_MS: number;
|
|
64
|
+
/**
|
|
65
|
+
* After this much silence the next message gets one deterministic reminder
|
|
66
|
+
* that it is about to enter a Session, before it is delivered. Prevents a
|
|
67
|
+
* next-day "check the weather" from landing in yesterday's coding Session
|
|
68
|
+
* with no warning.
|
|
69
|
+
*/
|
|
70
|
+
export declare const BOUND_ROUTE_STALE_PROMPT_MS: number;
|
|
71
|
+
export interface CreateConversationSessionRouteInput {
|
|
72
|
+
id: string;
|
|
73
|
+
routeKey: string;
|
|
74
|
+
channel: string;
|
|
75
|
+
target: string;
|
|
76
|
+
senderId: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
sessionTitle: string;
|
|
79
|
+
mode: ConversationSessionRouteMode;
|
|
80
|
+
origin: ConversationSessionRouteOrigin;
|
|
81
|
+
now: number;
|
|
82
|
+
}
|
|
83
|
+
export declare function createConversationSessionRoute(input: CreateConversationSessionRouteInput): ConversationSessionRoute | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* Reject anything that is not a well-formed route rather than repairing it.
|
|
86
|
+
* A half-understood route could deliver a user's message into the wrong
|
|
87
|
+
* Session, so an unreadable row is dropped and the conversation falls back to
|
|
88
|
+
* Mimi. One bad row never blocks the rest of the file.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseConversationSessionRoute(value: unknown): ConversationSessionRoute | undefined;
|
|
91
|
+
/** Upgrade a route to `bound`: this conversation now talks to the Session. */
|
|
92
|
+
export declare function enterConversationSessionRoute(route: ConversationSessionRoute, now: number): ConversationSessionRoute;
|
|
93
|
+
/**
|
|
94
|
+
* Downgrade to `notify` instead of deleting. The user stops chatting with the
|
|
95
|
+
* Session but still gets told when it finishes — that is what makes Mimi a
|
|
96
|
+
* front desk rather than a switch.
|
|
97
|
+
*/
|
|
98
|
+
export declare function leaveConversationSessionRoute(route: ConversationSessionRoute, now: number, reason: ConversationSessionRouteLeaveReason): ConversationSessionRoute;
|
|
99
|
+
/** Mark a route unusable. The caller then routes the message back to Mimi. */
|
|
100
|
+
export declare function suspendConversationSessionRoute(route: ConversationSessionRoute, now: number, reason: ConversationSessionRouteSuspendReason): ConversationSessionRoute;
|
|
101
|
+
export declare function recordConversationSessionRouteInbound(route: ConversationSessionRoute, now: number): ConversationSessionRoute;
|
|
102
|
+
export declare function markConversationSessionRouteStalePrompted(route: ConversationSessionRoute, now: number): ConversationSessionRoute;
|
|
103
|
+
export declare function isConversationSessionRouteExpired(route: ConversationSessionRoute, now: number): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* True when the user should get one reminder that they are still inside a
|
|
106
|
+
* Session before this message is delivered to it. Sent at most once per quiet
|
|
107
|
+
* period so an active conversation is never nagged.
|
|
108
|
+
*/
|
|
109
|
+
export declare function shouldPromptConversationSessionRouteStale(route: ConversationSessionRoute, now: number): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The bound route for a conversation, if it is still usable right now.
|
|
112
|
+
* Expiry is evaluated at read time so a route that aged out while the app was
|
|
113
|
+
* closed never captures the next message.
|
|
114
|
+
*/
|
|
115
|
+
export declare function activeBoundRoute(routes: readonly ConversationSessionRoute[], routeKey: string, now: number): ConversationSessionRoute | undefined;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable routing between one external IM conversation and one Work Session.
|
|
3
|
+
*
|
|
4
|
+
* Three relationships already pointed an IM conversation at a Work Session
|
|
5
|
+
* before this module existed: a DelegateWork launch (PetLongTask
|
|
6
|
+
* completionTarget), a WatchSession subscription, and — new — explicitly
|
|
7
|
+
* entering a Session so the chat talks to it directly. All three need the same
|
|
8
|
+
* things: existence checks, terminal reconciliation, restart recovery and
|
|
9
|
+
* fail-closed revocation. They are therefore one record with a `mode`, not
|
|
10
|
+
* three stores.
|
|
11
|
+
*
|
|
12
|
+
* `notify` means "send this conversation the Session's terminal outcome".
|
|
13
|
+
* `bound` means "this conversation currently IS the Session's chat". Entering
|
|
14
|
+
* upgrades notify → bound; leaving downgrades bound → notify rather than
|
|
15
|
+
* deleting, so a user who exits still receives the completion they were
|
|
16
|
+
* waiting for.
|
|
17
|
+
*
|
|
18
|
+
* This module is pure domain logic: no fs, no clock, no channel access. The
|
|
19
|
+
* host owns persistence (desktop conversation-session-route-store.ts) and
|
|
20
|
+
* every authorization decision.
|
|
21
|
+
*/
|
|
22
|
+
export const CONVERSATION_SESSION_ROUTE_SCHEMA_VERSION = 1;
|
|
23
|
+
/** A bound route with no inbound traffic for this long downgrades itself. */
|
|
24
|
+
export const BOUND_ROUTE_IDLE_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* After this much silence the next message gets one deterministic reminder
|
|
27
|
+
* that it is about to enter a Session, before it is delivered. Prevents a
|
|
28
|
+
* next-day "check the weather" from landing in yesterday's coding Session
|
|
29
|
+
* with no warning.
|
|
30
|
+
*/
|
|
31
|
+
export const BOUND_ROUTE_STALE_PROMPT_MS = 2 * 60 * 60 * 1000;
|
|
32
|
+
const MAX_ID_LENGTH = 128;
|
|
33
|
+
const MAX_ROUTE_KEY_LENGTH = 512;
|
|
34
|
+
const MAX_TITLE_LENGTH = 160;
|
|
35
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
|
|
36
|
+
function isPlainRecord(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function safeInteger(value) {
|
|
40
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A route key embeds NUL separators by construction, so it is checked for
|
|
44
|
+
* length and shape rather than for control characters.
|
|
45
|
+
*/
|
|
46
|
+
function isRouteKey(value) {
|
|
47
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_ROUTE_KEY_LENGTH;
|
|
48
|
+
}
|
|
49
|
+
function isBoundedText(value, maxLength) {
|
|
50
|
+
return (typeof value === "string" &&
|
|
51
|
+
value.length > 0 &&
|
|
52
|
+
value.length <= maxLength &&
|
|
53
|
+
!CONTROL_CHARACTER_RE.test(value));
|
|
54
|
+
}
|
|
55
|
+
function isMode(value) {
|
|
56
|
+
return value === "notify" || value === "bound";
|
|
57
|
+
}
|
|
58
|
+
function isOrigin(value) {
|
|
59
|
+
return value === "delegate" || value === "watch" || value === "enter";
|
|
60
|
+
}
|
|
61
|
+
function isSuspendReason(value) {
|
|
62
|
+
return (value === "session-missing" ||
|
|
63
|
+
value === "session-archived" ||
|
|
64
|
+
value === "workspace-missing" ||
|
|
65
|
+
value === "worktree-missing" ||
|
|
66
|
+
value === "authorization-revoked" ||
|
|
67
|
+
value === "session-terminal");
|
|
68
|
+
}
|
|
69
|
+
export function createConversationSessionRoute(input) {
|
|
70
|
+
if (!isBoundedText(input.id, MAX_ID_LENGTH) ||
|
|
71
|
+
!isRouteKey(input.routeKey) ||
|
|
72
|
+
!isBoundedText(input.channel, 64) ||
|
|
73
|
+
!isBoundedText(input.target, 256) ||
|
|
74
|
+
!isBoundedText(input.senderId, 256) ||
|
|
75
|
+
!isBoundedText(input.sessionId, MAX_ID_LENGTH) ||
|
|
76
|
+
!isMode(input.mode) ||
|
|
77
|
+
!isOrigin(input.origin) ||
|
|
78
|
+
safeInteger(input.now) === undefined) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
// Validate the title AFTER normalizing it: trimming and slicing must not be
|
|
82
|
+
// able to smuggle a control character past the check, and the session-id
|
|
83
|
+
// fallback has to satisfy the same rule the stored value does.
|
|
84
|
+
const normalizedTitle = input.sessionTitle.trim().slice(0, MAX_TITLE_LENGTH);
|
|
85
|
+
const title = normalizedTitle || input.sessionId;
|
|
86
|
+
if (!isBoundedText(title, MAX_TITLE_LENGTH))
|
|
87
|
+
return undefined;
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: CONVERSATION_SESSION_ROUTE_SCHEMA_VERSION,
|
|
90
|
+
id: input.id,
|
|
91
|
+
routeKey: input.routeKey,
|
|
92
|
+
channel: input.channel,
|
|
93
|
+
target: input.target,
|
|
94
|
+
senderId: input.senderId,
|
|
95
|
+
sessionId: input.sessionId,
|
|
96
|
+
sessionTitle: title,
|
|
97
|
+
mode: input.mode,
|
|
98
|
+
origin: input.origin,
|
|
99
|
+
status: "active",
|
|
100
|
+
revision: 1,
|
|
101
|
+
createdAt: input.now,
|
|
102
|
+
updatedAt: input.now,
|
|
103
|
+
...(input.mode === "bound" ? { expiresAt: input.now + BOUND_ROUTE_IDLE_EXPIRY_MS } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Reject anything that is not a well-formed route rather than repairing it.
|
|
108
|
+
* A half-understood route could deliver a user's message into the wrong
|
|
109
|
+
* Session, so an unreadable row is dropped and the conversation falls back to
|
|
110
|
+
* Mimi. One bad row never blocks the rest of the file.
|
|
111
|
+
*/
|
|
112
|
+
export function parseConversationSessionRoute(value) {
|
|
113
|
+
if (!isPlainRecord(value))
|
|
114
|
+
return undefined;
|
|
115
|
+
if (value.schemaVersion !== CONVERSATION_SESSION_ROUTE_SCHEMA_VERSION)
|
|
116
|
+
return undefined;
|
|
117
|
+
const createdAt = safeInteger(value.createdAt);
|
|
118
|
+
const updatedAt = safeInteger(value.updatedAt);
|
|
119
|
+
const revision = safeInteger(value.revision);
|
|
120
|
+
if (!isBoundedText(value.id, MAX_ID_LENGTH) ||
|
|
121
|
+
!isRouteKey(value.routeKey) ||
|
|
122
|
+
!isBoundedText(value.channel, 64) ||
|
|
123
|
+
!isBoundedText(value.target, 256) ||
|
|
124
|
+
!isBoundedText(value.senderId, 256) ||
|
|
125
|
+
!isBoundedText(value.sessionId, MAX_ID_LENGTH) ||
|
|
126
|
+
!isBoundedText(value.sessionTitle, MAX_TITLE_LENGTH) ||
|
|
127
|
+
!isMode(value.mode) ||
|
|
128
|
+
!isOrigin(value.origin) ||
|
|
129
|
+
(value.status !== "active" && value.status !== "suspended") ||
|
|
130
|
+
createdAt === undefined ||
|
|
131
|
+
updatedAt === undefined ||
|
|
132
|
+
revision === undefined) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if (value.suspendedReason !== undefined && !isSuspendReason(value.suspendedReason)) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const lastInboundAt = value.lastInboundAt === undefined ? undefined : safeInteger(value.lastInboundAt);
|
|
139
|
+
if (value.lastInboundAt !== undefined && lastInboundAt === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
const expiresAt = value.expiresAt === undefined ? undefined : safeInteger(value.expiresAt);
|
|
142
|
+
if (value.expiresAt !== undefined && expiresAt === undefined)
|
|
143
|
+
return undefined;
|
|
144
|
+
const stalePromptAt = value.stalePromptAt === undefined ? undefined : safeInteger(value.stalePromptAt);
|
|
145
|
+
if (value.stalePromptAt !== undefined && stalePromptAt === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
return {
|
|
148
|
+
schemaVersion: CONVERSATION_SESSION_ROUTE_SCHEMA_VERSION,
|
|
149
|
+
id: value.id,
|
|
150
|
+
routeKey: value.routeKey,
|
|
151
|
+
channel: value.channel,
|
|
152
|
+
target: value.target,
|
|
153
|
+
senderId: value.senderId,
|
|
154
|
+
sessionId: value.sessionId,
|
|
155
|
+
sessionTitle: value.sessionTitle,
|
|
156
|
+
mode: value.mode,
|
|
157
|
+
origin: value.origin,
|
|
158
|
+
status: value.status,
|
|
159
|
+
...(value.suspendedReason ? { suspendedReason: value.suspendedReason } : {}),
|
|
160
|
+
revision,
|
|
161
|
+
createdAt,
|
|
162
|
+
updatedAt,
|
|
163
|
+
...(lastInboundAt !== undefined ? { lastInboundAt } : {}),
|
|
164
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
165
|
+
...(stalePromptAt !== undefined ? { stalePromptAt } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Upgrade a route to `bound`: this conversation now talks to the Session. */
|
|
169
|
+
export function enterConversationSessionRoute(route, now) {
|
|
170
|
+
return {
|
|
171
|
+
...route,
|
|
172
|
+
mode: "bound",
|
|
173
|
+
origin: "enter",
|
|
174
|
+
status: "active",
|
|
175
|
+
suspendedReason: undefined,
|
|
176
|
+
revision: route.revision + 1,
|
|
177
|
+
updatedAt: now,
|
|
178
|
+
expiresAt: now + BOUND_ROUTE_IDLE_EXPIRY_MS,
|
|
179
|
+
stalePromptAt: undefined,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Downgrade to `notify` instead of deleting. The user stops chatting with the
|
|
184
|
+
* Session but still gets told when it finishes — that is what makes Mimi a
|
|
185
|
+
* front desk rather than a switch.
|
|
186
|
+
*/
|
|
187
|
+
export function leaveConversationSessionRoute(route, now, reason) {
|
|
188
|
+
return {
|
|
189
|
+
...route,
|
|
190
|
+
mode: "notify",
|
|
191
|
+
status: reason === "suspended" ? route.status : "active",
|
|
192
|
+
revision: route.revision + 1,
|
|
193
|
+
updatedAt: now,
|
|
194
|
+
expiresAt: undefined,
|
|
195
|
+
stalePromptAt: undefined,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Mark a route unusable. The caller then routes the message back to Mimi. */
|
|
199
|
+
export function suspendConversationSessionRoute(route, now, reason) {
|
|
200
|
+
return {
|
|
201
|
+
...route,
|
|
202
|
+
mode: "notify",
|
|
203
|
+
status: "suspended",
|
|
204
|
+
suspendedReason: reason,
|
|
205
|
+
revision: route.revision + 1,
|
|
206
|
+
updatedAt: now,
|
|
207
|
+
expiresAt: undefined,
|
|
208
|
+
stalePromptAt: undefined,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export function recordConversationSessionRouteInbound(route, now) {
|
|
212
|
+
return {
|
|
213
|
+
...route,
|
|
214
|
+
revision: route.revision + 1,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
lastInboundAt: now,
|
|
217
|
+
...(route.mode === "bound" ? { expiresAt: now + BOUND_ROUTE_IDLE_EXPIRY_MS } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export function markConversationSessionRouteStalePrompted(route, now) {
|
|
221
|
+
return { ...route, revision: route.revision + 1, updatedAt: now, stalePromptAt: now };
|
|
222
|
+
}
|
|
223
|
+
export function isConversationSessionRouteExpired(route, now) {
|
|
224
|
+
if (route.mode !== "bound")
|
|
225
|
+
return false;
|
|
226
|
+
return route.expiresAt !== undefined && now >= route.expiresAt;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* True when the user should get one reminder that they are still inside a
|
|
230
|
+
* Session before this message is delivered to it. Sent at most once per quiet
|
|
231
|
+
* period so an active conversation is never nagged.
|
|
232
|
+
*/
|
|
233
|
+
export function shouldPromptConversationSessionRouteStale(route, now) {
|
|
234
|
+
if (route.mode !== "bound" || route.status !== "active")
|
|
235
|
+
return false;
|
|
236
|
+
const since = route.lastInboundAt ?? route.createdAt;
|
|
237
|
+
if (now - since < BOUND_ROUTE_STALE_PROMPT_MS)
|
|
238
|
+
return false;
|
|
239
|
+
return route.stalePromptAt === undefined || route.stalePromptAt <= since;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The bound route for a conversation, if it is still usable right now.
|
|
243
|
+
* Expiry is evaluated at read time so a route that aged out while the app was
|
|
244
|
+
* closed never captures the next message.
|
|
245
|
+
*/
|
|
246
|
+
export function activeBoundRoute(routes, routeKey, now) {
|
|
247
|
+
return routes.find((route) => route.routeKey === routeKey &&
|
|
248
|
+
route.mode === "bound" &&
|
|
249
|
+
route.status === "active" &&
|
|
250
|
+
!isConversationSessionRouteExpired(route, now));
|
|
251
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything about a conversation that has entered a Work Session.
|
|
3
|
+
*
|
|
4
|
+
* These four modules are one feature and are always consumed together, so the
|
|
5
|
+
* package barrel re-exports this file rather than each of them: the durable
|
|
6
|
+
* route record, the chat-facing Session list, the receipt Mimi reads after a
|
|
7
|
+
* visit, and the deterministic commands that must work without a model.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./conversation-session-route.js";
|
|
10
|
+
export * from "./im-session-list.js";
|
|
11
|
+
export * from "./session-visit-receipt.js";
|
|
12
|
+
export * from "./bound-session-commands.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything about a conversation that has entered a Work Session.
|
|
3
|
+
*
|
|
4
|
+
* These four modules are one feature and are always consumed together, so the
|
|
5
|
+
* package barrel re-exports this file rather than each of them: the durable
|
|
6
|
+
* route record, the chat-facing Session list, the receipt Mimi reads after a
|
|
7
|
+
* visit, and the deterministic commands that must work without a model.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./conversation-session-route.js";
|
|
10
|
+
export * from "./im-session-list.js";
|
|
11
|
+
export * from "./session-visit-receipt.js";
|
|
12
|
+
export * from "./bound-session-commands.js";
|
package/dist/delegate-work.js
CHANGED
|
@@ -126,10 +126,19 @@ export async function delegateWorkTool(args, ctx) {
|
|
|
126
126
|
? reusableSessions.find((candidate) => candidate?.id === reusableSessionId)
|
|
127
127
|
: undefined;
|
|
128
128
|
if (reusableSessionId && !reusableSession) {
|
|
129
|
-
return `Error: unknown session_id ${JSON.stringify(reusableSessionId)}.
|
|
129
|
+
return (`Error: unknown session_id ${JSON.stringify(reusableSessionId)}. ` +
|
|
130
|
+
"Do not send it again. Omit session_id to start a new Session, or copy one exact id " +
|
|
131
|
+
"from the reusable Session list above.");
|
|
130
132
|
}
|
|
131
133
|
if (reusableSession && reusableSession.workspaceId !== workspaceId) {
|
|
132
|
-
|
|
134
|
+
// A bare "does not belong" is a dead end: the model has no action to take
|
|
135
|
+
// and re-sends the same pair. Name both halves and the two ways out, so
|
|
136
|
+
// the next call is a correction rather than a repeat.
|
|
137
|
+
return (`Error: session_id ${JSON.stringify(reusableSessionId)} belongs to Workspace ` +
|
|
138
|
+
`${JSON.stringify(reusableSession.workspaceId)}, not ${JSON.stringify(workspaceId)}. ` +
|
|
139
|
+
"Do not send this pair again. Either omit session_id to start a new Session in " +
|
|
140
|
+
`${JSON.stringify(workspaceId)}, or set workspace_id to ` +
|
|
141
|
+
`${JSON.stringify(reusableSession.workspaceId)} if you meant to continue that Session.`);
|
|
133
142
|
}
|
|
134
143
|
const decision = services.requestPetWorkDelegation({
|
|
135
144
|
workspaceId,
|
package/dist/host-actions.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* executor instead of a bespoke wire per feature.
|
|
8
8
|
*/
|
|
9
9
|
import type { ToolContext, ToolDefinition, ToolVisibilityContext } from "@cjhyy/code-shell-core/extension";
|
|
10
|
-
export declare const PET_HOST_ACTION_KINDS: readonly ["mobileRemote", "longTaskControl", "memory", "gatewayReply", "followUpMutation", "sessionWatch", "sessionArchive", "outboundMessage"];
|
|
10
|
+
export declare const PET_HOST_ACTION_KINDS: readonly ["mobileRemote", "longTaskControl", "memory", "gatewayReply", "followUpMutation", "sessionWatch", "sessionArchive", "sessionBind", "outboundMessage"];
|
|
11
11
|
export type PetHostActionKind = (typeof PET_HOST_ACTION_KINDS)[number];
|
|
12
12
|
export declare function isPetHostActionKind(value: unknown): value is PetHostActionKind;
|
|
13
13
|
export interface PetHostActionRequest {
|
|
@@ -17,6 +17,13 @@ export interface PetHostActionRequest {
|
|
|
17
17
|
export interface PetHostActionDecision {
|
|
18
18
|
ok: boolean;
|
|
19
19
|
error?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Why the host refused, as a code rather than prose. `already_accepted` means
|
|
22
|
+
* this kind was already recorded for the turn, so the work is done and the
|
|
23
|
+
* turn should end; every other refusal leaves the turn open. Callers branch
|
|
24
|
+
* on this, never on `error` — that text is for the model to read.
|
|
25
|
+
*/
|
|
26
|
+
reason?: "already_accepted";
|
|
20
27
|
}
|
|
21
28
|
export type PetHostActionService = (request: PetHostActionRequest) => PetHostActionDecision;
|
|
22
29
|
export type PetGatewayReplyAttachmentKind = "image" | "file" | "audio" | "video";
|
package/dist/host-actions.js
CHANGED
|
@@ -7,6 +7,7 @@ export const PET_HOST_ACTION_KINDS = [
|
|
|
7
7
|
"followUpMutation",
|
|
8
8
|
"sessionWatch",
|
|
9
9
|
"sessionArchive",
|
|
10
|
+
"sessionBind",
|
|
10
11
|
"outboundMessage",
|
|
11
12
|
];
|
|
12
13
|
export function isPetHostActionKind(value) {
|
|
@@ -289,12 +290,28 @@ export async function gatewayReplyTool(args, ctx) {
|
|
|
289
290
|
}
|
|
290
291
|
}
|
|
291
292
|
const decision = request({ kind: "gatewayReply", payload });
|
|
292
|
-
if (!decision.ok)
|
|
293
|
+
if (!decision.ok) {
|
|
294
|
+
// A repeat call means the authoritative reply is ALREADY recorded, so the
|
|
295
|
+
// turn has nothing left to do. Returning only an error string let the model
|
|
296
|
+
// reword and retry indefinitely — real WeChat transcripts show 20+ calls in
|
|
297
|
+
// three minutes, degrading into "我在,卡密sama。继续发任务即可。". Re-raise
|
|
298
|
+
// the reply barrier so the turn loop stops this round; the host's
|
|
299
|
+
// per-turn dedupe still prevents a second delivery.
|
|
300
|
+
//
|
|
301
|
+
// Only for the repeat case: a genuine rejection (closed route, bad
|
|
302
|
+
// capability) delivered nothing, so that turn must stay open for the model
|
|
303
|
+
// to report the failure.
|
|
304
|
+
if (decision.reason === "already_accepted") {
|
|
305
|
+
ctx?.runYield?.request("reply_committed");
|
|
306
|
+
}
|
|
293
307
|
return `Error: ${decision.error ?? "Gateway reply was rejected"}`;
|
|
308
|
+
}
|
|
309
|
+
ctx?.runYield?.request("reply_committed");
|
|
294
310
|
return ("ACCEPTED EXACTLY ONCE — NOT SENT YET. The Gateway reply was recorded for host validation " +
|
|
295
|
-
"after this turn. End the turn now without producing any assistant text
|
|
296
|
-
"
|
|
297
|
-
"
|
|
311
|
+
"after this turn. End the turn now without producing any assistant text. If a later user " +
|
|
312
|
+
"message is injected before the turn ends, call GatewayReply once with a complete replacement " +
|
|
313
|
+
"for the merged inputs; otherwise do not call it again. Never repeat the user-facing reply or " +
|
|
314
|
+
"claim sent, attached, or delivered. The host will supply the only visible reply.");
|
|
298
315
|
}
|
|
299
316
|
// ---------------------------------------------------------------------------
|
|
300
317
|
// ControlLongTask
|