@ccmsg/cli 0.3.5 → 0.4.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/daemon/registry.ts +50 -2
- package/src/instance/config.ts +102 -1
- package/src/instance/instance.ts +1 -0
- package/src/sessions/dump.ts +91 -53
- package/src/sessions/handlers.ts +14 -1
- package/src/sessions/index.ts +1 -1
- package/src/transcript/items/classify.ts +400 -0
- package/src/transcript/items/ids.ts +0 -0
- package/src/transcript/items/index.ts +4 -0
- package/src/transcript/items/item.ts +23 -0
- package/src/transcript/items/record.ts +71 -0
- package/src/transcript/items/select.ts +144 -0
- package/src/transcript/items/tools.ts +231 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccmsg/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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.12.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/daemon/registry.ts
CHANGED
|
@@ -95,10 +95,54 @@ export function registered(env: Env): Target[] {
|
|
|
95
95
|
return loadShared(paths.configFile).instances.map((entry) => targetFor(env, entry.dir));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** The selections the shared file starts with.
|
|
99
|
+
*
|
|
100
|
+
* Presets are the operator's to name — what one names is an interest, and this
|
|
101
|
+
* instance has no opinion on which interests a person has — so these are
|
|
102
|
+
* written into the file as examples to edit rather than built in. A default
|
|
103
|
+
* that lived in the code would be invisible in the file and would come back
|
|
104
|
+
* after being deleted.
|
|
105
|
+
*
|
|
106
|
+
* They also show the two things a person would otherwise have to be told: that
|
|
107
|
+
* a prefix takes a family, and that `@name` puts one selection inside
|
|
108
|
+
* another. */
|
|
109
|
+
const STARTING_PRESETS = [
|
|
110
|
+
{
|
|
111
|
+
name: "file",
|
|
112
|
+
description: "ファイル操作。読み書きと探索をひとまとめに",
|
|
113
|
+
opts: { types: ["tool:Read", "tool:Write", "tool:Edit", "tool:Glob", "tool:Grep"] },
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "howto",
|
|
117
|
+
description: "調査のノウハウだけ。何を考えて何を叩いて何を読み書きしたか",
|
|
118
|
+
opts: { types: ["thinking", "message:user", "message:sub", "tool:Bash", "@file"] },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "journal",
|
|
122
|
+
description: "日記用。人との往復と worker の答え、思考は要点だけ",
|
|
123
|
+
opts: { types: ["message:user", "message:sub:in", "thinking"] },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "handoff",
|
|
127
|
+
description: "後継セッションへの引き継ぎ。直近の会話と、走っているものの台帳",
|
|
128
|
+
opts: { types: ["message", "system:task", "ids"] },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "audit",
|
|
132
|
+
description: "何をしたかの追跡。会話は落として操作と通知だけ",
|
|
133
|
+
opts: { types: ["@file", "tool:Bash", "notice", "ids"] },
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
98
137
|
/** Add a config home to the shared file. The settings it will run with are the
|
|
99
138
|
* defaults until somebody edits its entry, so the entry starts empty — save
|
|
100
139
|
* for the harness, which is written down when it is not the default because it
|
|
101
|
-
* is the one setting the directory itself cannot be asked for (§3.8).
|
|
140
|
+
* is the one setting the directory itself cannot be asked for (§3.8).
|
|
141
|
+
*
|
|
142
|
+
* The dump presets above go to `defaults`, and only where the file names none:
|
|
143
|
+
* they are the same for every instance and are examples to edit, so writing
|
|
144
|
+
* them per entry would repeat them and re-adding a config home would bring
|
|
145
|
+
* back what somebody deleted. */
|
|
102
146
|
export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS): InstanceRow {
|
|
103
147
|
const home = configHome(dir, harness);
|
|
104
148
|
const file = resolvePaths(env).configFile;
|
|
@@ -110,7 +154,11 @@ export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS):
|
|
|
110
154
|
dir: home,
|
|
111
155
|
settings: harness === DEFAULT_HARNESS ? {} : { harness },
|
|
112
156
|
};
|
|
113
|
-
|
|
157
|
+
const defaults =
|
|
158
|
+
shared.defaults["dump"] === undefined
|
|
159
|
+
? { ...shared.defaults, dump: { presets: STARTING_PRESETS } }
|
|
160
|
+
: shared.defaults;
|
|
161
|
+
saveShared(file, { defaults, instances: [...shared.instances, entry] });
|
|
114
162
|
const target = targetFor(env, home);
|
|
115
163
|
// The id is made here rather than at the first start, so that what `add`
|
|
116
164
|
// prints is what the instance will answer to and so that a person can write
|
package/src/instance/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute } from "node:path";
|
|
3
|
-
import type { Endpoint } from "@ccmsg/protocol";
|
|
3
|
+
import type { DumpPreset, Endpoint } from "@ccmsg/protocol";
|
|
4
4
|
import { DEFAULT_HARNESS, type Harness, HARNESSES, isHarness } from "../harness/index.ts";
|
|
5
5
|
import { parseCidr } from "./client.ts";
|
|
6
6
|
|
|
@@ -91,6 +91,17 @@ export interface UpstreamConfig {
|
|
|
91
91
|
readonly sandbox_origin?: string;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The named selections a person dumps by.
|
|
95
|
+
*
|
|
96
|
+
* Configured rather than fixed in the contract because what a preset names is
|
|
97
|
+
* an interest — how the work was done, what to hand over — and an interest is
|
|
98
|
+
* not a property of the wire. A type name stays one to one with what a record
|
|
99
|
+
* is, and the groupings people reach for are made by naming a set of them. */
|
|
100
|
+
export interface DumpConfig {
|
|
101
|
+
/** In configured order, which is the order `dump_presets_read` answers in. */
|
|
102
|
+
readonly presets: readonly DumpPreset[];
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
export interface InstanceConfig {
|
|
95
106
|
/** Which harness this config home runs (§3.8).
|
|
96
107
|
*
|
|
@@ -118,6 +129,7 @@ export interface InstanceConfig {
|
|
|
118
129
|
* one that does not never pays for it. The `fork` capability follows this,
|
|
119
130
|
* so a client learns which it is from `hello`. */
|
|
120
131
|
readonly fork_origin: boolean;
|
|
132
|
+
readonly dump: DumpConfig;
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
/** A config file that could not be understood.
|
|
@@ -146,6 +158,7 @@ export const DEFAULT_CONFIG: InstanceConfig = {
|
|
|
146
158
|
upstream: {},
|
|
147
159
|
direct_delivery: true,
|
|
148
160
|
fork_origin: false,
|
|
161
|
+
dump: { presets: [] },
|
|
149
162
|
};
|
|
150
163
|
|
|
151
164
|
/** Read the config, once, at startup (DV-Q8).
|
|
@@ -258,6 +271,11 @@ export const MERGE_RULES: Readonly<Record<string, MergeRule>> = {
|
|
|
258
271
|
"upstream.launcher.templates": "replace",
|
|
259
272
|
"upstream.launcher.clean_env": "replace",
|
|
260
273
|
"upstream.launcher.keep_env": "replace",
|
|
274
|
+
dump: "merge",
|
|
275
|
+
// A preset list is a whole vocabulary: an instance that names its own means
|
|
276
|
+
// to dump by those and not by the defaults' as well, since a name it did not
|
|
277
|
+
// write could shadow or be referenced by one it did.
|
|
278
|
+
"dump.presets": "replace",
|
|
261
279
|
};
|
|
262
280
|
|
|
263
281
|
function ruleFor(path: string): MergeRule {
|
|
@@ -308,9 +326,92 @@ export function parseConfig(file: string, fields: Record<string, unknown>): Inst
|
|
|
308
326
|
DEFAULT_CONFIG.direct_delivery,
|
|
309
327
|
),
|
|
310
328
|
fork_origin: flagOf(file, "fork_origin", fields["fork_origin"], DEFAULT_CONFIG.fork_origin),
|
|
329
|
+
dump: dumpOf(file, fields["dump"]),
|
|
311
330
|
};
|
|
312
331
|
}
|
|
313
332
|
|
|
333
|
+
/** One element of a selection, as the contract spells it: a type name, a
|
|
334
|
+
* prefix of one, either negated with `-`, or `@name` for a preset. */
|
|
335
|
+
const SELECTOR = /^-?(?:@[A-Za-z0-9][A-Za-z0-9_-]*|[a-z]+(?::[A-Za-z0-9_.-]+)*)$/;
|
|
336
|
+
|
|
337
|
+
function dumpOf(file: string, raw: unknown): DumpConfig {
|
|
338
|
+
if (raw === undefined) return { presets: [] };
|
|
339
|
+
const fields = objectOf(file, "dump", raw);
|
|
340
|
+
const presets = presetsOf(file, fields["presets"]);
|
|
341
|
+
// A reference is resolved here rather than at each dump: a cycle or a name
|
|
342
|
+
// nobody configured would otherwise be found once per request, long after
|
|
343
|
+
// the file that holds the mistake was last looked at.
|
|
344
|
+
for (const preset of presets) resolvable(file, preset, presets, []);
|
|
345
|
+
return { presets };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function presetsOf(file: string, raw: unknown): DumpPreset[] {
|
|
349
|
+
if (raw === undefined) return [];
|
|
350
|
+
if (!Array.isArray(raw)) {
|
|
351
|
+
throw new ConfigError(file, "dump.presets must be an array of named selections");
|
|
352
|
+
}
|
|
353
|
+
const names = new Set<string>();
|
|
354
|
+
return raw.map((entry, index) => {
|
|
355
|
+
const at = `dump.presets[${index}]`;
|
|
356
|
+
const fields = objectOf(file, at, entry);
|
|
357
|
+
const name = fields["name"];
|
|
358
|
+
if (typeof name !== "string" || name === "") {
|
|
359
|
+
throw new ConfigError(file, `${at}.name must be a name for the selection`);
|
|
360
|
+
}
|
|
361
|
+
if (names.has(name)) throw new ConfigError(file, `${at}.name repeats ${name}`);
|
|
362
|
+
names.add(name);
|
|
363
|
+
const description = fields["description"];
|
|
364
|
+
if (description !== undefined && typeof description !== "string") {
|
|
365
|
+
throw new ConfigError(file, `${at}.description must be a string`);
|
|
366
|
+
}
|
|
367
|
+
const opts = objectOf(file, `${at}.opts`, fields["opts"]);
|
|
368
|
+
const types = stringsOf(file, `${at}.opts.types`, opts["types"]);
|
|
369
|
+
const wrong = types.filter((element) => !SELECTOR.test(element));
|
|
370
|
+
if (wrong.length > 0) {
|
|
371
|
+
throw new ConfigError(
|
|
372
|
+
file,
|
|
373
|
+
`${at}.opts.types must be item types, prefixes, exclusions or @presets, got ${wrong.join(", ")}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
name,
|
|
378
|
+
...(description === undefined ? {} : { description }),
|
|
379
|
+
opts: { types: [...types] },
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Every `@name` a preset reaches, down through the presets it names.
|
|
385
|
+
*
|
|
386
|
+
* The path is carried so a cycle is named where it closes rather than as a
|
|
387
|
+
* stack that ran out — an operator reading the refusal has to be able to find
|
|
388
|
+
* which two presets point at each other. */
|
|
389
|
+
function resolvable(
|
|
390
|
+
file: string,
|
|
391
|
+
preset: DumpPreset,
|
|
392
|
+
presets: readonly DumpPreset[],
|
|
393
|
+
path: readonly string[],
|
|
394
|
+
): void {
|
|
395
|
+
if (path.includes(preset.name)) {
|
|
396
|
+
throw new ConfigError(
|
|
397
|
+
file,
|
|
398
|
+
`dump.presets reference each other in a cycle: ${[...path, preset.name].join(" -> ")}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
for (const element of preset.opts.types) {
|
|
402
|
+
const name = element.startsWith("-") ? element.slice(1) : element;
|
|
403
|
+
if (!name.startsWith("@")) continue;
|
|
404
|
+
const referenced = presets.find((one) => one.name === name.slice(1));
|
|
405
|
+
if (referenced === undefined) {
|
|
406
|
+
throw new ConfigError(
|
|
407
|
+
file,
|
|
408
|
+
`dump.presets[${preset.name}] names ${name}, which is not configured`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
resolvable(file, referenced, presets, [...path, preset.name]);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
314
415
|
function harnessOf(file: string, raw: unknown): Harness {
|
|
315
416
|
if (raw === undefined) return DEFAULT_HARNESS;
|
|
316
417
|
if (!isHarness(raw)) {
|
package/src/instance/instance.ts
CHANGED
|
@@ -616,6 +616,7 @@ export class Instance {
|
|
|
616
616
|
hostProcessDeps(() => this.#sessions.rowsNow(), config.upstream.terminal_gateway),
|
|
617
617
|
),
|
|
618
618
|
forget: (sid) => this.#sessions.forget(sid),
|
|
619
|
+
presets: config.dump.presets,
|
|
619
620
|
}),
|
|
620
621
|
// The sandbox ops answer only where an origin is configured. Without one
|
|
621
622
|
// there is nothing to serve a minted URL, and dispatch already refuses
|
package/src/sessions/dump.ts
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DumpPreset,
|
|
5
|
+
InstanceId,
|
|
6
|
+
SessionDumpFile,
|
|
7
|
+
SessionDumpWriteArgs,
|
|
8
|
+
SessionDumpWriteResult,
|
|
9
|
+
} from "@ccmsg/protocol";
|
|
4
10
|
import { OpError } from "../dispatch/index.ts";
|
|
5
|
-
import {
|
|
11
|
+
import { classify, type Item, ledger, select, selection } from "../transcript/items/index.ts";
|
|
12
|
+
import type { TranscriptFiles } from "../transcript/index.ts";
|
|
6
13
|
|
|
7
14
|
/** Where dumps land: one directory under this instance's own state, named
|
|
8
15
|
* after the config home it answers for like every other per-instance path
|
|
9
16
|
* (§8.1). The caller never supplies a path, so there is none to contain. */
|
|
10
17
|
export const DUMPS = "dumps";
|
|
11
18
|
|
|
19
|
+
/** What a dump file is called. Two extensions rather than one so that a reader
|
|
20
|
+
* knows both that it is JSON and that it is JSON of a shape the contract
|
|
21
|
+
* states — the file travels by its path, outliving the request that made it,
|
|
22
|
+
* and is opened by whoever was handed that path. */
|
|
23
|
+
export const DUMP_SUFFIX = ".dump.json";
|
|
24
|
+
|
|
12
25
|
export interface DumpDeps {
|
|
13
26
|
readonly self: InstanceId;
|
|
14
27
|
readonly stateDir: string;
|
|
15
28
|
readonly files: TranscriptFiles;
|
|
29
|
+
/** The selections this instance is configured with, which is what a `preset`
|
|
30
|
+
* name and an `@name` inside a selection are resolved against. */
|
|
31
|
+
readonly presets: readonly DumpPreset[];
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** Write a session's dump and answer with where it went.
|
|
19
35
|
*
|
|
20
36
|
* What this adds over reading the transcript is a durable artifact whose path
|
|
21
37
|
* can be handed to a successor session, rather than a payload that would
|
|
22
|
-
* travel out through a client and back in again.
|
|
38
|
+
* travel out through a client and back in again.
|
|
39
|
+
*
|
|
40
|
+
* The subject is the session, or one agent below it when the request names
|
|
41
|
+
* one. Every item type is read from wherever the subject stands — an agent's
|
|
42
|
+
* `message:user:in` is the brief its parent gave it — so one selection carries
|
|
43
|
+
* unchanged down a chain of agents, which is what makes the ledger's agent ids
|
|
44
|
+
* a way to descend rather than just a list. */
|
|
23
45
|
export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDumpWriteResult {
|
|
24
46
|
if (args.since_at !== undefined && args.since_uuid !== undefined) {
|
|
25
47
|
throw new OpError("invalid_args", "a lower bound is a time or a record, not both");
|
|
@@ -27,79 +49,95 @@ export function dumpWrite(args: SessionDumpWriteArgs, deps: DumpDeps): SessionDu
|
|
|
27
49
|
if (args.until_at !== undefined && args.until_uuid !== undefined) {
|
|
28
50
|
throw new OpError("invalid_args", "an upper bound is a time or a record, not both");
|
|
29
51
|
}
|
|
30
|
-
const
|
|
52
|
+
const preset = presetFor(args.preset, deps.presets);
|
|
53
|
+
const file = deps.files.locate(
|
|
54
|
+
args.sid,
|
|
55
|
+
args.agent_id === undefined ? {} : { agent_id: args.agent_id },
|
|
56
|
+
);
|
|
31
57
|
let text: string;
|
|
32
58
|
try {
|
|
33
59
|
text = readFileSync(file, "utf8");
|
|
34
60
|
} catch {
|
|
35
61
|
throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
|
|
36
62
|
}
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
// The whole file is classified before the range is applied, so a result
|
|
64
|
+
// inside the range still names the call that fell before it. Cutting first
|
|
65
|
+
// would leave `parent_item` pointing at something the reader never saw.
|
|
66
|
+
const keep = selection(
|
|
67
|
+
{
|
|
68
|
+
...(args.types === undefined ? {} : { types: args.types }),
|
|
69
|
+
...(preset === undefined ? {} : { preset }),
|
|
70
|
+
...(args.no_thinking === undefined ? {} : { no_thinking: args.no_thinking }),
|
|
71
|
+
...(args.no_agent === undefined ? {} : { no_agent: args.no_agent }),
|
|
72
|
+
},
|
|
73
|
+
deps.presets,
|
|
74
|
+
);
|
|
75
|
+
const { items, entries } = select(within(classify(text.split("\n")), args), keep);
|
|
76
|
+
const ids = ledger(items);
|
|
77
|
+
const written_at = Date.now();
|
|
78
|
+
// The file repeats what it was asked for. A dump outlives the request that
|
|
79
|
+
// made it and is read by whoever was handed the path, so it has to say on
|
|
80
|
+
// its own what it is a dump of and what was left out — which is why the
|
|
81
|
+
// selection is written as applied, with the presets already expanded.
|
|
82
|
+
const document: SessionDumpFile = {
|
|
39
83
|
sid: args.sid,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
84
|
+
...(args.agent_id === undefined ? {} : { agent_id: args.agent_id }),
|
|
85
|
+
written_at,
|
|
86
|
+
types: [...keep.elements],
|
|
87
|
+
items,
|
|
88
|
+
ids,
|
|
44
89
|
};
|
|
45
90
|
const dir = join(deps.stateDir, DUMPS);
|
|
46
91
|
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const
|
|
92
|
+
const named = args.agent_id === undefined ? args.sid : `${args.sid}-agent-${args.agent_id}`;
|
|
93
|
+
const path = join(dir, `${named}-${written_at}${DUMP_SUFFIX}`);
|
|
48
94
|
const body = `${JSON.stringify(document, undefined, 2)}\n`;
|
|
49
95
|
writeFileSync(path, body);
|
|
50
|
-
return {
|
|
51
|
-
path,
|
|
52
|
-
instance: deps.self,
|
|
53
|
-
entries: entries.length,
|
|
54
|
-
bytes: Buffer.byteLength(body),
|
|
55
|
-
};
|
|
96
|
+
return { path, instance: deps.self, entries, ids, bytes: Buffer.byteLength(body) };
|
|
56
97
|
}
|
|
57
98
|
|
|
58
|
-
/**
|
|
99
|
+
/** The named selection a request asked for.
|
|
59
100
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
101
|
+
* A name this instance does not have is refused rather than ignored: a dump
|
|
102
|
+
* silently wider than what was asked for is the failure a selection exists to
|
|
103
|
+
* prevent. */
|
|
104
|
+
function presetFor(
|
|
105
|
+
name: string | undefined,
|
|
106
|
+
presets: readonly DumpPreset[],
|
|
107
|
+
): DumpPreset | undefined {
|
|
108
|
+
if (name === undefined) return undefined;
|
|
109
|
+
const found = presets.find((one) => one.name === name);
|
|
110
|
+
if (found === undefined) throw new OpError("invalid_args", `no preset is configured as ${name}`);
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
64
113
|
|
|
65
|
-
/** The
|
|
114
|
+
/** The items within the bounds, in the order the transcript holds them.
|
|
66
115
|
*
|
|
67
116
|
* A record bound cuts at that record's position rather than at its clock, so
|
|
68
117
|
* records sharing an instant stay on their own side of the cut — which is the
|
|
69
|
-
* whole reason the contract offers both kinds of bound.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
118
|
+
* whole reason the contract offers both kinds of bound. Every item a record
|
|
119
|
+
* became carries that record's id, so a bound by record keeps a turn's
|
|
120
|
+
* thinking, words and calls together. */
|
|
121
|
+
function within(items: readonly Item[], args: SessionDumpWriteArgs): Item[] {
|
|
122
|
+
const kept: Item[] = [];
|
|
123
|
+
// A lower bound by record starts closed: it opens at the record it names,
|
|
124
|
+
// which is included.
|
|
74
125
|
let open = args.since_uuid === undefined;
|
|
75
|
-
for (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (record === undefined) continue;
|
|
126
|
+
for (let at = 0; at < items.length; at += 1) {
|
|
127
|
+
const item = items[at];
|
|
128
|
+
if (item === undefined) continue;
|
|
79
129
|
if (!open) {
|
|
80
|
-
if (
|
|
130
|
+
if (item.uuid !== args.since_uuid) continue;
|
|
81
131
|
open = true;
|
|
82
132
|
}
|
|
83
|
-
if (args.since_at !== undefined &&
|
|
84
|
-
if (args.until_at !== undefined &&
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
if (args.
|
|
89
|
-
if (
|
|
90
|
-
continue;
|
|
133
|
+
if (args.since_at !== undefined && item.at < args.since_at) continue;
|
|
134
|
+
if (args.until_at !== undefined && item.at > args.until_at) break;
|
|
135
|
+
kept.push(item);
|
|
136
|
+
// An upper bound by record is inclusive and cuts after the last item that
|
|
137
|
+
// record became, so the rest of the same record is still let through.
|
|
138
|
+
if (args.until_uuid !== undefined && item.uuid === args.until_uuid) {
|
|
139
|
+
if (items[at + 1]?.uuid !== item.uuid) break;
|
|
91
140
|
}
|
|
92
|
-
entries.push({
|
|
93
|
-
...(record.uuid === undefined ? {} : { uuid: record.uuid }),
|
|
94
|
-
...(record.said_at === undefined ? {} : { said_at: record.said_at }),
|
|
95
|
-
...(record.said_by === undefined ? {} : { said_by: record.said_by }),
|
|
96
|
-
...(record.text === undefined ? {} : { text: record.text }),
|
|
97
|
-
...(args.no_thinking === true || record.thinking === undefined
|
|
98
|
-
? {}
|
|
99
|
-
: { thinking: record.thinking }),
|
|
100
|
-
});
|
|
101
|
-
// An upper bound by record is inclusive, so the cut is after it.
|
|
102
|
-
if (record.uuid !== undefined && record.uuid === args.until_uuid) break;
|
|
103
141
|
}
|
|
104
|
-
return
|
|
142
|
+
return kept;
|
|
105
143
|
}
|
package/src/sessions/handlers.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Capability,
|
|
3
|
+
type DumpPreset,
|
|
4
|
+
type DumpPresetsReadResult,
|
|
3
5
|
type InstanceId,
|
|
4
6
|
type SessionDumpWriteArgs,
|
|
5
7
|
type SessionEnvReadArgs,
|
|
@@ -57,9 +59,11 @@ export interface SessionOpsDeps {
|
|
|
57
59
|
* instance last saw them. The sessions domain owns the list; this op only
|
|
58
60
|
* asks it to forget a row. */
|
|
59
61
|
readonly forget: (sid: Sid) => boolean;
|
|
62
|
+
/** The named selections this instance is configured with (§3.6). */
|
|
63
|
+
readonly presets: readonly DumpPreset[];
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** The
|
|
66
|
+
/** The ops that observe and operate on sessions.
|
|
63
67
|
*
|
|
64
68
|
* None of them decides who may call it: dispatch has settled that from the
|
|
65
69
|
* attribute table. The one that narrows by role is `transcript_read`, and it
|
|
@@ -104,8 +108,17 @@ export function sessionHandlers(deps: SessionOpsDeps) {
|
|
|
104
108
|
self: deps.self,
|
|
105
109
|
stateDir: deps.stateDir,
|
|
106
110
|
files: deps.files,
|
|
111
|
+
presets: deps.presets,
|
|
107
112
|
}),
|
|
108
113
|
|
|
114
|
+
/** Which selections a dump may be asked for by name.
|
|
115
|
+
*
|
|
116
|
+
* Nothing else states them, so a client without this could only offer a
|
|
117
|
+
* free-text field and let the instance refuse. A preset that references
|
|
118
|
+
* another is answered as written: the expansion, and the refusal of a
|
|
119
|
+
* cycle, happen where the config is read. */
|
|
120
|
+
dump_presets_read: (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
|
|
121
|
+
|
|
109
122
|
session_fork_origin: (input: HandlerInput): SessionForkOriginResult => {
|
|
110
123
|
const args = input.args as unknown as SessionForkOriginArgs;
|
|
111
124
|
const origin = forkOrigin(args.sid, deps.files);
|
package/src/sessions/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from "./classify.ts";
|
|
2
|
-
export { DUMPS, dumpWrite } from "./dump.ts";
|
|
2
|
+
export { DUMP_SUFFIX, DUMPS, dumpWrite } from "./dump.ts";
|
|
3
3
|
export { forkOrigin } from "./fork.ts";
|
|
4
4
|
export * from "./harness.ts";
|
|
5
5
|
export { sessionCapabilities, sessionHandlers, type SessionOpsDeps } from "./handlers.ts";
|