@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mirror's own rules — deduplication on the way in, and what a dropped batch means.
|
|
3
|
+
*
|
|
4
|
+
* Two mirrors exist and they are not the same thing. The SDK dual-writes: the subprocess writes
|
|
5
|
+
* locally first, then hands a batch to the adapter. Its retry and its drop are the SDK's behaviour —
|
|
6
|
+
* this host cannot change them and must not pretend to. What belongs here is what an adapter does
|
|
7
|
+
* with a batch it receives, and what the host does when the SDK tells it a batch was lost.
|
|
8
|
+
*
|
|
9
|
+
* A dropped batch is the one failure that looks exactly like nothing happening. The store simply
|
|
10
|
+
* has fewer entries than local disk, with no error at the read and no gap anything can compute — the
|
|
11
|
+
* durable copy is silently behind local truth. So the host surfaces the SDK's own report as a named
|
|
12
|
+
* degrade rather than logging it and moving on. Silence here is indistinguishable from health.
|
|
13
|
+
*/
|
|
14
|
+
import type { TranscriptEntry } from './entry.js';
|
|
15
|
+
import type { TranscriptKey } from './key.js';
|
|
16
|
+
/**
|
|
17
|
+
* How a mirror write failed, as the SDK distinguishes them.
|
|
18
|
+
*
|
|
19
|
+
* The two are not one failure with two names, and a test that covers only the first measures the
|
|
20
|
+
* retry loop rather than the drop. A rejection is retried — three attempts in total, with short
|
|
21
|
+
* backoff. A timeout is not retried at all, because the in-flight call may still land, so a timeout
|
|
22
|
+
* drops on its first failure. An operator reasoning from "it retries three times" will therefore
|
|
23
|
+
* mis-predict every timeout-shaped outage, and a slow store is the likeliest way to lose data here.
|
|
24
|
+
*/
|
|
25
|
+
export type MirrorFailureKind = 'rejected' | 'timed-out';
|
|
26
|
+
/**
|
|
27
|
+
* Attempts each failure kind receives before the batch is dropped. Data, so the asymmetry above is
|
|
28
|
+
* checkable rather than remembered.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MIRROR_ATTEMPTS: Readonly<Record<MirrorFailureKind, number>>;
|
|
31
|
+
/** What was lost, and enough to say which session lost it. */
|
|
32
|
+
export interface MirrorDrop {
|
|
33
|
+
readonly key: TranscriptKey;
|
|
34
|
+
readonly kind: MirrorFailureKind;
|
|
35
|
+
readonly attempts: number;
|
|
36
|
+
/** The store's own error text. Never parsed — carried so a human can read it. */
|
|
37
|
+
readonly error: string;
|
|
38
|
+
/**
|
|
39
|
+
* The uuids in the lost batch, where the entries carried them.
|
|
40
|
+
*
|
|
41
|
+
* This is what makes a drop recoverable rather than merely reported. With the ids, a consumer
|
|
42
|
+
* can re-drive those entries from local disk, which is still authoritative. Without them a drop is
|
|
43
|
+
* only an alarm.
|
|
44
|
+
*/
|
|
45
|
+
readonly entryUuids: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/** A dropped batch as one line a human reads. Never parsed back. */
|
|
48
|
+
export declare function describeMirrorDrop(drop: MirrorDrop): string;
|
|
49
|
+
/** What a batch splits into once the store's existing ids are known. */
|
|
50
|
+
export interface DedupedBatch {
|
|
51
|
+
/** Entries to write. */
|
|
52
|
+
readonly append: readonly TranscriptEntry[];
|
|
53
|
+
/** Entries already present, identified by uuid. */
|
|
54
|
+
readonly skipped: readonly TranscriptEntry[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Split a batch into what to write and what is already there.
|
|
58
|
+
*
|
|
59
|
+
* An entry with no `uuid` is always appended, never deduplicated. The adapter contract is explicit
|
|
60
|
+
* that most entries carry a stable uuid and that the ones that do not — titles, tags, mode markers —
|
|
61
|
+
* should be appended without dedup. Treating "no uuid" as a duplicate would silently drop every one
|
|
62
|
+
* of them; treating them as distinct is the contract, and the cost is at worst a repeated marker.
|
|
63
|
+
*
|
|
64
|
+
* Why dedup at all: the contract says retries and transcript imports replay batches, and asks
|
|
65
|
+
* adapters to treat the uuid as an idempotency key so a replay does not create duplicate rows. A
|
|
66
|
+
* store without this grows a second copy of a session every time a batch is retried.
|
|
67
|
+
*/
|
|
68
|
+
export declare function dedupeBatch(knownUuids: ReadonlySet<string>, batch: readonly TranscriptEntry[]): DedupedBatch;
|
|
69
|
+
/** Every uuid in a stored transcript, for the dedup above. */
|
|
70
|
+
export declare function uuidsIn(entries: readonly TranscriptEntry[]): Set<string>;
|
|
71
|
+
//# sourceMappingURL=mirror.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempts each failure kind receives before the batch is dropped. Data, so the asymmetry above is
|
|
3
|
+
* checkable rather than remembered.
|
|
4
|
+
*/
|
|
5
|
+
export const MIRROR_ATTEMPTS = {
|
|
6
|
+
rejected: 3,
|
|
7
|
+
'timed-out': 1,
|
|
8
|
+
};
|
|
9
|
+
/** A dropped batch as one line a human reads. Never parsed back. */
|
|
10
|
+
export function describeMirrorDrop(drop) {
|
|
11
|
+
const attemptWord = drop.attempts === 1 ? 'attempt' : 'attempts';
|
|
12
|
+
const scope = drop.key.subpath === undefined ? drop.key.sessionId : `${drop.key.sessionId}/${drop.key.subpath}`;
|
|
13
|
+
return (`mirror batch DROPPED for ${scope} after ${drop.attempts} ${attemptWord} (${drop.kind}): ${drop.error} — ` +
|
|
14
|
+
`${drop.entryUuids.length} entr${drop.entryUuids.length === 1 ? 'y is' : 'ies are'} in the store's ` +
|
|
15
|
+
'copy no longer, though local disk still holds them');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Split a batch into what to write and what is already there.
|
|
19
|
+
*
|
|
20
|
+
* An entry with no `uuid` is always appended, never deduplicated. The adapter contract is explicit
|
|
21
|
+
* that most entries carry a stable uuid and that the ones that do not — titles, tags, mode markers —
|
|
22
|
+
* should be appended without dedup. Treating "no uuid" as a duplicate would silently drop every one
|
|
23
|
+
* of them; treating them as distinct is the contract, and the cost is at worst a repeated marker.
|
|
24
|
+
*
|
|
25
|
+
* Why dedup at all: the contract says retries and transcript imports replay batches, and asks
|
|
26
|
+
* adapters to treat the uuid as an idempotency key so a replay does not create duplicate rows. A
|
|
27
|
+
* store without this grows a second copy of a session every time a batch is retried.
|
|
28
|
+
*/
|
|
29
|
+
export function dedupeBatch(knownUuids, batch) {
|
|
30
|
+
const append = [];
|
|
31
|
+
const skipped = [];
|
|
32
|
+
const seenInBatch = new Set();
|
|
33
|
+
for (const entry of batch) {
|
|
34
|
+
const uuid = entry.uuid;
|
|
35
|
+
if (typeof uuid !== 'string' || uuid.length === 0) {
|
|
36
|
+
append.push(entry);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (knownUuids.has(uuid) || seenInBatch.has(uuid)) {
|
|
40
|
+
skipped.push(entry);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
seenInBatch.add(uuid);
|
|
44
|
+
append.push(entry);
|
|
45
|
+
}
|
|
46
|
+
return { append, skipped };
|
|
47
|
+
}
|
|
48
|
+
/** Every uuid in a stored transcript, for the dedup above. */
|
|
49
|
+
export function uuidsIn(entries) {
|
|
50
|
+
const uuids = new Set();
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (typeof entry.uuid === 'string' && entry.uuid.length > 0)
|
|
53
|
+
uuids.add(entry.uuid);
|
|
54
|
+
}
|
|
55
|
+
return uuids;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=mirror.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delivery receipt — did the text a controller injected actually land in the session?
|
|
3
|
+
*
|
|
4
|
+
* Getting this wrong is silent. Read the three defences below before changing anything here.
|
|
5
|
+
*
|
|
6
|
+
* What it is read from, and why that is the whole point. A receipt is answered from raw stored
|
|
7
|
+
* entries — the adapter's `load`, or the local JSONL — and never from the SDK's conversation reader.
|
|
8
|
+
* That reader reconstructs the conversation by following `parentUuid` links, and compaction relinks
|
|
9
|
+
* those links: the shipped types say a loader splices the preserved segment at an anchor uuid and
|
|
10
|
+
* relinks each preserved uuid to its predecessor rather than walking `parentUuid` at all, and that
|
|
11
|
+
* both the preserved-segment and preserved-messages blocks are "unset when compaction summarizes
|
|
12
|
+
* everything". So after a compaction a `parentUuid` walk structurally cannot reach pre-compaction
|
|
13
|
+
* turns. Raw entries are append-only and lose nothing, which is why the receipt reads those.
|
|
14
|
+
*
|
|
15
|
+
* The reader is not banned — it is for a different question. "What would the agent see?" is
|
|
16
|
+
* exactly the post-compaction conversation, and the SDK's reader answers it correctly. "What
|
|
17
|
+
* actually happened?" is this file. Two questions, two reads; using one for the other is the defect.
|
|
18
|
+
*
|
|
19
|
+
* The three defences, each with a different failure direction. They are named because an
|
|
20
|
+
* invariant defended in depth cannot be proven by removing one guard — the test then measures
|
|
21
|
+
* the redundancy instead of the invariant.
|
|
22
|
+
*
|
|
23
|
+
* Defence 1 Raw entries, never a `parentUuid` walk. Removing it means a false negative after
|
|
24
|
+
* a compaction.
|
|
25
|
+
* Defence 2 A uuid anchor, never a numeric offset. Removing it means a false negative, or a
|
|
26
|
+
* mis-anchored scan, the moment the entry
|
|
27
|
+
* count shrinks.
|
|
28
|
+
* Defence 3 Compaction-produced entries are not Removing it means a false positive — the
|
|
29
|
+
* candidates. summary a compaction writes is matched as
|
|
30
|
+
* if it were the injected turn, so the
|
|
31
|
+
* controller is told text landed that the
|
|
32
|
+
* agent never saw.
|
|
33
|
+
*
|
|
34
|
+
* Defences 1 and 2 fail toward "it did not land", which makes a controller paste the text a second
|
|
35
|
+
* time. Defence 3 fails toward "it landed", which makes a controller move on from an inject that
|
|
36
|
+
* never arrived. Both are silent; they are opposite, and no single test can see both.
|
|
37
|
+
*/
|
|
38
|
+
import type { Result } from '../core/result.js';
|
|
39
|
+
import type { TranscriptEntry } from './entry.js';
|
|
40
|
+
/**
|
|
41
|
+
* What to look for.
|
|
42
|
+
*
|
|
43
|
+
* There is no offset field, and its absence is Defence 2. A count is meaningless across a
|
|
44
|
+
* compaction: the conversation the agent sees can shrink from hundreds of entries to a handful, so
|
|
45
|
+
* "past entry 412" stops addressing anything. A uuid addresses one entry for as long as that entry
|
|
46
|
+
* exists, and in an append-only log it exists forever. If this interface ever grows a numeric
|
|
47
|
+
* baseline, the receipt has been reopened to the exact defect it was written to close.
|
|
48
|
+
*/
|
|
49
|
+
export interface ReceiptQuery {
|
|
50
|
+
/**
|
|
51
|
+
* The uuid of the last entry seen before the inject. `null` scans the whole transcript.
|
|
52
|
+
*
|
|
53
|
+
* A controller takes this from its own pre-inject read. `null` is for the first inject into a
|
|
54
|
+
* session that had no entries yet — not a fallback for an anchor that could not be found.
|
|
55
|
+
*/
|
|
56
|
+
readonly anchorUuid: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Text the delivered entry must carry.
|
|
59
|
+
*
|
|
60
|
+
* Optional, and stronger than the position alone. Without it the receipt answers "a user turn
|
|
61
|
+
* arrived after my anchor", which is true of any user turn — including one a different writer
|
|
62
|
+
* queued. With it the receipt answers "my text arrived". A controller driving one session at a
|
|
63
|
+
* time can omit it; anything with two writers should not.
|
|
64
|
+
*/
|
|
65
|
+
readonly expectText?: string;
|
|
66
|
+
}
|
|
67
|
+
/** What the transcript says happened. */
|
|
68
|
+
export interface ReceiptOutcome {
|
|
69
|
+
/** Whether a qualifying user entry arrived after the anchor. */
|
|
70
|
+
readonly delivered: boolean;
|
|
71
|
+
/** The uuid of the entry that satisfied it, when one did. */
|
|
72
|
+
readonly entryUuid: string | null;
|
|
73
|
+
/** That entry's own timestamp, when it carries one. */
|
|
74
|
+
readonly at: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a compaction sits between the anchor and now.
|
|
77
|
+
*
|
|
78
|
+
* Reported rather than hidden because it is the fact that makes this receipt trustworthy: a
|
|
79
|
+
* `true` here on a `delivered: true` receipt is the observation that the read survived the
|
|
80
|
+
* boundary. It is also what lets a reader tell a genuinely quiet session apart from one whose
|
|
81
|
+
* history was just rewritten underneath it.
|
|
82
|
+
*/
|
|
83
|
+
readonly crossedCompaction: boolean;
|
|
84
|
+
/** How many entries were examined after the anchor. Diagnostic; never a receipt on its own. */
|
|
85
|
+
readonly scannedAfterAnchor: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a delivery receipt against raw stored entries.
|
|
89
|
+
*
|
|
90
|
+
* An unfindable anchor is a refusal, not a negative receipt — and this is the single most
|
|
91
|
+
* important line in the file. Three situations exist: the text landed, the text did not land, and
|
|
92
|
+
* the question cannot be evaluated. Collapsing the third into the second is what makes the failure
|
|
93
|
+
* invisible: a controller reading a false "no" pastes the text again, and a double-paste is the
|
|
94
|
+
* exact production symptom this read path exists to prevent. So it refuses by name and the caller
|
|
95
|
+
* has to decide what to do about not knowing.
|
|
96
|
+
*
|
|
97
|
+
* @param entries Raw entries in append order — `store.load(key)` or the local JSONL. Not a
|
|
98
|
+
* reconstructed conversation: see Defence 1 in this file's header.
|
|
99
|
+
*/
|
|
100
|
+
export declare function resolveReceipt(entries: readonly TranscriptEntry[], query: ReceiptQuery): Result<ReceiptOutcome>;
|
|
101
|
+
/**
|
|
102
|
+
* The uuid a controller should hold as its next baseline — the last entry currently stored.
|
|
103
|
+
*
|
|
104
|
+
* Null for an empty transcript, which is the one case where a query may honestly carry a null
|
|
105
|
+
* anchor. Entries without a uuid are skipped: an anchor has to be addressable, and the contract
|
|
106
|
+
* says some entry kinds legitimately carry none.
|
|
107
|
+
*/
|
|
108
|
+
export declare function baselineAnchor(entries: readonly TranscriptEntry[]): string | null;
|
|
109
|
+
/** How many compactions this transcript records. For observability, not for the receipt. */
|
|
110
|
+
export declare function compactionCount(entries: readonly TranscriptEntry[]): number;
|
|
111
|
+
//# sourceMappingURL=receipt.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
import { isCompactBoundary, isCompactionProduced, isUserEntry } from './entry.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a delivery receipt against raw stored entries.
|
|
5
|
+
*
|
|
6
|
+
* An unfindable anchor is a refusal, not a negative receipt — and this is the single most
|
|
7
|
+
* important line in the file. Three situations exist: the text landed, the text did not land, and
|
|
8
|
+
* the question cannot be evaluated. Collapsing the third into the second is what makes the failure
|
|
9
|
+
* invisible: a controller reading a false "no" pastes the text again, and a double-paste is the
|
|
10
|
+
* exact production symptom this read path exists to prevent. So it refuses by name and the caller
|
|
11
|
+
* has to decide what to do about not knowing.
|
|
12
|
+
*
|
|
13
|
+
* @param entries Raw entries in append order — `store.load(key)` or the local JSONL. Not a
|
|
14
|
+
* reconstructed conversation: see Defence 1 in this file's header.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveReceipt(entries, query) {
|
|
17
|
+
// Defence 2: the anchor is located by identity. Nothing here counts.
|
|
18
|
+
const anchorIndex = query.anchorUuid === null ? -1 : entries.findIndex((entry) => entry.uuid === query.anchorUuid);
|
|
19
|
+
if (query.anchorUuid !== null && anchorIndex === -1) {
|
|
20
|
+
return refuse('receipt-anchor-unknown', `no entry carries the baseline uuid ${query.anchorUuid} in ${entries.length} stored entries — ` +
|
|
21
|
+
'the receipt cannot be evaluated, which is not the same as the text not arriving');
|
|
22
|
+
}
|
|
23
|
+
const after = entries.slice(anchorIndex + 1);
|
|
24
|
+
const crossedCompaction = after.some(isCompactBoundary);
|
|
25
|
+
for (const entry of after) {
|
|
26
|
+
// Defence 3: a compaction's own output is never a candidate, however much it looks like a turn.
|
|
27
|
+
if (isCompactionProduced(entry))
|
|
28
|
+
continue;
|
|
29
|
+
if (!isUserEntry(entry))
|
|
30
|
+
continue;
|
|
31
|
+
if (query.expectText !== undefined && !entryCarriesText(entry, query.expectText))
|
|
32
|
+
continue;
|
|
33
|
+
return ok({
|
|
34
|
+
delivered: true,
|
|
35
|
+
entryUuid: entry.uuid ?? null,
|
|
36
|
+
at: entry.timestamp ?? null,
|
|
37
|
+
crossedCompaction,
|
|
38
|
+
scannedAfterAnchor: after.length,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return ok({
|
|
42
|
+
delivered: false,
|
|
43
|
+
entryUuid: null,
|
|
44
|
+
at: null,
|
|
45
|
+
crossedCompaction,
|
|
46
|
+
scannedAfterAnchor: after.length,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The uuid a controller should hold as its next baseline — the last entry currently stored.
|
|
51
|
+
*
|
|
52
|
+
* Null for an empty transcript, which is the one case where a query may honestly carry a null
|
|
53
|
+
* anchor. Entries without a uuid are skipped: an anchor has to be addressable, and the contract
|
|
54
|
+
* says some entry kinds legitimately carry none.
|
|
55
|
+
*/
|
|
56
|
+
export function baselineAnchor(entries) {
|
|
57
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
58
|
+
const uuid = entries[index]?.uuid;
|
|
59
|
+
if (typeof uuid === 'string' && uuid.length > 0)
|
|
60
|
+
return uuid;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** How many compactions this transcript records. For observability, not for the receipt. */
|
|
65
|
+
export function compactionCount(entries) {
|
|
66
|
+
return entries.filter(isCompactBoundary).length;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether an entry's payload carries this text anywhere in it.
|
|
70
|
+
*
|
|
71
|
+
* A structural search, not a content model. A user entry's `message` is the CLI's own shape and
|
|
72
|
+
* this package does not model it, so the text is looked for across the serialised payload rather
|
|
73
|
+
* than at a path this file would have to keep in step with a format it does not own.
|
|
74
|
+
*/
|
|
75
|
+
function entryCarriesText(entry, text) {
|
|
76
|
+
if (text.length === 0)
|
|
77
|
+
return true;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.stringify(entry).includes(text);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=receipt.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long stored entries are kept — and why this file exists at all.
|
|
3
|
+
*
|
|
4
|
+
* The SDK never deletes from your store. Its adapter contract says so in as many words and hands
|
|
5
|
+
* retention to the adapter, naming TTLs and lifecycle policies as the adapter's job. So a compliance
|
|
6
|
+
* window is not something this host inherits — it is something it must implement, and a package that
|
|
7
|
+
* shipped without one would quietly keep every transcript forever.
|
|
8
|
+
*
|
|
9
|
+
* This is a plan, not a deletion. It returns what would go and what would stay; something in
|
|
10
|
+
* `host/` performs it. That is what makes "retention removes what it claims to" measurable before
|
|
11
|
+
* and after by a plain unit test instead of by deleting real files and hoping.
|
|
12
|
+
*
|
|
13
|
+
* It does not contradict the append-only log, and the distinction is exact. Append-only forbids
|
|
14
|
+
* editing or selectively erasing a record — rewriting an abandoned entry to look closed, dropping
|
|
15
|
+
* an inconvenient refusal. Retention drops whole entries once they age out of a stated window,
|
|
16
|
+
* uniformly and by a rule announced in advance. The first destroys a signal while keeping its
|
|
17
|
+
* neighbours, which makes the remaining record misleading; the second removes the record and
|
|
18
|
+
* says so.
|
|
19
|
+
*/
|
|
20
|
+
import type { Result } from '../core/result.js';
|
|
21
|
+
import type { TranscriptEntry } from './entry.js';
|
|
22
|
+
export interface RetentionPolicy {
|
|
23
|
+
/** Entries older than this go. Must be a finite, non-negative number of milliseconds. */
|
|
24
|
+
readonly maxAgeMs: number;
|
|
25
|
+
}
|
|
26
|
+
export interface RetentionPlan {
|
|
27
|
+
readonly keep: readonly TranscriptEntry[];
|
|
28
|
+
readonly remove: readonly TranscriptEntry[];
|
|
29
|
+
/**
|
|
30
|
+
* Entries kept only because they carry no readable timestamp.
|
|
31
|
+
*
|
|
32
|
+
* Surfaced rather than folded into `keep`, because it is the number that says whether this
|
|
33
|
+
* policy is actually doing what its window claims. A store full of undated entries is one where
|
|
34
|
+
* retention is silently a no-op, and that is a thing an operator must be able to see.
|
|
35
|
+
*/
|
|
36
|
+
readonly undatedKept: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What this policy would remove, given the time now.
|
|
40
|
+
*
|
|
41
|
+
* An entry that cannot be dated is kept. Deleting on a guess is worse than keeping too much: an
|
|
42
|
+
* unparseable or absent timestamp means the age is unknown, and treating unknown as old would delete
|
|
43
|
+
* exactly the malformed records somebody needs to look at. It errs toward keeping, and reports how
|
|
44
|
+
* often it did.
|
|
45
|
+
*/
|
|
46
|
+
export declare function planRetention(entries: readonly TranscriptEntry[], policy: RetentionPolicy, nowMs: number): Result<RetentionPlan>;
|
|
47
|
+
/**
|
|
48
|
+
* One entry's time, in epoch milliseconds, or null when it has none this can read.
|
|
49
|
+
*
|
|
50
|
+
* Exported because the retention plan's honesty depends on it: a caller that wants to know whether a
|
|
51
|
+
* store is datable at all asks this rather than inferring it from a plan that kept everything.
|
|
52
|
+
*/
|
|
53
|
+
export declare function entryTimeMs(entry: TranscriptEntry): number | null;
|
|
54
|
+
/**
|
|
55
|
+
* Why an abandonment mark can never be orphaned by this policy, stated because the pairing is the
|
|
56
|
+
* one thing retention could destroy while looking correct.
|
|
57
|
+
*
|
|
58
|
+
* A mark is written strictly after the entry it refers to — it records that an already-open entry
|
|
59
|
+
* was later found abandoned. An age window removes oldest-first, so the only orderings it can
|
|
60
|
+
* produce are "both kept", "both removed", or "the older subject removed while the newer mark
|
|
61
|
+
* stays". The dangerous fourth — the mark removed while the subject stays, leaving an abandoned
|
|
62
|
+
* entry reading as merely still-open — is unreachable, because that requires the mark to be older
|
|
63
|
+
* than its subject.
|
|
64
|
+
*
|
|
65
|
+
* This function is the assertion of that, so the property is checked rather than reasoned about.
|
|
66
|
+
*/
|
|
67
|
+
export declare function markOutlivesSubject(subjectAtMs: number, markAtMs: number, cutoffMs: number): boolean;
|
|
68
|
+
//# sourceMappingURL=retention.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
/**
|
|
3
|
+
* What this policy would remove, given the time now.
|
|
4
|
+
*
|
|
5
|
+
* An entry that cannot be dated is kept. Deleting on a guess is worse than keeping too much: an
|
|
6
|
+
* unparseable or absent timestamp means the age is unknown, and treating unknown as old would delete
|
|
7
|
+
* exactly the malformed records somebody needs to look at. It errs toward keeping, and reports how
|
|
8
|
+
* often it did.
|
|
9
|
+
*/
|
|
10
|
+
export function planRetention(entries, policy, nowMs) {
|
|
11
|
+
if (!Number.isFinite(policy.maxAgeMs) || policy.maxAgeMs < 0) {
|
|
12
|
+
return refuse('retention-window-invalid', `maxAgeMs must be a finite, non-negative number of milliseconds, got ${String(policy.maxAgeMs)}`);
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isFinite(nowMs)) {
|
|
15
|
+
return refuse('retention-window-invalid', `now must be a finite epoch value, got ${String(nowMs)}`);
|
|
16
|
+
}
|
|
17
|
+
const cutoff = nowMs - policy.maxAgeMs;
|
|
18
|
+
const keep = [];
|
|
19
|
+
const remove = [];
|
|
20
|
+
let undatedKept = 0;
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const at = entryTimeMs(entry);
|
|
23
|
+
if (at === null) {
|
|
24
|
+
keep.push(entry);
|
|
25
|
+
undatedKept += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (at < cutoff)
|
|
29
|
+
remove.push(entry);
|
|
30
|
+
else
|
|
31
|
+
keep.push(entry);
|
|
32
|
+
}
|
|
33
|
+
return ok({ keep, remove, undatedKept });
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One entry's time, in epoch milliseconds, or null when it has none this can read.
|
|
37
|
+
*
|
|
38
|
+
* Exported because the retention plan's honesty depends on it: a caller that wants to know whether a
|
|
39
|
+
* store is datable at all asks this rather than inferring it from a plan that kept everything.
|
|
40
|
+
*/
|
|
41
|
+
export function entryTimeMs(entry) {
|
|
42
|
+
const timestamp = entry.timestamp;
|
|
43
|
+
if (typeof timestamp !== 'string' || timestamp.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
const parsed = Date.parse(timestamp);
|
|
46
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Why an abandonment mark can never be orphaned by this policy, stated because the pairing is the
|
|
50
|
+
* one thing retention could destroy while looking correct.
|
|
51
|
+
*
|
|
52
|
+
* A mark is written strictly after the entry it refers to — it records that an already-open entry
|
|
53
|
+
* was later found abandoned. An age window removes oldest-first, so the only orderings it can
|
|
54
|
+
* produce are "both kept", "both removed", or "the older subject removed while the newer mark
|
|
55
|
+
* stays". The dangerous fourth — the mark removed while the subject stays, leaving an abandoned
|
|
56
|
+
* entry reading as merely still-open — is unreachable, because that requires the mark to be older
|
|
57
|
+
* than its subject.
|
|
58
|
+
*
|
|
59
|
+
* This function is the assertion of that, so the property is checked rather than reasoned about.
|
|
60
|
+
*/
|
|
61
|
+
export function markOutlivesSubject(subjectAtMs, markAtMs, cutoffMs) {
|
|
62
|
+
if (markAtMs < subjectAtMs)
|
|
63
|
+
return false;
|
|
64
|
+
const subjectKept = subjectAtMs >= cutoffMs;
|
|
65
|
+
const markKept = markAtMs >= cutoffMs;
|
|
66
|
+
return !(subjectKept && !markKept);
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=retention.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where transcripts are kept — the seam, and the JSONL implementation that ships.
|
|
3
|
+
*
|
|
4
|
+
* The store is the embedder's, the same way the workspace provider is. One implementation ships
|
|
5
|
+
* because everyone needs a local one; the interesting ones — S3, Postgres, a blob container behind
|
|
6
|
+
* a tenant boundary — are the embedder's and are written against this interface without ever
|
|
7
|
+
* learning what a session means. The effects are injected for the same reason they are there: the
|
|
8
|
+
* rules worth testing are which key becomes which record and what a missing transcript returns, and
|
|
9
|
+
* none of that needs a disk.
|
|
10
|
+
*
|
|
11
|
+
* This is not the sole record, by the SDK's design and not by this package's. The subprocess
|
|
12
|
+
* writes to local disk first and the mirror runs after that write succeeds, so local disk is
|
|
13
|
+
* authoritative and a store can legitimately lag it. The adapter contract also forbids turning
|
|
14
|
+
* local persistence off while a store is set. Anything reasoning about "what the store has" must
|
|
15
|
+
* hold that the local copy may be ahead — see `mirror.ts` on dropped batches, which is how it gets
|
|
16
|
+
* ahead and stays there.
|
|
17
|
+
*
|
|
18
|
+
* `listSessionSummaries` is deliberately not part of this interface — a decision, not an omission.
|
|
19
|
+
* The SDK offers a summary side-file a store can maintain inside `append()`, and it is declined
|
|
20
|
+
* here for three reasons. Its payload is documented as opaque SDK-owned state a store must persist
|
|
21
|
+
* verbatim and must not interpret, so it yields nothing this host could put on the wire;
|
|
22
|
+
* maintaining it requires serialising side-file writes behind a per-session lock on a path that
|
|
23
|
+
* receives batches at roughly ten a second; and it optimises a read path this host does not use.
|
|
24
|
+
* The underlying requirement — parse at the edge, ship summaries rather than transcripts — is met
|
|
25
|
+
* instead by `receipt.ts` and `transition-log.ts`, which answer the questions a controller actually
|
|
26
|
+
* asks without sending it a transcript. The adapter contract marks the method optional, so
|
|
27
|
+
* declining it is a supported posture rather than a gap.
|
|
28
|
+
*/
|
|
29
|
+
import type { Result } from '../core/result.js';
|
|
30
|
+
import type { TranscriptEntry } from './entry.js';
|
|
31
|
+
import type { TranscriptKey } from './key.js';
|
|
32
|
+
/** One stored session, as a listing reports it. */
|
|
33
|
+
export interface StoredSession {
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
/** Storage write time, epoch milliseconds. The store's own clock, never an entry's timestamp. */
|
|
36
|
+
readonly mtime: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Where transcripts go.
|
|
40
|
+
*
|
|
41
|
+
* `load` returns `null` for a transcript that was never written, and that is distinct from an
|
|
42
|
+
* empty list. "Never written" and "written and then emptied" are different facts, and a store that
|
|
43
|
+
* cannot tell them apart is allowed to say `null` for both — but it may not report either as an
|
|
44
|
+
* empty transcript, because a resume reading an empty list would start a session claiming it had
|
|
45
|
+
* checked and found no history.
|
|
46
|
+
*/
|
|
47
|
+
export interface TranscriptStore {
|
|
48
|
+
/** Mirror a batch. Idempotent on `uuid` — see mirror.ts. */
|
|
49
|
+
append(key: TranscriptKey, entries: readonly TranscriptEntry[]): Promise<Result<void>>;
|
|
50
|
+
/** Everything stored for this key, or null when nothing ever was. */
|
|
51
|
+
load(key: TranscriptKey): Promise<Result<TranscriptEntry[] | null>>;
|
|
52
|
+
/** Sessions under one project scope. Optional — a store may not be able to enumerate. */
|
|
53
|
+
listSessions?(projectKey: string): Promise<Result<StoredSession[]>>;
|
|
54
|
+
/**
|
|
55
|
+
* Remove a transcript.
|
|
56
|
+
*
|
|
57
|
+
* Optional, and absent means "deletion is a no-op" rather than "deletion fails". That is the
|
|
58
|
+
* adapter contract's own shape and it is the right one for append-only backends: a write-once
|
|
59
|
+
* bucket cannot delete, and pretending otherwise would make retention look like it ran.
|
|
60
|
+
*/
|
|
61
|
+
delete?(key: TranscriptKey): Promise<Result<void>>;
|
|
62
|
+
/** Subagent transcripts under one session, so a resume can materialise them too. */
|
|
63
|
+
listSubkeys?(key: TranscriptKey): Promise<Result<string[]>>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The storage effects a JSONL store needs, injected.
|
|
67
|
+
*
|
|
68
|
+
* Same rule as everywhere else: `src/host/` is the only directory that may touch a filesystem, so
|
|
69
|
+
* the logic lives here and the real implementation is host-side.
|
|
70
|
+
*/
|
|
71
|
+
export interface StoreEffects {
|
|
72
|
+
/** Append text to a record, creating it and any container it needs. */
|
|
73
|
+
appendTo(token: string, text: string): Promise<void>;
|
|
74
|
+
/** The whole record's text, or null when it does not exist. */
|
|
75
|
+
readAll(token: string): Promise<string | null>;
|
|
76
|
+
/** Tokens under one project scope, with their storage write times. */
|
|
77
|
+
list(projectKey: string): Promise<readonly {
|
|
78
|
+
token: string;
|
|
79
|
+
sessionId: string;
|
|
80
|
+
mtime: number;
|
|
81
|
+
}[]>;
|
|
82
|
+
/** Remove one record. Succeeds when it is already gone. */
|
|
83
|
+
remove(token: string): Promise<void>;
|
|
84
|
+
/** Subpath tokens under one session. */
|
|
85
|
+
subkeys(projectKey: string, sessionId: string): Promise<readonly string[]>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A store that keeps each transcript as JSONL, one line per entry.
|
|
89
|
+
*
|
|
90
|
+
* Append is a real append, not a read-modify-write of the whole file. A transcript reaches
|
|
91
|
+
* megabytes and batches arrive throughout a turn, so rewriting it per batch would make cost grow
|
|
92
|
+
* with the square of the session's length. The one place that does read first is deduplication,
|
|
93
|
+
* which is why `dedupeOnAppend` can be turned off for a caller that knows its batches are unique.
|
|
94
|
+
*/
|
|
95
|
+
export declare function createJsonlStore(effects: StoreEffects, options?: JsonlStoreOptions): TranscriptStore;
|
|
96
|
+
export interface JsonlStoreOptions {
|
|
97
|
+
/**
|
|
98
|
+
* Read before appending so a replayed batch does not duplicate. Defaults to true.
|
|
99
|
+
*
|
|
100
|
+
* Turning it off is a real choice with a named cost. It removes a full read per batch, which
|
|
101
|
+
* matters on a long transcript; what it costs is that a retried or re-imported batch lands twice,
|
|
102
|
+
* and nothing downstream can tell a duplicated entry from a repeated one.
|
|
103
|
+
*/
|
|
104
|
+
readonly dedupeOnAppend?: boolean;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
import { decodeTranscript, encodeTranscript } from './entry.js';
|
|
3
|
+
import { transcriptToken } from './key.js';
|
|
4
|
+
import { dedupeBatch, uuidsIn } from './mirror.js';
|
|
5
|
+
/**
|
|
6
|
+
* A store that keeps each transcript as JSONL, one line per entry.
|
|
7
|
+
*
|
|
8
|
+
* Append is a real append, not a read-modify-write of the whole file. A transcript reaches
|
|
9
|
+
* megabytes and batches arrive throughout a turn, so rewriting it per batch would make cost grow
|
|
10
|
+
* with the square of the session's length. The one place that does read first is deduplication,
|
|
11
|
+
* which is why `dedupeOnAppend` can be turned off for a caller that knows its batches are unique.
|
|
12
|
+
*/
|
|
13
|
+
export function createJsonlStore(effects, options = {}) {
|
|
14
|
+
const dedupe = options.dedupeOnAppend ?? true;
|
|
15
|
+
return {
|
|
16
|
+
async append(key, entries) {
|
|
17
|
+
if (entries.length === 0)
|
|
18
|
+
return ok(undefined);
|
|
19
|
+
const token = transcriptToken(key);
|
|
20
|
+
let toWrite = entries;
|
|
21
|
+
if (dedupe) {
|
|
22
|
+
const existing = await readEntries(effects, token);
|
|
23
|
+
if (!existing.ok)
|
|
24
|
+
return refuse(existing.refusal.reason, existing.refusal.detail);
|
|
25
|
+
toWrite = dedupeBatch(uuidsIn(existing.value ?? []), entries).append;
|
|
26
|
+
if (toWrite.length === 0)
|
|
27
|
+
return ok(undefined);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await effects.appendTo(token, `${encodeTranscript(toWrite)}\n`);
|
|
31
|
+
return ok(undefined);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return refuse('transcript-write-failed', `${token}: ${messageOf(error)}`);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async load(key) {
|
|
38
|
+
return readEntries(effects, transcriptToken(key));
|
|
39
|
+
},
|
|
40
|
+
async listSessions(projectKey) {
|
|
41
|
+
try {
|
|
42
|
+
const found = await effects.list(projectKey);
|
|
43
|
+
return ok(found.map((one) => ({ sessionId: one.sessionId, mtime: one.mtime })));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return refuse('transcript-read-failed', `${projectKey}: ${messageOf(error)}`);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async delete(key) {
|
|
50
|
+
try {
|
|
51
|
+
await effects.remove(transcriptToken(key));
|
|
52
|
+
return ok(undefined);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return refuse('transcript-write-failed', `${transcriptToken(key)}: ${messageOf(error)}`);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async listSubkeys(key) {
|
|
59
|
+
try {
|
|
60
|
+
return ok([...(await effects.subkeys(key.projectKey, key.sessionId))]);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return refuse('transcript-read-failed', `${transcriptToken(key)}: ${messageOf(error)}`);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function readEntries(effects, token) {
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = await effects.readAll(token);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return refuse('transcript-read-failed', `${token}: ${messageOf(error)}`);
|
|
75
|
+
}
|
|
76
|
+
if (text === null)
|
|
77
|
+
return ok(null);
|
|
78
|
+
const decoded = decodeTranscript(text);
|
|
79
|
+
if (!decoded.ok)
|
|
80
|
+
return refuse(decoded.refusal.reason, `${token}: ${decoded.refusal.detail}`);
|
|
81
|
+
return ok(decoded.value);
|
|
82
|
+
}
|
|
83
|
+
function messageOf(error) {
|
|
84
|
+
return error instanceof Error ? error.message : String(error);
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=store.js.map
|