@ccmsg/cli 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/auth/auth.ts +14 -14
- package/src/auth/http.ts +6 -6
- package/src/auth/records.ts +1 -1
- package/src/auth/topic.ts +1 -1
- package/src/cli.ts +21 -24
- package/src/daemon/control.ts +2 -2
- package/src/daemon/registry.ts +7 -7
- package/src/daemon/snapshot.ts +12 -10
- package/src/daemon/supervise.ts +1 -1
- package/src/files/containment.ts +5 -5
- package/src/files/files.ts +12 -12
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/meta.ts +5 -2
- package/src/instance/config.ts +21 -9
- package/src/instance/instance.ts +15 -13
- package/src/kv/store.ts +4 -3
- package/src/launcher/launcher.ts +3 -3
- package/src/mesh/mesh.ts +7 -8
- package/src/mesh/relay.ts +1 -1
- package/src/messaging/delivery.ts +4 -4
- package/src/messaging/direct.ts +2 -2
- package/src/messaging/handlers.ts +4 -4
- package/src/messaging/inbox.ts +1 -1
- package/src/messaging/notify.ts +11 -11
- package/src/sessions/dump.ts +1 -1
- package/src/sessions/handlers.ts +17 -17
- package/src/sessions/harness.ts +1 -1
- package/src/sessions/last-live.ts +1 -1
- package/src/sessions/registry.ts +60 -77
- package/src/sessions/search.ts +1 -1
- package/src/sessions/status.ts +7 -7
- package/src/topics/handlers.ts +2 -2
- package/src/topics/topics.ts +0 -0
- package/src/transcript/items/classify.ts +42 -37
- package/src/transcript/items/document.ts +2 -2
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/render.ts +24 -24
- package/src/transcript/items/select.ts +5 -5
- package/src/transcript/transcripts.ts +3 -3
- package/src/translate/translate.ts +1 -1
- package/src/transport/driver.ts +13 -11
- package/src/upstream/events.ts +108 -10
- package/src/upstream/gateway.ts +10 -5
- package/src/upstream/requests.ts +103 -9
- package/src/upstream/status.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -4,7 +4,7 @@ import { fields, type Item } from "./item.ts";
|
|
|
4
4
|
*
|
|
5
5
|
* A type is drawn by one function, the way the same type is drawn by one
|
|
6
6
|
* component where the destination is a screen instead of text. What the two
|
|
7
|
-
* share is the classification; how a `tool
|
|
7
|
+
* share is the classification; how a `tool.Bash` reads is the drawing's own
|
|
8
8
|
* business, and neither side carries the other's.
|
|
9
9
|
*
|
|
10
10
|
* A type nobody wrote a drawing for is still drawn. The generic shape says the
|
|
@@ -33,8 +33,8 @@ const EMPTY: readonly string[] = [];
|
|
|
33
33
|
/** The drawing for one item, whatever its type. */
|
|
34
34
|
export function fragment(item: Item): Fragment {
|
|
35
35
|
const type = item.type;
|
|
36
|
-
if (type.startsWith("tool
|
|
37
|
-
const name = type.slice("tool
|
|
36
|
+
if (type.startsWith("tool.")) {
|
|
37
|
+
const name = type.slice("tool.".length);
|
|
38
38
|
const result = isResult(item);
|
|
39
39
|
const draw = (result ? RESULTS : USES)[name];
|
|
40
40
|
if (draw !== undefined) return draw(item);
|
|
@@ -44,8 +44,8 @@ export function fragment(item: Item): Fragment {
|
|
|
44
44
|
}
|
|
45
45
|
const draw = ITEMS[type];
|
|
46
46
|
if (draw !== undefined) return draw(item);
|
|
47
|
-
if (type.startsWith("hook
|
|
48
|
-
if (type.startsWith("system
|
|
47
|
+
if (type.startsWith("hook.")) return hook(item);
|
|
48
|
+
if (type.startsWith("system.attachment."))
|
|
49
49
|
return { head: "", body: summary(fields(item)["attachment"]) };
|
|
50
50
|
return { head: "", body: summary(own(item)) };
|
|
51
51
|
}
|
|
@@ -60,16 +60,16 @@ function isResult(item: Item): boolean {
|
|
|
60
60
|
* kept as they were written: a dump is read to find out what somebody actually
|
|
61
61
|
* wrote, and a reader who wants less asks for less. */
|
|
62
62
|
const SAID: readonly string[] = [
|
|
63
|
-
"message
|
|
64
|
-
"message
|
|
63
|
+
"message.user.in",
|
|
64
|
+
"message.user.out",
|
|
65
65
|
"thinking",
|
|
66
|
-
"system
|
|
66
|
+
"system.compact",
|
|
67
67
|
];
|
|
68
68
|
|
|
69
69
|
const ITEMS: Record<string, Draw> = {
|
|
70
70
|
...Object.fromEntries(SAID.map((type) => [type, said])),
|
|
71
71
|
|
|
72
|
-
"message
|
|
72
|
+
"message.sub.out": (item) => ({
|
|
73
73
|
head: words(
|
|
74
74
|
field(item, "agent_id", "agent="),
|
|
75
75
|
field(item, "subagent_type", "type="),
|
|
@@ -79,7 +79,7 @@ const ITEMS: Record<string, Draw> = {
|
|
|
79
79
|
body: lines(str(item, "prompt")),
|
|
80
80
|
}),
|
|
81
81
|
|
|
82
|
-
"message
|
|
82
|
+
"message.sub.in": (item) => ({
|
|
83
83
|
head: words(
|
|
84
84
|
field(item, "agent_id", "agent="),
|
|
85
85
|
field(item, "status", "status="),
|
|
@@ -91,17 +91,17 @@ const ITEMS: Record<string, Draw> = {
|
|
|
91
91
|
// The one above and the ones alongside. A name is on the heading wherever
|
|
92
92
|
// the record gave one — an answer handed back as prose names nobody, and a
|
|
93
93
|
// heading that invented a name for it would say more than the file does.
|
|
94
|
-
"message
|
|
94
|
+
"message.parent.in": (item) => ({
|
|
95
95
|
head: words(field(item, "harness_name", "from="), mid(item)),
|
|
96
96
|
body: lines(str(item, "text")),
|
|
97
97
|
}),
|
|
98
98
|
|
|
99
|
-
"message
|
|
99
|
+
"message.parent.out": (item) => ({
|
|
100
100
|
head: words(field(item, "harness_name", "to="), str(item, "summary")),
|
|
101
101
|
body: lines(str(item, "text")),
|
|
102
102
|
}),
|
|
103
103
|
|
|
104
|
-
"message
|
|
104
|
+
"message.team.out": (item) => ({
|
|
105
105
|
head: words(
|
|
106
106
|
field(item, "harness_name", "to="),
|
|
107
107
|
field(item, "agent_id", "agent="),
|
|
@@ -115,7 +115,7 @@ const ITEMS: Record<string, Draw> = {
|
|
|
115
115
|
// Both halves of a teammate's correspondence arrive under one type: a letter
|
|
116
116
|
// it wrote, which names who wrote it, and its run ending, which names how it
|
|
117
117
|
// ended. Each heading says whichever of those the item carried.
|
|
118
|
-
"message
|
|
118
|
+
"message.team.in": (item) => ({
|
|
119
119
|
head: words(
|
|
120
120
|
field(item, "harness_name", "from="),
|
|
121
121
|
mid(item),
|
|
@@ -126,12 +126,12 @@ const ITEMS: Record<string, Draw> = {
|
|
|
126
126
|
body: lines(str(item, "text")),
|
|
127
127
|
}),
|
|
128
128
|
|
|
129
|
-
"message
|
|
129
|
+
"message.session.out": (item) => ({
|
|
130
130
|
head: words(field(item, "to", "to="), field(item, "reply_to", "reply_to="), mid(item)),
|
|
131
131
|
body: lines(str(item, "text")),
|
|
132
132
|
}),
|
|
133
133
|
|
|
134
|
-
"message
|
|
134
|
+
"message.session.in": (item) => ({
|
|
135
135
|
head: words(field(item, "from", "from="), mid(item)),
|
|
136
136
|
body: lines(str(item, "text")),
|
|
137
137
|
}),
|
|
@@ -140,15 +140,15 @@ const ITEMS: Record<string, Draw> = {
|
|
|
140
140
|
// voice. Both are why a conversation jumps rather than part of it, so they
|
|
141
141
|
// are a line each and the line names what happened. Everything the record
|
|
142
142
|
// held is in the JSON dump beside this one, addressable by the id shown.
|
|
143
|
-
"notice
|
|
143
|
+
"notice.slash": (item) => ({
|
|
144
144
|
head: words(`/${str(item, "command") ?? ""}`, str(item, "args"), first(str(item, "stdout"))),
|
|
145
145
|
body: EMPTY,
|
|
146
146
|
}),
|
|
147
|
-
"notice
|
|
148
|
-
"system
|
|
149
|
-
"system
|
|
150
|
-
"system
|
|
151
|
-
"system
|
|
147
|
+
"notice.interrupt": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
|
|
148
|
+
"system.api.error": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
|
|
149
|
+
"system.caveat": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
|
|
150
|
+
"system.resume": (item) => ({ head: first(str(item, "text")) ?? "", body: EMPTY }),
|
|
151
|
+
"system.task": (item) => ({
|
|
152
152
|
head: words(
|
|
153
153
|
field(item, "task_id", "task="),
|
|
154
154
|
field(item, "event", "event="),
|
|
@@ -156,7 +156,7 @@ const ITEMS: Record<string, Draw> = {
|
|
|
156
156
|
),
|
|
157
157
|
body: EMPTY,
|
|
158
158
|
}),
|
|
159
|
-
"system
|
|
159
|
+
"system.unknown": (item) => ({ head: "", body: summary(fields(item)["record"]) }),
|
|
160
160
|
};
|
|
161
161
|
|
|
162
162
|
function said(item: Item): Fragment {
|
|
@@ -214,7 +214,7 @@ const USES: Record<string, Draw> = {
|
|
|
214
214
|
Glob: pattern,
|
|
215
215
|
WebFetch: (item) => ({ head: words(str(item, "url"), str(item, "prompt")), body: EMPTY }),
|
|
216
216
|
WebSearch: (item) => ({ head: field(item, "query", "query=") ?? "", body: EMPTY }),
|
|
217
|
-
// The brief itself is the `message
|
|
217
|
+
// The brief itself is the `message.sub.out` beside this call, so the call
|
|
218
218
|
// says which agent was started and leaves the words to the message.
|
|
219
219
|
Agent: (item) => ({
|
|
220
220
|
head: words(
|
|
@@ -11,8 +11,8 @@ import type { Item } from "./item.ts";
|
|
|
11
11
|
* shape a person actually reaches for — every tool but the reads, the whole
|
|
12
12
|
* conversation but not the thinking.
|
|
13
13
|
*
|
|
14
|
-
* A prefix matches at segment boundaries, so `tool` reaches `tool
|
|
15
|
-
* `message
|
|
14
|
+
* A prefix matches at segment boundaries, so `tool` reaches `tool.Bash` and
|
|
15
|
+
* `message.user` reaches both directions, while `notice` never reaches a type
|
|
16
16
|
* that merely starts with those letters. */
|
|
17
17
|
|
|
18
18
|
/** What a dump keeps when nobody said: every family there is, less the
|
|
@@ -29,7 +29,7 @@ const DEFAULT_TYPES = [
|
|
|
29
29
|
"notice",
|
|
30
30
|
"system",
|
|
31
31
|
"hook",
|
|
32
|
-
"-system
|
|
32
|
+
"-system.attachment",
|
|
33
33
|
];
|
|
34
34
|
|
|
35
35
|
export interface Selection {
|
|
@@ -55,7 +55,7 @@ export interface Ask {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const NO_THINKING = ["-thinking"];
|
|
58
|
-
const NO_AGENT = ["-message
|
|
58
|
+
const NO_AGENT = ["-message.sub", "-tool.Agent"];
|
|
59
59
|
|
|
60
60
|
export function selection(ask: Ask, presets: readonly DumpPreset[]): Selection {
|
|
61
61
|
const asked = [...(ask.preset?.opts.types ?? []), ...(ask.types ?? [])];
|
|
@@ -94,7 +94,7 @@ function decide(type: string, elements: readonly string[]): boolean {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function reaches(name: string, type: string): boolean {
|
|
97
|
-
return type === name || type.startsWith(`${name}
|
|
97
|
+
return type === name || type.startsWith(`${name}.`);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/** A preset named in a selection, put where it was named.
|
|
@@ -4,7 +4,7 @@ import { NO_FACTS, type TranscriptFacts, TranscriptFold } from "./fold.ts";
|
|
|
4
4
|
import { Classification, type Item, positioned } from "./items/index.ts";
|
|
5
5
|
import { type Appended, TranscriptTail } from "./tail.ts";
|
|
6
6
|
|
|
7
|
-
/** How many items a subscription to `
|
|
7
|
+
/** How many items a subscription to `transcript.items:<sid>` opens with.
|
|
8
8
|
*
|
|
9
9
|
* The tail of the same megabyte the fold is seeded from, bounded by a count
|
|
10
10
|
* because that read is bounded by bytes: a file of many small records would
|
|
@@ -17,7 +17,7 @@ export const ITEMS_SNAPSHOT = 200;
|
|
|
17
17
|
/** Which of the two topics a name is. Both are fed by one tail, so the
|
|
18
18
|
* resource is entered by either name and answers each in its own vocabulary. */
|
|
19
19
|
function isItems(topic: string): boolean {
|
|
20
|
-
return topic.startsWith("
|
|
20
|
+
return topic.startsWith("transcript.items:");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface TranscriptsDeps {
|
|
@@ -183,7 +183,7 @@ export class Transcripts implements UpstreamResource {
|
|
|
183
183
|
// A record still being written was not read, so there is nothing to say
|
|
184
184
|
// about it yet; a chunk whose records were all the interface's own
|
|
185
185
|
// bookkeeping says nothing either.
|
|
186
|
-
if (items.length > 0) this.deps.publish(`
|
|
186
|
+
if (items.length > 0) this.deps.publish(`transcript.items:${sid}`, { sid, items });
|
|
187
187
|
if (changed) this.deps.onFacts(sid);
|
|
188
188
|
}
|
|
189
189
|
|
|
@@ -121,7 +121,7 @@ function read(line: string, id: string, expected: number): TranslateResult[] {
|
|
|
121
121
|
|
|
122
122
|
export function translateHandlers(translate: Translate) {
|
|
123
123
|
return {
|
|
124
|
-
|
|
124
|
+
"translate.run": (input: HandlerInput): Promise<TranslateRunResult> =>
|
|
125
125
|
translate.run(input.args as unknown as TranslateRunArgs),
|
|
126
126
|
};
|
|
127
127
|
}
|
package/src/transport/driver.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { helloRole, isHelloOp, MAX_FRAME_BYTES, type Sid } from "@ccmsg/protocol";
|
|
2
2
|
import { type DispatchResult, failure, type Requester } from "../dispatch/index.ts";
|
|
3
3
|
import type { Conn } from "./conn.ts";
|
|
4
4
|
|
|
@@ -8,10 +8,9 @@ export interface FrameHandler {
|
|
|
8
8
|
(frame: unknown, conn: Requester): Promise<DispatchResult>;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** The
|
|
12
|
-
*
|
|
13
|
-
* other op is opaque to it. */
|
|
14
|
-
const HELLO = "hello" satisfies OpName;
|
|
11
|
+
/** The ops whose reply settles the connection's identity. Transport knows these
|
|
12
|
+
* three op names because binding the identity is its job (daemon-v2 §3.1);
|
|
13
|
+
* every other op is opaque to it. */
|
|
15
14
|
|
|
16
15
|
/** Drive one connection: a line in, a frame answered on the same connection.
|
|
17
16
|
*
|
|
@@ -67,19 +66,22 @@ export function createDriver(conn: Conn, handle: FrameHandler) {
|
|
|
67
66
|
};
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
/** Bind role and sid at the moment
|
|
69
|
+
/** Bind role and sid at the moment a greeting's reply goes out.
|
|
71
70
|
*
|
|
72
|
-
* The
|
|
73
|
-
*
|
|
74
|
-
*
|
|
71
|
+
* The role is read from the op that carried the greeting rather than from a
|
|
72
|
+
* field of it: there is one op per role, so the name is the only place the
|
|
73
|
+
* role is said. The frame is safe to read because dispatch only answers
|
|
74
|
+
* `reply` after the op's own schema accepted it, so `sid` — asked for by
|
|
75
|
+
* `hello.session` and by neither of the others — is a sid. */
|
|
75
76
|
function settleIfHello(conn: Conn, frame: unknown, result: DispatchResult): void {
|
|
76
77
|
if (result.kind !== "reply") return;
|
|
77
78
|
const fields = frame as Record<string, unknown>;
|
|
78
|
-
|
|
79
|
+
const op = fields["op"];
|
|
80
|
+
if (typeof op !== "string" || !isHelloOp(op)) return;
|
|
79
81
|
const sid = fields["sid"];
|
|
80
82
|
conn.settle({
|
|
81
83
|
state: "settled",
|
|
82
|
-
role:
|
|
84
|
+
role: helloRole(op),
|
|
83
85
|
...(typeof sid === "string" ? { sid: sid as Sid } : {}),
|
|
84
86
|
});
|
|
85
87
|
}
|
package/src/upstream/events.ts
CHANGED
|
@@ -9,11 +9,50 @@ import type { LlmRequestInfo, Sid, Timestamp } from "@ccmsg/protocol";
|
|
|
9
9
|
* nothing about which instance received it. */
|
|
10
10
|
export type LlmRequestObservation = Omit<LlmRequestInfo, "main" | "instance">;
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
12
|
+
/** How the prompt cache actually worked for one request, as the gateway read it
|
|
13
|
+
* off the answer's usage. The gateway's own closed vocabulary: a word outside
|
|
14
|
+
* it is dropped rather than carried, so nothing downstream has to decide what
|
|
15
|
+
* an unknown verdict means for a countdown. */
|
|
16
|
+
export type CacheResult = "hit" | "written" | "partial" | "none" | "unknown";
|
|
17
|
+
|
|
18
|
+
const CACHE_RESULTS: readonly CacheResult[] = ["hit", "written", "partial", "none", "unknown"];
|
|
19
|
+
|
|
20
|
+
function cacheResultOf(value: unknown): CacheResult | undefined {
|
|
21
|
+
return CACHE_RESULTS.find((result) => result === value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An answer the gateway saw close. It says inference for that session has
|
|
25
|
+
* stopped running and when, and it carries the one thing only an answer knows:
|
|
26
|
+
* whether the cache the request counted on was actually there. That verdict
|
|
27
|
+
* belongs to a series, so the series is named too. */
|
|
14
28
|
export interface LlmResponseObservation {
|
|
15
29
|
readonly sid: Sid;
|
|
16
30
|
readonly at: Timestamp;
|
|
31
|
+
readonly prefix?: string;
|
|
32
|
+
readonly cache?: CacheResult;
|
|
33
|
+
/** The instant of the request this is the answer to. A series has several
|
|
34
|
+
* requests in flight, so it is what says which of them this verdict is
|
|
35
|
+
* about. */
|
|
36
|
+
readonly request_at?: Timestamp;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A keepalive the gateway raised into a conversation. Nothing here replays it;
|
|
40
|
+
* what is read is the name of the promise it carries, so a later withdrawal can
|
|
41
|
+
* be matched against it. On this notice the name is the signal's own `nonce`. */
|
|
42
|
+
export interface CacheKeepaliveObservation {
|
|
43
|
+
readonly sid: Sid;
|
|
44
|
+
readonly prefix?: string;
|
|
45
|
+
readonly notice: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The gateway withdrawing a promised lifetime by name. `of` names one promise
|
|
49
|
+
* and only that one: a series whose latest promise is a different name has been
|
|
50
|
+
* extended by someone else since, and this notice says nothing about it. */
|
|
51
|
+
export interface CacheExpiredObservation {
|
|
52
|
+
readonly sid: Sid;
|
|
53
|
+
readonly prefix?: string;
|
|
54
|
+
readonly of: string;
|
|
55
|
+
readonly at: Timestamp;
|
|
17
56
|
}
|
|
18
57
|
|
|
19
58
|
/** One item of a posted batch, as this instance reads it.
|
|
@@ -23,15 +62,25 @@ export interface LlmResponseObservation {
|
|
|
23
62
|
* difference is the whole value of the log line: a batch of ignorable items is
|
|
24
63
|
* the gateway working, a batch of unreadable ones is a schema that moved. */
|
|
25
64
|
export type GatewayItem =
|
|
26
|
-
| {
|
|
65
|
+
| {
|
|
66
|
+
readonly kind: "request";
|
|
67
|
+
readonly info: LlmRequestObservation;
|
|
68
|
+
/** The name of the lifetime this request promised, when it promised one.
|
|
69
|
+
* Kept beside the observation rather than inside it: it is how two
|
|
70
|
+
* notices of the gateway's are matched to each other, and nothing a
|
|
71
|
+
* client reads (§3.5). */
|
|
72
|
+
readonly notice?: string;
|
|
73
|
+
}
|
|
27
74
|
| { readonly kind: "response"; readonly info: LlmResponseObservation }
|
|
75
|
+
| { readonly kind: "keepalive"; readonly info: CacheKeepaliveObservation }
|
|
76
|
+
| { readonly kind: "cache_expired"; readonly info: CacheExpiredObservation }
|
|
28
77
|
| { readonly kind: "ignored" };
|
|
29
78
|
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
const IGNORED = new Set(["
|
|
79
|
+
/** A kind the gateway posts that nothing here reads: its keepalive strategy
|
|
80
|
+
* being held off for a session. It is named rather than reached as "not a
|
|
81
|
+
* request", so a kind the gateway grows still arrives as unreadable and shows
|
|
82
|
+
* up in the log. */
|
|
83
|
+
const IGNORED = new Set(["keepalive_paused"]);
|
|
35
84
|
|
|
36
85
|
/** The fields whose name is the same on both sides, and whose value is already
|
|
37
86
|
* this contract's unit — a count of seconds, or an instant in Unix ms. */
|
|
@@ -76,13 +125,54 @@ export function parseGatewayItem(value: unknown): GatewayItem | undefined {
|
|
|
76
125
|
const info = responseOf(raw);
|
|
77
126
|
return info === undefined ? undefined : { kind: "response", info };
|
|
78
127
|
}
|
|
128
|
+
if (kind === "cache_keepalive") {
|
|
129
|
+
const info = keepaliveOf(raw);
|
|
130
|
+
// A signal that named no promise is still the gateway working: it is the
|
|
131
|
+
// notice this instance has nothing to match later, not one it misread.
|
|
132
|
+
return info === undefined ? { kind: "ignored" } : { kind: "keepalive", info };
|
|
133
|
+
}
|
|
134
|
+
if (kind === "cache_expired") {
|
|
135
|
+
const info = expiredOf(raw);
|
|
136
|
+
return info === undefined ? undefined : { kind: "cache_expired", info };
|
|
137
|
+
}
|
|
79
138
|
// The forwarding notice is the one kind that carries no mark, because it
|
|
80
139
|
// existed before the others did. So it is a request by position, and only
|
|
81
140
|
// when it names no kind at all: an item that names one and is not handled
|
|
82
141
|
// above must not be read as a request whose fields happen to line up.
|
|
83
142
|
if (kind !== undefined) return undefined;
|
|
84
143
|
const info = requestOf(raw);
|
|
85
|
-
|
|
144
|
+
if (info === undefined) return undefined;
|
|
145
|
+
const notice = raw["cache_notice"];
|
|
146
|
+
return {
|
|
147
|
+
kind: "request",
|
|
148
|
+
info,
|
|
149
|
+
...(typeof notice === "string" && notice !== "" ? { notice } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function keepaliveOf(raw: Record<string, unknown>): CacheKeepaliveObservation | undefined {
|
|
154
|
+
const sid = raw["session_id"];
|
|
155
|
+
// On this notice the promise's name and the signal's own password are the
|
|
156
|
+
// same value, stated under either field, so both are read as the one name.
|
|
157
|
+
const notice = raw["cache_notice"] ?? raw["nonce"];
|
|
158
|
+
if (typeof sid !== "string" || sid === "") return undefined;
|
|
159
|
+
if (typeof notice !== "string" || notice === "") return undefined;
|
|
160
|
+
return { sid, notice, ...seriesOf(raw) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function expiredOf(raw: Record<string, unknown>): CacheExpiredObservation | undefined {
|
|
164
|
+
const at = raw["ts"];
|
|
165
|
+
const sid = raw["session_id"];
|
|
166
|
+
const of = raw["of"];
|
|
167
|
+
if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
|
|
168
|
+
if (typeof of !== "string" || of === "") return undefined;
|
|
169
|
+
return { sid, of, at, ...seriesOf(raw) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The series half of a key, when the notice names one. */
|
|
173
|
+
function seriesOf(raw: Record<string, unknown>): { prefix?: string } {
|
|
174
|
+
const prefix = raw["prefix"];
|
|
175
|
+
return typeof prefix === "string" && prefix !== "" ? { prefix } : {};
|
|
86
176
|
}
|
|
87
177
|
|
|
88
178
|
function requestOf(raw: Record<string, unknown>): LlmRequestObservation | undefined {
|
|
@@ -115,7 +205,15 @@ function responseOf(raw: Record<string, unknown>): LlmResponseObservation | unde
|
|
|
115
205
|
const at = raw["ts"];
|
|
116
206
|
const sid = raw["session_id"];
|
|
117
207
|
if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
|
|
118
|
-
|
|
208
|
+
const cache = cacheResultOf(raw["cache"]);
|
|
209
|
+
const requestAt = raw["request_ts"];
|
|
210
|
+
return {
|
|
211
|
+
sid,
|
|
212
|
+
at,
|
|
213
|
+
...seriesOf(raw),
|
|
214
|
+
...(cache === undefined ? {} : { cache }),
|
|
215
|
+
...(isInstant(requestAt) ? { request_at: requestAt } : {}),
|
|
216
|
+
};
|
|
119
217
|
}
|
|
120
218
|
|
|
121
219
|
/** A number that can be an instant on this wire. Rejecting a non-number is
|
package/src/upstream/gateway.ts
CHANGED
|
@@ -95,13 +95,13 @@ export function gatewayHandlers(setup: GatewaySetup, fetcher?: typeof fetch) {
|
|
|
95
95
|
...(usageUrl === undefined
|
|
96
96
|
? {}
|
|
97
97
|
: {
|
|
98
|
-
|
|
98
|
+
"llm.usage.read": (input: HandlerInput): Promise<LlmUsageReadResult> =>
|
|
99
99
|
readUsage({ url: usageUrl, ...call }, input.args as unknown as LlmUsageReadArgs),
|
|
100
100
|
}),
|
|
101
101
|
...(statsUrl === undefined
|
|
102
102
|
? {}
|
|
103
103
|
: {
|
|
104
|
-
|
|
104
|
+
"llm.stats.read": (input: HandlerInput): Promise<LlmStatsReadResult> =>
|
|
105
105
|
readStats({ url: statsUrl, ...call }, input.args as unknown as LlmStatsReadArgs),
|
|
106
106
|
}),
|
|
107
107
|
};
|
|
@@ -143,6 +143,7 @@ export class Gateway {
|
|
|
143
143
|
publish: deps.publish,
|
|
144
144
|
...(deps.onActivity === undefined ? {} : { onActivity: deps.onActivity }),
|
|
145
145
|
...(deps.onMoved === undefined ? {} : { onMoved: deps.onMoved }),
|
|
146
|
+
...(deps.log === undefined ? {} : { log: deps.log }),
|
|
146
147
|
});
|
|
147
148
|
this.status =
|
|
148
149
|
deps.setup.statusUrl === undefined
|
|
@@ -165,7 +166,7 @@ export class Gateway {
|
|
|
165
166
|
};
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
/** The resource behind `
|
|
169
|
+
/** The resource behind `llm.status`. A stand-in that states nothing when the
|
|
169
170
|
* gateway's address is not configured — the topic's capability is absent
|
|
170
171
|
* then, so nothing reaches it, and the attachment stays unconditional. */
|
|
171
172
|
get statusResource(): UpstreamResource {
|
|
@@ -200,10 +201,14 @@ export class Gateway {
|
|
|
200
201
|
continue;
|
|
201
202
|
}
|
|
202
203
|
if (item.kind === "request") {
|
|
203
|
-
this.requests.record(item.info);
|
|
204
|
+
this.requests.record(item.info, item.notice);
|
|
204
205
|
this.status?.noteRequestStatus(item.info.status);
|
|
205
206
|
} else if (item.kind === "response") {
|
|
206
|
-
this.requests.note(item.info
|
|
207
|
+
this.requests.note(item.info);
|
|
208
|
+
} else if (item.kind === "keepalive") {
|
|
209
|
+
this.requests.noteKeepalive(item.info);
|
|
210
|
+
} else if (item.kind === "cache_expired") {
|
|
211
|
+
this.requests.expire(item.info);
|
|
207
212
|
}
|
|
208
213
|
}
|
|
209
214
|
if (unreadable > 0) {
|
package/src/upstream/requests.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type InstanceId,
|
|
3
|
+
LLM_PROMPT_CACHE_TTL_MS,
|
|
3
4
|
llmCacheWindowEndAt,
|
|
4
5
|
type LlmRequestInfo,
|
|
5
6
|
type Sid,
|
|
@@ -7,7 +8,12 @@ import {
|
|
|
7
8
|
} from "@ccmsg/protocol";
|
|
8
9
|
import { GATEWAY_LIVE_WINDOW_MS } from "../sessions/index.ts";
|
|
9
10
|
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CacheExpiredObservation,
|
|
13
|
+
CacheKeepaliveObservation,
|
|
14
|
+
LlmRequestObservation,
|
|
15
|
+
LlmResponseObservation,
|
|
16
|
+
} from "./events.ts";
|
|
11
17
|
|
|
12
18
|
export interface LlmRequestsDeps {
|
|
13
19
|
readonly self: InstanceId;
|
|
@@ -21,6 +27,7 @@ export interface LlmRequestsDeps {
|
|
|
21
27
|
* of one row moved. Told apart from the above because what it asks for is
|
|
22
28
|
* that row restated rather than the whole domain recomputed. */
|
|
23
29
|
readonly onMoved?: (sid: Sid) => void;
|
|
30
|
+
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/** Which of a session's gateway facts moved.
|
|
@@ -49,6 +56,10 @@ interface Series {
|
|
|
49
56
|
/** Orders a session's series by when it started using them, which is the
|
|
50
57
|
* tiebreak when it has several the sharing rule does not disqualify. */
|
|
51
58
|
firstSeen: number;
|
|
59
|
+
/** The name of the lifetime this series was last promised, when the gateway
|
|
60
|
+
* named one. It is what a withdrawal is matched against, and it is held here
|
|
61
|
+
* rather than published because it means nothing outside that match. */
|
|
62
|
+
notice?: string;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
/** What the gateway saw go upstream, per conversation series, and when each
|
|
@@ -83,7 +94,7 @@ export class LlmRequests implements UpstreamResource {
|
|
|
83
94
|
* The newer of the two wins when a series already has one: events are
|
|
84
95
|
* near-ordered in practice, but a redelivery can put an older one after a
|
|
85
96
|
* newer, and a countdown must not walk backwards. */
|
|
86
|
-
record(info: LlmRequestObservation): void {
|
|
97
|
+
record(info: LlmRequestObservation, notice?: string): void {
|
|
87
98
|
this.moved(info.sid, this.active(info.sid, info.received_at));
|
|
88
99
|
const key = seriesKey(info.sid, info.prefix);
|
|
89
100
|
const held = this.#series.get(key);
|
|
@@ -93,7 +104,14 @@ export class LlmRequests implements UpstreamResource {
|
|
|
93
104
|
// end of the map's order, which is what makes the eviction below drop the
|
|
94
105
|
// one seen least recently. `firstSeen` survives that move.
|
|
95
106
|
this.#series.delete(key);
|
|
96
|
-
|
|
107
|
+
// The name is replaced rather than merged: a request that promises nothing
|
|
108
|
+
// leaves the series with no promise to withdraw, which is what a request
|
|
109
|
+
// that cached nothing means.
|
|
110
|
+
this.#series.set(key, {
|
|
111
|
+
info,
|
|
112
|
+
firstSeen: held?.firstSeen ?? ++this.#sequence,
|
|
113
|
+
...(notice === undefined ? {} : { notice }),
|
|
114
|
+
});
|
|
97
115
|
while (this.#series.size > MAX_SERIES) {
|
|
98
116
|
const oldest = this.#series.keys().next();
|
|
99
117
|
if (oldest.done === true) break;
|
|
@@ -102,11 +120,87 @@ export class LlmRequests implements UpstreamResource {
|
|
|
102
120
|
this.publish();
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
/** Take one answer the gateway saw close. It
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
/** Take one answer the gateway saw close. It says the session was still
|
|
124
|
+
* running inference at that instant, and it carries the one verdict only an
|
|
125
|
+
* answer holds: whether the cache the request counted on was there. `hit` and
|
|
126
|
+
* `partial` confirm the window the request stated, so nothing moves; `written`
|
|
127
|
+
* says that window was a promise about a cache that no longer existed. */
|
|
128
|
+
note(info: LlmResponseObservation): void {
|
|
129
|
+
this.moved(info.sid, this.active(info.sid, info.at));
|
|
130
|
+
if (info.cache === "written") this.rebuild(info);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A keepalive the gateway raised names the lifetime it promises. Held
|
|
134
|
+
* against the series so a withdrawal naming it can be told from one naming a
|
|
135
|
+
* promise since replaced. A series nothing is held for has no window to
|
|
136
|
+
* withdraw, so the name has nothing to attach to. */
|
|
137
|
+
noteKeepalive(info: CacheKeepaliveObservation): void {
|
|
138
|
+
const series = this.#series.get(seriesKey(info.sid, info.prefix));
|
|
139
|
+
if (series === undefined) return;
|
|
140
|
+
series.notice = info.notice;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The gateway withdrawing a promised lifetime by name.
|
|
144
|
+
*
|
|
145
|
+
* Only the series whose latest promise is the one named loses its window: a
|
|
146
|
+
* different name means that promise was replaced — by this gateway's next
|
|
147
|
+
* request or by another gateway watching the same series — and the window
|
|
148
|
+
* standing now is not the one being withdrawn. The window going to zero is
|
|
149
|
+
* the row leaving, since this topic carries the open ones. */
|
|
150
|
+
expire(info: CacheExpiredObservation): void {
|
|
151
|
+
const key = seriesKey(info.sid, info.prefix);
|
|
152
|
+
const series = this.#series.get(key);
|
|
153
|
+
if (series === undefined || series.notice !== info.of) return;
|
|
154
|
+
this.#series.delete(key);
|
|
155
|
+
this.publish();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The cache was gone and the whole prompt was written again, so the window
|
|
159
|
+
* starts at the moment of that writing rather than where the request said.
|
|
160
|
+
*
|
|
161
|
+
* The chain the request projected (`cache_until_at` and the breakeven beside
|
|
162
|
+
* it) described a chain that was not continued, so it is dropped rather than
|
|
163
|
+
* carried onto a window that begins elsewhere; the gateway states the new
|
|
164
|
+
* projection on its next event. The promise is dropped with it: it named the
|
|
165
|
+
* lifetime that just turned out not to exist. */
|
|
166
|
+
private rebuild(info: LlmResponseObservation): void {
|
|
167
|
+
const key = seriesKey(info.sid, info.prefix);
|
|
168
|
+
const series = this.#series.get(key);
|
|
169
|
+
if (series === undefined) return;
|
|
170
|
+
// The answer names the request it belongs to. A verdict about a request
|
|
171
|
+
// the series has already replaced is about a window that is no longer the
|
|
172
|
+
// one drawn, so it moves nothing.
|
|
173
|
+
const held = series.info;
|
|
174
|
+
if (
|
|
175
|
+
info.request_at === undefined
|
|
176
|
+
? held.received_at > info.at
|
|
177
|
+
: info.request_at !== held.received_at
|
|
178
|
+
) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// The gateway judged the signal applied because it came back in time; the
|
|
182
|
+
// answer says what it was applied to was written from nothing. Said out
|
|
183
|
+
// loud because it is the one case where those two readings disagree.
|
|
184
|
+
if (held.keepalive === "applied") {
|
|
185
|
+
this.deps.log?.("a keepalive was applied to a cache that had to be rebuilt", {
|
|
186
|
+
sid: info.sid,
|
|
187
|
+
...(info.prefix === undefined ? {} : { prefix: info.prefix }),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const {
|
|
191
|
+
cache_until_at: _until,
|
|
192
|
+
cache_until_count: _untilCount,
|
|
193
|
+
cache_breakeven_until_at: _breakeven,
|
|
194
|
+
cache_breakeven_count: _breakevenCount,
|
|
195
|
+
...rest
|
|
196
|
+
} = held;
|
|
197
|
+
const ttl =
|
|
198
|
+
rest.cache_ttl_secs === undefined ? LLM_PROMPT_CACHE_TTL_MS : rest.cache_ttl_secs * 1000;
|
|
199
|
+
this.#series.set(key, {
|
|
200
|
+
firstSeen: series.firstSeen,
|
|
201
|
+
info: { ...rest, cache_since_at: info.at, cache_expires_at: info.at + ttl },
|
|
202
|
+
});
|
|
203
|
+
this.publish();
|
|
110
204
|
}
|
|
111
205
|
|
|
112
206
|
/** Tell whoever holds the row what this event moved for that session. */
|
|
@@ -156,7 +250,7 @@ export class LlmRequests implements UpstreamResource {
|
|
|
156
250
|
}
|
|
157
251
|
|
|
158
252
|
private publish(): void {
|
|
159
|
-
this.deps.publish("
|
|
253
|
+
this.deps.publish("llm.requests", this.entries());
|
|
160
254
|
}
|
|
161
255
|
|
|
162
256
|
/** Note the session was seen, and say what that moved.
|
package/src/upstream/status.ts
CHANGED
package/src/version.ts
CHANGED
|
@@ -3,6 +3,6 @@ import { version } from "../package.json";
|
|
|
3
3
|
/** This build of ccmsg, taken from the package it is published as so that the
|
|
4
4
|
* binary, the CLI and the plugin it installs never name three versions.
|
|
5
5
|
*
|
|
6
|
-
* It is also the daemon build `hello` and `
|
|
6
|
+
* It is also the daemon build `hello` and `instance.ping` report, which is what
|
|
7
7
|
* makes "what a client is told" and "what was released" the same number. */
|
|
8
8
|
export const VERSION: string = version;
|