@ccmsg/cli 0.6.1 → 0.8.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 +2 -2
- package/src/daemon/registry.ts +6 -2
- package/src/instance/instance.ts +14 -4
- package/src/mesh/index.ts +1 -0
- package/src/mesh/instances.ts +76 -0
- package/src/mesh/mesh.ts +1 -1
- package/src/mesh/relay.ts +90 -42
- package/src/messaging/delivery.ts +17 -7
- package/src/sessions/classify.ts +12 -0
- package/src/sessions/dump.ts +4 -1
- package/src/sessions/items.ts +1 -1
- package/src/sessions/last-live.ts +22 -5
- package/src/sessions/registry.ts +63 -54
- package/src/topics/elements.ts +0 -0
- package/src/topics/index.ts +1 -0
- package/src/transcript/files.ts +38 -1
- package/src/transcript/items/classify.ts +117 -29
- package/src/transcript/items/document.ts +16 -7
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/render.ts +38 -0
- package/src/upstream/requests.ts +27 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "kawaz",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ccmsg/protocol": "1.
|
|
23
|
+
"@ccmsg/protocol": "1.20.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/daemon/registry.ts
CHANGED
|
@@ -115,12 +115,16 @@ const STARTING_PRESETS = [
|
|
|
115
115
|
{
|
|
116
116
|
name: "howto",
|
|
117
117
|
description: "調査のノウハウだけ。何を考えて何を叩いて何を読み書きしたか",
|
|
118
|
-
opts: {
|
|
118
|
+
opts: {
|
|
119
|
+
types: ["thinking", "message:user", "message:parent", "message:sub", "tool:Bash", "@file"],
|
|
120
|
+
},
|
|
119
121
|
},
|
|
120
122
|
{
|
|
121
123
|
name: "journal",
|
|
122
124
|
description: "日記用。人との往復と worker の答え、思考は要点だけ",
|
|
123
|
-
opts: {
|
|
125
|
+
opts: {
|
|
126
|
+
types: ["message:user", "message:parent", "message:sub:in", "message:team:in", "thinking"],
|
|
127
|
+
},
|
|
124
128
|
},
|
|
125
129
|
{
|
|
126
130
|
name: "handoff",
|
package/src/instance/instance.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
Transport,
|
|
63
63
|
type UpgradeDecision,
|
|
64
64
|
} from "../transport/index.ts";
|
|
65
|
-
import { Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
|
|
65
|
+
import { Instances, Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
|
|
66
66
|
import {
|
|
67
67
|
Gateway,
|
|
68
68
|
gatewayCapabilities,
|
|
@@ -317,6 +317,7 @@ export class Instance {
|
|
|
317
317
|
readonly #transport = new Transport();
|
|
318
318
|
readonly #topics: Topics;
|
|
319
319
|
readonly #sessions: Sessions;
|
|
320
|
+
readonly #instances: Instances;
|
|
320
321
|
readonly #status: SessionStatus;
|
|
321
322
|
readonly #transcripts: Transcripts;
|
|
322
323
|
readonly #gateway: Gateway;
|
|
@@ -401,10 +402,10 @@ export class Instance {
|
|
|
401
402
|
const stated = (data as { records?: AuthRecord[] } | undefined)?.records;
|
|
402
403
|
if (Array.isArray(stated)) this.#auth.merge(stated);
|
|
403
404
|
},
|
|
404
|
-
//
|
|
405
|
-
//
|
|
405
|
+
// The mesh view is this instance's own, so the topic that carries it is
|
|
406
|
+
// restated when that view moves (§7.5).
|
|
406
407
|
changed: () => {
|
|
407
|
-
this.#
|
|
408
|
+
this.#instances.refresh();
|
|
408
409
|
this.#linkMoved();
|
|
409
410
|
},
|
|
410
411
|
});
|
|
@@ -532,6 +533,15 @@ export class Instance {
|
|
|
532
533
|
publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
|
|
533
534
|
});
|
|
534
535
|
|
|
536
|
+
this.#instances = new Instances({
|
|
537
|
+
self: this.self,
|
|
538
|
+
...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self, mesh: this.#mesh }),
|
|
539
|
+
publish: (topic, data) => {
|
|
540
|
+
this.#topics.publish(topic, data);
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
this.#topics.attach("instances", this.#instances);
|
|
535
545
|
this.#topics.attach("peers", this.#sessions);
|
|
536
546
|
this.#topics.attach("agents", this.#sessions);
|
|
537
547
|
this.#topics.attach("inbox", this.#delivery);
|
package/src/mesh/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import type { Endpoint, InstanceId, InstanceInfo } from "@ccmsg/protocol";
|
|
3
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
4
|
+
|
|
5
|
+
/** What the `instances` topic reads: the mesh as this instance sees it, which
|
|
6
|
+
* is the same view `hello` answers with. */
|
|
7
|
+
export interface MeshView {
|
|
8
|
+
instances(): InstanceInfo[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The cluster as one instance sees it, itself included.
|
|
12
|
+
*
|
|
13
|
+
* An instance with no mesh is a cluster of one and says so: it is reached at
|
|
14
|
+
* whatever it serves, which is a row without an endpoint when that is the unix
|
|
15
|
+
* socket alone rather than no row at all (contract, `InstanceInfo`).
|
|
16
|
+
*
|
|
17
|
+
* The same answer feeds `hello` and the topic, from here rather than from two
|
|
18
|
+
* places: a greeting and a subscription that disagreed about who is in the
|
|
19
|
+
* cluster would be one instance stating two views of itself. */
|
|
20
|
+
export function clusterView(
|
|
21
|
+
self: InstanceId,
|
|
22
|
+
endpoint: Endpoint | undefined,
|
|
23
|
+
mesh: MeshView | undefined,
|
|
24
|
+
): InstanceInfo[] {
|
|
25
|
+
return (
|
|
26
|
+
mesh?.instances() ?? [
|
|
27
|
+
{
|
|
28
|
+
id: self,
|
|
29
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
30
|
+
host: hostname(),
|
|
31
|
+
reachable: true,
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `instances` topic (§7.5).
|
|
38
|
+
*
|
|
39
|
+
* A link going down reaches a subscriber where it is already listening, rather
|
|
40
|
+
* than only on its next greeting. The value is whole per instance: `reachable`
|
|
41
|
+
* is one instance's reading of every link it has, taken together, and two
|
|
42
|
+
* instances may legitimately disagree about the same link — so a frame states
|
|
43
|
+
* one sender's view entire and leaves every other sender's alone.
|
|
44
|
+
*
|
|
45
|
+
* Nothing is started or stopped by a subscription. The view exists because the
|
|
46
|
+
* mesh exists, and reading it costs a walk over the configured peers (§6.3:
|
|
47
|
+
* what subscription drives is a watch, and there is none here). */
|
|
48
|
+
export class Instances implements UpstreamResource {
|
|
49
|
+
constructor(
|
|
50
|
+
private readonly deps: {
|
|
51
|
+
readonly self: InstanceId;
|
|
52
|
+
readonly endpoint?: Endpoint;
|
|
53
|
+
readonly mesh?: MeshView;
|
|
54
|
+
readonly publish: (topic: string, data: unknown) => void;
|
|
55
|
+
},
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
view(): InstanceInfo[] {
|
|
59
|
+
return clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** State the view, which the mesh asks for whenever a link moves. A frame
|
|
63
|
+
* equal to the last one goes no further than the suppression every topic
|
|
64
|
+
* shares (M5), so restating it costs nothing when nothing moved. */
|
|
65
|
+
refresh(): void {
|
|
66
|
+
this.deps.publish("instances", { instances: this.view() });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
start(): void {}
|
|
70
|
+
|
|
71
|
+
stop(): void {}
|
|
72
|
+
|
|
73
|
+
snapshot(): readonly TopicValue[] {
|
|
74
|
+
return [{ instance: this.deps.self, data: { instances: this.view() } }];
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/mesh/mesh.ts
CHANGED
|
@@ -763,7 +763,7 @@ export class Mesh {
|
|
|
763
763
|
this.#host?.element(topic, instance as InstanceId, fields["data"]);
|
|
764
764
|
return true;
|
|
765
765
|
}
|
|
766
|
-
this.relay.accept(instance as InstanceId, topic, fields["data"]);
|
|
766
|
+
this.relay.accept(instance as InstanceId, topic, fields["data"], fields["snapshot"] === true);
|
|
767
767
|
return true;
|
|
768
768
|
}
|
|
769
769
|
|
package/src/mesh/relay.ts
CHANGED
|
@@ -1,40 +1,59 @@
|
|
|
1
|
+
import type { SessionRow } from "../topics/index.ts";
|
|
1
2
|
import {
|
|
2
|
-
type AgentInfo,
|
|
3
3
|
type InstanceId,
|
|
4
4
|
LAST_LIVE_RETENTION_MS,
|
|
5
|
-
type LastLiveSession,
|
|
6
|
-
type PeerInfo,
|
|
7
5
|
PLAIN_TOPICS,
|
|
8
6
|
type Sid,
|
|
9
7
|
TOPIC_ATTRIBUTES,
|
|
10
8
|
type Timestamp,
|
|
11
9
|
} from "@ccmsg/protocol";
|
|
12
|
-
import type
|
|
10
|
+
import { Elements, type TopicValue } from "../topics/index.ts";
|
|
11
|
+
|
|
12
|
+
/** The rows of sessions the whole cluster is seen through.
|
|
13
|
+
*
|
|
14
|
+
* They are `element`-granular, and an element topic is relayable only when its
|
|
15
|
+
* elements say whose they are: a row here names the instance that holds the
|
|
16
|
+
* session, so two instances' rows stand side by side under one topic name the
|
|
17
|
+
* way a whole value per instance does. `inbox` and `kv:<ns>` are elements of
|
|
18
|
+
* the same granularity and are not relayed, because their elements carry no
|
|
19
|
+
* such name — an `inbox` frame belongs to a session, not to an instance. */
|
|
20
|
+
const ROW_TOPICS: readonly string[] = ["peers", "agents"];
|
|
13
21
|
|
|
14
22
|
/** The topics a subscriber sees the whole cluster on.
|
|
15
23
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
(topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole",
|
|
25
|
-
);
|
|
24
|
+
* A per-instance whole is relayable by construction (§6.2): a frame replaces
|
|
25
|
+
* its own instance's entries and leaves every other instance's alone, so
|
|
26
|
+
* several instances can state the same topic name without colliding. The rows
|
|
27
|
+
* above are relayable for the same reason read one element at a time. */
|
|
28
|
+
export const CLUSTER_TOPICS: readonly string[] = [
|
|
29
|
+
...PLAIN_TOPICS.filter((topic) => TOPIC_ATTRIBUTES[topic].granularity === "per_instance_whole"),
|
|
30
|
+
...ROW_TOPICS,
|
|
31
|
+
];
|
|
26
32
|
|
|
27
33
|
/** The one topic the mesh carries that the relay does not.
|
|
28
34
|
*
|
|
29
|
-
* It is `element`-granular
|
|
30
|
-
*
|
|
31
|
-
*
|
|
35
|
+
* It is `element`-granular and its elements are the instances' own, so it is
|
|
36
|
+
* asked for as the instance rather than on a person's behalf, and folded into
|
|
37
|
+
* the set this instance holds rather than held here (DR-0001 §2.6). */
|
|
32
38
|
export const AUTH_TOPIC = "auth_records";
|
|
33
39
|
|
|
34
40
|
export function isClusterTopic(topic: string): boolean {
|
|
35
41
|
return CLUSTER_TOPICS.includes(topic);
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/** Whether what a frame of this topic carries is rows to be merged rather than
|
|
45
|
+
* a value to be replaced. */
|
|
46
|
+
function carriesRows(topic: string): boolean {
|
|
47
|
+
return ROW_TOPICS.includes(topic);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The rows of one frame, under the field each topic names them in. A frame
|
|
51
|
+
* that carries none is one there is nothing to merge from. */
|
|
52
|
+
function rowsOf(topic: string, data: unknown): readonly SessionRow[] {
|
|
53
|
+
const field = (data as Record<string, unknown> | undefined)?.[topic];
|
|
54
|
+
return Array.isArray(field) ? (field as SessionRow[]) : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
export interface RelayDeps {
|
|
39
58
|
/** Hand a relayed frame to this instance's own subscribers, under the
|
|
40
59
|
* instance that produced it (§7.4). */
|
|
@@ -70,19 +89,47 @@ export class Relay {
|
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
/** A frame a peer pushed on a topic this instance relays.
|
|
92
|
+
*
|
|
93
|
+
* `snapshot` marks the opening frame of a subscription, which carries the
|
|
94
|
+
* whole of what its instance holds rather than what changed.
|
|
73
95
|
*
|
|
74
96
|
* `instance` is the one that produced the value, which is not always the
|
|
75
97
|
* peer it arrived from: a mesh of three relays transitively, and the frame
|
|
76
98
|
* names its origin the whole way. Held under that origin, and passed on
|
|
77
99
|
* unchanged — recomputing it would put the same judgement in two places
|
|
78
100
|
* (§7.4). */
|
|
79
|
-
accept(instance: InstanceId, topic: string, data: unknown): void {
|
|
101
|
+
accept(instance: InstanceId, topic: string, data: unknown, snapshot = false): void {
|
|
80
102
|
if (!isClusterTopic(topic)) return;
|
|
81
103
|
this.#sweep();
|
|
82
104
|
const held = this.#held.get(instance) ?? new Map<string, unknown>();
|
|
83
105
|
this.#held.set(instance, held);
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
if (!carriesRows(topic)) {
|
|
107
|
+
held.set(topic, data);
|
|
108
|
+
this.deps.publish(topic, data, instance);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// A frame of rows says what changed, so what is held is the rows merged
|
|
112
|
+
// and what travels on is the part of it that said something. The same
|
|
113
|
+
// comparison the mechanism makes of a whole value, made of one element
|
|
114
|
+
// (M5) — and a frame left with no rows is not passed on at all, which is
|
|
115
|
+
// what stops a peer's restatement from becoming a frame for every local
|
|
116
|
+
// subscriber.
|
|
117
|
+
// An opening frame is the whole of what its instance holds, so it is taken
|
|
118
|
+
// as the list restated rather than as changes folded in: a row the peer no
|
|
119
|
+
// longer has is gone from it and from nowhere else, and merging would leave
|
|
120
|
+
// it here forever. What comes of that is the same kind of answer either
|
|
121
|
+
// way — the rows that told this instance something, removals included.
|
|
122
|
+
const rows = this.#rows(held, topic);
|
|
123
|
+
const stated = rowsOf(topic, data);
|
|
124
|
+
const news = snapshot ? rows.diff(stated) : rows.merge(stated);
|
|
125
|
+
if (news.length > 0) this.deps.publish(topic, { [topic]: news }, instance);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The rows one instance has stated on a topic, made the first time it does. */
|
|
129
|
+
#rows(held: Map<string, unknown>, topic: string): Elements {
|
|
130
|
+
const rows = (held.get(topic) as Elements | undefined) ?? new Elements();
|
|
131
|
+
held.set(topic, rows);
|
|
132
|
+
return rows;
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
/** The link to this instance is gone. What it said is kept and marked,
|
|
@@ -113,7 +160,16 @@ export class Relay {
|
|
|
113
160
|
const values: TopicValue[] = [];
|
|
114
161
|
for (const [instance, held] of this.#held) {
|
|
115
162
|
const data = held.get(topic);
|
|
116
|
-
if (data
|
|
163
|
+
if (data === undefined) continue;
|
|
164
|
+
// A topic of rows is held merged, and the opening frame of a topic is
|
|
165
|
+
// its whole value — so what a fresh subscriber is handed is every row
|
|
166
|
+
// that instance has stated, in one frame of the same shape as the ones
|
|
167
|
+
// that follow it.
|
|
168
|
+
values.push(
|
|
169
|
+
carriesRows(topic)
|
|
170
|
+
? { instance, data: { [topic]: (data as Elements).rows() } }
|
|
171
|
+
: { instance, data },
|
|
172
|
+
);
|
|
117
173
|
}
|
|
118
174
|
return values;
|
|
119
175
|
}
|
|
@@ -121,29 +177,21 @@ export class Relay {
|
|
|
121
177
|
/** Which instance a session belongs to, read from the cluster values the
|
|
122
178
|
* peers stated (§7.3).
|
|
123
179
|
*
|
|
124
|
-
* `peers` names every session an instance
|
|
125
|
-
*
|
|
126
|
-
* has no row there
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* instance still points at the session's own. */
|
|
180
|
+
* `peers` names every session an instance holds, connected and lost alike,
|
|
181
|
+
* and is checked first. A session whose greeting has not reached its
|
|
182
|
+
* instance yet has no row there while the harness may already know of it, so
|
|
183
|
+
* `agents` is checked next. Every row names its own instance rather than the
|
|
184
|
+
* one that relayed it, so a row that travelled through a third instance
|
|
185
|
+
* still points at the session's own. */
|
|
131
186
|
owner(sid: Sid): InstanceId | undefined {
|
|
132
187
|
this.#sweep();
|
|
133
|
-
for (const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const row = value?.agents?.find((agent) => agent.sid === sid);
|
|
141
|
-
if (row !== undefined) return row.instance;
|
|
142
|
-
}
|
|
143
|
-
for (const held of this.#held.values()) {
|
|
144
|
-
const value = held.get("peers") as { last_live?: LastLiveSession[] } | undefined;
|
|
145
|
-
const row = value?.last_live?.find((session) => session.sid === sid);
|
|
146
|
-
if (row !== undefined) return row.instance;
|
|
188
|
+
for (const topic of ROW_TOPICS) {
|
|
189
|
+
for (const held of this.#held.values()) {
|
|
190
|
+
const row = (held.get(topic) as Elements | undefined)
|
|
191
|
+
?.rows()
|
|
192
|
+
.find((held) => held.sid === sid);
|
|
193
|
+
if (row !== undefined) return row.instance;
|
|
194
|
+
}
|
|
147
195
|
}
|
|
148
196
|
return undefined;
|
|
149
197
|
}
|
|
@@ -3,7 +3,6 @@ import type {
|
|
|
3
3
|
CandidateSession,
|
|
4
4
|
InboxMessage,
|
|
5
5
|
InstanceId,
|
|
6
|
-
LastLiveSession,
|
|
7
6
|
MessageSendArgs,
|
|
8
7
|
MessageSendResult,
|
|
9
8
|
Mid,
|
|
@@ -15,6 +14,7 @@ import type {
|
|
|
15
14
|
UndeliveredReason,
|
|
16
15
|
} from "@ccmsg/protocol";
|
|
17
16
|
import { USER_SENDER } from "@ccmsg/protocol";
|
|
17
|
+
import { isLive } from "../sessions/classify.ts";
|
|
18
18
|
import {
|
|
19
19
|
type DispatchResult,
|
|
20
20
|
type HandlerInput,
|
|
@@ -35,7 +35,7 @@ const INBOX = "inbox";
|
|
|
35
35
|
* the reasons of §4.2 from growing a source per reason. */
|
|
36
36
|
export interface SessionLookup {
|
|
37
37
|
classify(sid: Sid): SessionState | undefined;
|
|
38
|
-
|
|
38
|
+
peerRows(): PeerInfo[];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** The rest of the cluster, for a message addressed outside this instance.
|
|
@@ -290,17 +290,27 @@ export class Delivery implements UpstreamResource {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/** Sessions live now in the repository the addressee belongs to (§4.2).
|
|
293
|
+
*
|
|
294
|
+
* The rows are one list of sessions, connected and lost alike, so which of
|
|
295
|
+
* them can be written to is the classification — asked of the domain, the
|
|
296
|
+
* same way the addressee's own reason was, rather than read off a field of
|
|
297
|
+
* the row (M1).
|
|
293
298
|
*
|
|
294
299
|
* The repository is `repo_root` as the session named it. A session that named
|
|
295
300
|
* none is left out rather than matched on something derived from its `cwd`:
|
|
296
301
|
* no primary source states that derivation, and the sessions domain does not
|
|
297
302
|
* make one up either. */
|
|
298
303
|
#candidates(to: Sid): CandidateSession[] {
|
|
299
|
-
const
|
|
300
|
-
const root =
|
|
304
|
+
const rows = this.deps.sessions.peerRows();
|
|
305
|
+
const root = rows.find((row) => row.sid === to)?.repo_root;
|
|
301
306
|
if (root === undefined) return [];
|
|
302
|
-
return
|
|
303
|
-
.filter(
|
|
307
|
+
return rows
|
|
308
|
+
.filter(
|
|
309
|
+
(peer) =>
|
|
310
|
+
peer.sid !== to &&
|
|
311
|
+
peer.repo_root === root &&
|
|
312
|
+
isLive({ state: this.deps.sessions.classify(peer.sid) }),
|
|
313
|
+
)
|
|
304
314
|
.map((peer) => ({
|
|
305
315
|
sid: peer.sid,
|
|
306
316
|
...(peer.ws === "" ? {} : { ws: peer.ws }),
|
|
@@ -374,7 +384,7 @@ function callerOf(input: HandlerInput): CallerIdentity | undefined {
|
|
|
374
384
|
* notification's subject are the same session seen from two ops, and a session
|
|
375
385
|
* shown one way there and another way here would read as two. */
|
|
376
386
|
export function sessionLabel(sessions: SessionLookup, sid: Sid): string {
|
|
377
|
-
const peer = sessions.
|
|
387
|
+
const peer = sessions.peerRows().find((row) => row.sid === sid);
|
|
378
388
|
if (peer === undefined) return sid;
|
|
379
389
|
const where = [peer.repo, peer.ws].filter((part) => part !== "").join("/");
|
|
380
390
|
return where === "" ? sid : where;
|
package/src/sessions/classify.ts
CHANGED
|
@@ -64,3 +64,15 @@ export function classify(
|
|
|
64
64
|
if (inputs.last_live === undefined) return undefined;
|
|
65
65
|
return inputs.last_live.stopped_at === undefined ? "disappeared" : "paused";
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/** Whether a classification is one of the connected ones, which is what a row
|
|
69
|
+
* has to be for anything here to reach it.
|
|
70
|
+
*
|
|
71
|
+
* Read off the classification rather than restated: the two sections the
|
|
72
|
+
* vocabulary is divided into are the contract's own, and a second list of
|
|
73
|
+
* which words mean "still there" would be a second place for the rule to drift
|
|
74
|
+
* in (M1). A row that states none is one no section holds, which is not a
|
|
75
|
+
* session anything can be handed to. */
|
|
76
|
+
export function isLive(row: { readonly state?: SessionState }): boolean {
|
|
77
|
+
return row.state !== undefined && row.state !== "paused" && row.state !== "disappeared";
|
|
78
|
+
}
|
package/src/sessions/dump.ts
CHANGED
|
@@ -75,7 +75,10 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
75
75
|
},
|
|
76
76
|
deps.presets,
|
|
77
77
|
);
|
|
78
|
-
const { items, entries } = select(
|
|
78
|
+
const { items, entries } = select(
|
|
79
|
+
within(classify(located(text), deps.files.subjectOf(file)), args),
|
|
80
|
+
keep,
|
|
81
|
+
);
|
|
79
82
|
const ids = ledger(items);
|
|
80
83
|
const written_at = Date.now();
|
|
81
84
|
// The file repeats what it was asked for. A dump outlives the request that
|
package/src/sessions/items.ts
CHANGED
|
@@ -68,7 +68,7 @@ export function itemsRead(
|
|
|
68
68
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
69
69
|
}
|
|
70
70
|
const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
|
|
71
|
-
const { items } = select(within(classify(located(text)), args), keep);
|
|
71
|
+
const { items } = select(within(classify(located(text), deps.files.subjectOf(file)), args), keep);
|
|
72
72
|
const page = paged(items, args.limit, backwards(args));
|
|
73
73
|
return {
|
|
74
74
|
items: page.items,
|
|
@@ -3,22 +3,39 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
LAST_LIVE_RETENTION_MS,
|
|
5
5
|
type InstanceId,
|
|
6
|
-
type
|
|
6
|
+
type PeerInfo,
|
|
7
7
|
type Sid,
|
|
8
8
|
type Timestamp,
|
|
9
9
|
} from "@ccmsg/protocol";
|
|
10
10
|
|
|
11
11
|
export const LAST_LIVE_FILE = "last-live.json";
|
|
12
12
|
|
|
13
|
-
/** What is stored per session: the
|
|
14
|
-
*
|
|
13
|
+
/** What is stored per session: the observations a lost session's row is built
|
|
14
|
+
* from, and nothing the row derives or a connection supplies.
|
|
15
15
|
*
|
|
16
16
|
* `state` is left out on purpose (M4). It follows from `stopped_at` and from
|
|
17
17
|
* whether the session is live again, both of which are known when the list is
|
|
18
18
|
* read, so storing it would be storing a conclusion that can go stale on disk.
|
|
19
19
|
* `pinned` is left out because no pin is held anywhere yet; when one is, it
|
|
20
|
-
* belongs to the session rather than to this list.
|
|
21
|
-
|
|
20
|
+
* belongs to the session rather than to this list. The connection fields go
|
|
21
|
+
* with the connection there is none of.
|
|
22
|
+
*
|
|
23
|
+
* `last_seen_at` is required here while the row states it optionally: a row
|
|
24
|
+
* this store holds is by definition one this instance has lost, and when it
|
|
25
|
+
* last saw it is what the retention window is measured from. */
|
|
26
|
+
export type StoredEntry = Omit<
|
|
27
|
+
PeerInfo,
|
|
28
|
+
| "state"
|
|
29
|
+
| "pinned"
|
|
30
|
+
| "last_activity_at"
|
|
31
|
+
| "last_user_input_at"
|
|
32
|
+
| "gateway_active_at"
|
|
33
|
+
| "send_message"
|
|
34
|
+
| "client_version"
|
|
35
|
+
| "protocol_version"
|
|
36
|
+
| "stale_client"
|
|
37
|
+
| "last_seen_at"
|
|
38
|
+
> & { last_seen_at: Timestamp };
|
|
22
39
|
|
|
23
40
|
interface Document {
|
|
24
41
|
version: number;
|
package/src/sessions/registry.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
|
-
import { hostname } from "node:os";
|
|
3
2
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
4
3
|
import {
|
|
5
4
|
type AgentInfo,
|
|
@@ -7,9 +6,10 @@ import {
|
|
|
7
6
|
type HelloArgs,
|
|
8
7
|
type HelloResult,
|
|
9
8
|
type Endpoint,
|
|
9
|
+
type AgentElement,
|
|
10
10
|
type InstanceId,
|
|
11
11
|
type InstanceInfo,
|
|
12
|
-
type
|
|
12
|
+
type PeerElement,
|
|
13
13
|
type PeerInfo,
|
|
14
14
|
PROTOCOL_VERSION,
|
|
15
15
|
type SessionState,
|
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
|
|
21
21
|
import { within } from "../files/index.ts";
|
|
22
22
|
import { HARNESS, type Harness } from "../harness/index.ts";
|
|
23
|
+
import { clusterView } from "../mesh/instances.ts";
|
|
23
24
|
import type { TranscriptFacts } from "../transcript/index.ts";
|
|
24
|
-
import type
|
|
25
|
+
import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
|
|
25
26
|
import { classify, type SessionInputs } from "./classify.ts";
|
|
26
27
|
import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
|
|
27
28
|
import { LastLiveStore, type StoredEntry } from "./last-live.ts";
|
|
@@ -211,6 +212,16 @@ export class Sessions implements UpstreamResource {
|
|
|
211
212
|
* then carries on stays connected and keeps its declaration, which is spent
|
|
212
213
|
* whenever it does leave. */
|
|
213
214
|
readonly #stopping = new Map<Sid, Timestamp>();
|
|
215
|
+
/** What the last frame of each topic left every subscriber holding, kept
|
|
216
|
+
* whether or not anybody is subscribed.
|
|
217
|
+
*
|
|
218
|
+
* A frame carries a difference, and the difference is taken against what was
|
|
219
|
+
* sent — not against what the last listener happened to see. Which is also
|
|
220
|
+
* what makes it right for a subscriber that arrives after a spell of nobody
|
|
221
|
+
* listening: it is handed every row as a snapshot, and every frame after it
|
|
222
|
+
* says what changed since the last one went out. */
|
|
223
|
+
readonly #sentPeers = new Elements();
|
|
224
|
+
readonly #sentAgents = new Elements();
|
|
214
225
|
|
|
215
226
|
constructor(private readonly deps: SessionsDeps) {
|
|
216
227
|
this.#harness = ownSessions(
|
|
@@ -294,18 +305,9 @@ export class Sessions implements UpstreamResource {
|
|
|
294
305
|
protocol_version: PROTOCOL_VERSION,
|
|
295
306
|
instance: this.deps.self,
|
|
296
307
|
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
// `InstanceInfo`).
|
|
301
|
-
instances: this.deps.mesh?.instances() ?? [
|
|
302
|
-
{
|
|
303
|
-
id: this.deps.self,
|
|
304
|
-
...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
|
|
305
|
-
host: hostname(),
|
|
306
|
-
reachable: true,
|
|
307
|
-
},
|
|
308
|
-
],
|
|
308
|
+
// The same view the `instances` topic carries, worked out in one place
|
|
309
|
+
// so a greeting and a subscription cannot state two different clusters.
|
|
310
|
+
instances: clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh),
|
|
309
311
|
capabilities: [...this.deps.capabilities],
|
|
310
312
|
version: this.deps.version,
|
|
311
313
|
started_at: this.deps.startedAt,
|
|
@@ -483,9 +485,21 @@ export class Sessions implements UpstreamResource {
|
|
|
483
485
|
if (this.#wanted.size === 0) this.#harness.stop();
|
|
484
486
|
}
|
|
485
487
|
|
|
488
|
+
/** What a fresh subscriber is handed: every row, connected and lost alike.
|
|
489
|
+
*
|
|
490
|
+
* A frame of either topic carries the rows that changed, so the opening one
|
|
491
|
+
* has to carry all of them — it is the only frame that states the rows a
|
|
492
|
+
* subscriber was not there for. Stating them is also what the difference
|
|
493
|
+
* after it is taken against: these rows are what the subscriber now holds,
|
|
494
|
+
* and they are the same rows every earlier subscriber was brought up to by
|
|
495
|
+
* the frames it has had. */
|
|
486
496
|
snapshot(topic: string): readonly TopicValue[] {
|
|
497
|
+
const now = Date.now();
|
|
487
498
|
const own = this.#own();
|
|
488
|
-
const data =
|
|
499
|
+
const data =
|
|
500
|
+
topic === "agents"
|
|
501
|
+
? { agents: this.#sentAgents.stated(this.agentRows(own)), polled_at: now }
|
|
502
|
+
: { peers: this.#sentPeers.stated(this.peerRows(now, own)) };
|
|
489
503
|
return [{ instance: this.deps.self, data }];
|
|
490
504
|
}
|
|
491
505
|
|
|
@@ -495,9 +509,11 @@ export class Sessions implements UpstreamResource {
|
|
|
495
509
|
return this.#harness.running;
|
|
496
510
|
}
|
|
497
511
|
|
|
498
|
-
/**
|
|
499
|
-
* instance last saw it.
|
|
500
|
-
*
|
|
512
|
+
/** Every row `peers` states: what is live now, and what was live when this
|
|
513
|
+
* instance last saw it. One kind of row rather than two lists, because
|
|
514
|
+
* coming back and going quiet are the same row changing its `state` — a
|
|
515
|
+
* client that held two lists would have to move an entry between them to
|
|
516
|
+
* follow one field.
|
|
501
517
|
*
|
|
502
518
|
* Live is not the same as connected (§5.2). A session the harness names is
|
|
503
519
|
* live whether or not it ever greeted us, and it has to be on this list for
|
|
@@ -510,41 +526,27 @@ export class Sessions implements UpstreamResource {
|
|
|
510
526
|
* — this instance is one that classifies, so it says so on every row rather
|
|
511
527
|
* than on the rows it happens to have an answer for.
|
|
512
528
|
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
return {
|
|
525
|
-
peers: [
|
|
526
|
-
...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
|
|
527
|
-
...[...own.present]
|
|
528
|
-
.filter((sid) => !this.#connected.has(sid))
|
|
529
|
-
.map((sid) => this.#unconnected(sid, now, own)),
|
|
530
|
-
],
|
|
531
|
-
last_live: this.#lastLive.entries(now).map((entry) => ({
|
|
529
|
+
* A lost session is a row here rather than a list of its own: its entry in
|
|
530
|
+
* the store holds what was observed, and the two fields a row derives —
|
|
531
|
+
* where it stands now, and whether it is pinned — are worked out at read
|
|
532
|
+
* time (M4). */
|
|
533
|
+
peerRows(now: Timestamp = Date.now(), own: Own = this.#own()): PeerInfo[] {
|
|
534
|
+
return [
|
|
535
|
+
...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
|
|
536
|
+
...[...own.present]
|
|
537
|
+
.filter((sid) => !this.#connected.has(sid))
|
|
538
|
+
.map((sid) => this.#unconnected(sid, now, own)),
|
|
539
|
+
...this.#lastLive.entries(now).map((entry) => ({
|
|
532
540
|
...entry,
|
|
533
541
|
state: this.classify(entry.sid, now, own) ?? "disappeared",
|
|
534
542
|
pinned: this.#pinned(entry.sid),
|
|
535
543
|
})),
|
|
536
|
-
|
|
537
|
-
};
|
|
544
|
+
];
|
|
538
545
|
}
|
|
539
546
|
|
|
540
|
-
/**
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
* make every confirmation poll a value the list did not have before, so the
|
|
544
|
-
* one suppression every topic shares (M5) would let a five-second heartbeat
|
|
545
|
-
* through for a directory that had not changed. */
|
|
546
|
-
agents(own: Own = this.#own()): { agents: AgentInfo[] } {
|
|
547
|
-
return { agents: [...own.rows.values()] };
|
|
547
|
+
/** Every row `agents` states: the harness's own view, as it stated it. */
|
|
548
|
+
agentRows(own: Own = this.#own()): AgentInfo[] {
|
|
549
|
+
return [...own.rows.values()];
|
|
548
550
|
}
|
|
549
551
|
|
|
550
552
|
/** Bind a session to this instance, and take what it says about itself. Its
|
|
@@ -596,11 +598,16 @@ export class Sessions implements UpstreamResource {
|
|
|
596
598
|
this.changed();
|
|
597
599
|
}
|
|
598
600
|
|
|
599
|
-
/** Recompute, record what stopped being live, and state both
|
|
601
|
+
/** Recompute, record what stopped being live, and state what moved on both
|
|
602
|
+
* topics.
|
|
600
603
|
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
+
* Both carry the rows that changed, so what is published is the difference
|
|
605
|
+
* against what was last published rather than the whole list: a session
|
|
606
|
+
* whose inference just ran is one row, and restating every row to say so
|
|
607
|
+
* would send a list to report one field. A recompute that found nothing
|
|
608
|
+
* different publishes nothing — the suppression every topic shares (M5) is
|
|
609
|
+
* whole-value and cannot drop a frame of elements, so the diff is where a
|
|
610
|
+
* repeat stops here. */
|
|
604
611
|
private changed(now: Timestamp = Date.now()): void {
|
|
605
612
|
const own = this.#own();
|
|
606
613
|
const live = this.#liveNow(now, own);
|
|
@@ -622,8 +629,10 @@ export class Sessions implements UpstreamResource {
|
|
|
622
629
|
}
|
|
623
630
|
this.#reclaim(live);
|
|
624
631
|
this.#live = live;
|
|
625
|
-
this.
|
|
626
|
-
this.deps.publish("
|
|
632
|
+
const peers = this.#sentPeers.diff(this.peerRows(now, own)) as PeerElement[];
|
|
633
|
+
if (peers.length > 0) this.deps.publish("peers", { peers });
|
|
634
|
+
const agents = this.#sentAgents.diff(this.agentRows(own)) as AgentElement[];
|
|
635
|
+
if (agents.length > 0) this.deps.publish("agents", { agents, polled_at: now });
|
|
627
636
|
this.deps.onChanged?.();
|
|
628
637
|
}
|
|
629
638
|
|
|
Binary file
|
package/src/topics/index.ts
CHANGED
package/src/transcript/files.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import type { Sid } from "@ccmsg/protocol";
|
|
3
|
+
import type { Sid, TranscriptSubject } from "@ccmsg/protocol";
|
|
4
4
|
import { type Harness, HARNESS } from "../harness/index.ts";
|
|
5
5
|
import { OpError } from "../dispatch/index.ts";
|
|
6
6
|
|
|
7
7
|
const SUFFIX = ".jsonl";
|
|
8
8
|
|
|
9
|
+
/** What the harness names an agent's transcript with, and what it calls the
|
|
10
|
+
* kind of task a teammate is. Both are its own words, read where they are
|
|
11
|
+
* written rather than mirrored anywhere. */
|
|
12
|
+
const AGENT_PREFIX = "agent-";
|
|
13
|
+
const TEAMMATE_TASK = "in_process_teammate";
|
|
14
|
+
|
|
9
15
|
/** Where one harness keeps transcripts under its config home, and how a file
|
|
10
16
|
* there says which session it belongs to (§3.8).
|
|
11
17
|
*
|
|
@@ -126,6 +132,37 @@ export class TranscriptFiles {
|
|
|
126
132
|
return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
/** Which standing a transcript was written from, which every item read out
|
|
136
|
+
* of it states (§3.6).
|
|
137
|
+
*
|
|
138
|
+
* The file itself does not say whether an agent was a teammate or an errand:
|
|
139
|
+
* both are marked as sidechains and both are briefed the same way. What says
|
|
140
|
+
* so is the harness's own note beside the file — the same note a teammate is
|
|
141
|
+
* found by name in — and `taskKind` on it is the harness stating which kind
|
|
142
|
+
* of task it started. An envelope in the opening brief looks like the same
|
|
143
|
+
* answer and is not one: it is text somebody wrote, and an errand handed a
|
|
144
|
+
* quoted message carries it too.
|
|
145
|
+
*
|
|
146
|
+
* A note that is missing or unreadable leaves the question unanswered, and
|
|
147
|
+
* the answer then is `sub`: an errand is the standing that claims the least —
|
|
148
|
+
* nothing goes on standing, nobody is addressed by name — so a teammate read
|
|
149
|
+
* as one loses a name it might have been drawn under, where the reverse would
|
|
150
|
+
* have a reader write back to something that is already gone. */
|
|
151
|
+
subjectOf(file: string): TranscriptSubject {
|
|
152
|
+
const name = basename(file);
|
|
153
|
+
if (!name.startsWith(AGENT_PREFIX) || !name.endsWith(SUFFIX)) return "main";
|
|
154
|
+
let note: unknown;
|
|
155
|
+
try {
|
|
156
|
+
note = JSON.parse(
|
|
157
|
+
readFileSync(join(dirname(file), `${name.slice(0, -SUFFIX.length)}.meta.json`), "utf8"),
|
|
158
|
+
);
|
|
159
|
+
} catch {
|
|
160
|
+
return "sub";
|
|
161
|
+
}
|
|
162
|
+
const kind = (note as { taskKind?: unknown } | null)?.taskKind;
|
|
163
|
+
return kind === TEAMMATE_TASK ? "team" : "sub";
|
|
164
|
+
}
|
|
165
|
+
|
|
129
166
|
/** A teammate's transcript, found by the name it is addressed by.
|
|
130
167
|
*
|
|
131
168
|
* The name a teammate carries in conversation is not its filename, so the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TranscriptSubject } from "@ccmsg/protocol";
|
|
1
2
|
import type { Item } from "./item.ts";
|
|
2
3
|
import {
|
|
3
4
|
count,
|
|
@@ -84,6 +85,7 @@ const OUTSTANDING_CALLS = 4096;
|
|
|
84
85
|
type Draft = Record<string, unknown> & {
|
|
85
86
|
id: string;
|
|
86
87
|
uuid: string;
|
|
88
|
+
subject: TranscriptSubject;
|
|
87
89
|
source: { offset: number; bytes: number };
|
|
88
90
|
type: string;
|
|
89
91
|
at: number;
|
|
@@ -96,8 +98,8 @@ type Draft = Record<string, unknown> & {
|
|
|
96
98
|
* apart the harness wrote them. Reading the whole file before any range is
|
|
97
99
|
* applied is what makes `parent_item` answerable: a result inside the range
|
|
98
100
|
* whose call fell before it still names the call. */
|
|
99
|
-
export function classify(records: Iterable<Located
|
|
100
|
-
const state = new Classification();
|
|
101
|
+
export function classify(records: Iterable<Located>, subject: TranscriptSubject = "main"): Item[] {
|
|
102
|
+
const state = new Classification(subject);
|
|
101
103
|
return state.readAll(records);
|
|
102
104
|
}
|
|
103
105
|
|
|
@@ -117,6 +119,24 @@ export class Classification {
|
|
|
117
119
|
#turn = 0;
|
|
118
120
|
/** The last slash command invoked, which is what its output belongs to. */
|
|
119
121
|
#slash: string | undefined;
|
|
122
|
+
/** Whose file this is, which every item states and which decides who is at
|
|
123
|
+
* the other end of a plain line. A session's own transcript has a person
|
|
124
|
+
* there; a file written for an agent has whoever started it, and calling that
|
|
125
|
+
* `user` would have a reader take a machine for a person.
|
|
126
|
+
*
|
|
127
|
+
* It is told rather than read out of the records, because what tells a
|
|
128
|
+
* teammate from an errand is not in the transcript at all: the harness states
|
|
129
|
+
* it beside the file, and whoever opened the file has already read that (§3.6).
|
|
130
|
+
*
|
|
131
|
+
* A record marked as a sidechain inside a file opened as a session's own says
|
|
132
|
+
* the file is an agent's after all, and the reading moves to `sub` — the
|
|
133
|
+
* standing that claims the least. It only ever narrows: a reading told which
|
|
134
|
+
* agent's file it has is not talked out of it by the records. */
|
|
135
|
+
#subject: TranscriptSubject;
|
|
136
|
+
|
|
137
|
+
constructor(subject: TranscriptSubject = "main") {
|
|
138
|
+
this.#subject = subject;
|
|
139
|
+
}
|
|
120
140
|
|
|
121
141
|
/** The records of one chunk as the items they were read as, oldest first.
|
|
122
142
|
*
|
|
@@ -147,6 +167,7 @@ export class Classification {
|
|
|
147
167
|
read(record: Row, source: { offset: number; bytes: number }): void {
|
|
148
168
|
const type = str(record["type"]);
|
|
149
169
|
if (type === undefined || NOT_ITEMS.has(type)) return;
|
|
170
|
+
if (record["isSidechain"] === true && this.#subject === "main") this.#subject = "sub";
|
|
150
171
|
// A record the harness wrote without an id of its own still happened, and
|
|
151
172
|
// an item is pointed at by the record it came from — so where the record
|
|
152
173
|
// stands in the file stands in for the id it lacks. The `@` says which of
|
|
@@ -162,6 +183,7 @@ export class Classification {
|
|
|
162
183
|
const draft: Draft = {
|
|
163
184
|
id: `${uuid}:${String(index)}`,
|
|
164
185
|
uuid,
|
|
186
|
+
subject: this.#subject,
|
|
165
187
|
source,
|
|
166
188
|
type: kind,
|
|
167
189
|
at,
|
|
@@ -239,7 +261,12 @@ export class Classification {
|
|
|
239
261
|
}
|
|
240
262
|
if (kind === "text") {
|
|
241
263
|
const said = str(fields["text"])?.trim();
|
|
242
|
-
|
|
264
|
+
// An agent's words are addressed to whoever started it — the last of
|
|
265
|
+
// them is the answer it was started for, and the ones before are what
|
|
266
|
+
// it hands back mid-flight. No call carries them, which is why
|
|
267
|
+
// `parent:out` is prose as well as a call.
|
|
268
|
+
const kind = this.#subject === "main" ? "message:user:out" : "message:parent:out";
|
|
269
|
+
if (said !== undefined && said !== "") make(kind, { text: said });
|
|
243
270
|
continue;
|
|
244
271
|
}
|
|
245
272
|
if (kind === "tool_use") this.#call(fields, make);
|
|
@@ -267,29 +294,51 @@ export class Classification {
|
|
|
267
294
|
});
|
|
268
295
|
let message: Draft | undefined;
|
|
269
296
|
if (SPAWNS.has(name)) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
297
|
+
// A name is what makes an agent a teammate: it stands under that name,
|
|
298
|
+
// can be written to again, and answers whenever it writes back. An agent
|
|
299
|
+
// started without one is an errand — it runs once, answers the call that
|
|
300
|
+
// started it, and is done.
|
|
301
|
+
const named = str(input["name"]) ?? str(input["team_name"]);
|
|
302
|
+
message =
|
|
303
|
+
named === undefined
|
|
304
|
+
? make("message:sub:out", {
|
|
305
|
+
role: "use",
|
|
306
|
+
tool_use_id: id,
|
|
307
|
+
prompt: str(input["prompt"]) ?? "",
|
|
308
|
+
...optional("subagent_type", str(input["subagent_type"])),
|
|
309
|
+
...optional("description", str(input["description"])),
|
|
310
|
+
})
|
|
311
|
+
: make("message:team:out", {
|
|
312
|
+
role: "use",
|
|
313
|
+
tool_use_id: id,
|
|
314
|
+
text: str(input["prompt"]) ?? "",
|
|
315
|
+
harness_name: named,
|
|
316
|
+
...optional("subagent_type", str(input["subagent_type"])),
|
|
317
|
+
...optional("description", str(input["description"])),
|
|
318
|
+
});
|
|
278
319
|
} else if (name === "SendMessage") {
|
|
279
320
|
const to = str(input["to"]) ?? "";
|
|
280
|
-
// A sid is the harness's own uuid
|
|
281
|
-
// is
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
321
|
+
// A sid is the harness's own uuid, so a message addressed by one goes to
|
|
322
|
+
// another session. Everything else is a name: the one above answers to
|
|
323
|
+
// the names a harness gives a lead, and any other name is somebody
|
|
324
|
+
// standing alongside.
|
|
325
|
+
if (addressed(to)) {
|
|
326
|
+
make("message:session:out", { text: text(input["message"]) ?? "", to });
|
|
327
|
+
} else {
|
|
328
|
+
// Writing to an agent is one direction of a correspondence, not a call
|
|
329
|
+
// that returns: what it says back arrives as its own message whenever
|
|
330
|
+
// it chooses to send one, under nothing that names this. So the
|
|
331
|
+
// message says it is waiting for nothing, and a reader is not left
|
|
332
|
+
// watching for an answer that has no way in.
|
|
333
|
+
make(LEADS.has(to) ? "message:parent:out" : "message:team:out", {
|
|
334
|
+
role: "use",
|
|
335
|
+
tool_use_id: id,
|
|
336
|
+
text: text(input["message"]) ?? "",
|
|
337
|
+
harness_name: to,
|
|
338
|
+
...optional("summary", str(input["summary"])),
|
|
339
|
+
one_way: true,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
293
342
|
} else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
|
|
294
343
|
make("message:session:out", { text: str(input["command"]) ?? "" });
|
|
295
344
|
}
|
|
@@ -375,7 +424,7 @@ export class Classification {
|
|
|
375
424
|
// decides is whether an answer came back, not which tool was called.
|
|
376
425
|
const said = answered(result["content"]);
|
|
377
426
|
if (said === undefined) return;
|
|
378
|
-
const reply = make(
|
|
427
|
+
const reply = make(answers(call.message), {
|
|
379
428
|
role: "result",
|
|
380
429
|
parent_item: call.message.id,
|
|
381
430
|
parent_tool_use_id: id,
|
|
@@ -434,17 +483,32 @@ export class Classification {
|
|
|
434
483
|
// stands, being told what to do is not the same as being written to.
|
|
435
484
|
if (record["parentUuid"] === null) {
|
|
436
485
|
this.#turn += 1;
|
|
437
|
-
make("message:user:in"
|
|
486
|
+
make(this.#subject === "main" ? "message:user:in" : "message:parent:in", {
|
|
487
|
+
text: said,
|
|
488
|
+
...(this.#subject === "main" ? {} : envelope(said)),
|
|
489
|
+
});
|
|
438
490
|
return;
|
|
439
491
|
}
|
|
440
|
-
if (said.includes("<cross-session-message")
|
|
492
|
+
if (said.includes("<cross-session-message")) {
|
|
441
493
|
make("message:session:in", {
|
|
442
494
|
text: said,
|
|
443
|
-
...optional("from", attribute(said, "from")
|
|
495
|
+
...optional("from", attribute(said, "from")),
|
|
444
496
|
...optional("msg_id", attribute(said, "mid")),
|
|
445
497
|
});
|
|
446
498
|
return;
|
|
447
499
|
}
|
|
500
|
+
if (said.includes("<teammate-message")) {
|
|
501
|
+
// Who wrote decides which it is. A lead is the one above, whatever the
|
|
502
|
+
// subject's own place in the team; anyone else writing under their own
|
|
503
|
+
// name is somebody standing alongside, and what they send is a message
|
|
504
|
+
// of its own rather than the answer to anything.
|
|
505
|
+
const from = attribute(said, "teammate_id");
|
|
506
|
+
make(LEADS.has(from ?? "") ? "message:parent:in" : "message:team:in", {
|
|
507
|
+
text: said,
|
|
508
|
+
...envelope(said),
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
448
512
|
if (record["isMeta"] === true) {
|
|
449
513
|
make("system:unknown", { record });
|
|
450
514
|
return;
|
|
@@ -471,7 +535,7 @@ export class Classification {
|
|
|
471
535
|
const call = this.#calls.get(key);
|
|
472
536
|
if (answer !== undefined && call !== undefined) {
|
|
473
537
|
const asked = call.message ?? call.tool;
|
|
474
|
-
const item = make(
|
|
538
|
+
const item = make(answers(asked), {
|
|
475
539
|
role: "result",
|
|
476
540
|
parent_item: asked.id,
|
|
477
541
|
parent_tool_use_id: key,
|
|
@@ -503,6 +567,30 @@ function addressed(to: string): boolean {
|
|
|
503
567
|
return SID.test(to);
|
|
504
568
|
}
|
|
505
569
|
|
|
570
|
+
/** The names a harness gives the one above. They are the harness's own words
|
|
571
|
+
* rather than a relation anyone chose, which is why they are matched here and
|
|
572
|
+
* not carried into the type: what the type says is that this is the parent,
|
|
573
|
+
* and the spelling stays on the item as the name it was addressed by. */
|
|
574
|
+
const LEADS = new Set(["main", "team-lead"]);
|
|
575
|
+
|
|
576
|
+
/** Who a message came from and under which id, as the envelope the harness
|
|
577
|
+
* wraps one in says it. The name is the harness's own spelling and not a sid,
|
|
578
|
+
* which is why it travels as `harness_name`: what the type already said is
|
|
579
|
+
* which party this was, and the name is only what that party was called. */
|
|
580
|
+
function envelope(said: string): Record<string, unknown> {
|
|
581
|
+
return {
|
|
582
|
+
...optional("harness_name", attribute(said, "teammate_id")),
|
|
583
|
+
...optional("msg_id", attribute(said, "mid")),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The type an agent's answer arrives under, which is the other half of
|
|
588
|
+
* whatever asked for it: a teammate's run ending answers the call that started
|
|
589
|
+
* it, an errand's answer is the errand's result. */
|
|
590
|
+
function answers(asked: Draft): string {
|
|
591
|
+
return asked.type === "message:team:out" ? "message:team:in" : "message:sub:in";
|
|
592
|
+
}
|
|
593
|
+
|
|
506
594
|
/** Whether a shell command is this session speaking to another one. */
|
|
507
595
|
function isCcmsgSend(command: string | undefined): boolean {
|
|
508
596
|
if (command === undefined) return false;
|
|
@@ -83,7 +83,7 @@ function heading(file: SessionDumpFile, view: DumpView): string[] {
|
|
|
83
83
|
function draw(item: Item, child: Item | undefined, view: DumpView, parent?: string): string[] {
|
|
84
84
|
const own = fragment(item);
|
|
85
85
|
const answer = child === undefined ? undefined : fragment(child);
|
|
86
|
-
const nested = child !== undefined && child.type
|
|
86
|
+
const nested = child !== undefined && spoken(child.type);
|
|
87
87
|
const link = isResult(item)
|
|
88
88
|
? arrow("←", parent)
|
|
89
89
|
: (arrow("→", fields(item)["result_item"]) ?? waiting(item));
|
|
@@ -240,7 +240,7 @@ function pair(items: readonly Item[]): {
|
|
|
240
240
|
// A pair the reader would have to scroll between is left where each half
|
|
241
241
|
// happened, unless it is an agent's: what an agent was asked and what it
|
|
242
242
|
// answered are one exchange whatever fell between them.
|
|
243
|
-
if (!item.type
|
|
243
|
+
if (!spoken(item.type) && call !== at - 1) continue;
|
|
244
244
|
child.set(call, at);
|
|
245
245
|
folded.add(at);
|
|
246
246
|
}
|
|
@@ -248,10 +248,19 @@ function pair(items: readonly Item[]): {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/** The key an exchange is joined on. One call the harness gave a key to is two
|
|
251
|
-
* items where it
|
|
252
|
-
* side of the exchange goes into the key: a tool's answer belongs to
|
|
253
|
-
* and an agent's to the
|
|
254
|
-
* which. */
|
|
251
|
+
* items where it also addressed somebody — the call and the message beside it
|
|
252
|
+
* — so the side of the exchange goes into the key: a tool's answer belongs to
|
|
253
|
+
* the call and an agent's to the message, and the harness's key alone would
|
|
254
|
+
* not say which. */
|
|
255
255
|
function joined(item: Item, key: string): string {
|
|
256
|
-
return `${item.type.startsWith("message:
|
|
256
|
+
return `${item.type.startsWith("message:") ? "message" : "tool"}\n${key}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Whether an item is the conversation half of starting an agent, as opposed
|
|
260
|
+
* to the call's own half. What was asked and what came back is one exchange
|
|
261
|
+
* however many turns fell between them, so these are drawn together wherever
|
|
262
|
+
* they ended up — a conversation split across the page is one nobody can
|
|
263
|
+
* follow. */
|
|
264
|
+
function spoken(type: string): boolean {
|
|
265
|
+
return type.startsWith("message:sub") || type.startsWith("message:team");
|
|
257
266
|
}
|
|
Binary file
|
|
@@ -88,6 +88,44 @@ const ITEMS: Record<string, Draw> = {
|
|
|
88
88
|
body: lines(str(item, "text")),
|
|
89
89
|
}),
|
|
90
90
|
|
|
91
|
+
// The one above and the ones alongside. A name is on the heading wherever
|
|
92
|
+
// the record gave one — an answer handed back as prose names nobody, and a
|
|
93
|
+
// heading that invented a name for it would say more than the file does.
|
|
94
|
+
"message:parent:in": (item) => ({
|
|
95
|
+
head: words(field(item, "harness_name", "from="), mid(item)),
|
|
96
|
+
body: lines(str(item, "text")),
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
"message:parent:out": (item) => ({
|
|
100
|
+
head: words(field(item, "harness_name", "to="), str(item, "summary")),
|
|
101
|
+
body: lines(str(item, "text")),
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
"message:team:out": (item) => ({
|
|
105
|
+
head: words(
|
|
106
|
+
field(item, "harness_name", "to="),
|
|
107
|
+
field(item, "agent_id", "agent="),
|
|
108
|
+
field(item, "subagent_type", "type="),
|
|
109
|
+
str(item, "description"),
|
|
110
|
+
str(item, "summary"),
|
|
111
|
+
),
|
|
112
|
+
body: lines(str(item, "text")),
|
|
113
|
+
}),
|
|
114
|
+
|
|
115
|
+
// Both halves of a teammate's correspondence arrive under one type: a letter
|
|
116
|
+
// it wrote, which names who wrote it, and its run ending, which names how it
|
|
117
|
+
// ended. Each heading says whichever of those the item carried.
|
|
118
|
+
"message:team:in": (item) => ({
|
|
119
|
+
head: words(
|
|
120
|
+
field(item, "harness_name", "from="),
|
|
121
|
+
mid(item),
|
|
122
|
+
field(item, "agent_id", "agent="),
|
|
123
|
+
field(item, "status", "status="),
|
|
124
|
+
elapsed(num(item, "duration_ms")),
|
|
125
|
+
),
|
|
126
|
+
body: lines(str(item, "text")),
|
|
127
|
+
}),
|
|
128
|
+
|
|
91
129
|
"message:session:out": (item) => ({
|
|
92
130
|
head: words(field(item, "to", "to="), field(item, "reply_to", "reply_to="), mid(item)),
|
|
93
131
|
body: lines(str(item, "text")),
|
package/src/upstream/requests.ts
CHANGED
|
@@ -18,6 +18,14 @@ export interface LlmRequestsDeps {
|
|
|
18
18
|
readonly onActivity?: () => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Which of a session's gateway facts moved.
|
|
22
|
+
*
|
|
23
|
+
* `live` is the only one the sessions domain has to hear: it is the moment the
|
|
24
|
+
* classification of §5.1 can change, because the window either opened or
|
|
25
|
+
* closed. `clock` is the same session seen again inside a window that was
|
|
26
|
+
* already open — the value of an attribute, not a section anything is in. */
|
|
27
|
+
type GatewayMove = "live" | "clock" | "none";
|
|
28
|
+
|
|
21
29
|
/** Series remembered at once. The prune below already holds this near the
|
|
22
30
|
* number active in the last cache window; the cap is what bounds a gateway
|
|
23
31
|
* whose clock runs ahead, whose events would otherwise never expire. */
|
|
@@ -69,7 +77,7 @@ export class LlmRequests implements UpstreamResource {
|
|
|
69
77
|
* near-ordered in practice, but a redelivery can put an older one after a
|
|
70
78
|
* newer, and a countdown must not walk backwards. */
|
|
71
79
|
record(info: LlmRequestObservation): void {
|
|
72
|
-
this.active(info.sid, info.received_at);
|
|
80
|
+
if (this.active(info.sid, info.received_at) === "live") this.deps.onActivity?.();
|
|
73
81
|
const key = seriesKey(info.sid, info.prefix);
|
|
74
82
|
const held = this.#series.get(key);
|
|
75
83
|
if (held !== undefined && held.info.received_at >= info.received_at) return;
|
|
@@ -91,7 +99,7 @@ export class LlmRequests implements UpstreamResource {
|
|
|
91
99
|
* the window belongs to the request that opened it — and only says the
|
|
92
100
|
* session was still running inference at that instant. */
|
|
93
101
|
note(sid: Sid, at: Timestamp): void {
|
|
94
|
-
if (this.active(sid, at)) this.deps.onActivity?.();
|
|
102
|
+
if (this.active(sid, at) === "live") this.deps.onActivity?.();
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/** When the gateway last saw inference for a session (§5.1). Undefined once
|
|
@@ -136,21 +144,32 @@ export class LlmRequests implements UpstreamResource {
|
|
|
136
144
|
|
|
137
145
|
private publish(): void {
|
|
138
146
|
this.deps.publish("llm_requests", this.entries());
|
|
139
|
-
this.deps.onActivity?.();
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
/** Note the session was seen, and say
|
|
143
|
-
|
|
149
|
+
/** Note the session was seen, and say what that moved.
|
|
150
|
+
*
|
|
151
|
+
* A session already inside its window moves its clock and nothing else. The
|
|
152
|
+
* sessions domain is told about `live` alone, because telling it about every
|
|
153
|
+
* event would restate the whole of `peers` once per call a session makes:
|
|
154
|
+
* inference is observed several times a second and the row it lands on
|
|
155
|
+
* differs only in an attribute (§5.2). */
|
|
156
|
+
private active(sid: Sid, at: Timestamp): GatewayMove {
|
|
144
157
|
const held = this.#activeAt.get(sid);
|
|
145
|
-
if (held !== undefined && held >= at) return
|
|
158
|
+
if (held !== undefined && held >= at) return "none";
|
|
159
|
+
const wasLive = held !== undefined && at - held <= GATEWAY_LIVE_WINDOW_MS;
|
|
146
160
|
this.#activeAt.set(sid, at);
|
|
147
161
|
// Sessions the gateway has not seen for longer than the window go: what is
|
|
148
162
|
// left is what any of this can still say something about.
|
|
149
|
-
|
|
163
|
+
this.prune(at);
|
|
164
|
+
return wasLive ? "clock" : "live";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Drop the sessions whose window has closed. */
|
|
168
|
+
private prune(now: Timestamp): void {
|
|
169
|
+
const floor = now - GATEWAY_LIVE_WINDOW_MS;
|
|
150
170
|
for (const [seen, when] of this.#activeAt) {
|
|
151
171
|
if (when < floor) this.#activeAt.delete(seen);
|
|
152
172
|
}
|
|
153
|
-
return true;
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
private notePrefix(info: LlmRequestObservation): void {
|