@ccmsg/protocol 0.3.1 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -45,25 +45,63 @@ export const Topic = Type.String({
45
45
  ].join(""),
46
46
  });
47
47
 
48
+ /** How a subscriber folds a frame into what it already holds.
49
+ *
50
+ * Snapshot and delta share one payload type, which leaves one question the
51
+ * shape cannot answer: what a later frame does to the value before it. Stating
52
+ * it here means the daemon and the web UI fold the same way instead of each
53
+ * keeping its own table of which topic behaves how. */
54
+ export const TOPIC_GRANULARITIES = [
55
+ /** The frame is the whole value; it replaces everything held. */
56
+ "whole",
57
+ /** The frame is the whole of what its `instance` knows; it replaces that
58
+ * instance's entries and leaves every other instance's alone. What the
59
+ * subscriber holds is the union across instances. */
60
+ "per_instance_whole",
61
+ /** The frame carries the elements that changed, keyed by their own id.
62
+ * Elements it does not mention are untouched, so a removal has to be a
63
+ * marked element rather than an absence. */
64
+ "element",
65
+ /** The frame carries what has been added since the last one; the subscriber
66
+ * appends and never rewrites what it already has. */
67
+ "append",
68
+ /** The frame is an occurrence, not a value. Nothing is held, so there is
69
+ * nothing to snapshot: subscribing yields the next occurrence, never a
70
+ * current state. */
71
+ "event",
72
+ ] as const;
73
+
74
+ export type TopicGranularity = (typeof TOPIC_GRANULARITIES)[number];
75
+
48
76
  export interface TopicAttributes {
49
77
  readonly roles: readonly Role[];
50
78
  readonly capability?: Capability;
79
+ /** How a later frame relates to the value already held. Every topic but an
80
+ * `event` one opens with a `snapshot: true` frame. */
81
+ readonly granularity: TopicGranularity;
51
82
  }
52
83
 
53
- /** Who may subscribe to what. The same question the op table answers for ops:
54
- * a subscribe from a role outside the set answers `forbidden`, and one naming
55
- * a capability the instance lacks answers `capability_unavailable`. */
84
+ /** Who may subscribe to what, and how the frames fold. The same question the
85
+ * op table answers for ops: a subscribe from a role outside the set answers
86
+ * `forbidden`, and one naming a capability the instance lacks answers
87
+ * `capability_unavailable`. */
56
88
  export const TOPIC_ATTRIBUTES = {
57
- inbox: { roles: ["session", "user"] },
58
- notify: { roles: ["session", "user"] },
59
- peers: { roles: ["session", "user"] },
60
- agents: { roles: ["user"] },
61
- session_errors: { roles: ["user"] },
62
- llm_requests: { roles: ["user"], capability: "llm_events" },
63
- llm_status: { roles: ["user"], capability: "llm_status" },
64
- session_status: { roles: ["user"] },
65
- transcript: { roles: ["user"] },
66
- kv: { roles: ["user"] },
89
+ inbox: { roles: ["session", "user"], granularity: "element" },
90
+ notify: { roles: ["session", "user"], granularity: "event" },
91
+ peers: { roles: ["session", "user"], granularity: "per_instance_whole" },
92
+ agents: { roles: ["user"], granularity: "per_instance_whole" },
93
+ session_errors: { roles: ["user"], granularity: "per_instance_whole" },
94
+ llm_requests: {
95
+ roles: ["user"],
96
+ capability: "llm_events",
97
+ granularity: "per_instance_whole",
98
+ },
99
+ llm_status: { roles: ["user"], capability: "llm_status", granularity: "per_instance_whole" },
100
+ // One session lives on one instance, so its status has no other instance's
101
+ // half to leave alone: the frame is simply the whole of it.
102
+ session_status: { roles: ["user"], granularity: "whole" },
103
+ transcript: { roles: ["user"], granularity: "append" },
104
+ kv: { roles: ["user"], granularity: "element" },
67
105
  } as const satisfies Record<
68
106
  PlainTopic | SessionScopedTopic | NamespaceScopedTopic,
69
107
  TopicAttributes
@@ -77,6 +115,14 @@ export function topicKind(topic: string): TopicKind | undefined {
77
115
  return head in TOPIC_ATTRIBUTES ? head : undefined;
78
116
  }
79
117
 
118
+ /** How the frames of a topic name fold, taken from the name a subscriber
119
+ * actually uses — `session_status:<sid>` rather than the kind behind it.
120
+ * `undefined` for a name this generation does not define. */
121
+ export function topicGranularity(topic: string): TopicGranularity | undefined {
122
+ const kind = topicKind(topic);
123
+ return kind === undefined ? undefined : TOPIC_ATTRIBUTES[kind].granularity;
124
+ }
125
+
80
126
  export const TopicSubscribeArgs = Type.Object({ topic: Topic });
81
127
  export type TopicSubscribeArgs = Static<typeof TopicSubscribeArgs>;
82
128
 
@@ -249,8 +249,21 @@ export const LlmRequestInfo = Type.Object(
249
249
  * sessions, which is the other half of why the pair is the key. */
250
250
  prefix: Type.Optional(Type.String()),
251
251
  /** True for the series the session's own turns keep warm, as opposed to a
252
- * subagent's. The instance decides it so that every client agrees on which
253
- * window is the session's. */
252
+ * subagent's. The instance decides it, by the rule below, so that every
253
+ * client agrees on which window is the session's rather than each reading
254
+ * the same events into a different verdict.
255
+ *
256
+ * 1. `origin` decides it whenever the gateway states one. It watched the
257
+ * request go out and nothing here knows better.
258
+ * 2. Otherwise a `prefix` seen under two or more sessions is a subagent's:
259
+ * a series the session's own turns keep warm is not shared, so sharing
260
+ * is the evidence. Among the series left, the one that session used
261
+ * first wins — a session's own conversation starts before anything it
262
+ * spawns.
263
+ * 3. If every series of a session is shared, none of them is that session's
264
+ * own, so the newest is marked instead. It is the window a countdown
265
+ * would be about, and marking nothing would leave the session with no
266
+ * window at all. */
254
267
  main: Type.Boolean(),
255
268
  /** Whose turn issued the request, as the gateway read it. An open set; this
256
269
  * is one of the inputs to `main`, and `main` is the verdict clients read. */
@@ -72,6 +72,11 @@ export const PeerInfo = Type.Object(
72
72
  transcript_path: Type.Optional(SessionMetaFields.transcript_path),
73
73
  repo_root: Type.Optional(SessionMetaFields.repo_root),
74
74
  branch: Type.Optional(SessionMetaFields.branch),
75
+ /** The session's own title, as it named it. Also what resolves a display
76
+ * name for this session elsewhere — a message's `from_label`, a
77
+ * notification's `sid_label` — so the material for those is on the row that
78
+ * every client already holds. */
79
+ title: Type.Optional(SessionMetaFields.title),
75
80
  /** How this session stands. One of `waiting`, `live` or `live_unmanaged`:
76
81
  * a session in this list is connected, so it is by definition not gone.
77
82
  * Absent from an instance that states no classification, and a client then
@@ -91,6 +96,19 @@ export const PeerInfo = Type.Object(
91
96
  * while none has been found; a client orders such a session after every
92
97
  * session that has one rather than treating it as long ago. */
93
98
  last_user_input_at: Type.Optional(Timestamp),
99
+ /** When inference last ran for this session, as the gateway saw it.
100
+ *
101
+ * How busy a session is, carried as an attribute of the row rather than
102
+ * folded into `state`: a session is busy while it stands in any of the
103
+ * connected classifications, so the two answer different questions and
104
+ * collapsing them would lose one. It is an instant rather than a flag
105
+ * because there is no moment a request stops being in flight that anything
106
+ * observes — a client reads recency and decides its own threshold.
107
+ *
108
+ * Absent from an instance with no gateway configured, where nothing
109
+ * observes inference at all. That is not "idle": a client shows such a
110
+ * session without the mark rather than as quiet. */
111
+ gateway_active_at: Type.Optional(Timestamp),
94
112
  /** Whether the asking session can reach this one with the harness's own
95
113
  * cross-session messaging, which does not cross config homes. Computed
96
114
  * against the asker, so it never appears on the asker's own entry nor for a
package/src/errors.ts CHANGED
@@ -11,6 +11,13 @@ export const ERROR_CODES = [
11
11
  "unknown_op",
12
12
  /** An op with `needs_hello` arrived before `hello` settled the identity. */
13
13
  "hello_required",
14
+ /** The op's implementation failed for a reason that is not the caller's: the
15
+ * arguments were right and the call was allowed. It belongs beside the other
16
+ * connection-level codes because no op owns it — any op can fail this way, and
17
+ * naming it apart from `bad_request` is what keeps a caller from re-reading
18
+ * arguments that were never the problem. Whether retrying helps is not stated;
19
+ * `msg` is the only thing that says more. */
20
+ "internal_error",
14
21
  // --- rule-derived (op attribute table §0) ---
15
22
  /** The connection's role is outside the op's `roles`. Argument problems stay
16
23
  * on `invalid_args` / `bad_request`. */
@@ -8,6 +8,22 @@ export const Sid = Type.String({
8
8
  });
9
9
  export type Sid = Static<typeof Sid>;
10
10
 
11
+ /** Who sent a message: a session, or the person at the web UI.
12
+ *
13
+ * A person has no sid — the `user` role greets without one — so the sender of a
14
+ * message cannot be a `Sid` alone. The literal is spelled out rather than left
15
+ * as an absent field, because a reader has to tell "a person sent this" from "a
16
+ * session sent this and the id was lost". Every session id remains a valid
17
+ * sender, so a reader that only knew sids keeps working.
18
+ *
19
+ * There is one person per instance to a session's eye, so the literal carries
20
+ * no id of its own; which browser it was is not a thing this contract names. */
21
+ export const Sender = Type.Union([Sid, Type.Literal("user")], { $id: "Sender" });
22
+ export type Sender = Static<typeof Sender>;
23
+
24
+ /** The sender that is the person rather than a session. */
25
+ export const USER_SENDER = "user" as const;
26
+
11
27
  /** An instance id: the endpoint URL other instances dial, compared as a whole
12
28
  * string including its path (mesh-peer-auth §4.2 — one origin may host several
13
29
  * instances, so origin-level comparison would confuse them). The display name
@@ -1,5 +1,5 @@
1
1
  import type { InboxMessage } from "./message.ts";
2
- import type { Mid, Sid } from "../identifiers.ts";
2
+ import { type Mid, type Sender, USER_SENDER } from "../identifiers.ts";
3
3
 
4
4
  /** How an `InboxMessage` is worded when it is handed to a session through the
5
5
  * harness's own messaging socket rather than over this protocol.
@@ -39,15 +39,22 @@ export const DIRECT_DELIVERY_FROM_MODE = "prompting";
39
39
  * the frame being answered — so nothing has to be looked up to use it. A `mid`
40
40
  * does not name its sender, and the alternative to spelling the sid out here
41
41
  * would be an op that resolves one, which means a sent-message index the
42
- * daemon does not otherwise need. */
43
- export function directDeliveryReplyLine(mid: Mid, from: Sid): string {
44
- return `Reply with: ccmsg reply ${mid} --to ${from} <text>`;
42
+ * daemon does not otherwise need.
43
+ *
44
+ * A message from a person carries no addressee: `user` is not a sid and
45
+ * addressing it would be a send that fails. The line drops `--to` instead, and
46
+ * what an answer to a person becomes is the instance's to decide — it reaches
47
+ * them as a notification, which is a route the recipient does not have to know
48
+ * about to run this line. */
49
+ export function directDeliveryReplyLine(mid: Mid, from: Sender): string {
50
+ const to = from === USER_SENDER ? "" : ` --to ${from}`;
51
+ return `Reply with: ccmsg reply ${mid}${to} <text>`;
45
52
  }
46
53
 
47
54
  /** An `InboxMessage` as it reaches a session through the messaging socket. */
48
55
  export interface DirectDelivery {
49
56
  mid: Mid;
50
- from: Sid;
57
+ from: Sender;
51
58
  from_label: string;
52
59
  reply_to?: Mid;
53
60
  /** The sender's text, exactly as it was sent. */
@@ -1,6 +1,6 @@
1
1
  import { type Static, Type } from "@sinclair/typebox";
2
2
  import { request, response, topicFrame } from "../envelope.ts";
3
- import { InstanceId, Mid, Sid, Timestamp } from "../identifiers.ts";
3
+ import { InstanceId, Mid, Sender, Sid, Timestamp } from "../identifiers.ts";
4
4
 
5
5
  /** Why a message was not handed to its recipient right away.
6
6
  *
@@ -70,14 +70,20 @@ export const MessageSendResponse = response("message_send", MessageSendResult);
70
70
 
71
71
  /** A message as the recipient receives it, on topic `inbox`.
72
72
  *
73
- * To answer it, send to `from`. The route is the sender's id and nothing else,
74
- * so no reply instructions travel on the wire: the wording a session sees
75
- * belongs to whoever renders it — see `direct-delivery.ts` for the one route
76
- * whose recipient reads text instead of this frame. */
73
+ * To answer it, send to `from`. The route is the sender and nothing else, so no
74
+ * reply instructions travel on the wire: the wording a session sees belongs to
75
+ * whoever renders it — see `direct-delivery.ts` for the one route whose
76
+ * recipient reads text instead of this frame.
77
+ *
78
+ * A `from` of `user` is the exception: `message_send` addresses a sid, so there
79
+ * is no such thing as sending back to the person. An answer to one reaches them
80
+ * as a notification instead, which is the instance's to arrange — this contract
81
+ * only states that the sender can be a person, so a client stops treating one
82
+ * as a malformed message. */
77
83
  export const InboxMessage = Type.Object(
78
84
  {
79
85
  mid: Mid,
80
- from: Sid,
86
+ from: Sender,
81
87
  /** How the sender should be shown, resolved by the issuing instance. */
82
88
  from_label: Type.String(),
83
89
  text: Type.String(),