@joe-broadhead/messaging 0.0.0-stage → 0.7.1
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/CHANGELOG.md +220 -0
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +36 -2
- package/dist/bin/messaging-mcp.js +26524 -0
- package/dist/bin/messaging-relay.js +19768 -0
- package/dist/bin/messaging.js +15465 -0
- package/dist/bin/messagingd.js +29449 -0
- package/dist/lib/core-testing.js +1 -0
- package/dist/lib/core.js +1 -0
- package/dist/lib/ipc-testing.js +1 -0
- package/dist/lib/ipc.js +1 -0
- package/dist/lib/modules/core/address.js +42 -0
- package/dist/lib/modules/core/agent/operations.js +595 -0
- package/dist/lib/modules/core/agent/present.js +244 -0
- package/dist/lib/modules/core/agent/text.js +79 -0
- package/dist/lib/modules/core/agent-tools.js +477 -0
- package/dist/lib/modules/core/b64u.js +20 -0
- package/dist/lib/modules/core/cards.js +55 -0
- package/dist/lib/modules/core/check.js +20 -0
- package/dist/lib/modules/core/consent.js +199 -0
- package/dist/lib/modules/core/contact-card.js +74 -0
- package/dist/lib/modules/core/conversation/context.js +80 -0
- package/dist/lib/modules/core/conversation/event.js +100 -0
- package/dist/lib/modules/core/conversation/ids.js +38 -0
- package/dist/lib/modules/core/conversation/link.js +35 -0
- package/dist/lib/modules/core/conversation/log.js +766 -0
- package/dist/lib/modules/core/conversation/ratchet.js +243 -0
- package/dist/lib/modules/core/conversation/seal.js +198 -0
- package/dist/lib/modules/core/delegation.js +174 -0
- package/dist/lib/modules/core/directory/device-log.js +258 -0
- package/dist/lib/modules/core/directory/epoch.js +103 -0
- package/dist/lib/modules/core/directory/handle.js +29 -0
- package/dist/lib/modules/core/directory/jelly-device.js +204 -0
- package/dist/lib/modules/core/directory/jelly-issuer.js +12 -0
- package/dist/lib/modules/core/directory/jelly-token.js +305 -0
- package/dist/lib/modules/core/directory/keys-statement.js +43 -0
- package/dist/lib/modules/core/directory/link.js +81 -0
- package/dist/lib/modules/core/directory/merkle-log.js +250 -0
- package/dist/lib/modules/core/directory/person-view.js +197 -0
- package/dist/lib/modules/core/directory/person.js +40 -0
- package/dist/lib/modules/core/directory/realm.js +59 -0
- package/dist/lib/modules/core/directory/smt.js +266 -0
- package/dist/lib/modules/core/envelope.js +320 -0
- package/dist/lib/modules/core/hash.js +5 -0
- package/dist/lib/modules/core/head.js +27 -0
- package/dist/lib/modules/core/identity.js +71 -0
- package/dist/lib/modules/core/index.js +53 -0
- package/dist/lib/modules/core/jcs.js +96 -0
- package/dist/lib/modules/core/jelly-host-double.js +35 -0
- package/dist/lib/modules/core/jelly-host.js +211 -0
- package/dist/lib/modules/core/keys.js +79 -0
- package/dist/lib/modules/core/peer-text.js +563 -0
- package/dist/lib/modules/core/realm.js +1 -0
- package/dist/lib/modules/core/roster.js +454 -0
- package/dist/lib/modules/core/schema/a2a.js +85 -0
- package/dist/lib/modules/core/schema/common.js +310 -0
- package/dist/lib/modules/core/schema/consent.js +120 -0
- package/dist/lib/modules/core/schema/conversation.js +292 -0
- package/dist/lib/modules/core/schema/delegation.js +58 -0
- package/dist/lib/modules/core/schema/directory-frames.js +270 -0
- package/dist/lib/modules/core/schema/directory.js +169 -0
- package/dist/lib/modules/core/schema/envelope.js +211 -0
- package/dist/lib/modules/core/schema/errors.js +104 -0
- package/dist/lib/modules/core/schema/index.js +16 -0
- package/dist/lib/modules/core/schema/ipc/consent.js +181 -0
- package/dist/lib/modules/core/schema/ipc/device.js +276 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +189 -0
- package/dist/lib/modules/core/schema/ipc/shapes.js +185 -0
- package/dist/lib/modules/core/schema/ipc/surfaces.js +223 -0
- package/dist/lib/modules/core/schema/ipc.js +451 -0
- package/dist/lib/modules/core/schema/jelly.js +122 -0
- package/dist/lib/modules/core/schema/realm.js +27 -0
- package/dist/lib/modules/core/schema/relay.js +112 -0
- package/dist/lib/modules/core/schema/roster.js +126 -0
- package/dist/lib/modules/core/sign.js +43 -0
- package/dist/lib/modules/core/task.js +37 -0
- package/dist/lib/modules/core/testing.js +21 -0
- package/dist/lib/modules/core/ulid.js +36 -0
- package/dist/lib/modules/core/version.js +6 -0
- package/dist/lib/modules/core/wire.js +255 -0
- package/dist/lib/modules/ipc/client.js +289 -0
- package/dist/lib/modules/ipc/framing.js +33 -0
- package/dist/lib/modules/ipc/index.js +6 -0
- package/dist/lib/modules/ipc/paths.js +102 -0
- package/dist/lib/modules/ipc/pipes.js +65 -0
- package/dist/lib/modules/ipc/rpc.js +8 -0
- package/dist/lib/modules/ipc/server.js +317 -0
- package/dist/lib/modules/ipc/testing.js +145 -0
- package/dist/lib/modules/ipc/trust.js +166 -0
- package/dist/lib/modules/ipc/windows-acl.js +275 -0
- package/dist/lib/modules/vendor/zod/index.js +7 -0
- package/dist/lib/modules/vendor/zod/v4/classic/checks.js +1 -0
- package/dist/lib/modules/vendor/zod/v4/classic/coerce.js +17 -0
- package/dist/lib/modules/vendor/zod/v4/classic/compat.js +31 -0
- package/dist/lib/modules/vendor/zod/v4/classic/deep-partial.js +13 -0
- package/dist/lib/modules/vendor/zod/v4/classic/errors.js +57 -0
- package/dist/lib/modules/vendor/zod/v4/classic/external.js +17 -0
- package/dist/lib/modules/vendor/zod/v4/classic/from-json-schema.js +881 -0
- package/dist/lib/modules/vendor/zod/v4/classic/in-out.js +36 -0
- package/dist/lib/modules/vendor/zod/v4/classic/iso.js +15 -0
- package/dist/lib/modules/vendor/zod/v4/classic/parse.js +16 -0
- package/dist/lib/modules/vendor/zod/v4/classic/schemas.js +1535 -0
- package/dist/lib/modules/vendor/zod/v4/core/api.js +1107 -0
- package/dist/lib/modules/vendor/zod/v4/core/checks.js +516 -0
- package/dist/lib/modules/vendor/zod/v4/core/compile.js +1866 -0
- package/dist/lib/modules/vendor/zod/v4/core/core.js +141 -0
- package/dist/lib/modules/vendor/zod/v4/core/doc.js +38 -0
- package/dist/lib/modules/vendor/zod/v4/core/errors.js +267 -0
- package/dist/lib/modules/vendor/zod/v4/core/index.js +19 -0
- package/dist/lib/modules/vendor/zod/v4/core/json-schema-generator.js +99 -0
- package/dist/lib/modules/vendor/zod/v4/core/json-schema-processors.js +843 -0
- package/dist/lib/modules/vendor/zod/v4/core/json-schema.js +1 -0
- package/dist/lib/modules/vendor/zod/v4/core/memoizer.js +311 -0
- package/dist/lib/modules/vendor/zod/v4/core/parse.js +175 -0
- package/dist/lib/modules/vendor/zod/v4/core/regexes.js +156 -0
- package/dist/lib/modules/vendor/zod/v4/core/registries.js +51 -0
- package/dist/lib/modules/vendor/zod/v4/core/schemas.js +2636 -0
- package/dist/lib/modules/vendor/zod/v4/core/to-json-schema.js +651 -0
- package/dist/lib/modules/vendor/zod/v4/core/util.js +873 -0
- package/dist/lib/modules/vendor/zod/v4/core/versions.js +5 -0
- package/dist/lib/modules/vendor/zod/v4/core/visit.js +169 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ar.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/az.js +110 -0
- package/dist/lib/modules/vendor/zod/v4/locales/be.js +168 -0
- package/dist/lib/modules/vendor/zod/v4/locales/bg.js +125 -0
- package/dist/lib/modules/vendor/zod/v4/locales/bn.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ca.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ckb.js +133 -0
- package/dist/lib/modules/vendor/zod/v4/locales/cs.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/da.js +120 -0
- package/dist/lib/modules/vendor/zod/v4/locales/de.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/el.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/en.js +125 -0
- package/dist/lib/modules/vendor/zod/v4/locales/eo.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/es.js +136 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fa.js +119 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fi.js +117 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fr-CA.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fr.js +129 -0
- package/dist/lib/modules/vendor/zod/v4/locales/gu.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/he.js +221 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hi.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hr.js +126 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hu.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hy.js +158 -0
- package/dist/lib/modules/vendor/zod/v4/locales/id.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/index.js +63 -0
- package/dist/lib/modules/vendor/zod/v4/locales/is.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/it.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ja.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ka.js +117 -0
- package/dist/lib/modules/vendor/zod/v4/locales/kh.js +5 -0
- package/dist/lib/modules/vendor/zod/v4/locales/km.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/kn.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ko.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/lt.js +207 -0
- package/dist/lib/modules/vendor/zod/v4/locales/mk.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ms.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ne.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/nl.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/nn.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/no.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ota.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/pl.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ps.js +119 -0
- package/dist/lib/modules/vendor/zod/v4/locales/pt-BR.js +143 -0
- package/dist/lib/modules/vendor/zod/v4/locales/pt.js +142 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ro.js +122 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ru.js +168 -0
- package/dist/lib/modules/vendor/zod/v4/locales/sk.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/sl.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/sv.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ta.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/tg.js +117 -0
- package/dist/lib/modules/vendor/zod/v4/locales/th.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/tk.js +107 -0
- package/dist/lib/modules/vendor/zod/v4/locales/tr.js +110 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ua.js +5 -0
- package/dist/lib/modules/vendor/zod/v4/locales/uk.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ur.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/uz.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/vi.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/yo.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/zh-CN.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/zh-TW.js +112 -0
- package/dist/lib/testing-jelly-id.js +1219 -0
- package/dist/lib/zod.js +1 -0
- package/dist/spec/messaging-v1.md +3744 -0
- package/dist/third-party-notices.md +239 -0
- package/dist/third-party.json +88 -0
- package/dist/types/core/address.d.ts +16 -0
- package/dist/types/core/agent/operations.d.ts +129 -0
- package/dist/types/core/agent/present.d.ts +57 -0
- package/dist/types/core/agent/text.d.ts +62 -0
- package/dist/types/core/agent-tools.d.ts +966 -0
- package/dist/types/core/b64u.d.ts +6 -0
- package/dist/types/core/cards.d.ts +21 -0
- package/dist/types/core/check.d.ts +10 -0
- package/dist/types/core/consent.d.ts +132 -0
- package/dist/types/core/contact-card.d.ts +34 -0
- package/dist/types/core/conversation/context.d.ts +56 -0
- package/dist/types/core/conversation/event.d.ts +36 -0
- package/dist/types/core/conversation/ids.d.ts +18 -0
- package/dist/types/core/conversation/link.d.ts +16 -0
- package/dist/types/core/conversation/log.d.ts +142 -0
- package/dist/types/core/conversation/ratchet.d.ts +95 -0
- package/dist/types/core/conversation/seal.d.ts +47 -0
- package/dist/types/core/delegation.d.ts +71 -0
- package/dist/types/core/directory/device-log.d.ts +119 -0
- package/dist/types/core/directory/epoch.d.ts +53 -0
- package/dist/types/core/directory/handle.d.ts +11 -0
- package/dist/types/core/directory/jelly-device.d.ts +88 -0
- package/dist/types/core/directory/jelly-issuer.d.ts +8 -0
- package/dist/types/core/directory/jelly-token.d.ts +116 -0
- package/dist/types/core/directory/keys-statement.d.ts +22 -0
- package/dist/types/core/directory/link.d.ts +36 -0
- package/dist/types/core/directory/merkle-log.d.ts +40 -0
- package/dist/types/core/directory/person-view.d.ts +90 -0
- package/dist/types/core/directory/person.d.ts +8 -0
- package/dist/types/core/directory/realm.d.ts +23 -0
- package/dist/types/core/directory/smt.d.ts +52 -0
- package/dist/types/core/envelope.d.ts +68 -0
- package/dist/types/core/hash.d.ts +2 -0
- package/dist/types/core/head.d.ts +34 -0
- package/dist/types/core/identity.d.ts +35 -0
- package/dist/types/core/index.d.ts +45 -0
- package/dist/types/core/jcs.d.ts +12 -0
- package/dist/types/core/jelly-host-double.d.ts +21 -0
- package/dist/types/core/jelly-host.d.ts +86 -0
- package/dist/types/core/keys.d.ts +16 -0
- package/dist/types/core/peer-text.d.ts +87 -0
- package/dist/types/core/realm.d.ts +36 -0
- package/dist/types/core/roster.d.ts +146 -0
- package/dist/types/core/schema/a2a.d.ts +251 -0
- package/dist/types/core/schema/common.d.ts +328 -0
- package/dist/types/core/schema/consent.d.ts +140 -0
- package/dist/types/core/schema/conversation.d.ts +1657 -0
- package/dist/types/core/schema/delegation.d.ts +58 -0
- package/dist/types/core/schema/directory-frames.d.ts +1301 -0
- package/dist/types/core/schema/directory.d.ts +375 -0
- package/dist/types/core/schema/envelope.d.ts +756 -0
- package/dist/types/core/schema/errors.d.ts +142 -0
- package/dist/types/core/schema/index.d.ts +16 -0
- package/dist/types/core/schema/ipc/consent.d.ts +555 -0
- package/dist/types/core/schema/ipc/device.d.ts +735 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +501 -0
- package/dist/types/core/schema/ipc/shapes.d.ts +414 -0
- package/dist/types/core/schema/ipc/surfaces.d.ts +680 -0
- package/dist/types/core/schema/ipc.d.ts +3146 -0
- package/dist/types/core/schema/jelly.d.ts +160 -0
- package/dist/types/core/schema/realm.d.ts +38 -0
- package/dist/types/core/schema/relay.d.ts +1134 -0
- package/dist/types/core/schema/roster.d.ts +347 -0
- package/dist/types/core/sign.d.ts +13 -0
- package/dist/types/core/task.d.ts +6 -0
- package/dist/types/core/testing.d.ts +19 -0
- package/dist/types/core/ulid.d.ts +5 -0
- package/dist/types/core/version.d.ts +6 -0
- package/dist/types/core/wire.d.ts +14 -0
- package/dist/types/ipc/client.d.ts +85 -0
- package/dist/types/ipc/framing.d.ts +12 -0
- package/dist/types/ipc/index.d.ts +6 -0
- package/dist/types/ipc/paths.d.ts +39 -0
- package/dist/types/ipc/pipes.d.ts +16 -0
- package/dist/types/ipc/rpc.d.ts +13 -0
- package/dist/types/ipc/server.d.ts +94 -0
- package/dist/types/ipc/testing.d.ts +89 -0
- package/dist/types/ipc/trust.d.ts +49 -0
- package/dist/types/ipc/windows-acl.d.ts +56 -0
- package/dist/types/testing/jelly-id.d.ts +518 -0
- package/dist/types/zod.d.ts +1 -0
- package/package.json +77 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./modules/core/testing.js";
|
package/dist/lib/core.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./modules/core/index.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./modules/ipc/testing.js";
|
package/dist/lib/ipc.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./modules/ipc/index.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Address, MachineLabel, PersonId, SessionName } from "./schema/common.js";
|
|
2
|
+
import { MessagingError } from "./schema/errors.js";
|
|
3
|
+
/** Parses the address grammar in `schema/common.ts`. Anything else is `invalid_request`. */
|
|
4
|
+
export function parseAddress(text) {
|
|
5
|
+
if (!Address.safeParse(text).success)
|
|
6
|
+
throw invalid();
|
|
7
|
+
const parts = text.split("/");
|
|
8
|
+
if (parts.length !== 2)
|
|
9
|
+
throw invalid();
|
|
10
|
+
const [person, rest] = parts;
|
|
11
|
+
if (!PersonId.safeParse(person).success)
|
|
12
|
+
throw invalid();
|
|
13
|
+
const at = rest.split("@");
|
|
14
|
+
if (at.length > 2)
|
|
15
|
+
throw invalid();
|
|
16
|
+
const [session, label] = at;
|
|
17
|
+
if (!SessionName.safeParse(session).success)
|
|
18
|
+
throw invalid();
|
|
19
|
+
if (label === undefined)
|
|
20
|
+
return { kind: "session", person, session };
|
|
21
|
+
if (!MachineLabel.safeParse(label).success)
|
|
22
|
+
throw invalid();
|
|
23
|
+
return { kind: "session", person, session, label };
|
|
24
|
+
}
|
|
25
|
+
export function formatAddress(first, session, label) {
|
|
26
|
+
const address = typeof first === "string"
|
|
27
|
+
? { kind: "session", person: first, session: session, ...(label === undefined ? {} : { label }) }
|
|
28
|
+
: first;
|
|
29
|
+
if (address.kind !== "session")
|
|
30
|
+
throw invalid();
|
|
31
|
+
if (!SessionName.safeParse(address.session).success)
|
|
32
|
+
throw invalid();
|
|
33
|
+
if (address.label !== undefined && !MachineLabel.safeParse(address.label).success)
|
|
34
|
+
throw invalid();
|
|
35
|
+
const text = `${address.person}/${address.session}${address.label === undefined ? "" : `@${address.label}`}`;
|
|
36
|
+
// Re-parsing checks every remaining part (person, length).
|
|
37
|
+
parseAddress(text);
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
function invalid() {
|
|
41
|
+
return new MessagingError("invalid_request", "invalid address");
|
|
42
|
+
}
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { a2aTaskState, conversationOf, decodeToolCursor, DONE_STATES, encodeToolCursor, formatToolAddress, parseToolAddress, taskStateName, TOOL_REMEDIES, } from "../agent-tools.js";
|
|
3
|
+
import { LIMITS } from "../schema/common.js";
|
|
4
|
+
import { MessagingError } from "../schema/errors.js";
|
|
5
|
+
import { canTransition, isTerminal } from "../task.js";
|
|
6
|
+
import { agentCan, conversationOfPeer, isoOf, presentAgent, presentConvEntry, presentInboxItem, presentLine, presentMail, presentSent, presentTask, presentTaskItem, presentTaskView, } from "./present.js";
|
|
7
|
+
import { compact, delegationPrompt, EXPECT_TEXT, validatedAddress } from "./text.js";
|
|
8
|
+
const DEFAULT_CONTACTS = 20;
|
|
9
|
+
const DEFAULT_INBOX = 20;
|
|
10
|
+
const DEFAULT_PAGE = 30;
|
|
11
|
+
const DEFAULT_TASKS = 20;
|
|
12
|
+
/** The daemon's largest `inbox.list` and `task.list` page. */
|
|
13
|
+
const PAGE = 100;
|
|
14
|
+
/** Pages a filtered task listing reads at most. */
|
|
15
|
+
const TASK_PAGES = 10;
|
|
16
|
+
const NOTE = {
|
|
17
|
+
line: "A line to a person: they see it in their chats, and it wakes no agent. messaging_read {with} shows when it is delivered.",
|
|
18
|
+
post: "Posted: everyone in the channel sees it, and it wakes no agent.",
|
|
19
|
+
sent: "Sent. messaging_read {id} shows how far it got.",
|
|
20
|
+
task: " Follow the task with messaging_read {id: taskId}.",
|
|
21
|
+
held: "Their person will see a request card first; you will be told when they answer. Follow it with messaging_read {id}.",
|
|
22
|
+
suggest: "Your person sees your line and your suggestion of that contact; only they can send a message request, and you are not told what they decide.",
|
|
23
|
+
};
|
|
24
|
+
/** `me`, or one's own person by name: the person's own conversation, `dm:<self>` (spec §6.8, design §5.9). */
|
|
25
|
+
function ownPerson(address, self) {
|
|
26
|
+
return address.kind === "me" || (address.kind === "person" && address.person === self);
|
|
27
|
+
}
|
|
28
|
+
/** `me` as the person it stands for, for the conversation checks and the daemon. */
|
|
29
|
+
function asPerson(address, self) {
|
|
30
|
+
return address.kind === "me" ? { kind: "person", person: self } : address;
|
|
31
|
+
}
|
|
32
|
+
export class Operations {
|
|
33
|
+
ctx;
|
|
34
|
+
constructor(ctx) {
|
|
35
|
+
this.ctx = ctx;
|
|
36
|
+
}
|
|
37
|
+
get framer() {
|
|
38
|
+
return this.ctx.framer;
|
|
39
|
+
}
|
|
40
|
+
consent(person, s) {
|
|
41
|
+
return this.ctx.consent(person, s.person);
|
|
42
|
+
}
|
|
43
|
+
/** Refuses a chat conversation this client may not reach, before any daemon call. */
|
|
44
|
+
reachable(target, field) {
|
|
45
|
+
const { access } = this.ctx;
|
|
46
|
+
if (target.kind === "person" && !access.dms)
|
|
47
|
+
fail("forbidden", `${field}: conversations with persons are not available to this client`);
|
|
48
|
+
if (target.kind === "channel" && access.channels !== undefined && !access.channels.has(target.name)) {
|
|
49
|
+
fail("forbidden", `${field}: this client may reach only the channels its user allowed: ${[...access.channels].map((name) => `#${name}`).join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ---- contacts ----
|
|
53
|
+
async contacts(sessionID, input, signal) {
|
|
54
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
55
|
+
const [status, { entries, conversations: rooms, reach }] = await Promise.all([
|
|
56
|
+
s.link.call("daemon.status", {}, signal),
|
|
57
|
+
s.link.call("contacts.list", { includeOffline: true }, signal),
|
|
58
|
+
]);
|
|
59
|
+
const others = entries.filter((entry) => entry.address !== s.address);
|
|
60
|
+
const byPerson = new Map();
|
|
61
|
+
for (const entry of others)
|
|
62
|
+
byPerson.set(entry.person, [...(byPerson.get(entry.person) ?? []), entry]);
|
|
63
|
+
const unread = new Map(rooms.map((room) => [room.conversation, room.unread]));
|
|
64
|
+
for (const room of rooms)
|
|
65
|
+
if (room.kind === "dm")
|
|
66
|
+
byPerson.set(room.conversation.slice("dm:".length), byPerson.get(room.conversation.slice("dm:".length)) ?? []);
|
|
67
|
+
const name = (text) => this.framer.wrap(text, {}, 64);
|
|
68
|
+
// What each person's card says (spec §6.9, "Contact cards"), or the defaults; the daemon's, never a peer's text.
|
|
69
|
+
const cards = new Map(reach.map((r) => [r.person, r]));
|
|
70
|
+
const reachOf = (person) => {
|
|
71
|
+
const r = cards.get(person);
|
|
72
|
+
if (!r)
|
|
73
|
+
return undefined;
|
|
74
|
+
return { chat: r.can.chat, agentMail: r.can.agentMail, ask: r.can.ask, source: r.source, ...(r.source === "card" && r.at !== undefined ? { card: isoOf(r.at) } : {}) };
|
|
75
|
+
};
|
|
76
|
+
const canAgent = (entry) => {
|
|
77
|
+
const base = agentCan(entry);
|
|
78
|
+
const card = reachOf(entry.person);
|
|
79
|
+
if (!card)
|
|
80
|
+
return base;
|
|
81
|
+
return { ...card, chat: false, ask: base.ask === "no" ? "no" : card.ask };
|
|
82
|
+
};
|
|
83
|
+
const agent = (entry) => ({
|
|
84
|
+
...presentAgent(this.framer, entry),
|
|
85
|
+
kind: "agent",
|
|
86
|
+
name: name(entry.agent),
|
|
87
|
+
org: "same",
|
|
88
|
+
presence: entry.online ? "online" : "offline",
|
|
89
|
+
can: canAgent(entry),
|
|
90
|
+
});
|
|
91
|
+
const persons = [...byPerson]
|
|
92
|
+
.sort(([a], [b]) => (a === s.person ? -1 : b === s.person ? 1 : a.localeCompare(b)))
|
|
93
|
+
.map(([person, list]) => {
|
|
94
|
+
const can = list.map(canAgent);
|
|
95
|
+
const self = person === s.person;
|
|
96
|
+
const card = self ? undefined : reachOf(person);
|
|
97
|
+
return {
|
|
98
|
+
match: [person, ...list.map((entry) => entry.agent)],
|
|
99
|
+
contact: {
|
|
100
|
+
address: self ? "me" : person,
|
|
101
|
+
kind: "person",
|
|
102
|
+
name: name(person),
|
|
103
|
+
org: "same",
|
|
104
|
+
presence: list.some((entry) => entry.online) ? "online" : "offline",
|
|
105
|
+
agents: list.map((entry) => presentAgent(this.framer, entry)),
|
|
106
|
+
can: card
|
|
107
|
+
? { ...card, chat: card.chat && this.ctx.access.dms }
|
|
108
|
+
: { chat: !self && this.ctx.access.dms, agentMail: can.some((c) => c.agentMail), ask: can.some((c) => c.ask === "open") ? "open" : "no", source: "default" },
|
|
109
|
+
...(unread.get(`dm:${person}`) ? { unread: unread.get(`dm:${person}`) } : {}),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
const allowed = this.ctx.access.channels;
|
|
114
|
+
const channels = rooms
|
|
115
|
+
.filter((room) => room.kind === "channel" && (allowed === undefined || allowed.has(room.conversation.slice("channel:".length))))
|
|
116
|
+
.map((room) => {
|
|
117
|
+
const local = room.conversation.slice("channel:".length);
|
|
118
|
+
return {
|
|
119
|
+
match: [local, room.name ?? local],
|
|
120
|
+
contact: {
|
|
121
|
+
address: `#${local}`,
|
|
122
|
+
kind: "channel",
|
|
123
|
+
name: name(room.name ?? local),
|
|
124
|
+
org: "same",
|
|
125
|
+
presence: "unknown",
|
|
126
|
+
can: { chat: true, agentMail: false, ask: "no", source: "default" },
|
|
127
|
+
...(room.unread > 0 ? { unread: room.unread } : {}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
const agents = others.map((entry) => ({ match: [entry.person, entry.agent], person: entry.person, contact: agent(entry) }));
|
|
132
|
+
let list = input.kind === "agent" ? agents : input.kind === "person" ? persons : input.kind === "channel" ? channels : [...persons, ...channels];
|
|
133
|
+
// A guarded peer turn sees only the teammate who started it.
|
|
134
|
+
if (s.peer !== undefined)
|
|
135
|
+
list = list.filter((item) => item.contact.kind !== "channel" && (item.person ?? item.contact.address) === s.peer);
|
|
136
|
+
const query = input.query?.toLowerCase();
|
|
137
|
+
if (query !== undefined)
|
|
138
|
+
list = list.filter((item) => item.match.some((text) => text.toLowerCase().includes(query)));
|
|
139
|
+
if (input.can === "chat")
|
|
140
|
+
list = list.filter((item) => item.contact.can.chat);
|
|
141
|
+
if (input.can === "ask")
|
|
142
|
+
list = list.filter((item) => item.contact.can.ask !== "no");
|
|
143
|
+
const limit = input.limit ?? DEFAULT_CONTACTS;
|
|
144
|
+
const contacts = list.slice(0, limit).map((item) => item.contact);
|
|
145
|
+
return {
|
|
146
|
+
me: { address: s.address, person: s.person, agent: s.address.slice(s.person.length + 1).split("@")[0], device: status.identity?.label ?? "", relay: status.relay.connected ? "connected" : "offline" },
|
|
147
|
+
contacts,
|
|
148
|
+
more: list.length > limit,
|
|
149
|
+
...(contacts.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ---- send ----
|
|
153
|
+
/**
|
|
154
|
+
* Checks a send without side effects: the address, the peer-turn allowance, what may go to whom,
|
|
155
|
+
* what `replyTo` and `onBehalfOf` name. Returns what the send will do, for the permission prompt.
|
|
156
|
+
*/
|
|
157
|
+
async planSend(sessionID, input, signal) {
|
|
158
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
159
|
+
const to = parseToolAddress(input.to, s.person, "to");
|
|
160
|
+
const own = ownPerson(to, s.person);
|
|
161
|
+
if (s.peer !== undefined && !own && !((to.kind === "person" || to.kind === "agent") && to.person === s.peer)) {
|
|
162
|
+
throw fail("guarded_turn", "to: this turn was started by a teammate's agent, so it can send only to that teammate or to your person");
|
|
163
|
+
}
|
|
164
|
+
const conversation = conversationOf(to, s.person);
|
|
165
|
+
// Spec §6.9, "Message requests": an agent never sends a request; it asks its own person to.
|
|
166
|
+
if (input.request !== undefined && !own)
|
|
167
|
+
throw fail("invalid_request", 'request: only with to: "me"; your person sends the message request');
|
|
168
|
+
if (to.kind !== "agent") {
|
|
169
|
+
const target = asPerson(to, s.person);
|
|
170
|
+
this.reachable(target, "to");
|
|
171
|
+
if (input.ask && own)
|
|
172
|
+
throw fail("invalid_request", "ask: a request card to your own person is not in this build; send them a line (without ask)");
|
|
173
|
+
if (input.ask)
|
|
174
|
+
throw fail("invalid_request", "ask: a task goes to an agent, <person>/<agent>");
|
|
175
|
+
if (input.files)
|
|
176
|
+
throw fail("invalid_request", "files: files go to an agent, never in a chat line");
|
|
177
|
+
if (input.onBehalfOf)
|
|
178
|
+
throw fail("invalid_request", "onBehalfOf: only with ask, to an agent");
|
|
179
|
+
if (input.text.length > LIMITS.chatChars)
|
|
180
|
+
throw fail("too_large", `text: a chat line is at most ${LIMITS.chatChars} characters`);
|
|
181
|
+
return {
|
|
182
|
+
kind: target.kind === "person" ? "line" : "post",
|
|
183
|
+
to,
|
|
184
|
+
conversation,
|
|
185
|
+
target: target.kind === "person" ? target.person : `#${target.name}`,
|
|
186
|
+
text: input.text,
|
|
187
|
+
...(input.replyTo !== undefined ? { thread: input.replyTo } : {}),
|
|
188
|
+
...(input.request !== undefined ? { request: input.request } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (input.onBehalfOf !== undefined && !input.ask)
|
|
192
|
+
throw fail("invalid_request", "onBehalfOf: only with ask: true");
|
|
193
|
+
const reply = input.replyTo !== undefined ? await this.replyOf(s, to, input.replyTo, input.ask === true, signal) : {};
|
|
194
|
+
const parent = input.onBehalfOf !== undefined ? await this.delegationParent(s, input.onBehalfOf, signal) : undefined;
|
|
195
|
+
const parts = [{ text: input.text }, ...filesParts(input.files)];
|
|
196
|
+
return {
|
|
197
|
+
kind: input.ask ? "task" : "mail",
|
|
198
|
+
to,
|
|
199
|
+
conversation,
|
|
200
|
+
params: compact({
|
|
201
|
+
to: formatToolAddress(to),
|
|
202
|
+
kind: input.ask ? "task" : "message",
|
|
203
|
+
parts,
|
|
204
|
+
...reply,
|
|
205
|
+
onBehalfOf: input.onBehalfOf !== undefined ? { taskId: input.onBehalfOf } : undefined,
|
|
206
|
+
wake: true,
|
|
207
|
+
}),
|
|
208
|
+
...(input.ask ? { delegation: delegationPrompt(parent?.chain) } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async send(sessionID, plan, idempotencyKey, signal) {
|
|
212
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
213
|
+
const to = formatToolAddress(plan.to);
|
|
214
|
+
if ("target" in plan) {
|
|
215
|
+
const sent = s.link.call("msg.send", compact({ key: s.key, to: plan.target, kind: "message", parts: [{ text: plan.text }], replyTo: plan.thread, request: plan.request, idempotencyKey }), signal);
|
|
216
|
+
// With a thread, not_found may be the thread's: it stays not_found.
|
|
217
|
+
const { results } = await (plan.thread === undefined ? unknownAs(sent, "not_found", "to") : sent);
|
|
218
|
+
return { id: results[0]?.envelopeId ?? "", conversation: plan.conversation, kind: plan.kind, results: [{ to, stage: "accepted" }], note: plan.request !== undefined ? NOTE.suggest : NOTE[plan.kind] };
|
|
219
|
+
}
|
|
220
|
+
const { results } = await unknownAs(s.link.call("msg.send", { ...plan.params, key: s.key, idempotencyKey }, signal), "no_recipients", "to");
|
|
221
|
+
const first = results[0];
|
|
222
|
+
const failed = first?.code !== undefined && (first.stage === "rejected" || first.stage === "expired");
|
|
223
|
+
const held = results.some((result) => result.stage === "held");
|
|
224
|
+
const note = failed
|
|
225
|
+
? `Not delivered (${first.code}). Next: ${TOOL_REMEDIES[first.code]}`
|
|
226
|
+
: held
|
|
227
|
+
? NOTE.held
|
|
228
|
+
: (first?.expect !== undefined ? EXPECT_TEXT[first.expect] : NOTE.sent) + (plan.kind === "task" ? NOTE.task : "");
|
|
229
|
+
return {
|
|
230
|
+
id: first?.envelopeId ?? "",
|
|
231
|
+
conversation: plan.conversation,
|
|
232
|
+
kind: plan.kind,
|
|
233
|
+
...(first?.taskId !== undefined ? { taskId: first.taskId } : {}),
|
|
234
|
+
results: results.map((result) => compact({ to: result.to, stage: result.stage, code: result.code, expect: result.stage === "held" ? "on_consent" : result.expect })),
|
|
235
|
+
note,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* What `replyTo` names, for a send to the agent `to`: a message it sent this session (the reply
|
|
240
|
+
* joins its conversation), or a task this session asked it to run (the text is input for it).
|
|
241
|
+
*/
|
|
242
|
+
async replyOf(s, to, id, ask, signal) {
|
|
243
|
+
const item = await found(s.link.call("inbox.get", { key: s.key, id }, signal));
|
|
244
|
+
if (item) {
|
|
245
|
+
const from = item.item.from;
|
|
246
|
+
if (from.person !== to.person || from.agent !== to.session)
|
|
247
|
+
throw fail("invalid_request", `replyTo: that message is from ${from.address}; send the reply there`);
|
|
248
|
+
return compact({ replyTo: item.item.id, contextId: item.item.contextId });
|
|
249
|
+
}
|
|
250
|
+
const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
|
|
251
|
+
if (!record || record.key !== s.key)
|
|
252
|
+
throw fail("not_found", "replyTo: no message or task with this id belongs to this session");
|
|
253
|
+
if (ask)
|
|
254
|
+
throw fail("invalid_request", "replyTo: a task request answers a message, not a task");
|
|
255
|
+
if (record.role !== "requester")
|
|
256
|
+
throw fail("invalid_state", "replyTo: you run this task; report on it with messaging_task_update");
|
|
257
|
+
if (record.peer.person !== to.person || record.peer.agent !== to.session)
|
|
258
|
+
throw fail("invalid_state", `replyTo: that task runs at ${record.peer.address}; send the input there`);
|
|
259
|
+
return { taskId: record.task.id };
|
|
260
|
+
}
|
|
261
|
+
/** The task a send `onBehalfOf` delegates for: this session runs it and it is not finished (spec §6.7). */
|
|
262
|
+
async delegationParent(s, taskId, signal) {
|
|
263
|
+
const record = await this.ownTask(s, taskId, "onBehalfOf", signal);
|
|
264
|
+
if (record.role !== "executor")
|
|
265
|
+
throw fail("invalid_state", "onBehalfOf: name a task this session runs; this session asked for it");
|
|
266
|
+
if (isTerminal(record.task.status.state))
|
|
267
|
+
throw fail("invalid_state", `onBehalfOf: the task is already ${taskStateName(record.task.status.state)}`);
|
|
268
|
+
return record;
|
|
269
|
+
}
|
|
270
|
+
// ---- inbox ----
|
|
271
|
+
/** Lists items; previews put in front of the agent are acknowledged `handed_to_agent` (spec §8.2). */
|
|
272
|
+
async inbox(sessionID, input, signal) {
|
|
273
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
274
|
+
const unreadOnly = input.unread ?? true;
|
|
275
|
+
const limit = input.limit ?? DEFAULT_INBOX;
|
|
276
|
+
const from = input.from !== undefined ? parseToolAddress(input.from, s.person, "from") : undefined;
|
|
277
|
+
if (from?.kind === "channel")
|
|
278
|
+
throw fail("invalid_request", "from: channels hold no mail; read one with messaging_read {with}");
|
|
279
|
+
const filtered = from !== undefined || s.peer !== undefined;
|
|
280
|
+
const listed = await s.link.call("inbox.list", { key: s.key, unreadOnly, limit: filtered ? PAGE : limit }, signal);
|
|
281
|
+
const keep = (item) => (s.peer === undefined || item.from.person === s.peer) && (from === undefined || fromMatches(item, from, s.person));
|
|
282
|
+
const items = listed.items.filter(keep).slice(0, limit);
|
|
283
|
+
const shown = items.filter((item) => item.stage === "delivered");
|
|
284
|
+
for (const item of shown)
|
|
285
|
+
this.ctx.hooks.shown(item.id);
|
|
286
|
+
await Promise.all(shown.map((item) => this.ctx.hooks.ack(s.link, s.key, item.id)));
|
|
287
|
+
let unread;
|
|
288
|
+
if (unreadOnly && !filtered && listed.items.length < limit)
|
|
289
|
+
unread = listed.items.length;
|
|
290
|
+
else
|
|
291
|
+
unread = (await s.link.call("inbox.list", { key: s.key, unreadOnly: true, limit: PAGE }, signal)).items.filter((item) => s.peer === undefined || item.from.person === s.peer).length;
|
|
292
|
+
return {
|
|
293
|
+
items: items.map((item) => presentInboxItem(this.framer, { ...item, stage: item.stage === "delivered" ? "handed_to_agent" : item.stage }, item.consent ?? this.consent(item.from.person, s))),
|
|
294
|
+
unread,
|
|
295
|
+
...(items.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// ---- read ----
|
|
299
|
+
/**
|
|
300
|
+
* One item: a message or task request this session received (marks it read and sends a read
|
|
301
|
+
* receipt), a task this session asked for or runs, or something it sent (how far it got).
|
|
302
|
+
* `assert` gets the item's conversation before anything changes or is shown.
|
|
303
|
+
*/
|
|
304
|
+
async readItem(sessionID, id, assert, signal) {
|
|
305
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
306
|
+
const got = await found(s.link.call("inbox.get", { key: s.key, id }, signal));
|
|
307
|
+
if (got) {
|
|
308
|
+
scoped(s.peer, got.item.from.person);
|
|
309
|
+
await assert(conversationOfPeer(got.item.from));
|
|
310
|
+
const result = await s.link.call("item.read", { key: s.key, id }, signal);
|
|
311
|
+
if (result.kind !== "received")
|
|
312
|
+
throw fail("not_found", "id: no message, task or sent item with this id belongs to this session");
|
|
313
|
+
this.ctx.hooks.read(id);
|
|
314
|
+
const consent = result.item.consent ?? this.consent(result.item.from.person, s);
|
|
315
|
+
const taskId = result.item.kind === "task" ? (result.item.taskId ?? result.message.taskId) : undefined;
|
|
316
|
+
const record = taskId !== undefined ? await found(s.link.call("task.get", { key: s.key, taskId }, signal)) : undefined;
|
|
317
|
+
if (record)
|
|
318
|
+
this.ctx.hooks.sawTask(record);
|
|
319
|
+
return { item: presentMail(this.framer, result, record ? presentTaskView(this.framer, record, consent) : undefined), notice: this.framer.notice() };
|
|
320
|
+
}
|
|
321
|
+
const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
|
|
322
|
+
if (record && record.key === s.key) {
|
|
323
|
+
scoped(s.peer, record.peer.person);
|
|
324
|
+
await assert(conversationOfPeer(record.peer));
|
|
325
|
+
this.ctx.hooks.sawTask(record);
|
|
326
|
+
return { item: presentTaskItem(this.framer, record, s.address, this.consent(record.peer.person, s)), notice: this.framer.notice() };
|
|
327
|
+
}
|
|
328
|
+
const item = await found(s.link.call("item.read", { key: s.key, id }, signal));
|
|
329
|
+
const sent = item?.kind === "sent" ? item : undefined;
|
|
330
|
+
if (sent) {
|
|
331
|
+
scoped(s.peer, sent.to.slice(0, sent.to.indexOf("/")));
|
|
332
|
+
const item = presentSent(sent, s.address);
|
|
333
|
+
await assert(item.conversation);
|
|
334
|
+
return { item };
|
|
335
|
+
}
|
|
336
|
+
throw fail("not_found", "id: no message, task or sent item with this id belongs to this session");
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* A page of one conversation, oldest first: the chat lines with a person or in a channel, or the
|
|
340
|
+
* mail and tasks with an agent. Mail previews shown here count as handed to the agent.
|
|
341
|
+
*/
|
|
342
|
+
async readConversation(sessionID, input, assert, signal) {
|
|
343
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
344
|
+
const named = parseToolAddress(input.with, s.person, "with");
|
|
345
|
+
const target = asPerson(named, s.person);
|
|
346
|
+
if (s.peer !== undefined && (target.kind === "channel" || target.person !== s.peer))
|
|
347
|
+
scoped(s.peer, "");
|
|
348
|
+
if (input.before !== undefined && input.after !== undefined)
|
|
349
|
+
throw fail("invalid_request", "before, after: pass one, not both");
|
|
350
|
+
if (target.kind !== "agent")
|
|
351
|
+
this.reachable(target, "with");
|
|
352
|
+
const conversation = conversationOf(target, s.person);
|
|
353
|
+
const cursor = input.before ?? input.after;
|
|
354
|
+
const place = cursor !== undefined ? decodeToolCursor(cursor, input.before !== undefined ? "before" : "after") : undefined;
|
|
355
|
+
const limit = input.limit ?? DEFAULT_PAGE;
|
|
356
|
+
await assert(conversation);
|
|
357
|
+
if (target.kind !== "agent") {
|
|
358
|
+
if (place?.kind === "conv")
|
|
359
|
+
throw fail("invalid_request", "before, after: that cursor is from another conversation");
|
|
360
|
+
const room = target.kind === "person" ? `dm:${target.person}` : `channel:${target.name}`;
|
|
361
|
+
const seq = place?.seq;
|
|
362
|
+
const page = await unknownAs(s.link.call("conv.read", compact({ conversation: room, limit, before: input.before !== undefined ? seq : undefined, after: input.after !== undefined ? seq : undefined }), signal), "not_found", "with");
|
|
363
|
+
if (page.kind !== "lines")
|
|
364
|
+
throw fail("internal", "with: the daemon answered a DM or channel page with agent entries");
|
|
365
|
+
const { lines: messages, more } = page;
|
|
366
|
+
return this.page(conversation, messages, (line) => presentLine(this.framer, line, target.kind === "person" ? "line" : "post", conversation), (line) => ({ kind: "seq", seq: line.seq }), more);
|
|
367
|
+
}
|
|
368
|
+
if (place?.kind === "seq")
|
|
369
|
+
throw fail("invalid_request", "before, after: that cursor is from another conversation");
|
|
370
|
+
const at = place?.kind === "conv" ? { at: place.at, id: place.id } : undefined;
|
|
371
|
+
const page = await s.link.call("conv.read", compact({ key: s.key, conversation: `agent:${target.person}/${target.session}`, limit, before: input.before !== undefined ? at : undefined, after: input.after !== undefined ? at : undefined }), signal);
|
|
372
|
+
if (page.kind !== "agent")
|
|
373
|
+
throw fail("internal", "with: the daemon answered an agent page with lines");
|
|
374
|
+
const { entries, more } = page;
|
|
375
|
+
const shown = entries.filter((entry) => entry.kind === "mail" && entry.direction === "in" && entry.stage === "delivered");
|
|
376
|
+
for (const entry of shown)
|
|
377
|
+
this.ctx.hooks.shown(entry.id);
|
|
378
|
+
await Promise.all(shown.map((entry) => this.ctx.hooks.ack(s.link, s.key, entry.id)));
|
|
379
|
+
return this.page(conversation, entries, (entry) => presentConvEntry(this.framer, entry, s.address), (entry) => ({ kind: "conv", at: entry.at, id: entry.id }), more);
|
|
380
|
+
}
|
|
381
|
+
page(conversation, rows, present, place, more) {
|
|
382
|
+
const first = rows[0];
|
|
383
|
+
const last = rows.at(-1);
|
|
384
|
+
return {
|
|
385
|
+
conversation,
|
|
386
|
+
entries: rows.map(present),
|
|
387
|
+
...(first !== undefined ? { before: encodeToolCursor(place(first)) } : {}),
|
|
388
|
+
...(last !== undefined ? { after: encodeToolCursor(place(last)) } : {}),
|
|
389
|
+
more,
|
|
390
|
+
...(rows.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// ---- search ----
|
|
394
|
+
/** Chat lines with persons and in channels. Each conversation in the hits is asserted; `allowed` false drops it. */
|
|
395
|
+
async search(sessionID, input, allowed, signal) {
|
|
396
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
397
|
+
let room;
|
|
398
|
+
if (input.with !== undefined) {
|
|
399
|
+
const named = parseToolAddress(input.with, s.person, "with");
|
|
400
|
+
if (named.kind === "agent")
|
|
401
|
+
throw fail("invalid_request", "with: agent mail is not searched; page it with messaging_read {with}");
|
|
402
|
+
const target = asPerson(named, s.person);
|
|
403
|
+
this.reachable(target, "with");
|
|
404
|
+
room = target.kind === "person" ? `dm:${target.person}` : `channel:${target.name}`;
|
|
405
|
+
}
|
|
406
|
+
if (s.peer !== undefined)
|
|
407
|
+
room = room === undefined || room === `dm:${s.peer}` ? `dm:${s.peer}` : (scoped(s.peer, ""), room);
|
|
408
|
+
const channels = this.ctx.access.channels;
|
|
409
|
+
// A client limited to some channels searches each of them: an unscoped search would read the rest.
|
|
410
|
+
const rooms = room === undefined && channels !== undefined ? [...channels].map((name) => `channel:${name}`) : [room];
|
|
411
|
+
const limit = input.limit ?? 20;
|
|
412
|
+
const search = (one) => s.link.call("search", compact({ query: input.query, conversation: one, limit: input.limit }), signal);
|
|
413
|
+
// A named conversation the daemon does not know is unknown_address; an allowed channel this
|
|
414
|
+
// person is not in (or no longer) has nothing to find.
|
|
415
|
+
const pages = await Promise.all(rooms.map((one) => (room !== undefined ? unknownAs(search(one), "not_found", "with") : found(search(one)).then((page) => page ?? { hits: [] }))));
|
|
416
|
+
const messages = pages
|
|
417
|
+
.flatMap((page) => page.hits)
|
|
418
|
+
.filter((line) => (line.conversation.startsWith("dm:") ? this.ctx.access.dms : channels === undefined || channels.has(line.conversation.slice("channel:".length))))
|
|
419
|
+
.sort((a, b) => b.at - a.at)
|
|
420
|
+
.slice(0, limit);
|
|
421
|
+
const verdicts = new Map();
|
|
422
|
+
for (const conversation of new Set(messages.map((line) => roomConversation(line.conversation))))
|
|
423
|
+
verdicts.set(conversation, await allowed(conversation));
|
|
424
|
+
const hits = messages
|
|
425
|
+
.filter((line) => verdicts.get(roomConversation(line.conversation)))
|
|
426
|
+
.map((line) => {
|
|
427
|
+
const conversation = roomConversation(line.conversation);
|
|
428
|
+
const from = line.agent === undefined ? line.person : `${line.person}/${line.agent}`;
|
|
429
|
+
return { id: line.id, conversation, from, at: new Date(line.at).toISOString(), snippet: this.framer.wrap(line.body, { from, conversation, id: line.id }, 2_000) };
|
|
430
|
+
});
|
|
431
|
+
return { hits, ...(hits.length > 0 ? { notice: this.framer.notice() } : {}) };
|
|
432
|
+
}
|
|
433
|
+
// ---- tasks ----
|
|
434
|
+
async tasks(sessionID, input, signal) {
|
|
435
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
436
|
+
const role = input.role === "mine" ? "requester" : input.role === "asked" ? "executor" : undefined;
|
|
437
|
+
const limit = input.limit ?? DEFAULT_TASKS;
|
|
438
|
+
const present = (record) => {
|
|
439
|
+
this.ctx.hooks.sawTask(record);
|
|
440
|
+
return presentTask(this.framer, record, this.consent(record.peer.person, s));
|
|
441
|
+
};
|
|
442
|
+
const filtered = input.state === "open" || input.state === "done" || s.peer !== undefined;
|
|
443
|
+
if (!filtered) {
|
|
444
|
+
if (input.cursor !== undefined && !input.cursor.startsWith("d"))
|
|
445
|
+
throw fail("invalid_request", "cursor: not a cursor of this listing");
|
|
446
|
+
const state = input.state !== undefined ? a2aTaskState(input.state) : undefined;
|
|
447
|
+
const page = await s.link.call("task.list", compact({ key: s.key, role, state, limit, cursor: input.cursor?.slice(1) }), signal);
|
|
448
|
+
const tasks = page.tasks.map(present);
|
|
449
|
+
return { tasks, ...(page.nextCursor !== undefined ? { nextCursor: `d${page.nextCursor}` } : {}), total: page.total, ...(tasks.length > 0 ? { notice: this.framer.notice() } : {}) };
|
|
450
|
+
}
|
|
451
|
+
const offset = input.cursor === undefined ? 0 : /^o(\d{1,6})$/.exec(input.cursor)?.[1];
|
|
452
|
+
if (offset === undefined)
|
|
453
|
+
throw fail("invalid_request", "cursor: not a cursor of this listing");
|
|
454
|
+
const all = [];
|
|
455
|
+
let cursor;
|
|
456
|
+
for (let page = 0; page < TASK_PAGES; page++) {
|
|
457
|
+
const listed = await s.link.call("task.list", compact({ key: s.key, role, limit: PAGE, cursor }), signal);
|
|
458
|
+
all.push(...listed.tasks);
|
|
459
|
+
cursor = listed.nextCursor;
|
|
460
|
+
if (cursor === undefined)
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
const done = (record) => DONE_STATES.includes(taskStateName(record.task.status.state));
|
|
464
|
+
const matching = all.filter((record) => (s.peer === undefined || record.peer.person === s.peer) &&
|
|
465
|
+
(input.state === undefined || (input.state === "open" ? !done(record) : input.state === "done" ? done(record) : taskStateName(record.task.status.state) === input.state)));
|
|
466
|
+
const start = Number(offset);
|
|
467
|
+
const tasks = matching.slice(start, start + limit).map(present);
|
|
468
|
+
return {
|
|
469
|
+
tasks,
|
|
470
|
+
...(start + limit < matching.length ? { nextCursor: `o${start + limit}` } : {}),
|
|
471
|
+
total: matching.length,
|
|
472
|
+
...(tasks.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The task an update or a cancel names, checked locally before any permission prompt: this
|
|
477
|
+
* session holds `role`, and for an update the task is not finished and `next` is legal.
|
|
478
|
+
*/
|
|
479
|
+
async taskTarget(sessionID, id, role, next, signal) {
|
|
480
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
481
|
+
const record = await this.ownTask(s, id, "id", signal);
|
|
482
|
+
this.ctx.hooks.sawTask(record);
|
|
483
|
+
if (record.role !== role) {
|
|
484
|
+
throw fail("invalid_state", role === "executor" ? "id: only the executor updates a task; this session asked for it" : "id: only the requester cancels a task; this session runs it");
|
|
485
|
+
}
|
|
486
|
+
const state = record.task.status.state;
|
|
487
|
+
if (next !== undefined && isTerminal(state))
|
|
488
|
+
throw fail("invalid_state", `id: the task is already ${taskStateName(state)}`);
|
|
489
|
+
if (next !== undefined && !canTransition(state, next))
|
|
490
|
+
throw fail("invalid_state", `state: a task cannot move from ${taskStateName(state)} to ${taskStateName(next)}`);
|
|
491
|
+
const peer = validatedAddress(record.peer);
|
|
492
|
+
if (!peer)
|
|
493
|
+
throw fail("invalid_request", "id: the task's peer has no session address");
|
|
494
|
+
return { record, to: { kind: "agent", ...peer } };
|
|
495
|
+
}
|
|
496
|
+
async updateTask(sessionID, record, input, signal) {
|
|
497
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
498
|
+
const files = filesParts(input.files);
|
|
499
|
+
const updated = await s.link.call("task.update", compact({
|
|
500
|
+
key: s.key,
|
|
501
|
+
taskId: record.task.id,
|
|
502
|
+
state: a2aTaskState(input.state),
|
|
503
|
+
message: input.text !== undefined ? { parts: [{ text: input.text }] } : undefined,
|
|
504
|
+
artifact: files.length > 0 ? { artifactId: randomUUID(), parts: files } : undefined,
|
|
505
|
+
}), signal);
|
|
506
|
+
this.ctx.hooks.sawTask(updated);
|
|
507
|
+
return { id: updated.task.id, state: taskStateName(updated.task.status.state), stage: "accepted", to: updated.peer.address };
|
|
508
|
+
}
|
|
509
|
+
async cancelTask(sessionID, record, reason, signal) {
|
|
510
|
+
const s = await this.ctx.session(sessionID, signal);
|
|
511
|
+
const updated = await s.link.call("task.cancel", compact({ key: s.key, taskId: record.task.id, reason }), signal);
|
|
512
|
+
this.ctx.hooks.sawTask(updated);
|
|
513
|
+
return { id: updated.task.id, state: taskStateName(updated.task.status.state), to: updated.peer.address };
|
|
514
|
+
}
|
|
515
|
+
/** A task of this session, in its peer scope; `field` names the input for the error. */
|
|
516
|
+
async ownTask(s, id, field, signal) {
|
|
517
|
+
const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
|
|
518
|
+
if (!record || record.key !== s.key)
|
|
519
|
+
throw fail("not_found", `${field}: no task with this id belongs to this session`);
|
|
520
|
+
scoped(s.peer, record.peer.person);
|
|
521
|
+
return record;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function fail(code, message) {
|
|
525
|
+
throw new MessagingError(code, message);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* A daemon call that names an address: the daemon's code for a person, agent or channel it does not
|
|
529
|
+
* know (`not_found` for a person or channel, `no_recipients` for an agent, from `msg.send`) is `unknown_address`
|
|
530
|
+
* to the agent (spec §11), naming the field.
|
|
531
|
+
*/
|
|
532
|
+
async function unknownAs(call, code, field) {
|
|
533
|
+
try {
|
|
534
|
+
return await call;
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (error instanceof MessagingError && error.code === code)
|
|
538
|
+
fail("unknown_address", `${field}: no person, agent or channel by that name here`);
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** The result, or undefined for `not_found`. */
|
|
543
|
+
async function found(call) {
|
|
544
|
+
try {
|
|
545
|
+
return await call;
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (error instanceof MessagingError && error.code === "not_found")
|
|
549
|
+
return undefined;
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** In a guarded peer turn, only what concerns that peer exists: anything else is `not_found`. */
|
|
554
|
+
function scoped(peer, person) {
|
|
555
|
+
if (peer !== undefined && person !== peer)
|
|
556
|
+
throw fail("not_found", "nothing with this id or address concerns the teammate who started this turn");
|
|
557
|
+
}
|
|
558
|
+
function fromMatches(item, from, self) {
|
|
559
|
+
switch (from.kind) {
|
|
560
|
+
case "me":
|
|
561
|
+
return item.from.person === self;
|
|
562
|
+
case "person":
|
|
563
|
+
return item.from.person === from.person;
|
|
564
|
+
case "agent":
|
|
565
|
+
return item.from.person === from.person && item.from.agent === from.session && (from.label === undefined || item.from.label === from.label);
|
|
566
|
+
case "channel":
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function roomConversation(room) {
|
|
571
|
+
return room.startsWith("dm:") ? `person:${room.slice("dm:".length)}` : room;
|
|
572
|
+
}
|
|
573
|
+
/** Media types sent as text parts: their content stays readable to an A2A client and to the recipient's agent. */
|
|
574
|
+
const TEXTUAL = /^(?:text\/[\w.+-]+|application\/(?:json|xml|yaml|x-yaml|javascript|x-sh|x-diff|x-patch|[\w.-]+\+(?:json|xml)))(?:\s*;.*)?$/i;
|
|
575
|
+
/**
|
|
576
|
+
* Files as message or artifact parts: a textual file whose bytes are UTF-8 becomes a text part with
|
|
577
|
+
* its file name and media type; any other file a raw (base64) part.
|
|
578
|
+
*/
|
|
579
|
+
function filesParts(files) {
|
|
580
|
+
return (files ?? []).map((file) => {
|
|
581
|
+
const text = TEXTUAL.test(file.mime) ? utf8(file.data) : undefined;
|
|
582
|
+
return text !== undefined ? { text, filename: file.name, mediaType: file.mime } : { raw: file.data, filename: file.name, mediaType: file.mime };
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/** Base64 to text, or undefined when the bytes are not UTF-8. */
|
|
586
|
+
function utf8(base64) {
|
|
587
|
+
try {
|
|
588
|
+
const binary = atob(base64);
|
|
589
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
590
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
}
|