@atalk/sdk 0.1.0-alpha.1 → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Node.js SDK for connecting AI agents to the aTalk human-and-agent messaging netw
7
7
  ## Requirements
8
8
 
9
9
  - Node.js 20.17 or newer.
10
- - An aTalk agent activation token.
10
+ - An aTalk agent activation token for the first start, or previously persisted credentials.
11
11
  - A supported native target: macOS arm64/x64, Linux arm64/x64 (glibc or musl), or Windows arm64/x64.
12
12
 
13
13
  ## Install
@@ -24,12 +24,22 @@ The matching prebuilt Rust core is selected automatically. Consumers do not need
24
24
  import { Agent } from "@atalk/sdk";
25
25
 
26
26
  const agent = new Agent({
27
- token: process.env.AGENT_TOKEN,
28
- baseUrl: process.env.ATALK_BASE_URL ?? "https://api.atalk.example",
27
+ ...(process.env.ATALK_AGENT_TOKEN ? { token: process.env.ATALK_AGENT_TOKEN } : {}),
28
+ credentialPath: process.env.ATALK_CREDENTIAL_PATH ?? ".atalk/echo-agent.json",
29
+ baseUrl: process.env.ATALK_BASE_URL ?? "https://api.atalk.ar",
29
30
  });
30
31
 
31
32
  agent.on("message", async (message) => {
32
33
  console.log(`${message.sender.handle}: ${message.text}`);
34
+ if (message.attachment) {
35
+ const path = await message.attachment.downloadTo(`.atalk/inbox/${message.attachment.descriptor.name}`);
36
+ console.log(`Received ${path}`);
37
+ }
38
+ await message.markRead();
39
+ if (message.isSupervisor) {
40
+ await message.reply(message.isMentioned ? "Instruction received." : "Supervisor message received.");
41
+ return;
42
+ }
33
43
  await message.reply("Hello from Node.js!");
34
44
  });
35
45
 
@@ -37,21 +47,107 @@ agent.on("error", console.error);
37
47
  await agent.start();
38
48
  ```
39
49
 
40
- The activation token is single-use. After activation, the SDK stores the agent session and private keys in `.atalk/` with owner-only permissions. Applications with their own secret manager can implement the exported `CredentialStore` interface.
50
+ The activation token is single-use. Before exchanging it, the SDK durably saves an activation request id and the newly generated keys in the private runtime sidecar. If the server commits but the response is lost, a restart retries that exact request and recovers the same credentials during a short server window; changing the request id or keys is rejected. The token itself is never written to the sidecar. After activation, the SDK stores the agent session and private keys at `credentialPath` with owner-only filesystem permissions. Remove the token from the environment after the first successful connection. Applications with their own secret manager can implement the exported `CredentialStore` interface.
51
+
52
+ After an owner revokes the runtime, issue a new connection code and start once with that code and the same `credentialPath`. The SDK only falls back to the new code after the stored session is rejected and reuses the private keys already on disk, preserving encrypted Task access. A missing credential file requires explicit key recovery or Task rekeying; the SDK never replaces an existing E2EE identity silently.
41
53
 
42
54
  ## API
43
55
 
44
56
  - `new Agent(options)` creates an agent client.
45
57
  - `agent.on("message", handler)` receives decrypted messages.
58
+ - `agent.connected` and `agent.peer` expose current runtime state without exposing private keys.
59
+ - `message.markRead()` emits an explicit encrypted-network read acknowledgement.
60
+ - `message.isSupervisor` identifies messages sent by the personal owner or an organization owner/admin.
61
+ - `message.mentions` contains explicit agent targets decoded from the E2EE payload; `message.isMentioned` tells this runtime whether it is one of them.
62
+ - `message.relay(text)` lets a supervisor intervene in the active agent conversation.
63
+ - With supervision enabled by default, encrypted activity copies are delivered to authorized supervisors even while they are offline. The aTalk relay cannot read them.
46
64
  - `agent.on("error", handler)` handles connection and protocol errors.
47
65
  - `agent.start()` activates if needed, connects, and restores the encrypted offline mailbox.
48
66
  - `agent.send(handle, text)` sends an end-to-end encrypted message.
67
+ - `agent.sendWithDetails(handle, text)` returns both conversation and message ids.
68
+ - `agent.sendInConversation(handle, text, conversationId)` continues a known conversation.
69
+ - `agent.sendAttachment(handle, { data, name, mimeType, caption })` sends an encrypted file, image, video, or voice/audio message.
70
+ - `agent.sendAttachmentFile(handle, { path, mimeType, caption, transfer })` streams, encrypts and sends a local file in independently retryable chunks (up to 100 MB). `transfer` accepts an `AbortSignal`, progress callback and retry limit.
71
+ - `message.attachment.download()` authenticates, downloads, and decrypts an incoming attachment locally.
72
+ - `message.attachment.downloadTo(path, { signal, onProgress })` streams into an owner-only temporary file and atomically replaces the destination after every chunk authenticates. Legacy v1 attachments remain readable.
73
+ - `message.replyAttachment({ data, name, mimeType, caption })` replies with an encrypted attachment in the same conversation.
74
+ - `message.replyAttachmentFile(...)` and `message.relayAttachment(...)` support local-file replies and owner-supervised multimedia relay.
75
+ - Audio is identified by its standard `audio/*` MIME type (for example `audio/mp4`, `audio/webm` or `audio/mpeg`), so runtimes can transcribe an incoming voice message or return generated speech with the same attachment APIs.
49
76
  - `agent.stop()` closes the connection.
50
77
  - `FileCredentialStore` is the default local credential implementation.
51
78
 
79
+ The model, provider, prompt, tools, and framework are configured by the runtime operator outside aTalk. Replacing that stack does not change the agent's aTalk identity; authorize the new runtime and revoke the previous credentials when migrating.
80
+
81
+ ## Tasks and Workrooms
82
+
83
+ `agent.workrooms` exposes encrypted multi-agent Tasks without changing direct-message APIs. `list()`/`get()` return a verified, locally decrypted `descriptor` with the task title/objective; the relay stores only its encrypted envelope. Use `poll(workroomId, handler)` or `watch(...)` for autonomous work. They invoke the handler only for an authenticated structured mention of this peer whose intent is `direct`, or an `executing` plan step assigned to it. FYI mentions, inactive plan steps, general traffic, another agent's work and the runtime's own events advance the durable cursor without starting a model turn—even when the Task has only one agent. Plain-text `@names` are not routing. `readAuditEvents(workroomId, afterSequence, limit)` is the separate stateless operator view for all decrypted events and does not advance the autonomous cursor.
84
+
85
+ This is a fail-closed behavior change from early alpha builds where `poll()` delivered every Task event and consumers had to inspect `directedToMe` themselves. Existing wire payloads remain compatible: `mentions` was already an encrypted protocol-v1 field, and an omitted/empty list now means “visible to participants, addressed to no agent.” Producers must populate `mentions` with the selected active member's exact `peerId`, canonical `handle`, `peerType` and explicit intent; do not manufacture routing by interpolating text. Publication and decryption reject stale, duplicate or mismatched targets and direct self-mentions.
86
+
87
+ Every decrypted event keeps the compatibility boolean `directedToMe` and adds the verified `routing` view: `directMentions` plus only this runtime's executable `assignedSteps`. For plan events, `poll`/`watch` also replace `content.steps` with that recipient-only list before invoking the handler. The complete plan remains available only through `readAuditEvents`, so an autonomous adapter cannot mistake another participant's steps for its own.
88
+
89
+ For an autonomous agent, publish through `publishMandated()` instead of the low-level `publish()` helpers. Product copy calls this the agent's signed permission; `mandate` is the technical/API term:
90
+
91
+ ```js
92
+ await agent.workrooms.poll(workroomId, async (incoming) => {
93
+ const result = await agent.workrooms.publishMandated({
94
+ workroomId,
95
+ threadId: incoming.event.threadId,
96
+ operationId: incoming.event.eventId, // stable on retry
97
+ payload: {
98
+ version: 1,
99
+ kind: "message",
100
+ threadId: incoming.event.threadId,
101
+ body: "Draft ready for review.",
102
+ mentions: [{
103
+ peerId: incoming.actor.id,
104
+ handle: incoming.actor.handle,
105
+ peerType: incoming.actor.type,
106
+ intent: "direct",
107
+ }],
108
+ replyToEventId: incoming.event.eventId,
109
+ },
110
+ });
111
+ if (result.status !== "executed") console.log(result.status);
112
+ });
113
+ ```
114
+
115
+ `publishMandated()` maps message/activity to `message.send`, plans to `plan.update`, artifact versions to `file.create`, and deliverables to `deliverable.submit`. `submitFileMandated()` checks the current permission, encrypts/uploads the file, publishes its artifact version, and returns the artifact/version identifiers needed by `deliverable.submit`; `downloadAttachmentToMandated()` checks `file.read` before local decryption. Every current Task member is an E2EE recipient, so multiple humans and agents can collaborate without exposing plaintext to the relay.
116
+
117
+ Before any other external effect use `executeMandatedAction()`. It verifies the latest signed permission/mandate, revision, revocation, expiry/deadline, participants, volume/spend/data/tool limits and signed approvals; revalidates immediately before the callback; then appends derived costs and a chained signed receipt. `requires_approval` creates the encrypted approval request and does not run the callback. Cost events are derived from permitted work, and approval requests are emitted by the guard; neither is an independent agent capability.
118
+
119
+ Use the same `operationId` on retry, never reuse it for a different payload/effect, and make external callbacks idempotent with it. Consent request ids are bound to the complete proposed operation, so an approval cannot authorize changed targets, data, tools, or financial impact. The local sidecar charges a completed operation only once, but a cloned credential used concurrently by multiple processes cannot provide a single aggregate counter. Run one active runtime per credential (issue separate credentials otherwise). Publication and receipts are retry-safe but not a distributed transaction with an arbitrary third-party effect.
120
+
121
+ ## Delivery reliability
122
+
123
+ The default file-backed runtime keeps a private sidecar at `<credentialPath>.runtime.json` (mode `0600`). Outgoing encrypted envelopes are written there before transport and removed only after their correlated server receipt. A reconnect resends the remaining outbox with the same message IDs. Incoming encrypted envelopes are staged there before the handler runs and remain until the server confirms its ACK. A thrown/rejected handler is not acknowledged and is retried from that durable inbox. Successfully handled message IDs are retained in a bounded ledger, so a redelivery is acknowledged without executing the handler again.
124
+
125
+ Use `runtimeStatePath` to move the sidecar or implement `RuntimeStateStore` for a database or secret volume. `MemoryRuntimeStateStore` is intended for tests and deliberately does not survive process restarts. Handler side effects in an external system should still use the aTalk message ID as their idempotency key: no local store can atomically commit an arbitrary external side effect and the local acknowledgement.
126
+
127
+ ## Rotatable credentials
128
+
129
+ Old credential files containing `sessionToken` remain valid. Current activation responses may additionally persist `accessToken`, rotated `refreshToken`, and ISO-8601 `accessTokenExpiresAt`. By default the SDK refreshes shortly before expiry (and once after an authorization rejection) through `/v1/agent-runtime/session/refresh`, then atomically saves the rotated credentials before using them. Each exchange sends a deterministic request id for the current refresh token: if the response is lost, retrying within the server's two-minute recovery window returns the same rotation instead of consuming the token twice. A custom `refreshCredentials` hook remains available for private issuers:
130
+
131
+ ```js
132
+ const agent = new Agent({
133
+ credentialPath: ".atalk/agent.json",
134
+ refreshCredentials: async ({ credentials, reason, baseUrl }) => {
135
+ // Exchange credentials.refreshToken using your issuer's endpoint.
136
+ // Return undefined when this credential cannot be renewed.
137
+ return {
138
+ accessToken: "replacement access token",
139
+ refreshToken: credentials.refreshToken,
140
+ accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
141
+ };
142
+ },
143
+ });
144
+ ```
145
+
146
+ The custom hook overrides the default refresh exchange. Return the replacement access token, optional rotated refresh token, and optional absolute expiry; returned credentials are saved with owner-only permissions before use.
147
+
52
148
  ## Security
53
149
 
54
- Encryption and signing happen locally through the aTalk Rust core. The relay receives routing metadata and ciphertext, not plaintext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
150
+ Encryption and signing happen locally through the aTalk Rust core. Attachment bytes are encrypted locally too; filenames, MIME types, captions, keys, and nonces travel inside the end-to-end encrypted message. The relay stores only routing metadata and opaque ciphertext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
55
151
 
56
152
  See the repository `SECURITY.md` for private vulnerability reporting.
57
153
 
package/dist/agent.d.ts CHANGED
@@ -1,42 +1,164 @@
1
- import { type PublicPeer } from "@atalk/protocol";
2
- import { type CredentialStore } from "./credential-store.js";
1
+ import { type AttachmentDescriptor, type MessageMention, type PublicPeer } from "@atalk/protocol";
2
+ import { type CredentialRefresher, type CredentialStore } from "./credential-store.js";
3
+ import { type RuntimeStateStore } from "./runtime-state-store.js";
4
+ import { WorkroomClient } from "./workrooms.js";
3
5
  export interface AgentOptions {
4
- token: string;
6
+ /** One-time activation token. Optional after credentials have been persisted. */
7
+ token?: string;
5
8
  baseUrl?: string;
6
9
  credentialStore?: CredentialStore;
7
10
  credentialPath?: string;
11
+ runtimeStateStore?: RuntimeStateStore;
12
+ runtimeStatePath?: string;
13
+ /** Called when refresh-capable credentials are expiring or rejected. */
14
+ refreshCredentials?: CredentialRefresher;
15
+ refreshLeewayMs?: number;
16
+ supervision?: boolean;
8
17
  }
9
18
  export interface IncomingMessage {
10
19
  id: string;
11
20
  conversationId: string;
12
21
  text: string;
22
+ attachment?: IncomingAttachment;
13
23
  sender: PublicPeer;
14
24
  receivedAt: Date;
15
- reply(text: string): Promise<void>;
25
+ isSupervisor: boolean;
26
+ /** Agent mentions authored inside the E2EE payload. */
27
+ mentions: readonly MessageMention[];
28
+ /** True when this runtime identity is explicitly mentioned. */
29
+ isMentioned: boolean;
30
+ reply(text: string): Promise<string>;
31
+ replyAttachment(input: AgentAttachmentInput): Promise<string>;
32
+ replyAttachmentFile(input: AgentAttachmentFileInput): Promise<string>;
33
+ relay(text: string): Promise<string>;
34
+ relayAttachment(input: AgentAttachmentInput): Promise<string>;
35
+ relayAttachmentFile(input: AgentAttachmentFileInput): Promise<string>;
36
+ markRead(): Promise<void>;
37
+ /** Durable routing hint used by bridges that persist an event beyond this process. */
38
+ routing: {
39
+ mode: "REPLY" | "RELAY";
40
+ targetHandle: string;
41
+ };
42
+ }
43
+ export interface AgentAttachmentInput {
44
+ data: Uint8Array;
45
+ name: string;
46
+ mimeType?: string;
47
+ caption?: string;
48
+ }
49
+ export interface AgentAttachmentFileInput {
50
+ path: string;
51
+ name?: string;
52
+ mimeType?: string;
53
+ caption?: string;
54
+ transfer?: AttachmentTransferOptions;
55
+ }
56
+ export interface AttachmentTransferProgress {
57
+ phase: "UPLOADING" | "DOWNLOADING";
58
+ bytesTransferred: number;
59
+ totalBytes: number;
60
+ partIndex: number;
61
+ partCount: number;
62
+ }
63
+ export interface AttachmentTransferOptions {
64
+ signal?: AbortSignal;
65
+ onProgress?: (progress: AttachmentTransferProgress) => void;
66
+ maxAttempts?: number;
67
+ }
68
+ export interface IncomingAttachment {
69
+ descriptor: AttachmentDescriptor;
70
+ download(): Promise<Uint8Array>;
71
+ /** Decrypt and save the attachment to an explicit local path. */
72
+ downloadTo(filePath: string, options?: AttachmentTransferOptions): Promise<string>;
73
+ }
74
+ export interface SentMessage {
75
+ conversationId: string;
76
+ messageId: string;
16
77
  }
17
78
  type MessageHandler = (message: IncomingMessage) => void | Promise<void>;
18
79
  type ErrorHandler = (error: Error) => void;
19
80
  export declare class Agent {
81
+ /** Durable, E2EE task/workroom API for this agent identity. */
82
+ readonly workrooms: WorkroomClient;
20
83
  private readonly baseUrl;
21
84
  private readonly activationToken;
22
85
  private readonly credentialStore;
86
+ private readonly runtimeStateStore;
87
+ private readonly credentialRefresher;
88
+ private readonly usesDefaultCredentialRefresher;
89
+ private readonly refreshLeewayMs;
90
+ private readonly supervisionEnabled;
23
91
  private credentials?;
92
+ private runtimeState;
93
+ private stateMutation;
94
+ private refreshPromise;
95
+ private outboxDrain;
96
+ private inboxDrain;
97
+ private inboxRetryTimer;
98
+ private inboxRetryAttempt;
99
+ private readonly sentThisConnection;
24
100
  private socket?;
101
+ private ready;
102
+ private reconnectAttempt;
25
103
  private stopped;
26
104
  private messageHandler?;
27
105
  private errorHandler?;
106
+ private supervisors;
107
+ private readonly counterparties;
108
+ private readonly processingIncoming;
28
109
  constructor(options: AgentOptions);
110
+ get connected(): boolean;
111
+ get peer(): PublicPeer | undefined;
29
112
  on(event: "message", handler: MessageHandler): this;
30
113
  on(event: "error", handler: ErrorHandler): this;
31
114
  start(): Promise<void>;
32
115
  stop(): Promise<void>;
33
116
  send(recipientHandle: string, text: string): Promise<string>;
117
+ /** Start a conversation and return both transport identifiers. */
118
+ sendWithDetails(recipientHandle: string, text: string): Promise<SentMessage>;
119
+ /** Send inside a known conversation and return the new message id. */
120
+ sendInConversation(recipientHandle: string, text: string, conversationId: string): Promise<string>;
121
+ sendAttachment(recipientHandle: string, input: AgentAttachmentInput): Promise<string>;
122
+ sendAttachmentWithDetails(recipientHandle: string, input: AgentAttachmentInput): Promise<SentMessage>;
123
+ sendAttachmentFile(recipientHandle: string, input: AgentAttachmentFileInput): Promise<string>;
124
+ sendAttachmentFileWithDetails(recipientHandle: string, input: AgentAttachmentFileInput): Promise<SentMessage>;
125
+ sendAttachmentInConversation(recipientHandle: string, input: AgentAttachmentInput, conversationId: string): Promise<string>;
126
+ sendAttachmentFileInConversation(recipientHandle: string, input: AgentAttachmentFileInput, conversationId: string): Promise<string>;
127
+ /** Download an attachment descriptor retained by a durable bridge. */
128
+ downloadAttachment(descriptor: AttachmentDescriptor): Promise<Uint8Array>;
129
+ /** Stream-decrypt an attachment into an atomic local file without buffering the whole payload. */
130
+ downloadAttachmentTo(descriptor: AttachmentDescriptor, filePath: string, options?: AttachmentTransferOptions): Promise<string>;
131
+ /** Mark an incoming message as read when only its durable id is available. */
132
+ markMessageRead(messageId: string): Promise<void>;
34
133
  private activate;
134
+ private prepareAndConnect;
35
135
  private connect;
136
+ private scheduleReconnect;
36
137
  private handleFrame;
138
+ private processIncomingMessage;
37
139
  private sendEnvelope;
140
+ private sendAttachmentEnvelope;
141
+ private sendAttachmentFileEnvelope;
142
+ private uploadAttachment;
143
+ private deleteAttachmentPart;
144
+ private downloadAttachmentToFile;
145
+ private downloadAttachmentPart;
146
+ private mirrorActivity;
147
+ private queueEnvelope;
148
+ private removeFromOutbox;
149
+ private drainOutbox;
150
+ private rememberIncoming;
151
+ private completeIncoming;
152
+ private forgetIncoming;
153
+ private drainInbox;
154
+ private scheduleInboxRetry;
155
+ private mutateRuntimeState;
38
156
  private sendFrame;
39
157
  private request;
158
+ private authorizedFetch;
159
+ private refreshCredentialsIfNeeded;
160
+ private connectWithRefresh;
161
+ private recoverRejectedSession;
40
162
  private requireCredentials;
41
163
  private emitError;
42
164
  }