@ctliz/agent-intercom-pi 0.11.0-connect.2
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/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +26 -0
- package/README.md +693 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/banner.png +0 -0
- package/boss-team-scope.ts +241 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +460 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +140 -0
- package/broker/boss-adapter.ts +383 -0
- package/broker/broker-impl.ts +2019 -0
- package/broker/broker.ts +7 -0
- package/broker/client.ts +932 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/protected-service.ts +158 -0
- package/broker/registration.ts +173 -0
- package/broker/spawn.ts +448 -0
- package/config.ts +183 -0
- package/control.ts +150 -0
- package/durable-json.ts +25 -0
- package/inbound-inbox.ts +296 -0
- package/index.ts +2826 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +72 -0
- package/protocol-v4/contract.ts +39 -0
- package/provider/provider.mjs +34 -0
- package/reply-tracker.ts +231 -0
- package/skills/pi-intercom/SKILL.md +537 -0
- package/team.ts +192 -0
- package/types.ts +183 -0
- package/ui/compose.ts +229 -0
- package/ui/inline-message.ts +126 -0
- package/ui/session-identity.ts +55 -0
- package/ui/session-list.ts +205 -0
- package/ui/timestamps.ts +50 -0
package/control.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { types as nodeUtilTypes } from "node:util";
|
|
2
|
+
import type { BossControlEnvelope } from "@ctliz/agent-intercom-core/boss";
|
|
3
|
+
import { parseBossControl } from "./broker/boss-adapter.ts";
|
|
4
|
+
|
|
5
|
+
export const INTERCOM_CONTROL_REGISTER_EVENT = "intercom:control:register";
|
|
6
|
+
export const INTERCOM_CONTROL_SEND_EVENT = "intercom:control:send";
|
|
7
|
+
export const INTERCOM_CONTROL_RECEIVED_EVENT = "intercom:control";
|
|
8
|
+
export const INTERCOM_CONTROL_DELIVERY_EVENT = "intercom:control:delivery";
|
|
9
|
+
|
|
10
|
+
export const MAX_CONTROL_TYPE_LENGTH = 128;
|
|
11
|
+
export const MAX_CONTROL_DATA_BYTES = 16 * 1024;
|
|
12
|
+
|
|
13
|
+
export interface IntercomControlEnvelope {
|
|
14
|
+
type: string;
|
|
15
|
+
version: number;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type IntercomCommonControlEnvelope = IntercomControlEnvelope | BossControlEnvelope;
|
|
20
|
+
|
|
21
|
+
export interface IntercomControlRegistration {
|
|
22
|
+
type: string;
|
|
23
|
+
version: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IntercomControlSendRequest {
|
|
27
|
+
requestId: string;
|
|
28
|
+
to: string;
|
|
29
|
+
control: IntercomCommonControlEnvelope;
|
|
30
|
+
fallbackText?: string;
|
|
31
|
+
messageId?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IntercomControlReceivedEvent {
|
|
35
|
+
from: {
|
|
36
|
+
id: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
cwd: string;
|
|
39
|
+
model: string;
|
|
40
|
+
origin?: "local" | "remote";
|
|
41
|
+
parentSessionId?: string;
|
|
42
|
+
rootSessionId?: string;
|
|
43
|
+
};
|
|
44
|
+
messageId: string;
|
|
45
|
+
receivedAt: number;
|
|
46
|
+
control: IntercomCommonControlEnvelope;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IntercomControlDeliveryEvent {
|
|
50
|
+
requestId: string;
|
|
51
|
+
delivered: boolean;
|
|
52
|
+
targetSessionId?: string;
|
|
53
|
+
messageId?: string;
|
|
54
|
+
deliveryId?: string;
|
|
55
|
+
code?: string;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
60
|
+
return typeof value === "object"
|
|
61
|
+
&& value !== null
|
|
62
|
+
&& !nodeUtilTypes.isProxy(value)
|
|
63
|
+
&& !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isPlainDataWrapper(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
if (!isObject(value) || Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
68
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
69
|
+
if (typeof key !== "string") return false;
|
|
70
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
71
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value")) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dataValue(record: Record<string, unknown>, key: string): unknown {
|
|
77
|
+
return Object.getOwnPropertyDescriptor(record, key)?.value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isIntercomControlEnvelope(value: unknown): value is IntercomControlEnvelope {
|
|
81
|
+
if (!isObject(value)) return false;
|
|
82
|
+
if (
|
|
83
|
+
typeof value.type !== "string"
|
|
84
|
+
|| value.type.length === 0
|
|
85
|
+
|| value.type.length > MAX_CONTROL_TYPE_LENGTH
|
|
86
|
+
|| !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(value.type)
|
|
87
|
+
) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isSafeInteger(value.version) || (value.version as number) < 1) return false;
|
|
91
|
+
if (value.data === undefined) return true;
|
|
92
|
+
try {
|
|
93
|
+
const encoded = JSON.stringify(value.data);
|
|
94
|
+
return encoded !== undefined && Buffer.byteLength(encoded, "utf-8") <= MAX_CONTROL_DATA_BYTES;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isIntercomCommonControlEnvelope(value: unknown): value is IntercomCommonControlEnvelope {
|
|
101
|
+
if (typeof value === "object" && value !== null && nodeUtilTypes.isProxy(value)) return false;
|
|
102
|
+
if (!isObject(value)) return false;
|
|
103
|
+
try {
|
|
104
|
+
if (typeof value.type === "string" && value.type.startsWith("boss.")) {
|
|
105
|
+
return parseBossControl(value) !== null;
|
|
106
|
+
}
|
|
107
|
+
return isIntercomControlEnvelope(value);
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseIntercomControlRegistration(value: unknown): IntercomControlRegistration | null {
|
|
114
|
+
if (!isObject(value)) return null;
|
|
115
|
+
const candidate = { type: value.type, version: value.version };
|
|
116
|
+
return isIntercomControlEnvelope(candidate) ? candidate : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseIntercomControlSendRequest(value: unknown): IntercomControlSendRequest | null {
|
|
120
|
+
if (!isPlainDataWrapper(value)) return null;
|
|
121
|
+
const requestId = dataValue(value, "requestId");
|
|
122
|
+
const to = dataValue(value, "to");
|
|
123
|
+
const control = dataValue(value, "control");
|
|
124
|
+
const fallbackTextValue = dataValue(value, "fallbackText");
|
|
125
|
+
const messageIdValue = dataValue(value, "messageId");
|
|
126
|
+
if (
|
|
127
|
+
typeof requestId !== "string"
|
|
128
|
+
|| requestId.trim().length === 0
|
|
129
|
+
|| typeof to !== "string"
|
|
130
|
+
|| to.trim().length === 0
|
|
131
|
+
|| !isIntercomCommonControlEnvelope(control)
|
|
132
|
+
) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
if (fallbackTextValue !== undefined && typeof fallbackTextValue !== "string") return null;
|
|
136
|
+
if (messageIdValue !== undefined && (typeof messageIdValue !== "string" || messageIdValue.length === 0)) return null;
|
|
137
|
+
const fallbackText = typeof fallbackTextValue === "string" ? fallbackTextValue : undefined;
|
|
138
|
+
const messageId = typeof messageIdValue === "string" ? messageIdValue : undefined;
|
|
139
|
+
return {
|
|
140
|
+
requestId,
|
|
141
|
+
to,
|
|
142
|
+
control,
|
|
143
|
+
...(fallbackText !== undefined ? { fallbackText } : {}),
|
|
144
|
+
...(messageId !== undefined ? { messageId } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function intercomControlKey(control: Pick<IntercomControlEnvelope, "type" | "version">): string {
|
|
149
|
+
return `${control.type}@${control.version}`;
|
|
150
|
+
}
|
package/durable-json.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from "fs";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
import { INTERCOM_RUNTIME_FILE_MODE, restrictIntercomRuntimeFile } from "./broker/paths.ts";
|
|
5
|
+
|
|
6
|
+
export function writeDurableJson(filePath: string, value: unknown): void {
|
|
7
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
8
|
+
writeFileSync(temporaryPath, JSON.stringify(value), { encoding: "utf-8", mode: INTERCOM_RUNTIME_FILE_MODE });
|
|
9
|
+
const fileDescriptor = openSync(temporaryPath, "r");
|
|
10
|
+
try {
|
|
11
|
+
fsyncSync(fileDescriptor);
|
|
12
|
+
} finally {
|
|
13
|
+
closeSync(fileDescriptor);
|
|
14
|
+
}
|
|
15
|
+
renameSync(temporaryPath, filePath);
|
|
16
|
+
restrictIntercomRuntimeFile(filePath);
|
|
17
|
+
if (process.platform !== "win32") {
|
|
18
|
+
const directoryDescriptor = openSync(dirname(filePath), "r");
|
|
19
|
+
try {
|
|
20
|
+
fsyncSync(directoryDescriptor);
|
|
21
|
+
} finally {
|
|
22
|
+
closeSync(directoryDescriptor);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/inbound-inbox.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
} from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import {
|
|
11
|
+
ensureIntercomRuntimeDir,
|
|
12
|
+
getIntercomDirPath,
|
|
13
|
+
INTERCOM_DIR_MODE,
|
|
14
|
+
restrictIntercomRuntimeFile,
|
|
15
|
+
} from "./broker/paths.ts";
|
|
16
|
+
import { writeDurableJson } from "./durable-json.ts";
|
|
17
|
+
import type { Message, SessionInfo } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
const INBOX_STATE_VERSION = 3;
|
|
20
|
+
const MAX_REMEMBERED_MESSAGE_KEYS = 4096;
|
|
21
|
+
|
|
22
|
+
export interface StoredInboundMessage {
|
|
23
|
+
key: string;
|
|
24
|
+
from: SessionInfo;
|
|
25
|
+
message: Message;
|
|
26
|
+
receivedAt: number;
|
|
27
|
+
sequence: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface InboxState {
|
|
31
|
+
version: typeof INBOX_STATE_VERSION;
|
|
32
|
+
nextSequence: number;
|
|
33
|
+
entries: StoredInboundMessage[];
|
|
34
|
+
pendingAsks: StoredInboundMessage[];
|
|
35
|
+
seen: string[];
|
|
36
|
+
fingerprints: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface EnqueueResult {
|
|
40
|
+
duplicate: boolean;
|
|
41
|
+
entry: StoredInboundMessage;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class InboundMessageConflictError extends Error {
|
|
45
|
+
constructor(readonly messageId: string) {
|
|
46
|
+
super(`Conflicting payload for reused message ID "${messageId}"`);
|
|
47
|
+
this.name = "InboundMessageConflictError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function messageKey(from: SessionInfo, message: Message): string {
|
|
52
|
+
return `${from.id}\u0000${message.id}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function messageFingerprint(message: Message): string {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
replyTo: message.replyTo,
|
|
58
|
+
expectsReply: message.expectsReply,
|
|
59
|
+
content: message.content,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isStoredInboundMessage(value: unknown): value is StoredInboundMessage {
|
|
68
|
+
if (!isObject(value) || typeof value.key !== "string" || typeof value.receivedAt !== "number" || typeof value.sequence !== "number") {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (!isObject(value.from) || typeof value.from.id !== "string" || typeof value.from.cwd !== "string") {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!isObject(value.message) || typeof value.message.id !== "string" || !isObject(value.message.content) || typeof value.message.content.text !== "string") {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return value.key === messageKey(value.from as unknown as SessionInfo, value.message as unknown as Message);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function emptyState(): InboxState {
|
|
81
|
+
return { version: INBOX_STATE_VERSION, nextSequence: 1, entries: [], pendingAsks: [], seen: [], fingerprints: {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseState(raw: string): InboxState {
|
|
85
|
+
const parsed: unknown = JSON.parse(raw);
|
|
86
|
+
if (
|
|
87
|
+
!isObject(parsed)
|
|
88
|
+
|| (parsed.version !== 1 && parsed.version !== 2 && parsed.version !== INBOX_STATE_VERSION)
|
|
89
|
+
|| typeof parsed.nextSequence !== "number"
|
|
90
|
+
|| !Number.isSafeInteger(parsed.nextSequence)
|
|
91
|
+
|| parsed.nextSequence < 1
|
|
92
|
+
|| !Array.isArray(parsed.entries)
|
|
93
|
+
|| !parsed.entries.every(isStoredInboundMessage)
|
|
94
|
+
|| !Array.isArray(parsed.seen)
|
|
95
|
+
|| !parsed.seen.every((key) => typeof key === "string")
|
|
96
|
+
) {
|
|
97
|
+
throw new Error("Invalid persistent inbound inbox state");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entries = [...parsed.entries].sort((a, b) => a.sequence - b.sequence);
|
|
101
|
+
const pendingAsks = parsed.version === 1
|
|
102
|
+
? entries.filter((entry) => entry.message.expectsReply)
|
|
103
|
+
: parsed.pendingAsks;
|
|
104
|
+
if (!Array.isArray(pendingAsks) || !pendingAsks.every(isStoredInboundMessage)) {
|
|
105
|
+
throw new Error("Invalid persistent inbound pending-ask state");
|
|
106
|
+
}
|
|
107
|
+
const uniquePendingAsks = Array.from(new Map(pendingAsks.map((entry) => [entry.key, entry])).values())
|
|
108
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
109
|
+
const seen = Array.from(new Set([
|
|
110
|
+
...parsed.seen,
|
|
111
|
+
...entries.map((entry) => entry.key),
|
|
112
|
+
...uniquePendingAsks.map((entry) => entry.key),
|
|
113
|
+
]));
|
|
114
|
+
const fingerprints: Record<string, string> = {};
|
|
115
|
+
if (parsed.version === INBOX_STATE_VERSION) {
|
|
116
|
+
if (!isObject(parsed.fingerprints) || !Object.values(parsed.fingerprints).every((value) => typeof value === "string")) {
|
|
117
|
+
throw new Error("Invalid persistent inbound fingerprint state");
|
|
118
|
+
}
|
|
119
|
+
Object.assign(fingerprints, parsed.fingerprints);
|
|
120
|
+
}
|
|
121
|
+
for (const entry of [...entries, ...uniquePendingAsks]) {
|
|
122
|
+
fingerprints[entry.key] = messageFingerprint(entry.message);
|
|
123
|
+
}
|
|
124
|
+
const nextSequence = Math.max(parsed.nextSequence, ...entries.map((entry) => entry.sequence + 1), 1);
|
|
125
|
+
return { version: INBOX_STATE_VERSION, nextSequence, entries, pendingAsks: uniquePendingAsks, seen, fingerprints };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sessionInboxFileName(sessionId: string): string {
|
|
129
|
+
const digest = createHash("sha256").update(sessionId).digest("hex");
|
|
130
|
+
return `${digest}.json`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sessionInboxFilePath(sessionId: string, intercomDir: string): string {
|
|
134
|
+
return join(intercomDir, "inbox", sessionInboxFileName(sessionId));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reads another session's current-format pending asks without creating,
|
|
139
|
+
* migrating, rewriting, or quarantining its inbox file.
|
|
140
|
+
*/
|
|
141
|
+
export function readPendingAsksSnapshot(
|
|
142
|
+
sessionId: string,
|
|
143
|
+
intercomDir: string = getIntercomDirPath(),
|
|
144
|
+
): StoredInboundMessage[] {
|
|
145
|
+
const filePath = sessionInboxFilePath(sessionId, intercomDir);
|
|
146
|
+
let raw: string;
|
|
147
|
+
try {
|
|
148
|
+
raw = readFileSync(filePath, "utf-8");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parsed: unknown = JSON.parse(raw);
|
|
155
|
+
if (!isObject(parsed) || parsed.version !== INBOX_STATE_VERSION) {
|
|
156
|
+
throw new Error("Unsupported persistent inbound inbox version for read-only inspection");
|
|
157
|
+
}
|
|
158
|
+
return parseState(raw).pendingAsks.map((entry) => ({ ...entry }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class PersistentInboundInbox {
|
|
162
|
+
private readonly filePath: string;
|
|
163
|
+
private state: InboxState;
|
|
164
|
+
private readonly seen = new Set<string>();
|
|
165
|
+
|
|
166
|
+
constructor(sessionId: string, intercomDir: string = getIntercomDirPath()) {
|
|
167
|
+
ensureIntercomRuntimeDir(intercomDir);
|
|
168
|
+
const inboxDir = join(intercomDir, "inbox");
|
|
169
|
+
mkdirSync(inboxDir, { recursive: true, mode: INTERCOM_DIR_MODE });
|
|
170
|
+
if (process.platform !== "win32") chmodSync(inboxDir, INTERCOM_DIR_MODE);
|
|
171
|
+
this.filePath = sessionInboxFilePath(sessionId, intercomDir);
|
|
172
|
+
this.state = this.loadState();
|
|
173
|
+
for (const key of this.state.seen) this.seen.add(key);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get size(): number {
|
|
177
|
+
return this.state.entries.length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
list(): StoredInboundMessage[] {
|
|
181
|
+
return this.state.entries.map((entry) => ({ ...entry }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
listPendingAsks(): StoredInboundMessage[] {
|
|
185
|
+
return this.state.pendingAsks.map((entry) => ({ ...entry }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
enqueue(from: SessionInfo, message: Message, receivedAt = Date.now()): EnqueueResult {
|
|
189
|
+
const key = messageKey(from, message);
|
|
190
|
+
const fingerprint = messageFingerprint(message);
|
|
191
|
+
const existing = this.state.entries.find((entry) => entry.key === key);
|
|
192
|
+
if (existing) {
|
|
193
|
+
if (messageFingerprint(existing.message) !== fingerprint) {
|
|
194
|
+
throw new InboundMessageConflictError(message.id);
|
|
195
|
+
}
|
|
196
|
+
return { duplicate: true, entry: { ...existing } };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (this.seen.has(key)) {
|
|
200
|
+
const previousFingerprint = this.state.fingerprints[key];
|
|
201
|
+
if (previousFingerprint !== undefined && previousFingerprint !== fingerprint) {
|
|
202
|
+
throw new InboundMessageConflictError(message.id);
|
|
203
|
+
}
|
|
204
|
+
if (previousFingerprint === undefined) {
|
|
205
|
+
this.state.fingerprints[key] = fingerprint;
|
|
206
|
+
this.persist();
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
duplicate: true,
|
|
210
|
+
entry: { key, from, message, receivedAt, sequence: -1 },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const entry: StoredInboundMessage = {
|
|
215
|
+
key,
|
|
216
|
+
from,
|
|
217
|
+
message,
|
|
218
|
+
receivedAt,
|
|
219
|
+
sequence: this.state.nextSequence,
|
|
220
|
+
};
|
|
221
|
+
this.state.nextSequence += 1;
|
|
222
|
+
this.state.entries.push(entry);
|
|
223
|
+
if (message.expectsReply) this.state.pendingAsks.push(entry);
|
|
224
|
+
this.state.seen.push(key);
|
|
225
|
+
this.state.fingerprints[key] = fingerprint;
|
|
226
|
+
this.seen.add(key);
|
|
227
|
+
this.trimSeen();
|
|
228
|
+
this.persist();
|
|
229
|
+
return { duplicate: false, entry: { ...entry } };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
consume(keys: readonly string[]): void {
|
|
233
|
+
if (keys.length === 0) return;
|
|
234
|
+
const consumed = new Set(keys);
|
|
235
|
+
const remaining = this.state.entries.filter((entry) => !consumed.has(entry.key));
|
|
236
|
+
if (remaining.length === this.state.entries.length) return;
|
|
237
|
+
this.state.entries = remaining;
|
|
238
|
+
this.trimSeen();
|
|
239
|
+
this.persist();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
dismissPendingAsk(messageId: string, fromSessionId?: string): void {
|
|
243
|
+
const remaining = this.state.pendingAsks.filter((entry) =>
|
|
244
|
+
entry.message.id !== messageId || (fromSessionId !== undefined && entry.from.id !== fromSessionId)
|
|
245
|
+
);
|
|
246
|
+
if (remaining.length === this.state.pendingAsks.length) return;
|
|
247
|
+
this.state.pendingAsks = remaining;
|
|
248
|
+
this.trimSeen();
|
|
249
|
+
this.persist();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
prunePendingAsks(timeoutMs: number, now = Date.now()): void {
|
|
253
|
+
const remaining = this.state.pendingAsks.filter((entry) => now - entry.receivedAt <= timeoutMs);
|
|
254
|
+
if (remaining.length === this.state.pendingAsks.length) return;
|
|
255
|
+
this.state.pendingAsks = remaining;
|
|
256
|
+
this.trimSeen();
|
|
257
|
+
this.persist();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private loadState(): InboxState {
|
|
261
|
+
if (!existsSync(this.filePath)) return emptyState();
|
|
262
|
+
try {
|
|
263
|
+
return parseState(readFileSync(this.filePath, "utf-8"));
|
|
264
|
+
} catch {
|
|
265
|
+
const corruptPath = `${this.filePath}.corrupt-${Date.now()}`;
|
|
266
|
+
renameSync(this.filePath, corruptPath);
|
|
267
|
+
restrictIntercomRuntimeFile(corruptPath);
|
|
268
|
+
return emptyState();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private trimSeen(): void {
|
|
273
|
+
if (this.state.seen.length <= MAX_REMEMBERED_MESSAGE_KEYS) return;
|
|
274
|
+
const pendingKeys = new Set([
|
|
275
|
+
...this.state.entries.map((entry) => entry.key),
|
|
276
|
+
...this.state.pendingAsks.map((entry) => entry.key),
|
|
277
|
+
]);
|
|
278
|
+
const retainedRecent: string[] = [];
|
|
279
|
+
const recentLimit = Math.max(0, MAX_REMEMBERED_MESSAGE_KEYS - pendingKeys.size);
|
|
280
|
+
for (let index = this.state.seen.length - 1; index >= 0 && retainedRecent.length < recentLimit; index -= 1) {
|
|
281
|
+
const key = this.state.seen[index]!;
|
|
282
|
+
if (!pendingKeys.has(key)) retainedRecent.push(key);
|
|
283
|
+
}
|
|
284
|
+
this.state.seen = [...pendingKeys, ...retainedRecent.reverse()];
|
|
285
|
+
const retainedKeys = new Set(this.state.seen);
|
|
286
|
+
for (const key of Object.keys(this.state.fingerprints)) {
|
|
287
|
+
if (!retainedKeys.has(key)) delete this.state.fingerprints[key];
|
|
288
|
+
}
|
|
289
|
+
this.seen.clear();
|
|
290
|
+
for (const key of this.state.seen) this.seen.add(key);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private persist(): void {
|
|
294
|
+
writeDurableJson(this.filePath, this.state);
|
|
295
|
+
}
|
|
296
|
+
}
|